From 1232d028ce89a7d4b3b6c6cc04c7b208bbbe2516 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 16 Sep 2025 07:57:33 +1000 Subject: [PATCH] Add project property filter support --- docs/features/task-management.md | 3 +- docs/settings/appearance.md | 1 + src/modals/ProjectSelectModal.ts | 13 +++- src/settings/defaults.ts | 6 +- src/settings/tabs/appearanceTab.ts | 55 +++++++++++--- src/suggest/FileSuggestHelper.ts | 10 ++- src/types/settings.ts | 4 +- src/utils/projectFilterUtils.ts | 73 +++++++++++++++++++ ...ProjectSelectModal.property-filter.test.ts | 61 ++++++++++++++++ tests/unit/utils/projectFilterUtils.test.ts | 45 ++++++++++++ 10 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 src/utils/projectFilterUtils.ts create mode 100644 tests/unit/modals/ProjectSelectModal.property-filter.test.ts create mode 100644 tests/unit/utils/projectFilterUtils.test.ts diff --git a/docs/features/task-management.md b/docs/features/task-management.md index f64a59ed..6b2a1f65 100644 --- a/docs/features/task-management.md +++ b/docs/features/task-management.md @@ -34,6 +34,7 @@ Project suggestions search across: - File names (basename without extension) - Frontmatter titles (using your configured field mapping) - Frontmatter aliases +- Optional filtering by required tags, folders, and a specific frontmatter property/value defined in Settings → Appearance & UI → Project Autosuggest Selecting a project suggestion inserts it as `+[[filename]]`, creating a wikilink to the file while maintaining the `+` project marker that the natural language parser recognizes. @@ -563,4 +564,4 @@ TaskNotes reminder implementation follows the iCalendar VALARM specification: The reminder system is designed for efficiency: - Lazy loading of reminder data - Minimal impact on task loading performance -- Efficient storage in YAML frontmatter format \ No newline at end of file +- Efficient storage in YAML frontmatter format diff --git a/docs/settings/appearance.md b/docs/settings/appearance.md index 5b875a78..213b391a 100644 --- a/docs/settings/appearance.md +++ b/docs/settings/appearance.md @@ -65,6 +65,7 @@ These settings control the visual appearance of the plugin, including the calend - **Required tags**: Show only notes with any of these tags (comma-separated). Leave empty to show all notes. - **Include folders**: Show only notes in these folders (comma-separated paths). Leave empty to show all folders. +- **Required property key/value**: Filter notes by a frontmatter property. Provide the property name and, optionally, the value it must match (leave the value blank to require only that the property exists). - **Customize suggestion display**: Show advanced options to configure how project suggestions appear and what information they display. - **Enable fuzzy matching**: Allow typos and partial matches in project search. May be slower in large vaults. - **Row 1, 2, 3**: Configure up to 3 lines of information to show for each project suggestion. diff --git a/src/modals/ProjectSelectModal.ts b/src/modals/ProjectSelectModal.ts index be19d405..2379082a 100644 --- a/src/modals/ProjectSelectModal.ts +++ b/src/modals/ProjectSelectModal.ts @@ -2,6 +2,7 @@ import { App, FuzzySuggestModal, TAbstractFile, TFile, SearchResult, parseFrontM import type TaskNotesPlugin from '../main'; import { ProjectMetadataResolver } from '../utils/projectMetadataResolver'; import { parseDisplayFieldsRow } from '../utils/projectAutosuggestDisplayFieldsParser'; +import { getProjectPropertyFilter, matchesProjectProperty } from '../utils/projectFilterUtils'; /** * Modal for selecting project notes using fuzzy search @@ -31,9 +32,10 @@ export class ProjectSelectModal extends FuzzySuggestModal { // Get filtering settings const requiredTags = this.plugin.settings?.projectAutosuggest?.requiredTags ?? []; const includeFolders = this.plugin.settings?.projectAutosuggest?.includeFolders ?? []; + const propertyFilter = getProjectPropertyFilter(this.plugin.settings?.projectAutosuggest); // Apply filtering if any filters are configured - if (requiredTags.length === 0 && includeFolders.length === 0) { + if (requiredTags.length === 0 && includeFolders.length === 0 && !propertyFilter.enabled) { return allFiles; // No filtering needed } @@ -69,6 +71,13 @@ export class ProjectSelectModal extends FuzzySuggestModal { } } + if (propertyFilter.enabled) { + const frontmatter = cache?.frontmatter; + if (!matchesProjectProperty(frontmatter, propertyFilter)) { + return false; + } + } + return true; // File passed all filters }); } @@ -242,4 +251,4 @@ export class ProjectSelectModal extends FuzzySuggestModal { onChooseItem(file: TAbstractFile, evt: MouseEvent | KeyboardEvent) { this.onChoose(file); } -} \ No newline at end of file +} diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 5e29085e..88016bca 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -169,7 +169,9 @@ export const DEFAULT_PROJECT_AUTOSUGGEST: ProjectAutosuggestSettings = { ], showAdvanced: false, requiredTags: [], - includeFolders: [] + includeFolders: [], + propertyKey: '', + propertyValue: '' }; export const DEFAULT_SETTINGS: TaskNotesSettings = { @@ -274,4 +276,4 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { enableBases: true, // Recurring task behavior defaults maintainDueDateOffsetInRecurring: false -}; \ No newline at end of file +}; diff --git a/src/settings/tabs/appearanceTab.ts b/src/settings/tabs/appearanceTab.ts index 7dee9538..0354d2c2 100644 --- a/src/settings/tabs/appearanceTab.ts +++ b/src/settings/tabs/appearanceTab.ts @@ -545,7 +545,7 @@ export function renderAppearanceTab(container: HTMLElement, plugin: TaskNotesPlu getValue: () => plugin.settings.projectAutosuggest?.requiredTags?.join(', ') ?? '', setValue: async (value: string) => { if (!plugin.settings.projectAutosuggest) { - plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false, requiredTags: [], includeFolders: [] }; + plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false, requiredTags: [], includeFolders: [], propertyKey: '', propertyValue: '' }; } plugin.settings.projectAutosuggest.requiredTags = value .split(',') @@ -564,7 +564,7 @@ export function renderAppearanceTab(container: HTMLElement, plugin: TaskNotesPlu getValue: () => plugin.settings.projectAutosuggest?.includeFolders?.join(', ') ?? '', setValue: async (value: string) => { if (!plugin.settings.projectAutosuggest) { - plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false, requiredTags: [], includeFolders: [] }; + plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false, requiredTags: [], includeFolders: [], propertyKey: '', propertyValue: '' }; } plugin.settings.projectAutosuggest.includeFolders = value .split(',') @@ -575,13 +575,44 @@ export function renderAppearanceTab(container: HTMLElement, plugin: TaskNotesPlu ariaLabel: 'Include folders for project suggestions' }); + // Property filtering + createTextSetting(container, { + name: 'Required property key', + desc: 'Show only notes where this frontmatter property matches the value below. Leave empty to ignore.', + placeholder: 'type', + getValue: () => plugin.settings.projectAutosuggest?.propertyKey ?? '', + setValue: async (value: string) => { + if (!plugin.settings.projectAutosuggest) { + plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false, requiredTags: [], includeFolders: [], propertyKey: '', propertyValue: '' }; + } + plugin.settings.projectAutosuggest.propertyKey = value.trim(); + save(); + }, + ariaLabel: 'Required frontmatter property key for project suggestions' + }); + + createTextSetting(container, { + name: 'Required property value', + desc: 'Only notes where the property equals this value are suggested. Leave empty to require the property to exist.', + placeholder: 'project', + getValue: () => plugin.settings.projectAutosuggest?.propertyValue ?? '', + setValue: async (value: string) => { + if (!plugin.settings.projectAutosuggest) { + plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false, requiredTags: [], includeFolders: [], propertyKey: '', propertyValue: '' }; + } + plugin.settings.projectAutosuggest.propertyValue = value.trim(); + save(); + }, + ariaLabel: 'Required frontmatter property value for project suggestions' + }); + createToggleSetting(container, { name: 'Customize suggestion display', desc: 'Show advanced options to configure how project suggestions appear and what information they display.', getValue: () => plugin.settings.projectAutosuggest?.showAdvanced ?? false, setValue: async (value: boolean) => { if (!plugin.settings.projectAutosuggest) { - plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false }; + plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false, requiredTags: [], includeFolders: [], propertyKey: '', propertyValue: '' }; } plugin.settings.projectAutosuggest.showAdvanced = value; save(); @@ -596,14 +627,14 @@ export function renderAppearanceTab(container: HTMLElement, plugin: TaskNotesPlu name: 'Enable fuzzy matching', desc: 'Allow typos and partial matches in project search. May be slower in large vaults.', getValue: () => plugin.settings.projectAutosuggest?.enableFuzzy ?? false, - setValue: async (value: boolean) => { - if (!plugin.settings.projectAutosuggest) { - plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false }; - } - plugin.settings.projectAutosuggest.enableFuzzy = value; - save(); + setValue: async (value: boolean) => { + if (!plugin.settings.projectAutosuggest) { + plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false, requiredTags: [], includeFolders: [], propertyKey: '', propertyValue: '' }; } - }); + plugin.settings.projectAutosuggest.enableFuzzy = value; + save(); + } + }); // Display rows configuration createHelpText(container, 'Configure up to 3 lines of information to show for each project suggestion.'); @@ -612,7 +643,7 @@ export function renderAppearanceTab(container: HTMLElement, plugin: TaskNotesPlu const setRow = async (idx: number, value: string) => { if (!plugin.settings.projectAutosuggest) { - plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false }; + plugin.settings.projectAutosuggest = { enableFuzzy: false, rows: [], showAdvanced: false, requiredTags: [], includeFolders: [], propertyKey: '', propertyValue: '' }; } const current = plugin.settings.projectAutosuggest.rows ?? []; const next = [...current]; @@ -661,4 +692,4 @@ export function renderAppearanceTab(container: HTMLElement, plugin: TaskNotesPlu cls: 'settings-help-note' }); } -} \ No newline at end of file +} diff --git a/src/suggest/FileSuggestHelper.ts b/src/suggest/FileSuggestHelper.ts index ebd4f89c..b1607e00 100644 --- a/src/suggest/FileSuggestHelper.ts +++ b/src/suggest/FileSuggestHelper.ts @@ -2,6 +2,7 @@ import type TaskNotesPlugin from '../main'; import { parseFrontMatterAliases } from 'obsidian'; import { scoreMultiword } from '../utils/fuzzyMatch'; import { parseDisplayFieldsRow } from '../utils/projectAutosuggestDisplayFieldsParser'; +import { getProjectPropertyFilter, matchesProjectProperty } from '../utils/projectFilterUtils'; export interface FileSuggestionItem { insertText: string; // usually basename @@ -33,6 +34,7 @@ export const FileSuggestHelper = { // Get filtering settings const requiredTags = plugin.settings?.projectAutosuggest?.requiredTags ?? []; const includeFolders = plugin.settings?.projectAutosuggest?.includeFolders ?? []; + const propertyFilter = getProjectPropertyFilter(plugin.settings?.projectAutosuggest); for (const file of files) { const cache = plugin.app.metadataCache.getFileCache(file); @@ -64,6 +66,13 @@ export const FileSuggestHelper = { } } + if (propertyFilter.enabled) { + const frontmatter = cache?.frontmatter; + if (!matchesProjectProperty(frontmatter, propertyFilter)) { + continue; + } + } + // Gather fields const basename = file.basename; let title = ''; @@ -161,4 +170,3 @@ export const FileSuggestHelper = { }); } }; - diff --git a/src/types/settings.ts b/src/types/settings.ts index ab0b5361..a81be033 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -21,6 +21,8 @@ export interface ProjectAutosuggestSettings { showAdvanced?: boolean; // Show advanced configuration options requiredTags?: string[]; // Show notes that have ANY of these tags includeFolders?: string[]; // Only show notes in these folders (empty = all folders) + propertyKey?: string; // Frontmatter property name to match + propertyValue?: string; // Expected value for the property (empty = property must exist) } export interface TaskNotesSettings { @@ -201,4 +203,4 @@ export interface CalendarViewSettings { weekNumbers: boolean; // Today highlighting showTodayHighlight: boolean; -} \ No newline at end of file +} diff --git a/src/utils/projectFilterUtils.ts b/src/utils/projectFilterUtils.ts new file mode 100644 index 00000000..1f0a8d66 --- /dev/null +++ b/src/utils/projectFilterUtils.ts @@ -0,0 +1,73 @@ +import type { ProjectAutosuggestSettings } from '../types/settings'; + +export interface ProjectPropertyFilter { + key: string; + value: string; + enabled: boolean; +} + +function normalizePropertyValue(value?: string): string { + return value != null ? value.trim() : ''; +} + +export function normalizeProjectPropertyKey(key?: string): string { + return key ? key.trim() : ''; +} + +export function getProjectPropertyFilter(settings?: ProjectAutosuggestSettings): ProjectPropertyFilter { + const key = normalizeProjectPropertyKey(settings?.propertyKey); + const value = normalizePropertyValue(settings?.propertyValue); + return { + key, + value, + enabled: key.length > 0, + }; +} + +export function matchesProjectProperty(frontmatter: Record | undefined | null, filter: ProjectPropertyFilter): boolean { + if (!filter.enabled) { + return true; + } + + if (!frontmatter || typeof frontmatter !== 'object') { + return false; + } + + if (!(filter.key in frontmatter)) { + return false; + } + + const actualValue = (frontmatter as Record)[filter.key]; + + const expected = normalizePropertyValue(filter.value); + if (expected.length === 0) { + return actualValue !== undefined && actualValue !== null; + } + + const normalizedExpected = expected.toLowerCase(); + + const matchesValue = (value: unknown): boolean => { + if (value === null || value === undefined) { + return false; + } + if (Array.isArray(value)) { + return value.some(item => matchesValue(item)); + } + if (typeof value === 'string') { + return value.trim().toLowerCase() === normalizedExpected; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value).toLowerCase() === normalizedExpected; + } + if (typeof value === 'object') { + try { + return JSON.stringify(value).toLowerCase() === normalizedExpected; + } catch { + return false; + } + } + return String(value).toLowerCase() === normalizedExpected; + }; + + return matchesValue(actualValue); +} diff --git a/tests/unit/modals/ProjectSelectModal.property-filter.test.ts b/tests/unit/modals/ProjectSelectModal.property-filter.test.ts new file mode 100644 index 00000000..3f3bbea5 --- /dev/null +++ b/tests/unit/modals/ProjectSelectModal.property-filter.test.ts @@ -0,0 +1,61 @@ +jest.mock('obsidian'); + +import type { App } from 'obsidian'; +import { ProjectSelectModal } from '../../../src/modals/ProjectSelectModal'; +import { MockObsidian } from '../../__mocks__/obsidian'; + +describe('ProjectSelectModal property filtering', () => { + let mockApp: App; + let mockPlugin: any; + + beforeEach(async () => { + MockObsidian.reset(); + mockApp = MockObsidian.createMockApp() as unknown as App; + // Provide getAllLoadedFiles for the modal under test + (mockApp.vault as any).getAllLoadedFiles = () => mockApp.vault.getFiles(); + + const yaml = require('yaml'); + await mockApp.vault.create('Projects/Alpha.md', `---\n${yaml.stringify({ type: 'project' })}---\n`); + await mockApp.vault.create('Notes/Idea.md', `---\n${yaml.stringify({ type: 'note' })}---\n`); + mockApp.metadataCache.setCache('Projects/Alpha.md', { + frontmatter: { type: 'project' }, + tags: [], + }); + mockApp.metadataCache.setCache('Notes/Idea.md', { + frontmatter: { type: 'note' }, + tags: [], + }); + + mockPlugin = { + app: mockApp, + settings: { + projectAutosuggest: { + enableFuzzy: false, + rows: [], + showAdvanced: false, + requiredTags: [], + includeFolders: [], + propertyKey: 'type', + propertyValue: 'project', + }, + storeTitleInFilename: false, + }, + fieldMapper: { + mapFromFrontmatter: jest.fn(() => ({ title: '' })), + }, + }; + }); + + it('only returns files that match the configured property filter', () => { + const alphaFile = mockApp.vault.getAbstractFileByPath('Projects/Alpha.md'); + expect(alphaFile).toBeTruthy(); + const cache = mockApp.metadataCache.getFileCache(alphaFile as any); + expect(cache?.frontmatter?.type).toBe('project'); + + const modal = new ProjectSelectModal(mockApp, mockPlugin, jest.fn()); + const items = modal.getItems(); + const paths = items.map(item => (item as any).path ?? ''); + expect(paths).toContain('Projects/Alpha.md'); + expect(paths).not.toContain('Notes/Idea.md'); + }); +}); diff --git a/tests/unit/utils/projectFilterUtils.test.ts b/tests/unit/utils/projectFilterUtils.test.ts new file mode 100644 index 00000000..d6a43f42 --- /dev/null +++ b/tests/unit/utils/projectFilterUtils.test.ts @@ -0,0 +1,45 @@ +import { getProjectPropertyFilter, matchesProjectProperty } from '../../../src/utils/projectFilterUtils'; + +describe('projectFilterUtils', () => { + describe('getProjectPropertyFilter', () => { + it('returns disabled filter when key is missing', () => { + const filter = getProjectPropertyFilter(undefined); + expect(filter).toEqual({ key: '', value: '', enabled: false }); + }); + + it('trims key and value', () => { + const filter = getProjectPropertyFilter({ propertyKey: ' type ', propertyValue: ' project ' } as any); + expect(filter).toEqual({ key: 'type', value: 'project', enabled: true }); + }); + }); + + describe('matchesProjectProperty', () => { + const baseFilter = { key: 'type', value: 'project', enabled: true }; + + it('matches string values case-insensitively', () => { + expect(matchesProjectProperty({ type: 'Project' }, baseFilter)).toBe(true); + expect(matchesProjectProperty({ type: 'other' }, baseFilter)).toBe(false); + }); + + it('matches array values', () => { + expect(matchesProjectProperty({ type: ['note', 'project'] }, baseFilter)).toBe(true); + }); + + it('matches boolean and numeric values using string comparison', () => { + const booleanFilter = { key: 'pinned', value: 'true', enabled: true }; + expect(matchesProjectProperty({ pinned: true }, booleanFilter)).toBe(true); + const numericFilter = { key: 'year', value: '2024', enabled: true }; + expect(matchesProjectProperty({ year: 2024 }, numericFilter)).toBe(true); + }); + + it('requires property to exist when expected value is empty', () => { + const existenceFilter = { key: 'type', value: '', enabled: true }; + expect(matchesProjectProperty({ type: 'project' }, existenceFilter)).toBe(true); + expect(matchesProjectProperty({}, existenceFilter)).toBe(false); + }); + + it('returns true for disabled filter regardless of frontmatter', () => { + expect(matchesProjectProperty(undefined, { key: '', value: '', enabled: false })).toBe(true); + }); + }); +});