Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/settings/task-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions src/i18n/resources/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
172 changes: 126 additions & 46 deletions src/utils/projectFilterUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string>): 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<string, unknown> | undefined | null,
filter: ProjectPropertyFilter
Expand All @@ -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;
}
66 changes: 66 additions & 0 deletions tests/unit/utils/projectFilterUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading