Skip to content

Commit b9c09be

Browse files
authored
fix: prevent project filters from affecting custom field suggestions (#908)
- Add generic FileFilterConfig interface for flexible filtering - Update FileSuggestHelper.suggest() to accept optional filterConfig parameter - No filterConfig = no filtering (returns all files) - TaskCreationModal explicitly passes project filter config for project field - TaskModal passes no filterConfig for custom fields (shows all files) - Update ProjectSelectModal to use FilterUtils.matchesTagConditions for consistent tag matching - Add unit tests for FileSuggestHelper filtering behavior Root cause: FileSuggestHelper always applied project filters to ALL callers, breaking custom field wikilink suggestions which should show all vault files. ProjectSelectModal used simple tag matching instead of hierarchical matching. Solution: Made filtering opt-in and generic. Helper is now filter-agnostic. Callers decide which filters to apply. Both modals now use consistent tag matching. Future-proof for custom field filters.
1 parent 0ba740f commit b9c09be

File tree

5 files changed

+312
-13
lines changed

5 files changed

+312
-13
lines changed

src/modals/ProjectSelectModal.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type TaskNotesPlugin from "../main";
1010
import { ProjectMetadataResolver } from "../utils/projectMetadataResolver";
1111
import { parseDisplayFieldsRow } from "../utils/projectAutosuggestDisplayFieldsParser";
1212
import { getProjectPropertyFilter, matchesProjectProperty } from "../utils/projectFilterUtils";
13+
import { FilterUtils } from "../utils/FilterUtils";
1314

1415
/**
1516
* Modal for selecting project notes using fuzzy search
@@ -56,7 +57,7 @@ export class ProjectSelectModal extends FuzzySuggestModal<TAbstractFile> {
5657

5758
const cache = this.app.metadataCache.getFileCache(file);
5859

59-
// Apply tag filtering - use native Obsidian API
60+
// Apply tag filtering - use FilterUtils for consistent hierarchical tag matching
6061
if (requiredTags.length > 0) {
6162
// Get tags from both native tag detection and frontmatter
6263
const nativeTags = cache?.tags?.map((t) => t.tag.replace("#", "")) || [];
@@ -68,9 +69,8 @@ export class ProjectSelectModal extends FuzzySuggestModal<TAbstractFile> {
6869
: [frontmatterTags].filter(Boolean)),
6970
];
7071

71-
// Check if file has ANY of the required tags
72-
const hasRequiredTag = requiredTags.some((reqTag) => allTags.includes(reqTag));
73-
if (!hasRequiredTag) {
72+
// Use FilterUtils.matchesTagConditions for hierarchical matching and exclusion support
73+
if (!FilterUtils.matchesTagConditions(allTags, requiredTags)) {
7474
return false; // Skip this file
7575
}
7676
}

src/modals/TaskCreationModal.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,13 @@ class NLPSuggest extends AbstractInputSuggest<
186186
.map((s) => s.trim())
187187
.filter(Boolean);
188188

189-
// Get suggestions using FileSuggestHelper (with multi-word support)
190-
const list = await FileSuggestHelper.suggest(this.plugin, queryAfterTrigger);
189+
// Get suggestions using FileSuggestHelper with explicit project filter configuration
190+
const list = await FileSuggestHelper.suggest(
191+
this.plugin,
192+
queryAfterTrigger,
193+
20,
194+
this.plugin.settings.projectAutosuggest
195+
);
191196

192197
// Filter out excluded folders
193198
const filteredList = list.filter((item) => {

src/modals/TaskModal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,6 +1768,7 @@ class UserFieldSuggest extends AbstractInputSuggest<UserFieldSuggestion> {
17681768
if (wikiMatch) {
17691769
const partial = wikiMatch[1] || "";
17701770
const { FileSuggestHelper } = await import("../suggest/FileSuggestHelper");
1771+
// Custom fields show ALL files (no filterConfig = no filtering)
17711772
const list = await FileSuggestHelper.suggest(this.plugin, partial);
17721773
return list.map((item) => ({
17731774
value: item.insertText,

src/suggest/FileSuggestHelper.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,24 @@ export interface FileSuggestionItem {
1111
score: number;
1212
}
1313

14+
/**
15+
* Generic file filter configuration.
16+
* Can be used for project filters, custom field filters, or any other filtering needs.
17+
* If undefined, no filtering is applied (all files are considered).
18+
*/
19+
export interface FileFilterConfig {
20+
requiredTags?: string[];
21+
includeFolders?: string[];
22+
propertyKey?: string;
23+
propertyValue?: string;
24+
}
25+
1426
export const FileSuggestHelper = {
1527
async suggest(
1628
plugin: TaskNotesPlugin,
1729
query: string,
18-
limit = 20
30+
limit = 20,
31+
filterConfig?: FileFilterConfig
1932
): Promise<FileSuggestionItem[]> {
2033
const run = async () => {
2134
const files = plugin?.app?.vault?.getMarkdownFiles
@@ -40,15 +53,18 @@ export const FileSuggestHelper = {
4053
}
4154
const qLower = (query || "").toLowerCase();
4255

43-
// Get filtering settings
44-
const requiredTags = plugin.settings?.projectAutosuggest?.requiredTags ?? [];
45-
const includeFolders = plugin.settings?.projectAutosuggest?.includeFolders ?? [];
46-
const propertyFilter = getProjectPropertyFilter(plugin.settings?.projectAutosuggest);
56+
// Get filtering settings - only apply if filterConfig is provided
57+
const requiredTags = filterConfig?.requiredTags ?? [];
58+
const includeFolders = filterConfig?.includeFolders ?? [];
59+
const propertyFilter =
60+
filterConfig?.propertyKey && filterConfig?.propertyValue
61+
? { key: filterConfig.propertyKey, value: filterConfig.propertyValue, enabled: true }
62+
: { key: "", value: "", enabled: false };
4763

4864
for (const file of files) {
4965
const cache = plugin.app.metadataCache.getFileCache(file);
5066

51-
// Apply tag filtering - use native Obsidian API
67+
// Apply tag filtering if configured
5268
if (requiredTags.length > 0) {
5369
// Get tags from both native tag detection and frontmatter
5470
const nativeTags = cache?.tags?.map((t) => t.tag.replace("#", "")) || [];
@@ -67,7 +83,7 @@ export const FileSuggestHelper = {
6783
}
6884
}
6985

70-
// Apply folder filtering
86+
// Apply folder filtering if configured
7187
if (includeFolders.length > 0) {
7288
const isInIncludedFolder = includeFolders.some(
7389
(folder) =>
@@ -78,6 +94,7 @@ export const FileSuggestHelper = {
7894
}
7995
}
8096

97+
// Apply property filtering if configured
8198
if (propertyFilter.enabled) {
8299
const frontmatter = cache?.frontmatter;
83100
if (!matchesProjectProperty(frontmatter, propertyFilter)) {
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { FileSuggestHelper, FileFilterConfig } from '../../../src/suggest/FileSuggestHelper';
2+
import { TFile } from 'obsidian';
3+
import type TaskNotesPlugin from '../../../src/main';
4+
5+
// Mock parseFrontMatterAliases
6+
jest.mock('obsidian', () => ({
7+
...jest.requireActual('obsidian'),
8+
parseFrontMatterAliases: jest.fn((frontmatter: any) => {
9+
if (!frontmatter || !frontmatter.aliases) return [];
10+
if (Array.isArray(frontmatter.aliases)) return frontmatter.aliases;
11+
return [frontmatter.aliases];
12+
}),
13+
}));
14+
15+
describe('FileSuggestHelper', () => {
16+
let mockPlugin: any;
17+
let mockFiles: TFile[];
18+
let projectFilterConfig: FileFilterConfig;
19+
20+
beforeEach(() => {
21+
// Create mock files
22+
mockFiles = [
23+
{
24+
basename: 'Project A',
25+
path: 'projects/Project A.md',
26+
extension: 'md',
27+
parent: { path: 'projects' }
28+
} as TFile,
29+
{
30+
basename: 'Project B',
31+
path: 'projects/Project B.md',
32+
extension: 'md',
33+
parent: { path: 'projects' }
34+
} as TFile,
35+
{
36+
basename: 'Note 1',
37+
path: 'notes/Note 1.md',
38+
extension: 'md',
39+
parent: { path: 'notes' }
40+
} as TFile,
41+
{
42+
basename: 'Note 2',
43+
path: 'notes/Note 2.md',
44+
extension: 'md',
45+
parent: { path: 'notes' }
46+
} as TFile,
47+
];
48+
49+
// Create project filter configuration
50+
projectFilterConfig = {
51+
requiredTags: ['project'],
52+
includeFolders: [],
53+
propertyKey: '',
54+
propertyValue: ''
55+
};
56+
57+
// Create mock plugin with settings
58+
mockPlugin = {
59+
app: {
60+
vault: {
61+
getMarkdownFiles: jest.fn(() => mockFiles),
62+
},
63+
metadataCache: {
64+
getFileCache: jest.fn((file: TFile) => {
65+
// Project files have #project tag
66+
if (file.path.startsWith('projects/')) {
67+
return {
68+
frontmatter: {
69+
tags: ['project'],
70+
type: 'project'
71+
},
72+
tags: [{ tag: '#project', position: { start: { line: 0, col: 0, offset: 0 }, end: { line: 0, col: 8, offset: 8 } } }]
73+
};
74+
}
75+
// Note files don't have #project tag
76+
return {
77+
frontmatter: {},
78+
tags: []
79+
};
80+
}),
81+
},
82+
},
83+
settings: {
84+
suggestionDebounceMs: 0
85+
},
86+
fieldMapper: {
87+
mapFromFrontmatter: jest.fn((fm: any) => ({
88+
title: fm.title || ''
89+
}))
90+
}
91+
} as unknown as TaskNotesPlugin;
92+
});
93+
94+
describe('Filter Configuration', () => {
95+
it('should return ALL files when no filterConfig is provided', async () => {
96+
const results = await FileSuggestHelper.suggest(mockPlugin, '');
97+
98+
// Should return ALL files (4 total) - no filtering
99+
expect(results.length).toBe(4);
100+
const basenames = results.map(r => r.insertText);
101+
expect(basenames).toContain('Project A');
102+
expect(basenames).toContain('Project B');
103+
expect(basenames).toContain('Note 1');
104+
expect(basenames).toContain('Note 2');
105+
});
106+
107+
it('should apply filters when filterConfig is provided', async () => {
108+
const results = await FileSuggestHelper.suggest(
109+
mockPlugin,
110+
'Project',
111+
20,
112+
projectFilterConfig
113+
);
114+
115+
// Should only return files with #project tag
116+
expect(results.length).toBe(2);
117+
expect(results.every(r => r.insertText.startsWith('Project'))).toBe(true);
118+
});
119+
120+
it('should return ALL files when filterConfig is undefined', async () => {
121+
const results = await FileSuggestHelper.suggest(
122+
mockPlugin,
123+
'',
124+
20,
125+
undefined
126+
);
127+
128+
// Should return ALL files (4 total)
129+
expect(results.length).toBe(4);
130+
const basenames = results.map(r => r.insertText);
131+
expect(basenames).toContain('Project A');
132+
expect(basenames).toContain('Project B');
133+
expect(basenames).toContain('Note 1');
134+
expect(basenames).toContain('Note 2');
135+
});
136+
});
137+
138+
describe('Tag Filtering', () => {
139+
it('should filter by required tags when configured', async () => {
140+
const filterConfig: FileFilterConfig = {
141+
requiredTags: ['project']
142+
};
143+
144+
const results = await FileSuggestHelper.suggest(
145+
mockPlugin,
146+
'',
147+
20,
148+
filterConfig
149+
);
150+
151+
// Only files with #project tag
152+
expect(results.length).toBe(2);
153+
expect(results.every(r => r.insertText.startsWith('Project'))).toBe(true);
154+
});
155+
156+
it('should NOT filter by tags when no filterConfig provided', async () => {
157+
const results = await FileSuggestHelper.suggest(
158+
mockPlugin,
159+
'',
160+
20
161+
);
162+
163+
// All files should be returned
164+
expect(results.length).toBe(4);
165+
});
166+
});
167+
168+
describe('Folder Filtering', () => {
169+
it('should filter by included folders when configured', async () => {
170+
const filterConfig: FileFilterConfig = {
171+
includeFolders: ['projects']
172+
};
173+
174+
const results = await FileSuggestHelper.suggest(
175+
mockPlugin,
176+
'',
177+
20,
178+
filterConfig
179+
);
180+
181+
// Only files in projects/ folder
182+
expect(results.length).toBe(2);
183+
expect(results.every(r => r.insertText.startsWith('Project'))).toBe(true);
184+
});
185+
186+
it('should NOT filter by folders when no filterConfig provided', async () => {
187+
const results = await FileSuggestHelper.suggest(
188+
mockPlugin,
189+
'',
190+
20
191+
);
192+
193+
// All files should be returned
194+
expect(results.length).toBe(4);
195+
});
196+
});
197+
198+
describe('Property Filtering', () => {
199+
it('should filter by property when configured', async () => {
200+
const filterConfig: FileFilterConfig = {
201+
propertyKey: 'type',
202+
propertyValue: 'project'
203+
};
204+
205+
const results = await FileSuggestHelper.suggest(
206+
mockPlugin,
207+
'',
208+
20,
209+
filterConfig
210+
);
211+
212+
// Only files with type: project
213+
expect(results.length).toBe(2);
214+
expect(results.every(r => r.insertText.startsWith('Project'))).toBe(true);
215+
});
216+
217+
it('should NOT filter by property when no filterConfig provided', async () => {
218+
const results = await FileSuggestHelper.suggest(
219+
mockPlugin,
220+
'',
221+
20
222+
);
223+
224+
// All files should be returned
225+
expect(results.length).toBe(4);
226+
});
227+
});
228+
229+
describe('Multiple Filters Combined', () => {
230+
it('should apply all filters when configured', async () => {
231+
const filterConfig: FileFilterConfig = {
232+
requiredTags: ['project'],
233+
includeFolders: ['projects'],
234+
propertyKey: 'type',
235+
propertyValue: 'project'
236+
};
237+
238+
const results = await FileSuggestHelper.suggest(
239+
mockPlugin,
240+
'',
241+
20,
242+
filterConfig
243+
);
244+
245+
// Only files matching ALL criteria
246+
expect(results.length).toBe(2);
247+
expect(results.every(r => r.insertText.startsWith('Project'))).toBe(true);
248+
});
249+
250+
it('should ignore all filters when no filterConfig provided', async () => {
251+
const results = await FileSuggestHelper.suggest(
252+
mockPlugin,
253+
'',
254+
20
255+
);
256+
257+
// All files should be returned regardless of filters
258+
expect(results.length).toBe(4);
259+
});
260+
});
261+
262+
describe('Query Matching', () => {
263+
it('should match query regardless of filter settings', async () => {
264+
const resultsWithoutFilters = await FileSuggestHelper.suggest(
265+
mockPlugin,
266+
'Note 1',
267+
20
268+
);
269+
270+
// Should match "Note 1" specifically
271+
expect(resultsWithoutFilters.length).toBeGreaterThanOrEqual(1);
272+
expect(resultsWithoutFilters.some(r => r.insertText === 'Note 1')).toBe(true);
273+
});
274+
});
275+
});
276+

0 commit comments

Comments
 (0)