diff --git a/docs/docs/FormatSyntax.md b/docs/docs/FormatSyntax.md index 8cdb8d2e..699172cf 100644 --- a/docs/docs/FormatSyntax.md +++ b/docs/docs/FormatSyntax.md @@ -24,7 +24,7 @@ title: Format syntax | `{{TEMPLATE:}}` | Include templates in your `format`. Supports Templater syntax. | | `{{GLOBAL_VAR:}}` | Inserts the value of a globally defined snippet from QuickAdd settings. Snippet values can include other QuickAdd tokens (e.g., `{{VALUE:...}}`, `{{VDATE:...}}`) and are processed by the usual formatter passes. Names match case‑insensitively in the token. | | `{{MVALUE}}` | Math modal for writing LaTeX. Use CTRL + Enter to submit. | -| `{{FIELD:}}` | Suggest the values of `FIELDNAME` anywhere `{{FIELD:FIELDNAME}}` is used. Fields are YAML fields, and the values represent any value this field has in your vault. If there exists no such field or value, you are instead prompted to enter one.

**Enhanced Filtering Options:**
• `{{FIELD:fieldname\|folder:path/to/folder}}` - Only suggest values from files in specific folder
• `{{FIELD:fieldname\|tag:tagname}}` - Only suggest values from files with specific tag
• `{{FIELD:fieldname\|inline:true}}` - Include Dataview inline fields (fieldname:: value)
• `{{FIELD:fieldname\|exclude-folder:templates}}` - Exclude values from files in specific folder
• `{{FIELD:fieldname\|exclude-tag:deprecated}}` - Exclude values from files with specific tag
• `{{FIELD:fieldname\|exclude-file:example.md}}` - Exclude values from specific file
• `{{FIELD:fieldname\|default:Status - To Do}}` - Prepend a default suggestion; the modal placeholder shows it and pressing Enter accepts it.
• `{{FIELD:fieldname\|default:Draft\|default-empty:true}}` - Only add the default when no matching values are found.
• `{{FIELD:fieldname\|default:Draft\|default-always:true}}` - Keep the default first even if other suggestions exist.
• Combine filters: `{{FIELD:fieldname\|folder:daily\|tag:work\|exclude-folder:templates\|inline:true}}`
• Multiple exclusions: `{{FIELD:fieldname\|exclude-folder:templates\|exclude-folder:archive}}`

This is currently in beta, and the syntax can change—leave your thoughts [here](https://github.com/chhoumann/quickadd/issues/337). | +| `{{FIELD:}}` | Suggest the values of `FIELDNAME` anywhere `{{FIELD:FIELDNAME}}` is used. Fields are YAML fields, and the values represent any value this field has in your vault. If there exists no such field or value, you are instead prompted to enter one.

**Enhanced Filtering Options:**
• `{{FIELD:fieldname\|folder:path/to/folder}}` - Only suggest values from files in specific folder
• `{{FIELD:fieldname\|tag:tagname}}` - Only suggest values from files with specific tag
• `{{FIELD:fieldname\|inline:true}}` - Include Dataview inline fields (fieldname:: value)
• `{{FIELD:fieldname\|inline:true\|inline-code-blocks:ad-note}}` - Include inline fields inside specific fenced code blocks (opt-in)
• `{{FIELD:fieldname\|exclude-folder:templates}}` - Exclude values from files in specific folder
• `{{FIELD:fieldname\|exclude-tag:deprecated}}` - Exclude values from files with specific tag
• `{{FIELD:fieldname\|exclude-file:example.md}}` - Exclude values from specific file
• `{{FIELD:fieldname\|default:Status - To Do}}` - Prepend a default suggestion; the modal placeholder shows it and pressing Enter accepts it.
• `{{FIELD:fieldname\|default:Draft\|default-empty:true}}` - Only add the default when no matching values are found.
• `{{FIELD:fieldname\|default:Draft\|default-always:true}}` - Keep the default first even if other suggestions exist.
• Combine filters: `{{FIELD:fieldname\|folder:daily\|tag:work\|exclude-folder:templates\|inline:true\|inline-code-blocks:ad-note}}`
• Multiple exclusions: `{{FIELD:fieldname\|exclude-folder:templates\|exclude-folder:archive}}`

This is currently in beta, and the syntax can change—leave your thoughts [here](https://github.com/chhoumann/quickadd/issues/337). | | `{{selected}}` | The selected text in the current editor. Will be empty if no active editor exists. | | `{{CLIPBOARD}}` | The current clipboard content. Will be empty if clipboard access fails due to permissions or security restrictions. | | `{{RANDOM:}}` | Generates a random alphanumeric string of the specified length (1-100). Useful for creating unique identifiers, block references, or temporary codes. Example: `{{RANDOM:6}}` generates something like `3YusT5`. | diff --git a/docs/docs/QuickAddAPI.md b/docs/docs/QuickAddAPI.md index cc8e21d3..f98e0b39 100644 --- a/docs/docs/QuickAddAPI.md +++ b/docs/docs/QuickAddAPI.md @@ -732,6 +732,7 @@ Retrieves all unique values for a specific field across your vault. - `folder`: Only search in specific folder (e.g., "daily/notes") - `tags`: Only search in files with specific tags (array) - `includeInline`: Include Dataview inline fields (default: false) + - `includeInlineCodeBlocks`: Include inline fields inside specific fenced code block types when `includeInline` is true (e.g., `["ad-note"]`) **Returns:** Promise resolving to sorted array of unique field values @@ -774,6 +775,18 @@ const clients = await quickAddApi.fieldSuggestions.getFieldValues( ); ``` +Include inline fields in specific code block types: +```javascript +const ids = await quickAddApi.fieldSuggestions.getFieldValues( + "Id", + { + folder: "work/projects", + includeInline: true, + includeInlineCodeBlocks: ["ad-note"] + } +); +``` + ### `clearCache(fieldName?: string): void` Clears the field suggestions cache for better performance. diff --git a/src/constants.ts b/src/constants.ts index fd35ed90..0196110b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -42,6 +42,7 @@ export const FORMAT_SYNTAX: string[] = [ "{{field:|folder:}}", "{{field:|tag:}}", "{{field:|inline:true}}", + "{{field:|inline:true|inline-code-blocks:ad-note}}", LINKCURRENT_SYNTAX, FILENAMECURRENT_SYNTAX, "{{macro:}}", diff --git a/src/quickAddApi.fieldSuggestions.test.ts b/src/quickAddApi.fieldSuggestions.test.ts new file mode 100644 index 00000000..2cfc0795 --- /dev/null +++ b/src/quickAddApi.fieldSuggestions.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App, TFile } from "obsidian"; +import type { IChoiceExecutor } from "./IChoiceExecutor"; +import type QuickAdd from "./main"; +import { QuickAddApi } from "./quickAddApi"; + +vi.mock("./quickAddSettingsTab", () => ({ + DEFAULT_SETTINGS: {}, + QuickAddSettingsTab: class {}, +})); + +vi.mock("./formatters/completeFormatter", () => ({ + CompleteFormatter: class CompleteFormatterMock {}, +})); + +vi.mock("obsidian-dataview", () => ({ + getAPI: vi.fn(), +})); + +const INLINE_CONTENT = ` +Id:: 343434 + +\`\`\`ad-note +Id:: 121212 +\`\`\` + +\`\`\`js +Id:: 999999 +\`\`\` +`; + +function createApp(content: string): App { + const file = { path: "QuickAdd-Issue-998/repro.md" } as TFile; + return { + vault: { + getMarkdownFiles: () => [file], + read: vi.fn(async () => content), + }, + metadataCache: { + getFileCache: vi.fn(() => ({ frontmatter: {} })), + }, + } as unknown as App; +} + +describe("QuickAddApi.fieldSuggestions.getFieldValues", () => { + let variables: Map; + let choiceExecutor: IChoiceExecutor; + let plugin: QuickAdd; + + beforeEach(() => { + variables = new Map(); + choiceExecutor = { + execute: vi.fn(), + variables, + } as unknown as IChoiceExecutor; + plugin = {} as QuickAdd; + }); + + it("keeps code-block values excluded by default when includeInline is true", async () => { + const app = createApp(INLINE_CONTENT); + const api = QuickAddApi.GetApi(app, plugin, choiceExecutor); + + const result = await api.fieldSuggestions.getFieldValues("Id", { + includeInline: true, + }); + + expect(result).toEqual(["343434"]); + }); + + it("includes allowlisted code-block values when includeInlineCodeBlocks is provided", async () => { + const app = createApp(INLINE_CONTENT); + const api = QuickAddApi.GetApi(app, plugin, choiceExecutor); + + const result = await api.fieldSuggestions.getFieldValues("Id", { + includeInline: true, + includeInlineCodeBlocks: ["ad-note"], + }); + + expect(result).toEqual(["121212", "343434"]); + }); + + it("does not scan inline values when includeInline is false", async () => { + const app = createApp(INLINE_CONTENT); + const api = QuickAddApi.GetApi(app, plugin, choiceExecutor); + + const result = await api.fieldSuggestions.getFieldValues("Id", { + includeInline: false, + includeInlineCodeBlocks: ["ad-note"], + }); + + expect(result).toEqual([]); + }); +}); diff --git a/src/quickAddApi.ts b/src/quickAddApi.ts index e400b2e9..d64ea6ec 100644 --- a/src/quickAddApi.ts +++ b/src/quickAddApi.ts @@ -536,12 +536,17 @@ export class QuickAddApi { folder?: string; tags?: string[]; includeInline?: boolean; + includeInlineCodeBlocks?: string[]; }, ) => { + const inlineCodeBlocks = options?.includeInlineCodeBlocks + ?.map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0); const filters = { folder: options?.folder, tags: options?.tags, inline: options?.includeInline ?? false, + inlineCodeBlocks, }; // Get all markdown files and apply filters @@ -578,6 +583,9 @@ export class QuickAddApi { const inlineValues = InlineFieldParser.getFieldValues( content, fieldName, + { + includeCodeBlocks: inlineCodeBlocks, + }, ); inlineValues.forEach((v) => values.add(v)); } diff --git a/src/utils/FieldSuggestionParser.test.ts b/src/utils/FieldSuggestionParser.test.ts index b94bd3b0..096ec0bf 100644 --- a/src/utils/FieldSuggestionParser.test.ts +++ b/src/utils/FieldSuggestionParser.test.ts @@ -49,6 +49,19 @@ describe("FieldSuggestionParser", () => { }); }); + it("should parse inline code block allowlist filter", () => { + const result = FieldSuggestionParser.parse( + "fieldname|inline:true|inline-code-blocks:ad-note, dataview", + ); + expect(result).toEqual({ + fieldName: "fieldname", + filters: { + inline: true, + inlineCodeBlocks: ["ad-note", "dataview"], + }, + }); + }); + it("should parse field name with multiple filters", () => { const result = FieldSuggestionParser.parse( "fieldname|folder:daily|tag:work|inline:true", @@ -182,4 +195,4 @@ describe("FieldSuggestionParser", () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/src/utils/FieldSuggestionParser.ts b/src/utils/FieldSuggestionParser.ts index 3c22223c..b88ec213 100644 --- a/src/utils/FieldSuggestionParser.ts +++ b/src/utils/FieldSuggestionParser.ts @@ -4,6 +4,7 @@ export interface FieldFilter { folder?: string; tags?: string[]; inline?: boolean; + inlineCodeBlocks?: string[]; defaultValue?: string; defaultEmpty?: boolean; defaultAlways?: boolean; @@ -54,6 +55,17 @@ export class FieldSuggestionParser { case "inline": filters.inline = filterValue.toLowerCase() === "true"; break; + case "inline-code-blocks": + if (!filters.inlineCodeBlocks) { + filters.inlineCodeBlocks = []; + } + filters.inlineCodeBlocks.push( + ...filterValue + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0), + ); + break; case "default": filters.defaultValue = filterValue; break; diff --git a/src/utils/FieldValueCollector.issue671.test.ts b/src/utils/FieldValueCollector.issue671.test.ts index f3e07ac7..7a9056c9 100644 --- a/src/utils/FieldValueCollector.issue671.test.ts +++ b/src/utils/FieldValueCollector.issue671.test.ts @@ -29,4 +29,34 @@ describe("Issue #671 - {{FIELD:tags}} suggestions", () => { expect.arrayContaining(["ai/technology", "cook/hoven"]), ); }); + + it("includes inline fields inside allowlisted code blocks only when configured", async () => { + const app = new App(); + const file = { path: "folder/note.md" } as any; + + app.vault.getMarkdownFiles = () => [file]; + app.metadataCache.getFileCache = () => ({ frontmatter: {} } as any); + app.vault.read = vi.fn(async () => ` +Id:: 343434 +\`\`\`ad-note +Id:: 121212 +\`\`\` +\`\`\`js +Id:: 999999 +\`\`\` +`); + + const withoutCodeBlockAllowlist = await collectFieldValuesProcessed( + app, + "Id", + { inline: true }, + ); + const withCodeBlockAllowlist = await collectFieldValuesProcessed(app, "Id", { + inline: true, + inlineCodeBlocks: ["ad-note"], + }); + + expect(withoutCodeBlockAllowlist).toEqual(["343434"]); + expect(withCodeBlockAllowlist).toEqual(["121212", "343434"]); + }); }); diff --git a/src/utils/FieldValueCollector.ts b/src/utils/FieldValueCollector.ts index 525c8997..c3ab9e7c 100644 --- a/src/utils/FieldValueCollector.ts +++ b/src/utils/FieldValueCollector.ts @@ -11,6 +11,9 @@ export function generateFieldCacheKey(filters: FieldFilter): string { if (filters.folder) parts.push(`folder:${filters.folder}`); if (filters.tags) parts.push(`tags:${filters.tags.join(",")}`); if (filters.inline) parts.push("inline:true"); + if (filters.inlineCodeBlocks?.length) { + parts.push(`inline-code-blocks:${filters.inlineCodeBlocks.join(",")}`); + } if (filters.caseSensitive) parts.push("case-sensitive:true"); if (filters.excludeFolders) parts.push(`exclude-folders:${filters.excludeFolders.join(",")}`); @@ -243,6 +246,9 @@ async function collectFieldValuesManually( const inlineValues = InlineFieldParser.getFieldValues( content, fieldName, + { + includeCodeBlocks: filters.inlineCodeBlocks, + }, ); inlineValues.forEach((s) => { const t = String(s).trim(); diff --git a/src/utils/InlineFieldParser.test.ts b/src/utils/InlineFieldParser.test.ts index e8979b40..0ffdfbd4 100644 --- a/src/utils/InlineFieldParser.test.ts +++ b/src/utils/InlineFieldParser.test.ts @@ -47,8 +47,8 @@ type:: task expect(result.get("type")).toEqual(new Set(["task", "meeting"])); }); - it("should ignore fields in code blocks", () => { - const content = ` + it("should ignore fields in code blocks", () => { + const content = ` Real field:: value1 \`\`\` code:: should-be-ignored @@ -60,8 +60,67 @@ field2:: value2 expect(result.has("code")).toBe(false); expect(result.has("inline")).toBe(false); - expect(result.get("Real field")).toEqual(new Set(["value1"])); - expect(result.get("field2")).toEqual(new Set(["value2"])); + expect(result.get("Real field")).toEqual(new Set(["value1"])); + expect(result.get("field2")).toEqual(new Set(["value2"])); + }); + + it("should ignore inline fields in fenced blocks after an empty fenced block", () => { + const content = ` +Id:: outside +\`\`\` +\`\`\` +\`\`\`ad-note +Id:: inside +\`\`\` + `; + const result = InlineFieldParser.parseInlineFields(content); + + expect(result.get("Id")).toEqual(new Set(["outside"])); + }); + + it("should parse allowlisted fenced blocks with indented closing fences", () => { + const content = ` + \`\`\`ad-note +Id:: 121212 + \`\`\` + `; + const result = InlineFieldParser.parseInlineFields(content, { + includeCodeBlocks: ["ad-note"], + }); + + expect(result.get("Id")).toEqual(new Set(["121212"])); + }); + + it("should include fields inside allowlisted fenced code blocks", () => { + const content = ` +Id:: 343434 + +\`\`\`ad-note +Id:: 121212 +\`\`\` + +\`\`\`js +Id:: 999999 +\`\`\` + `; + const result = InlineFieldParser.parseInlineFields(content, { + includeCodeBlocks: ["ad-note"], + }); + + expect(result.get("Id")).toEqual(new Set(["343434", "121212"])); + }); + + it("should match allowlisted fenced code block types case-insensitively", () => { + const content = ` +\`\`\`Ad-Note title="Meta data" +Id:: 121212 +\`\`\` + `; + const result = InlineFieldParser.parseInlineFields(content, { + includeCodeBlocks: ["ad-note"], + }); + + expect(result.get("Id")).toEqual(new Set(["121212"])); }); it("should ignore fields in frontmatter", () => { @@ -108,8 +167,7 @@ regular:: this should be parsed }); it("should handle field names with numbers and hyphens", () => { - const content = - "field-1:: value1\nfield_2:: value2\nfield3:: value3"; + const content = "field-1:: value1\nfield_2:: value2\nfield3:: value3"; const result = InlineFieldParser.parseInlineFields(content); expect(result.get("field-1")).toEqual(new Set(["value1"])); @@ -128,10 +186,7 @@ regular:: this should be parsed it("should return empty set for non-existent field", () => { const content = "status:: active"; - const result = InlineFieldParser.getFieldValues( - content, - "nonexistent", - ); + const result = InlineFieldParser.getFieldValues(content, "nonexistent"); expect(result).toEqual(new Set()); }); @@ -166,5 +221,22 @@ regular:: this should be parsed const result = InlineFieldParser.getFieldValues(content, "status"); expect(result).toEqual(new Set(["complete"])); }); + + it("should only include allowlisted fenced code block values", () => { + const content = ` +Id:: 343434 +\`\`\`ad-note +Id:: 121212 +\`\`\` +\`\`\`js +Id:: 999999 +\`\`\` + `; + const result = InlineFieldParser.getFieldValues(content, "Id", { + includeCodeBlocks: ["ad-note"], + }); + + expect(result).toEqual(new Set(["343434", "121212"])); + }); }); -}); \ No newline at end of file +}); diff --git a/src/utils/InlineFieldParser.ts b/src/utils/InlineFieldParser.ts index 22d706c5..771d4610 100644 --- a/src/utils/InlineFieldParser.ts +++ b/src/utils/InlineFieldParser.ts @@ -4,16 +4,26 @@ export class InlineFieldParser { private static readonly INLINE_FIELD_REGEX = /(?:^|[\n\r])[ \t]*(?![-*+][ \t]+\[[ xX]\])([^:\n\r]+?)::[ \t]*(.*)$/gmu; + private static readonly FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?\r?\n---\r?\n/; + private static readonly FENCED_CODE_BLOCK_REGEX = + /(`{3,})([^\r\n`]*)\r?\n([\s\S]*?)(?:\r?\n[ \t]*|[ \t]*)\1/g; + private static readonly INLINE_CODE_SPAN_REGEX = /`[^`]*`/g; + /** * Extracts inline fields from the content of a file * @param content The file content to parse * @returns Map of field names to their values */ - static parseInlineFields(content: string): Map> { + static parseInlineFields( + content: string, + options?: { + includeCodeBlocks?: string[]; + }, + ): Map> { const fields = new Map>(); - // Remove code blocks and frontmatter to avoid false positives - const cleanedContent = this.removeCodeBlocksAndFrontmatter(content); + // Remove frontmatter and code spans, and include only explicitly allowlisted fences. + const cleanedContent = this.removeCodeBlocksAndFrontmatter(content, options); let match; while ( @@ -49,16 +59,44 @@ export class InlineFieldParser { return fields; } - private static removeCodeBlocksAndFrontmatter(content: string): string { - // Remove frontmatter (handle both Unix and Windows line endings) - const frontmatterRegex = /^---\r?\n[\s\S]*?\r?\n---\r?\n/; - content = content.replace(frontmatterRegex, ""); + private static removeCodeBlocksAndFrontmatter( + content: string, + options?: { + includeCodeBlocks?: string[]; + }, + ): string { + content = content.replace(this.FRONTMATTER_REGEX, ""); + content = this.filterFencedCodeBlocks(content, options?.includeCodeBlocks); + return content.replace(this.INLINE_CODE_SPAN_REGEX, ""); + } - // Remove code blocks (both ``` and `) - const codeBlockRegex = /```[\s\S]*?```|`[^`]*`/g; - content = content.replace(codeBlockRegex, ""); + private static filterFencedCodeBlocks( + content: string, + includeCodeBlocks?: string[], + ): string { + const allowlistedTypes = new Set( + (includeCodeBlocks ?? []) + .map((type) => type.trim().toLowerCase()) + .filter((type) => type.length > 0), + ); - return content; + return content.replace( + this.FENCED_CODE_BLOCK_REGEX, + (_fullMatch, _fence, infoString, body: string) => { + const normalizedType = String(infoString) + .trim() + .split(/\s+/)[0] + ?.toLowerCase(); + if ( + allowlistedTypes.size > 0 && + normalizedType && + allowlistedTypes.has(normalizedType) + ) { + return body; + } + return ""; + }, + ); } /** @@ -67,8 +105,14 @@ export class InlineFieldParser { * @param fieldName The field name to look for * @returns Set of values for the field, or empty set if not found */ - static getFieldValues(content: string, fieldName: string): Set { - const fields = this.parseInlineFields(content); + static getFieldValues( + content: string, + fieldName: string, + options?: { + includeCodeBlocks?: string[]; + }, + ): Set { + const fields = this.parseInlineFields(content, options); return fields.get(fieldName) || new Set(); } -} \ No newline at end of file +}