Skip to content

Commit c179f52

Browse files
committed
feat: add context-sensitive hash tag escaping #27
Implement configurable escaping of # characters that are not valid Markdown headers to prevent unintended Obsidian tags.
1 parent 92d987f commit c179f52

9 files changed

Lines changed: 128 additions & 38 deletions

src/content-generator.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export class ContentGenerator {
2222
comments: any[],
2323
settings: GitHubTrackerSettings,
2424
): Promise<string> {
25+
// Determine whether to escape hash tags (repo setting takes precedence if ignoreGlobalSettings is true)
26+
const shouldEscapeHashTags = repo.ignoreGlobalSettings ? repo.escapeHashTags : settings.escapeHashTags;
27+
2528
// Check if custom template is enabled and load template content
2629
if (repo.useCustomIssueContentTemplate && repo.issueContentTemplate) {
2730
const templateContent = await this.fileHelpers.loadTemplateContent(repo.issueContentTemplate);
@@ -31,7 +34,8 @@ export class ContentGenerator {
3134
repo.repository,
3235
comments,
3336
settings.dateFormat,
34-
settings.escapeMode
37+
settings.escapeMode,
38+
shouldEscapeHashTags
3539
);
3640
return processContentTemplate(templateContent, templateData, settings.dateFormat);
3741
}
@@ -68,14 +72,14 @@ updateMode: "${repo.issueUpdateMode}"
6872
allowDelete: ${repo.allowDeleteIssue ? true : false}
6973
---
7074
71-
# ${escapeBody(issue.title, settings.escapeMode)}
75+
# ${escapeBody(issue.title, settings.escapeMode, false)}
7276
${
7377
issue.body
74-
? escapeBody(issue.body, settings.escapeMode)
78+
? escapeBody(issue.body, settings.escapeMode, shouldEscapeHashTags)
7579
: "No description found"
7680
}
7781
78-
${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFormat)}
82+
${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFormat, shouldEscapeHashTags)}
7983
`;
8084
}
8185

@@ -88,6 +92,9 @@ ${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFo
8892
comments: any[],
8993
settings: GitHubTrackerSettings,
9094
): Promise<string> {
95+
// Determine whether to escape hash tags (repo setting takes precedence if ignoreGlobalSettings is true)
96+
const shouldEscapeHashTags = repo.ignoreGlobalSettings ? repo.escapeHashTags : settings.escapeHashTags;
97+
9198
// Check if custom template is enabled and load template content
9299
if (repo.useCustomPullRequestContentTemplate && repo.pullRequestContentTemplate) {
93100
const templateContent = await this.fileHelpers.loadTemplateContent(repo.pullRequestContentTemplate);
@@ -97,7 +104,8 @@ ${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFo
97104
repo.repository,
98105
comments,
99106
settings.dateFormat,
100-
settings.escapeMode
107+
settings.escapeMode,
108+
shouldEscapeHashTags
101109
);
102110
return processContentTemplate(templateContent, templateData, settings.dateFormat);
103111
}
@@ -139,14 +147,14 @@ updateMode: "${repo.pullRequestUpdateMode}"
139147
allowDelete: ${repo.allowDeletePullRequest ? true : false}
140148
---
141149
142-
# ${escapeBody(pr.title, settings.escapeMode)}
150+
# ${escapeBody(pr.title, settings.escapeMode, false)}
143151
${
144152
pr.body
145-
? escapeBody(pr.body, settings.escapeMode)
153+
? escapeBody(pr.body, settings.escapeMode, shouldEscapeHashTags)
146154
: "No description found"
147155
}
148156
149-
${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFormat)}
157+
${this.fileHelpers.formatComments(comments, settings.escapeMode, settings.dateFormat, shouldEscapeHashTags)}
150158
`;
151159
}
152160
}

src/issue-file-manager.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,16 @@ export class IssueFileManager {
162162
this.noticeManager.debug(`Updated issue ${issue.number}`);
163163
}
164164
} else if (updateMode === "append") {
165+
const shouldEscapeHashTags = repo.ignoreGlobalSettings ? repo.escapeHashTags : this.settings.escapeHashTags;
165166
content = `---\n### New status: "${
166167
issue.state
167168
}"\n\n# ${escapeBody(
168169
issue.title,
169170
this.settings.escapeMode,
171+
false,
170172
)}\n${
171173
issue.body
172-
? escapeBody(issue.body, this.settings.escapeMode)
174+
? escapeBody(issue.body, this.settings.escapeMode, shouldEscapeHashTags)
173175
: "No description found"
174176
}\n`;
175177

@@ -178,6 +180,7 @@ export class IssueFileManager {
178180
comments,
179181
this.settings.escapeMode,
180182
this.settings.dateFormat,
183+
shouldEscapeHashTags,
181184
);
182185
}
183186
const currentFileContent = await this.app.vault.read(file);

src/pr-file-manager.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,21 +164,24 @@ export class PullRequestFileManager {
164164
this.noticeManager.debug(`Updated PR ${pr.number}`);
165165
}
166166
} else if (updateMode === "append") {
167+
const shouldEscapeHashTags = repo.ignoreGlobalSettings ? repo.escapeHashTags : this.settings.escapeHashTags;
167168
content = `---\n### New status: "${
168169
pr.state
169170
}"\n\n# ${escapeBody(
170171
pr.title,
171172
this.settings.escapeMode,
173+
false,
172174
)}\n${
173175
pr.body
174-
? escapeBody(pr.body, this.settings.escapeMode)
176+
? escapeBody(pr.body, this.settings.escapeMode, shouldEscapeHashTags)
175177
: "No description found"
176178
}\n`;
177179
if (comments.length > 0) {
178180
content += this.fileHelpers.formatComments(
179181
comments,
180182
this.settings.escapeMode,
181183
this.settings.dateFormat,
184+
shouldEscapeHashTags,
182185
);
183186
}
184187

src/settings-tab.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,18 @@ export class GitHubTrackerSettingTab extends PluginSettingTab {
362362
escapingContent.createEl("p").textContent = "• Strict: Only allows alphanumeric, '.,'()/[]{}*+-:\"' and whitespace";
363363
escapingContent.createEl("p").textContent = "• Very Strict: Only allows alphanumeric, '.,' and whitespace";
364364

365+
new Setting(advancedContainer)
366+
.setName("Escape hash tags")
367+
.setDesc("Escape # characters that are not valid Markdown headers to prevent unintended Obsidian tags (e.g., #134 becomes \\#134)")
368+
.addToggle((toggle) =>
369+
toggle
370+
.setValue(this.plugin.settings.escapeHashTags)
371+
.onChange(async (value) => {
372+
this.plugin.settings.escapeHashTags = value;
373+
await this.plugin.saveSettings();
374+
}),
375+
);
376+
365377
// Global Defaults Section
366378
const globalDefaultsContainer = containerEl.createDiv("github-issues-settings-group");
367379
const globalDefaultsHeader = new Setting(globalDefaultsContainer)

src/settings/repository-list-manager.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,16 @@ export class RepositoryListManager {
370370
}),
371371
);
372372

373+
new Setting(detailsContainer)
374+
.setName("Escape hash tags")
375+
.setDesc("Escape # characters for this repository (overrides global setting if 'Ignore global settings' is enabled)")
376+
.addToggle((toggle: any) =>
377+
toggle.setValue(repo.escapeHashTags).onChange(async (value: boolean) => {
378+
repo.escapeHashTags = value;
379+
await this.plugin.saveSettings();
380+
}),
381+
);
382+
373383
const issuesContainer = detailsContainer.createDiv(
374384
"github-issues-settings-section",
375385
);

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface RepositoryTracking {
3535
includePullRequestComments: boolean;
3636
includeClosedIssues: boolean;
3737
includeClosedPullRequests: boolean;
38+
escapeHashTags: boolean;
3839
}
3940

4041
export interface GlobalDefaults {
@@ -62,6 +63,7 @@ export interface GitHubTrackerSettings {
6263
syncNoticeMode: "minimal" | "normal" | "extensive" | "debug";
6364
syncInterval: number;
6465
escapeMode: "disabled" | "normal" | "strict" | "veryStrict";
66+
escapeHashTags: boolean;
6567
enableBackgroundSync: boolean;
6668
backgroundSyncInterval: number; // in minutes
6769
cleanupClosedIssuesDays: number;
@@ -93,6 +95,7 @@ export const DEFAULT_SETTINGS: GitHubTrackerSettings = {
9395
syncNoticeMode: "normal",
9496
syncInterval: 0,
9597
escapeMode: "strict",
98+
escapeHashTags: false,
9699
enableBackgroundSync: false,
97100
backgroundSyncInterval: 30,
98101
cleanupClosedIssuesDays: 30,
@@ -137,4 +140,5 @@ export const DEFAULT_REPOSITORY_TRACKING: RepositoryTracking = {
137140
includePullRequestComments: true,
138141
includeClosedIssues: false,
139142
includeClosedPullRequests: false,
143+
escapeHashTags: false,
140144
};

src/util/escapeUtils.ts

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,34 @@
1+
/**
2+
* Escapes # characters that are not valid Markdown headers to prevent
3+
* unintended Obsidian tags (e.g., #1337-YesSr becomes \#1337-YesSr)
4+
* while preserving valid Markdown headers (# followed by space)
5+
* @param text The text to process
6+
* @returns The text with escaped # characters
7+
*/
8+
function escapeHashTags(text: string): string {
9+
const lines = text.split('\n');
10+
11+
return lines.map(line => {
12+
const trimmed = line.trim();
13+
14+
// Check if this is a valid Markdown header (one or more # followed by a space)
15+
// Valid: "# Header", "## Header", "### Header", etc.
16+
// Invalid: "#1337", "#tag", "###NoSpace"
17+
if (/^#{1,6}\s/.test(trimmed)) {
18+
// This is a valid Markdown header, don't escape
19+
return line;
20+
}
21+
22+
// Escape all # characters in this line
23+
return line.replace(/#/g, '\\#');
24+
}).join('\n');
25+
}
26+
127
/**
228
* Utility function for escaping content in different modes
329
* @param unsafe The string to escape
430
* @param mode The escaping mode: "disabled", "normal", "strict", or "veryStrict"
31+
* @param shouldEscapeHashTags Whether to apply context-sensitive # escaping
532
* @returns The escaped string
633
* @throws Error if input is null or undefined
734
*
@@ -14,39 +41,53 @@
1441
export function escapeBody(
1542
unsafe: string,
1643
mode: "disabled" | "normal" | "strict" | "veryStrict" = "normal",
44+
shouldEscapeHashTags: boolean = false,
1745
): string {
46+
// Validate input
1847
if (unsafe === null || unsafe === undefined) {
1948
throw new Error("Input cannot be null or undefined");
2049
}
2150

51+
// No escaping in disabled mode
2252
if (mode === "disabled") {
2353
return unsafe;
2454
}
2555

26-
if (mode === "strict") {
27-
// Allow Unicode characters, whitespace, common punctuation, and URL/Markdown specific characters
28-
// Remove potentially dangerous characters while preserving Chinese and other Unicode characters
29-
return unsafe
30-
.replace(/[<>{}$`\\]/g, "") // Remove potentially dangerous HTML/JS/template characters
31-
.replace(/---/g, "- - -"); // Escape YAML frontmatter separators
32-
}
56+
// Apply mode-specific escaping
57+
let escaped: string;
58+
59+
switch (mode) {
60+
case "strict":
61+
// Allow Unicode characters, whitespace, common punctuation, and URL/Markdown specific characters
62+
// Remove potentially dangerous characters while preserving Chinese and other Unicode characters
63+
escaped = unsafe
64+
.replace(/[<>{}$`\\]/g, "") // Remove potentially dangerous HTML/JS/template characters
65+
.replace(/---/g, "- - -"); // Escape YAML frontmatter separators
66+
break;
67+
68+
case "veryStrict":
69+
// Allow Unicode characters, whitespace, basic punctuation, and essential URL/Markdown characters
70+
// More restrictive than strict mode but still preserves Chinese and other Unicode characters
71+
escaped = unsafe
72+
.replace(/[<>{}$`\\"'|&*~^]/g, "") // Remove more potentially dangerous characters
73+
.replace(/---/g, "- - -"); // Escape YAML frontmatter separators
74+
break;
3375

34-
if (mode === "veryStrict") {
35-
// Allow Unicode characters, whitespace, basic punctuation, and essential URL/Markdown characters
36-
// More restrictive than strict mode but still preserves Chinese and other Unicode characters
37-
return unsafe
38-
.replace(/[<>{}$`\\"'|&*~^]/g, "") // Remove more potentially dangerous characters
39-
.replace(/---/g, "- - -"); // Escape YAML frontmatter separators
76+
case "normal":
77+
default:
78+
// Basic escaping for Templater and Dataview compatibility
79+
escaped = unsafe
80+
.replace(/<%/g, "'<<'") // Templater tags
81+
.replace(/%>/g, "'>>'") // Templater tags
82+
.replace(/`/g, '"') // Backticks (can interfere with Dataview)
83+
.replace(/---/g, "- - -") // YAML frontmatter separators
84+
.replace(/{{/g, "((") // Templater/Dataview variables
85+
.replace(/}}/g, "))"); // Templater/Dataview variables
86+
break;
4087
}
4188

42-
// normal mode
43-
return unsafe
44-
.replace(/<%/g, "'<<'")
45-
.replace(/%>/g, "'>>'")
46-
.replace(/`/g, '"')
47-
.replace(/---/g, "- - -")
48-
.replace(/{{/g, "((")
49-
.replace(/}}/g, "))");
89+
// Apply context-sensitive # escaping if enabled
90+
return shouldEscapeHashTags ? escapeHashTags(escaped) : escaped;
5091
}
5192

5293
/**
@@ -56,5 +97,7 @@ export function escapeBody(
5697
*/
5798
export function escapeYamlString(str: string): string {
5899
// In YAML double-quoted strings, we need to escape backslashes and double quotes
59-
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
100+
return str
101+
.replace(/\\/g, '\\\\') // Escape backslashes first
102+
.replace(/"/g, '\\"'); // Then escape double quotes
60103
}

src/util/file-helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export class FileHelpers {
5757
comments: any[],
5858
escapeMode: "disabled" | "normal" | "strict" | "veryStrict",
5959
dateFormat: string,
60+
escapeHashTags: boolean = false,
6061
): string {
6162
if (!comments || comments.length === 0) {
6263
return "";
@@ -87,6 +88,7 @@ export class FileHelpers {
8788
commentSection += `${escapeBody(
8889
comment.body || "No content",
8990
escapeMode,
91+
escapeHashTags,
9092
)}\n\n---\n\n`;
9193
});
9294

src/util/templateUtils.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,14 @@ export function sanitizeFilename(filename: string): string {
6767
* @param comments Array of comment objects from GitHub API
6868
* @param dateFormat Date format string for comment timestamps
6969
* @param escapeMode Escape mode for comment body text
70+
* @param escapeHashTags Whether to escape # characters
7071
* @returns Formatted comments string
7172
*/
7273
export function formatComments(
7374
comments: any[],
7475
dateFormat: string = "",
75-
escapeMode: "disabled" | "normal" | "strict" | "veryStrict" = "normal"
76+
escapeMode: "disabled" | "normal" | "strict" | "veryStrict" = "normal",
77+
escapeHashTags: boolean = false
7678
): string {
7779
if (!comments || comments.length === 0) {
7880
return "";
@@ -102,7 +104,8 @@ export function formatComments(
102104
// Use escapeBody function for proper text escaping
103105
commentSection += `${escapeBody(
104106
comment.body || "No content",
105-
escapeMode
107+
escapeMode,
108+
escapeHashTags
106109
)}\n\n`;
107110
});
108111

@@ -293,7 +296,8 @@ export function createIssueTemplateData(
293296
repository: string,
294297
comments: any[] = [],
295298
dateFormat: string = "",
296-
escapeMode: "disabled" | "normal" | "strict" | "veryStrict" = "normal"
299+
escapeMode: "disabled" | "normal" | "strict" | "veryStrict" = "normal",
300+
escapeHashTags: boolean = false
297301
): TemplateData {
298302
const [owner, repoName] = repository.split("/");
299303

@@ -323,7 +327,7 @@ export function createIssueTemplateData(
323327
commentsCount: issue.comments || 0,
324328
isLocked: issue.locked || false,
325329
lockReason: issue.active_lock_reason || "",
326-
comments: formatComments(comments, dateFormat, escapeMode)
330+
comments: formatComments(comments, dateFormat, escapeMode, escapeHashTags)
327331
};
328332
}
329333

@@ -338,7 +342,8 @@ export function createPullRequestTemplateData(
338342
repository: string,
339343
comments: any[] = [],
340344
dateFormat: string = "",
341-
escapeMode: "disabled" | "normal" | "strict" | "veryStrict" = "normal"
345+
escapeMode: "disabled" | "normal" | "strict" | "veryStrict" = "normal",
346+
escapeHashTags: boolean = false
342347
): TemplateData {
343348
const [owner, repoName] = repository.split("/");
344349

@@ -374,7 +379,7 @@ export function createPullRequestTemplateData(
374379
merged: pr.merged || false,
375380
baseBranch: pr.base?.ref,
376381
headBranch: pr.head?.ref,
377-
comments: formatComments(comments, dateFormat, escapeMode)
382+
comments: formatComments(comments, dateFormat, escapeMode, escapeHashTags)
378383
};
379384
}
380385

0 commit comments

Comments
 (0)