From fa2ba212ad128b0549caaa40cc3c76c8b76aed62 Mon Sep 17 00:00:00 2001 From: Kapej Date: Sun, 28 Jun 2026 21:20:46 +0200 Subject: [PATCH] feat(project-autosuggest): support containsAny / negation in property value filter The "Required property value" filter for the project autosuggest (and the shared wikilink `[[` file suggest) previously only accepted a plain comma-separated allow-list, matched as an equality set. There was no way to *exclude* values, so archived/completed/cancelled projects could not be hidden from the `+` project picker without enumerating every still-active status. Extend the property value grammar in `matchesProjectProperty` with a small, backwards-compatible expression syntax: - `active, planned` -> legacy allow-list (unchanged) - `containsAny("active", "planned")` -> explicit allow-list - `!containsAny("completed", "archived")` -> exclude these values - `not containsAny("done")` / `!completed` -> alternate negation forms The configured property key remains required: a note that lacks the property (or whose value is empty) is never suggested, matching the "Required property key" field semantics. The expression only narrows which existing values are allowed. Matching stays case-insensitive across string, list, numeric and boolean values. Update the settings description/placeholder and the docs accordingly, and add unit tests covering the new expression forms alongside the existing behavior. --- docs/settings/task-properties.md | 12 +- src/i18n/resources/en.ts | 4 +- src/utils/projectFilterUtils.ts | 172 ++++++++++++++------ tests/unit/utils/projectFilterUtils.test.ts | 66 ++++++++ 4 files changed, 203 insertions(+), 51 deletions(-) diff --git a/docs/settings/task-properties.md b/docs/settings/task-properties.md index b01d05831..4d4fe25f5 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 0926250b6..1412cd7ce 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 0b5f79fc9..0e0e0a92e 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 69704d273..8491882e5 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); + }); + }); });