Add project property filter support

This commit is contained in:
callumalpass 2025-09-16 07:57:33 +10:00
parent 77b41ec194
commit 1232d028ce
10 changed files with 252 additions and 19 deletions

View file

@ -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
- Efficient storage in YAML frontmatter format

View file

@ -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.

View file

@ -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<TAbstractFile> {
// 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<TAbstractFile> {
}
}
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<TAbstractFile> {
onChooseItem(file: TAbstractFile, evt: MouseEvent | KeyboardEvent) {
this.onChoose(file);
}
}
}

View file

@ -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
};
};

View file

@ -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'
});
}
}
}

View file

@ -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 = {
});
}
};

View file

@ -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;
}
}

View file

@ -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<string, unknown> | 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<string, unknown>)[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);
}

View file

@ -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');
});
});

View file

@ -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);
});
});
});