Skip to content

Commit 8a19e12

Browse files
authored
fix: suggest vault tags for FIELD:tags
Fixes #671. - Use Obsidian's vault-wide tag index for `tags`/`tag` field suggestions when no file filters are applied. - Fall back to scanning the filtered file set when FIELD filters are present (folder/tag/exclusions) to preserve semantics. Includes a regression test.
1 parent 3c13731 commit 8a19e12

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { App } from "obsidian";
3+
import { FieldSuggestionCache } from "./FieldSuggestionCache";
4+
import { collectFieldValuesProcessed } from "./FieldValueCollector";
5+
6+
vi.mock("obsidian-dataview", () => ({
7+
getAPI: () => null,
8+
}));
9+
10+
describe("Issue #671 - {{FIELD:tags}} suggestions", () => {
11+
beforeEach(() => {
12+
FieldSuggestionCache.getInstance().clear();
13+
});
14+
15+
it("includes tags from the vault tag index", async () => {
16+
const app = new App();
17+
18+
// @ts-expect-error - getTags exists in Obsidian but is not typed
19+
app.metadataCache.getTags = () => ({
20+
"#ai/technology": 1,
21+
"#cook/hoven": 1,
22+
});
23+
24+
app.vault.getMarkdownFiles = () => [];
25+
26+
const values = await collectFieldValuesProcessed(app, "tags", {});
27+
28+
expect(values).toEqual(
29+
expect.arrayContaining(["ai/technology", "cook/hoven"]),
30+
);
31+
});
32+
});

src/utils/FieldValueCollector.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ export async function collectFieldValuesRaw(
6666
fieldName: string,
6767
filters: FieldFilter,
6868
): Promise<Set<string>> {
69+
const normalizedFieldName = fieldName.trim().toLowerCase();
70+
if (normalizedFieldName === "tags" || normalizedFieldName === "tag") {
71+
const tagValues = await collectTagValues(app, filters);
72+
if (tagValues.size > 0) return tagValues;
73+
}
74+
6975
// Try Dataview when allowed; fall back to manual collection
7076
try {
7177
if (!filters.inline && DataviewIntegration.isAvailable(app)) {
@@ -86,6 +92,113 @@ export async function collectFieldValuesRaw(
8692
return await collectFieldValuesManually(app, fieldName, filters);
8793
}
8894

95+
async function collectTagValues(app: App, filters: FieldFilter): Promise<Set<string>> {
96+
const hasFileFilters =
97+
Boolean(filters.folder) ||
98+
Boolean(filters.tags?.length) ||
99+
Boolean(filters.excludeFolders?.length) ||
100+
Boolean(filters.excludeTags?.length) ||
101+
Boolean(filters.excludeFiles?.length);
102+
103+
if (!hasFileFilters) {
104+
const fromIndex = collectAllVaultTags(app);
105+
if (fromIndex.size > 0) return fromIndex;
106+
}
107+
108+
return await collectTagValuesFromFiles(app, filters);
109+
}
110+
111+
function collectAllVaultTags(app: App): Set<string> {
112+
const values = new Set<string>();
113+
114+
try {
115+
// @ts-expect-error - getTags exists in Obsidian but is not typed
116+
const tagObj = app.metadataCache.getTags?.() as
117+
| Record<string, number>
118+
| undefined;
119+
120+
if (!tagObj) return values;
121+
122+
for (const rawTag of Object.keys(tagObj)) {
123+
const cleaned = rawTag.startsWith("#") ? rawTag.substring(1) : rawTag;
124+
const tag = cleaned.trim();
125+
if (tag) values.add(tag);
126+
}
127+
} catch {
128+
// ignore and fall back to file-based collection
129+
}
130+
131+
return values;
132+
}
133+
134+
async function collectTagValuesFromFiles(
135+
app: App,
136+
filters: FieldFilter,
137+
): Promise<Set<string>> {
138+
const rawValues = new Set<string>();
139+
140+
let files = app.vault.getMarkdownFiles();
141+
files = EnhancedFieldSuggestionFileFilter.filterFiles(
142+
files,
143+
filters,
144+
(file: TFile) => app.metadataCache.getFileCache(file),
145+
);
146+
147+
const batchSize = 50;
148+
for (let i = 0; i < files.length; i += batchSize) {
149+
const batch = files.slice(i, i + batchSize);
150+
const promises = batch.map(async (file) => {
151+
const values = new Set<string>();
152+
try {
153+
const metadataCache = app.metadataCache.getFileCache(file);
154+
155+
// Frontmatter tags
156+
const frontmatterTags: unknown = metadataCache?.frontmatter?.tags;
157+
if (frontmatterTags !== undefined && frontmatterTags !== null) {
158+
const tags = Array.isArray(frontmatterTags)
159+
? frontmatterTags
160+
: [frontmatterTags];
161+
162+
for (const tag of tags) {
163+
const s = String(tag).trim();
164+
if (s) values.add(s);
165+
}
166+
}
167+
168+
// Frontmatter tag (singular)
169+
const frontmatterTag: unknown = metadataCache?.frontmatter?.tag;
170+
if (frontmatterTag !== undefined && frontmatterTag !== null) {
171+
const tags = Array.isArray(frontmatterTag)
172+
? frontmatterTag
173+
: [frontmatterTag];
174+
175+
for (const tag of tags) {
176+
const s = String(tag).trim();
177+
if (s) values.add(s);
178+
}
179+
}
180+
181+
// Inline tags
182+
if (metadataCache?.tags) {
183+
for (const t of metadataCache.tags) {
184+
const raw = String(t.tag ?? "").trim();
185+
const tag = raw.startsWith("#") ? raw.substring(1) : raw;
186+
if (tag) values.add(tag);
187+
}
188+
}
189+
} catch {}
190+
return values;
191+
});
192+
193+
const batchResults = await Promise.all(promises);
194+
for (const set of batchResults) {
195+
for (const v of set) rawValues.add(v);
196+
}
197+
}
198+
199+
return rawValues;
200+
}
201+
89202
async function collectFieldValuesManually(
90203
app: App,
91204
fieldName: string,

0 commit comments

Comments
 (0)