Skip to content

Commit c9de468

Browse files
fix: support frontmatter tags in getFieldValues filtering (#980)
* fix: support frontmatter tags in getFieldValues filtering Fixes #927 The getFieldValues API method was not correctly filtering files when tags were specified in the frontmatter. The FieldSuggestionFileFilter.matchesTags method only checked metadata.tags (inline tags like #Test) and ignored metadata.frontmatter.tags. Changes: - Extended matchesTags to check both frontmatter and inline tags - Added getAllTags helper method to collect tags from: - metadata.frontmatter.tags (array or string) - metadata.frontmatter.tag (singular field) - metadata.tags (inline tags) - Normalized tags by removing leading # and trimming whitespace - Maintained AND logic for multiple tag filtering - Added comprehensive tests covering: - Frontmatter tags as string - Frontmatter tags as array - Tags with/without leading # - Singular 'tag' field - Multiple tags with AND logic - Mixed frontmatter and inline tags Co-Authored-By: Christian <christian@bagerbach.com> * fix: add position property to TagCache mocks in tests The TypeScript compiler requires TagCache objects to have a position property. Added a makeTag helper function to create properly typed TagCache objects and updated all test mocks to use it for consistency. Co-Authored-By: Christian <christian@bagerbach.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Christian <christian@bagerbach.com>
1 parent 05aed30 commit c9de468

2 files changed

Lines changed: 280 additions & 12 deletions

File tree

src/utils/FieldSuggestionFileFilter.test.ts

Lines changed: 235 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, it, expect, beforeEach } from "vitest";
22
import { FieldSuggestionFileFilter } from "./FieldSuggestionFileFilter";
3-
import type { TFile, CachedMetadata } from "obsidian";
3+
import type { TFile, CachedMetadata, TagCache } from "obsidian";
4+
5+
const makeTag = (tag: string): TagCache => ({
6+
tag,
7+
position: {
8+
start: { line: 0, col: 0, offset: 0 },
9+
end: { line: 0, col: 0, offset: 0 },
10+
},
11+
});
412

513
describe("FieldSuggestionFileFilter", () => {
614
let mockFiles: TFile[];
@@ -21,25 +29,25 @@ describe("FieldSuggestionFileFilter", () => {
2129
[
2230
"daily/2024-01-01.md",
2331
{
24-
tags: [{ tag: "#daily" }, { tag: "#work" }],
32+
tags: [makeTag("#daily"), makeTag("#work")],
2533
} as CachedMetadata,
2634
],
2735
[
2836
"daily/2024-01-02.md",
2937
{
30-
tags: [{ tag: "#daily" }, { tag: "#personal" }],
38+
tags: [makeTag("#daily"), makeTag("#personal")],
3139
} as CachedMetadata,
3240
],
3341
[
3442
"projects/project1.md",
3543
{
36-
tags: [{ tag: "#project" }, { tag: "#work" }],
44+
tags: [makeTag("#project"), makeTag("#work")],
3745
} as CachedMetadata,
3846
],
3947
[
4048
"projects/work/task1.md",
4149
{
42-
tags: [{ tag: "#work" }, { tag: "#task" }],
50+
tags: [makeTag("#work"), makeTag("#task")],
4351
} as CachedMetadata,
4452
],
4553
["notes/random.md", {} as CachedMetadata],
@@ -146,5 +154,226 @@ describe("FieldSuggestionFileFilter", () => {
146154
);
147155
expect(result).toHaveLength(0);
148156
});
157+
158+
it("should filter files by frontmatter tags (string)", () => {
159+
const filesWithFrontmatter = [
160+
{ path: "note1.md" } as TFile,
161+
{ path: "note2.md" } as TFile,
162+
];
163+
164+
const metadataWithFrontmatter = (file: TFile) => {
165+
if (file.path === "note1.md") {
166+
return {
167+
frontmatter: { tags: "Test" },
168+
} as CachedMetadata;
169+
}
170+
if (file.path === "note2.md") {
171+
return {
172+
frontmatter: { tags: "Other" },
173+
} as CachedMetadata;
174+
}
175+
return null;
176+
};
177+
178+
const result = FieldSuggestionFileFilter.filterFiles(
179+
filesWithFrontmatter,
180+
{ tags: ["Test"] },
181+
metadataWithFrontmatter,
182+
);
183+
expect(result).toHaveLength(1);
184+
expect(result[0].path).toBe("note1.md");
185+
});
186+
187+
it("should filter files by frontmatter tags (array)", () => {
188+
const filesWithFrontmatter = [
189+
{ path: "note1.md" } as TFile,
190+
{ path: "note2.md" } as TFile,
191+
];
192+
193+
const metadataWithFrontmatter = (file: TFile) => {
194+
if (file.path === "note1.md") {
195+
return {
196+
frontmatter: { tags: ["Test", "Work"] },
197+
} as CachedMetadata;
198+
}
199+
if (file.path === "note2.md") {
200+
return {
201+
frontmatter: { tags: ["Other"] },
202+
} as CachedMetadata;
203+
}
204+
return null;
205+
};
206+
207+
const result = FieldSuggestionFileFilter.filterFiles(
208+
filesWithFrontmatter,
209+
{ tags: ["Test"] },
210+
metadataWithFrontmatter,
211+
);
212+
expect(result).toHaveLength(1);
213+
expect(result[0].path).toBe("note1.md");
214+
});
215+
216+
it("should filter files by frontmatter tags with leading # in frontmatter", () => {
217+
const filesWithFrontmatter = [
218+
{ path: "note1.md" } as TFile,
219+
];
220+
221+
const metadataWithFrontmatter = (file: TFile) => {
222+
if (file.path === "note1.md") {
223+
return {
224+
frontmatter: { tags: ["#Test"] },
225+
} as CachedMetadata;
226+
}
227+
return null;
228+
};
229+
230+
const result = FieldSuggestionFileFilter.filterFiles(
231+
filesWithFrontmatter,
232+
{ tags: ["Test"] },
233+
metadataWithFrontmatter,
234+
);
235+
expect(result).toHaveLength(1);
236+
expect(result[0].path).toBe("note1.md");
237+
});
238+
239+
it("should filter files by frontmatter tags with leading # in filter", () => {
240+
const filesWithFrontmatter = [
241+
{ path: "note1.md" } as TFile,
242+
];
243+
244+
const metadataWithFrontmatter = (file: TFile) => {
245+
if (file.path === "note1.md") {
246+
return {
247+
frontmatter: { tags: ["Test"] },
248+
} as CachedMetadata;
249+
}
250+
return null;
251+
};
252+
253+
const result = FieldSuggestionFileFilter.filterFiles(
254+
filesWithFrontmatter,
255+
{ tags: ["#Test"] },
256+
metadataWithFrontmatter,
257+
);
258+
expect(result).toHaveLength(1);
259+
expect(result[0].path).toBe("note1.md");
260+
});
261+
262+
it("should filter files by frontmatter tag (singular field)", () => {
263+
const filesWithFrontmatter = [
264+
{ path: "note1.md" } as TFile,
265+
{ path: "note2.md" } as TFile,
266+
];
267+
268+
const metadataWithFrontmatter = (file: TFile) => {
269+
if (file.path === "note1.md") {
270+
return {
271+
frontmatter: { tag: "Test" },
272+
} as CachedMetadata;
273+
}
274+
if (file.path === "note2.md") {
275+
return {
276+
frontmatter: { tag: "Other" },
277+
} as CachedMetadata;
278+
}
279+
return null;
280+
};
281+
282+
const result = FieldSuggestionFileFilter.filterFiles(
283+
filesWithFrontmatter,
284+
{ tags: ["Test"] },
285+
metadataWithFrontmatter,
286+
);
287+
expect(result).toHaveLength(1);
288+
expect(result[0].path).toBe("note1.md");
289+
});
290+
291+
it("should filter files by multiple frontmatter tags (AND logic)", () => {
292+
const filesWithFrontmatter = [
293+
{ path: "note1.md" } as TFile,
294+
{ path: "note2.md" } as TFile,
295+
{ path: "note3.md" } as TFile,
296+
];
297+
298+
const metadataWithFrontmatter = (file: TFile) => {
299+
if (file.path === "note1.md") {
300+
return {
301+
frontmatter: { tags: ["Test", "Work"] },
302+
} as CachedMetadata;
303+
}
304+
if (file.path === "note2.md") {
305+
return {
306+
frontmatter: { tags: ["Test"] },
307+
} as CachedMetadata;
308+
}
309+
if (file.path === "note3.md") {
310+
return {
311+
frontmatter: { tags: ["Work"] },
312+
} as CachedMetadata;
313+
}
314+
return null;
315+
};
316+
317+
const result = FieldSuggestionFileFilter.filterFiles(
318+
filesWithFrontmatter,
319+
{ tags: ["Test", "Work"] },
320+
metadataWithFrontmatter,
321+
);
322+
expect(result).toHaveLength(1);
323+
expect(result[0].path).toBe("note1.md");
324+
});
325+
326+
it("should filter files by mixed frontmatter and inline tags", () => {
327+
const filesWithMixedTags = [
328+
{ path: "note1.md" } as TFile,
329+
{ path: "note2.md" } as TFile,
330+
];
331+
332+
const metadataWithMixedTags = (file: TFile) => {
333+
if (file.path === "note1.md") {
334+
return {
335+
frontmatter: { tags: ["Test"] },
336+
tags: [makeTag("#work")],
337+
} as CachedMetadata;
338+
}
339+
if (file.path === "note2.md") {
340+
return {
341+
frontmatter: { tags: ["Test"] },
342+
} as CachedMetadata;
343+
}
344+
return null;
345+
};
346+
347+
const result = FieldSuggestionFileFilter.filterFiles(
348+
filesWithMixedTags,
349+
{ tags: ["Test", "work"] },
350+
metadataWithMixedTags,
351+
);
352+
expect(result).toHaveLength(1);
353+
expect(result[0].path).toBe("note1.md");
354+
});
355+
356+
it("should handle inline field with value outside frontmatter", () => {
357+
const filesWithInlineField = [
358+
{ path: "note1.md" } as TFile,
359+
];
360+
361+
const metadataWithInlineField = (file: TFile) => {
362+
if (file.path === "note1.md") {
363+
return {
364+
frontmatter: { tags: ["Test"] },
365+
} as CachedMetadata;
366+
}
367+
return null;
368+
};
369+
370+
const result = FieldSuggestionFileFilter.filterFiles(
371+
filesWithInlineField,
372+
{ tags: ["Test"] },
373+
metadataWithInlineField,
374+
);
375+
expect(result).toHaveLength(1);
376+
expect(result[0].path).toBe("note1.md");
377+
});
149378
});
150-
});
379+
});

src/utils/FieldSuggestionFileFilter.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,22 +54,61 @@ export class FieldSuggestionFileFilter {
5454
metadataCache: (file: TFile) => CachedMetadata | null,
5555
): boolean {
5656
const metadata = metadataCache(file);
57-
if (!metadata || !metadata.tags) {
57+
if (!metadata) {
5858
return false;
5959
}
6060

61-
const fileTags = metadata.tags.map((tag) =>
62-
tag.tag.startsWith("#") ? tag.tag.substring(1) : tag.tag,
61+
// Get all tags from the file (both frontmatter and inline)
62+
const fileTags = this.getAllTags(metadata);
63+
64+
// Normalize required tags (remove leading # and trim)
65+
const normalizedRequiredTags = requiredTags.map((tag) =>
66+
tag.startsWith("#") ? tag.substring(1).trim() : tag.trim(),
6367
);
6468

65-
// Check if file has all required tags
66-
return requiredTags.every((requiredTag) =>
69+
// Check if file has all required tags (AND logic)
70+
return normalizedRequiredTags.every((requiredTag) =>
6771
fileTags.includes(requiredTag),
6872
);
6973
}
7074

75+
private static getAllTags(metadata: CachedMetadata): string[] {
76+
const tags: string[] = [];
77+
78+
if (metadata.frontmatter?.tags) {
79+
const frontmatterTags = Array.isArray(metadata.frontmatter.tags)
80+
? metadata.frontmatter.tags
81+
: [metadata.frontmatter.tags];
82+
83+
tags.push(...frontmatterTags.map(tag => {
84+
const tagStr = typeof tag === 'string' ? tag : String(tag);
85+
return tagStr.startsWith("#") ? tagStr.substring(1).trim() : tagStr.trim();
86+
}));
87+
}
88+
89+
if (metadata.frontmatter?.tag) {
90+
const frontmatterTag = Array.isArray(metadata.frontmatter.tag)
91+
? metadata.frontmatter.tag
92+
: [metadata.frontmatter.tag];
93+
94+
tags.push(...frontmatterTag.map(tag => {
95+
const tagStr = typeof tag === 'string' ? tag : String(tag);
96+
return tagStr.startsWith("#") ? tagStr.substring(1).trim() : tagStr.trim();
97+
}));
98+
}
99+
100+
// Get inline tags
101+
if (metadata.tags) {
102+
tags.push(...metadata.tags.map(tag =>
103+
tag.tag.startsWith("#") ? tag.tag.substring(1).trim() : tag.tag.trim()
104+
));
105+
}
106+
107+
return tags;
108+
}
109+
71110
private static normalizePath(path: string): string {
72111
// Remove leading/trailing slashes and normalize
73112
return path.replace(/^\/+|\/+$/g, "");
74113
}
75-
}
114+
}

0 commit comments

Comments
 (0)