From 566587641d839600f40c3fd4917479cb2ba7c430 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 13 Dec 2025 14:33:51 +0100 Subject: [PATCH] fix: suggest vault tags for FIELD:tags Use Obsidian's tag index for tags/tag field suggestions when no file filters are applied, with a filtered-files fallback. Adds a regression test for issue #671. --- .../FieldValueCollector.issue671.test.ts | 32 +++++ src/utils/FieldValueCollector.ts | 113 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/utils/FieldValueCollector.issue671.test.ts diff --git a/src/utils/FieldValueCollector.issue671.test.ts b/src/utils/FieldValueCollector.issue671.test.ts new file mode 100644 index 00000000..f3e07ac7 --- /dev/null +++ b/src/utils/FieldValueCollector.issue671.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "obsidian"; +import { FieldSuggestionCache } from "./FieldSuggestionCache"; +import { collectFieldValuesProcessed } from "./FieldValueCollector"; + +vi.mock("obsidian-dataview", () => ({ + getAPI: () => null, +})); + +describe("Issue #671 - {{FIELD:tags}} suggestions", () => { + beforeEach(() => { + FieldSuggestionCache.getInstance().clear(); + }); + + it("includes tags from the vault tag index", async () => { + const app = new App(); + + // @ts-expect-error - getTags exists in Obsidian but is not typed + app.metadataCache.getTags = () => ({ + "#ai/technology": 1, + "#cook/hoven": 1, + }); + + app.vault.getMarkdownFiles = () => []; + + const values = await collectFieldValuesProcessed(app, "tags", {}); + + expect(values).toEqual( + expect.arrayContaining(["ai/technology", "cook/hoven"]), + ); + }); +}); diff --git a/src/utils/FieldValueCollector.ts b/src/utils/FieldValueCollector.ts index 515534b0..525c8997 100644 --- a/src/utils/FieldValueCollector.ts +++ b/src/utils/FieldValueCollector.ts @@ -66,6 +66,12 @@ export async function collectFieldValuesRaw( fieldName: string, filters: FieldFilter, ): Promise> { + const normalizedFieldName = fieldName.trim().toLowerCase(); + if (normalizedFieldName === "tags" || normalizedFieldName === "tag") { + const tagValues = await collectTagValues(app, filters); + if (tagValues.size > 0) return tagValues; + } + // Try Dataview when allowed; fall back to manual collection try { if (!filters.inline && DataviewIntegration.isAvailable(app)) { @@ -86,6 +92,113 @@ export async function collectFieldValuesRaw( return await collectFieldValuesManually(app, fieldName, filters); } +async function collectTagValues(app: App, filters: FieldFilter): Promise> { + const hasFileFilters = + Boolean(filters.folder) || + Boolean(filters.tags?.length) || + Boolean(filters.excludeFolders?.length) || + Boolean(filters.excludeTags?.length) || + Boolean(filters.excludeFiles?.length); + + if (!hasFileFilters) { + const fromIndex = collectAllVaultTags(app); + if (fromIndex.size > 0) return fromIndex; + } + + return await collectTagValuesFromFiles(app, filters); +} + +function collectAllVaultTags(app: App): Set { + const values = new Set(); + + try { + // @ts-expect-error - getTags exists in Obsidian but is not typed + const tagObj = app.metadataCache.getTags?.() as + | Record + | undefined; + + if (!tagObj) return values; + + for (const rawTag of Object.keys(tagObj)) { + const cleaned = rawTag.startsWith("#") ? rawTag.substring(1) : rawTag; + const tag = cleaned.trim(); + if (tag) values.add(tag); + } + } catch { + // ignore and fall back to file-based collection + } + + return values; +} + +async function collectTagValuesFromFiles( + app: App, + filters: FieldFilter, +): Promise> { + const rawValues = new Set(); + + let files = app.vault.getMarkdownFiles(); + files = EnhancedFieldSuggestionFileFilter.filterFiles( + files, + filters, + (file: TFile) => app.metadataCache.getFileCache(file), + ); + + const batchSize = 50; + for (let i = 0; i < files.length; i += batchSize) { + const batch = files.slice(i, i + batchSize); + const promises = batch.map(async (file) => { + const values = new Set(); + try { + const metadataCache = app.metadataCache.getFileCache(file); + + // Frontmatter tags + const frontmatterTags: unknown = metadataCache?.frontmatter?.tags; + if (frontmatterTags !== undefined && frontmatterTags !== null) { + const tags = Array.isArray(frontmatterTags) + ? frontmatterTags + : [frontmatterTags]; + + for (const tag of tags) { + const s = String(tag).trim(); + if (s) values.add(s); + } + } + + // Frontmatter tag (singular) + const frontmatterTag: unknown = metadataCache?.frontmatter?.tag; + if (frontmatterTag !== undefined && frontmatterTag !== null) { + const tags = Array.isArray(frontmatterTag) + ? frontmatterTag + : [frontmatterTag]; + + for (const tag of tags) { + const s = String(tag).trim(); + if (s) values.add(s); + } + } + + // Inline tags + if (metadataCache?.tags) { + for (const t of metadataCache.tags) { + const raw = String(t.tag ?? "").trim(); + const tag = raw.startsWith("#") ? raw.substring(1) : raw; + if (tag) values.add(tag); + } + } + } catch {} + return values; + }); + + const batchResults = await Promise.all(promises); + for (const set of batchResults) { + for (const v of set) rawValues.add(v); + } + } + + return rawValues; +} + async function collectFieldValuesManually( app: App, fieldName: string,