mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Add project property filter support
This commit is contained in:
parent
77b41ec194
commit
1232d028ce
10 changed files with 252 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
src/utils/projectFilterUtils.ts
Normal file
73
src/utils/projectFilterUtils.ts
Normal 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);
|
||||
}
|
||||
61
tests/unit/modals/ProjectSelectModal.property-filter.test.ts
Normal file
61
tests/unit/modals/ProjectSelectModal.property-filter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
45
tests/unit/utils/projectFilterUtils.test.ts
Normal file
45
tests/unit/utils/projectFilterUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue