diff --git a/docs/settings/task-properties.md b/docs/settings/task-properties.md index b01d0583..4d4fe25f 100644 --- a/docs/settings/task-properties.md +++ b/docs/settings/task-properties.md @@ -143,8 +143,14 @@ Control which notes appear when selecting projects: - **Required tags**: Comma-separated list of tags (shows notes with ANY of these tags) - **Include folders**: Comma-separated list of folder paths (shows notes in ANY of these folders) - **Required property key**: Frontmatter property that must exist -- **Required property value**: Expected value for the property (optional) -These filters reduce suggestion noise in large vaults. +- **Required property value**: Which values of that property are allowed (optional). Supports: + - A comma-separated allow-list — `active, planned` shows notes whose property equals any of these. + - A `containsAny(...)` expression — `containsAny("active", "planned")` (equivalent to the allow-list, but explicit). + - A negated expression — prefix with `!` or `not` to *exclude* values, e.g. `!containsAny("completed", "archived", "cancelled", "done")`. + - Leave empty to allow any value (the property just has to exist). + + In all cases the **property must exist** with a non-empty value: a note without the configured property (e.g. a project with no `status`) is never suggested. +These filters reduce suggestion noise in large vaults — for example, hiding completed or archived projects from the `+` project picker. A "Filters On" badge appears when any filters are configured. @@ -270,7 +276,7 @@ Each custom field can configure filters to control which files appear in wikilin - **Required tags**: Comma-separated list of tags (shows files with ANY of these tags) - **Include folders**: Comma-separated list of folder paths (shows files in ANY of these folders) - **Required property key**: Frontmatter property that must exist -- **Required property value**: Expected value for the property (optional) +- **Required property value**: Which values of that property are allowed (optional). Accepts a comma-separated allow-list, a `containsAny("a", "b")` expression, or a negated `!containsAny("a", "b")` expression to exclude values A "Filters On" badge appears when filters are configured. diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 0926250b..1412cd7c 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1639,8 +1639,8 @@ export const en: TranslationTree = { requiredPropertyValue: { name: "Required property value", description: - "Only notes where the property equals this value are suggested. Leave empty to require the property to exist.", - placeholder: "project", + 'Only notes that have the property (above) are suggested; this narrows which values are allowed. Use a comma-separated allow-list (e.g. "project, area"), or an expression like containsAny("active", "planned"). Prefix with ! or "not" to exclude, e.g. !containsAny("completed", "archived"). Leave empty to allow any value as long as the property exists.', + placeholder: '!containsAny("completed", "archived")', }, customizeDisplay: { name: "Customize suggestion display", diff --git a/src/utils/projectFilterUtils.ts b/src/utils/projectFilterUtils.ts index 0b5f79fc..0e0e0a92 100644 --- a/src/utils/projectFilterUtils.ts +++ b/src/utils/projectFilterUtils.ts @@ -15,24 +15,86 @@ export interface PropertyFilterSettings { propertyValue?: string; } -function normalizePropertyValue(value?: string): string { - return value != null ? value.trim() : ""; +/** + * Result of parsing a "Required property value" expression. + * + * The value field supports three forms: + * - A plain comma-separated allow-list: `project, area` + * - A `containsAny(...)` function call: `containsAny("project", "area")` + * - A negated form using a leading `!` or `not `: + * `!containsAny("completed", "archived")` / `not project, area` + */ +interface ParsedPropertyValueExpression { + negate: boolean; + values: string[]; } -function normalizePropertyValues(value?: string): string[] { - const normalized = normalizePropertyValue(value); - return normalized.length > 0 - ? normalized - .split(",") - .map((item) => item.trim()) - .filter(Boolean) - : []; +/** Function names understood in the property value expression (lower-cased). */ +const KNOWN_FUNCTIONS = new Set(["containsany"]); + +function normalizePropertyValue(value?: string): string { + return value != null ? value.trim() : ""; } export function normalizeProjectPropertyKey(key?: string): string { return key ? key.trim() : ""; } +function stripQuotes(value: string): string { + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + } + return value; +} + +/** Split a comma-separated argument list, trimming and unquoting each entry. */ +function splitArguments(input: string): string[] { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return []; + } + return trimmed + .split(",") + .map((item) => stripQuotes(item.trim())) + .filter((item) => item.length > 0); +} + +/** + * Parse the raw "Required property value" string into an expression. + * + * Backwards compatible: a plain comma-separated list (the historical format) + * parses to `{ negate: false, values: [...] }`, preserving existing behavior. + */ +export function parsePropertyValueExpression(rawValue?: string): ParsedPropertyValueExpression { + let expr = normalizePropertyValue(rawValue); + let negate = false; + + // Leading negation: "!" or "not " (case-insensitive). + if (expr.startsWith("!")) { + negate = true; + expr = expr.slice(1).trim(); + } else { + const notMatch = /^not\s+/i.exec(expr); + if (notMatch) { + negate = true; + expr = expr.slice(notMatch[0].length).trim(); + } + } + + // Function-call form: name( ... ). + const fnMatch = /^([a-zA-Z][\w]*)\s*\(([\s\S]*)\)$/.exec(expr); + if (fnMatch && KNOWN_FUNCTIONS.has(fnMatch[1].toLowerCase())) { + return { negate, values: splitArguments(fnMatch[2]) }; + } + + // Fallback: legacy comma-separated allow-list. + return { negate, values: splitArguments(expr) }; +} + export function getProjectPropertyFilter( settings?: PropertyFilterSettings ): ProjectPropertyFilter { @@ -45,6 +107,44 @@ export function getProjectPropertyFilter( }; } +/** True when a property value is absent or carries no meaningful content. */ +function isEmptyValue(value: unknown): boolean { + if (value === null || value === undefined) { + return true; + } + if (typeof value === "string") { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + return false; +} + +/** True when `value` (a scalar, array, or object) equals any of the expected values. */ +function matchesAnyValue(value: unknown, expected: Set): boolean { + if (value === null || value === undefined) { + return false; + } + if (Array.isArray(value)) { + return value.some((item) => matchesAnyValue(item, expected)); + } + if (typeof value === "string") { + return expected.has(value.trim().toLowerCase()); + } + if (typeof value === "number" || typeof value === "boolean") { + return expected.has(String(value).toLowerCase()); + } + if (typeof value === "object") { + try { + return expected.has(JSON.stringify(value).toLowerCase()); + } catch { + return false; + } + } + return expected.has(stringifyUnknown(value).toLowerCase()); +} + export function matchesProjectProperty( frontmatter: Record | undefined | null, filter: ProjectPropertyFilter @@ -53,47 +153,27 @@ export function matchesProjectProperty( return true; } - if (!frontmatter || typeof frontmatter !== "object") { - return false; - } + const parsed = parsePropertyValueExpression(filter.value); - if (!(filter.key in frontmatter)) { + const hasFrontmatter = !!frontmatter && typeof frontmatter === "object"; + const hasKey = hasFrontmatter && filter.key in frontmatter; + const actualValue = hasKey ? frontmatter[filter.key] : undefined; + + // Honor the "Required property key" contract: the property must exist with a + // non-empty value. Notes lacking it are never suggested, whether the value + // expression is an allow-list or a negation. + if (!hasKey || isEmptyValue(actualValue)) { return false; } - const actualValue = (frontmatter)[filter.key]; - - const expectedValues = normalizePropertyValues(filter.value); - if (expectedValues.length === 0) { - return actualValue !== undefined && actualValue !== null; + // Only a key was configured (or a bare "!"/"not"): existence is enough. + if (parsed.values.length === 0) { + return true; } - const normalizedExpectedValues = new Set( - expectedValues.map((expectedValue) => expectedValue.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 normalizedExpectedValues.has(value.trim().toLowerCase()); - } - if (typeof value === "number" || typeof value === "boolean") { - return normalizedExpectedValues.has(String(value).toLowerCase()); - } - if (typeof value === "object") { - try { - return normalizedExpectedValues.has(JSON.stringify(value).toLowerCase()); - } catch { - return false; - } - } - return normalizedExpectedValues.has(stringifyUnknown(value).toLowerCase()); - }; + const expectedValues = new Set(parsed.values.map((value) => value.toLowerCase())); + const matched = matchesAnyValue(actualValue, expectedValues); - return matchesValue(actualValue); + // A negation shows notes whose (existing) value is none of the listed values. + return parsed.negate ? !matched : matched; } diff --git a/tests/unit/utils/projectFilterUtils.test.ts b/tests/unit/utils/projectFilterUtils.test.ts index 69704d27..8491882e 100644 --- a/tests/unit/utils/projectFilterUtils.test.ts +++ b/tests/unit/utils/projectFilterUtils.test.ts @@ -49,4 +49,70 @@ describe('projectFilterUtils', () => { expect(matchesProjectProperty(undefined, { key: '', value: '', enabled: false })).toBe(true); }); }); + + describe('matchesProjectProperty - expression syntax', () => { + it('supports containsAny(...) as an allow-list', () => { + const filter = { key: 'status', value: 'containsAny("active", "planned")', enabled: true }; + expect(matchesProjectProperty({ status: 'active' }, filter)).toBe(true); + expect(matchesProjectProperty({ status: 'Planned' }, filter)).toBe(true); + expect(matchesProjectProperty({ status: 'completed' }, filter)).toBe(false); + }); + + it('negates with !containsAny(...) to exclude values', () => { + const filter = { + key: 'status', + value: '!containsAny("completed", "archived", "cancelled", "done")', + enabled: true, + }; + expect(matchesProjectProperty({ status: 'active' }, filter)).toBe(true); + expect(matchesProjectProperty({ status: 'Completed' }, filter)).toBe(false); + expect(matchesProjectProperty({ status: 'archived' }, filter)).toBe(false); + }); + + it('hides notes with a missing or empty property even under a negation filter', () => { + const filter = { + key: 'status', + value: '!containsAny("completed", "archived")', + enabled: true, + }; + expect(matchesProjectProperty({}, filter)).toBe(false); + expect(matchesProjectProperty({ status: '' }, filter)).toBe(false); + expect(matchesProjectProperty({ status: ' ' }, filter)).toBe(false); + expect(matchesProjectProperty({ status: null }, filter)).toBe(false); + expect(matchesProjectProperty({ status: [] }, filter)).toBe(false); + expect(matchesProjectProperty(undefined, filter)).toBe(false); + // ...but a note that has a non-excluded status is still shown. + expect(matchesProjectProperty({ status: 'active' }, filter)).toBe(true); + }); + + it('negation applies to list-valued properties', () => { + const filter = { key: 'tags', value: '!containsAny("archived")', enabled: true }; + expect(matchesProjectProperty({ tags: ['active', 'work'] }, filter)).toBe(true); + expect(matchesProjectProperty({ tags: ['work', 'archived'] }, filter)).toBe(false); + }); + + it('accepts the "not " prefix as a negation', () => { + const filter = { key: 'status', value: 'not containsAny("done")', enabled: true }; + expect(matchesProjectProperty({ status: 'done' }, filter)).toBe(false); + expect(matchesProjectProperty({ status: 'todo' }, filter)).toBe(true); + }); + + it('supports a bare negated comma-separated list', () => { + const filter = { key: 'status', value: '!completed, archived', enabled: true }; + expect(matchesProjectProperty({ status: 'completed' }, filter)).toBe(false); + expect(matchesProjectProperty({ status: 'active' }, filter)).toBe(true); + }); + + it('is case-insensitive for the function name and strips quotes', () => { + const filter = { key: 'status', value: "CONTAINSANY('active')", enabled: true }; + expect(matchesProjectProperty({ status: 'active' }, filter)).toBe(true); + expect(matchesProjectProperty({ status: 'active-extra' }, filter)).toBe(false); + }); + + it('treats a bare "!" as a pure existence check', () => { + const filter = { key: 'status', value: '!', enabled: true }; + expect(matchesProjectProperty({ status: 'completed' }, filter)).toBe(true); + expect(matchesProjectProperty({}, filter)).toBe(false); + }); + }); });