From 9d69478aed78aa507ccc45dbf5801116ebb02a9e Mon Sep 17 00:00:00 2001 From: ther12k Date: Sun, 21 Jun 2026 17:28:18 +0000 Subject: [PATCH] fix(bases-filter-defaults): extract property from generated conjunction + map filter (closes #2043) The default-relationships Subtasks filter generated by formatProjectEntryLinkExpression in defaultBasesFiles.ts emits: file.hasLink(this.file) && list(note.PROP).map().asLink()).contains(this.file.asLink()) The currentFileContainsMatch regex captures the entire conjunction as the property expression. normalizeFilterProperty then sees: file.hasLink(this.file) && list(note.projects).map(...).asLink()) which fails every check (no leading 'list(' since the string starts with 'file.hasLink', no trailing match for the core field set, etc.) and returns null. Net effect: clicking the column '+' button on the default Subtasks tab creates a task with no 'projects' field, which then fails the view filter and disappears. Fix: strip the &&-joined left side and the generated .map(...) wrapper (balanced-paren walk, since .asLink() lives inside the .map argument) before the existing list()/note-prefix recognition runs. Tests: - 3 new regression tests in basesFilterDefaults.test.ts covering the generated core-field case, the user-defined-field case, and the missing-current-file-link fallback. - All 6/6 tests in basesFilterDefaults.test.ts pass. - All 13/13 tests across kanbanCreationDefaults, basesTaskCreation, KanbanView.manualOrderFastPath, and basesCreateFileForView pass. Refs: #2043, #1657, #1902. --- src/bases/basesFilterDefaults.ts | 38 ++++++++++- tests/unit/bases/basesFilterDefaults.test.ts | 68 ++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/bases/basesFilterDefaults.ts b/src/bases/basesFilterDefaults.ts index 7b1270185..d9debf425 100644 --- a/src/bases/basesFilterDefaults.ts +++ b/src/bases/basesFilterDefaults.ts @@ -139,11 +139,47 @@ function normalizeFilterProperty( options: BasesFilterDefaultOptions ): string | null { let property = propertyExpression.trim(); + + // Strip a leading `&&`-joined left side. The generated default-relationships + // filter (see `formatProjectEntryLinkExpression` in `defaultBasesFiles.ts`, + // issue #2043) concatenates `file.hasLink(this.file) &&` before the + // `list(note.PROP).map(...)` expression so the regex above matches the + // whole conjunction. Keep only the right-hand operand. + const conjunctionIndex = property.lastIndexOf("&&"); + if (conjunctionIndex !== -1) { + property = property.slice(conjunctionIndex + 2).trim(); + } + + // Strip generated `.map(...)` chains emitted by the relationship templates + // in `defaultBasesFiles.ts`. These wrap `list(note.PROP)` to normalize link + // formats (markdown links, `%20`, paths). The wrapper closes one balanced + // call, so find the last `.map(` and strip up to its matching `)`. + const mapStart = property.lastIndexOf(".map("); + if (mapStart !== -1) { + let depth = 0; + let endIndex = -1; + for (let i = mapStart + 5; i < property.length; i++) { + const ch = property[i]; + if (ch === "(") { + depth++; + } else if (ch === ")") { + if (depth === 0) { + endIndex = i; + break; + } + depth--; + } + } + if (endIndex !== -1) { + property = property.slice(0, mapStart).trim(); + } + } + const listMatch = property.match(/^list\((.+)\)$/); if (listMatch) { property = listMatch[1].trim(); } - property = property.replace(/^(note|task)\./, ""); + property = property.replace(/^(note|task|this\.note)\./, ""); if (property === "tags" || property === "file.tags") { return "tags"; diff --git a/tests/unit/bases/basesFilterDefaults.test.ts b/tests/unit/bases/basesFilterDefaults.test.ts index d4833dc6c..febc25070 100644 --- a/tests/unit/bases/basesFilterDefaults.test.ts +++ b/tests/unit/bases/basesFilterDefaults.test.ts @@ -124,4 +124,72 @@ describe("Bases filter defaults", () => { expect(defaults).toEqual({}); }); + + // Regression: issue #2043. The default relationships Subtasks filter + // generated by `formatProjectEntryLinkExpression` in `defaultBasesFiles.ts` + // emits `file.hasLink(this.file) && list(note.PROP).map().asLink()).contains(this.file.asLink())`. + // The `currentFileContainsMatch` regex captures the entire conjunction, so + // the parser must strip the `&&` left side and the generated `.map(...).asLink()` + // chain before extracting the property name. + it("extracts project default from generated `file.hasLink(this.file) &&` conjunction filter (#2043)", () => { + const generatedRule = + 'file.hasLink(this.file) && list(note.projects).map(file(value.replace(/^\\[[^\\]]+\\]\\((.*)\\)$/, "$1").replace(/%20/g, " ")).asLink()).contains(this.file.asLink())'; + + const defaults = extractBasesFilterDefaults({ + config: { + filters: { + rule: { text: generatedRule }, + }, + }, + fieldMapper: createFieldMapper(), + taskTag: "task", + currentFileLink: "[[Current]]", + }); + + expect(defaults).toEqual({ + projects: ["[[Current]]"], + }); + }); + + it("extracts user-defined field from generated conjunction + map filter (#2043)", () => { + const generatedRule = + 'file.hasLink(this.file) && list(note.customField).map(file(value.replace(/^\\[[^\\]]+\\]\\((.*)\\)$/, "$1").replace(/%20/g, " ")).asLink()).contains(this.file.asLink())'; + + const defaults = extractBasesFilterDefaults({ + config: { + filters: { + rule: { text: generatedRule }, + }, + }, + fieldMapper: createFieldMapper(), + taskTag: "task", + userFields: [{ key: "customField" }], + currentFileLink: "[[Current]]", + }); + + // Unknown user-defined fields fall through to scalar storage in + // `addFrontmatterDefault` (the list-merge path is reserved for the + // core list fields: tags, contexts, projects, blockedBy). + expect(defaults).toEqual({ + customField: "[[Current]]", + }); + }); + + it("falls back gracefully when current file link is missing for the generated filter (#2043)", () => { + const generatedRule = + 'file.hasLink(this.file) && list(note.projects).map(file(value.replace(/^\\[[^\\]]+\\]\\((.*)\\)$/, "$1").replace(/%20/g, " ")).asLink()).contains(this.file.asLink())'; + + const defaults = extractBasesFilterDefaults({ + config: { + filters: { + rule: { text: generatedRule }, + }, + }, + fieldMapper: createFieldMapper(), + taskTag: "task", + currentFileLink: null, + }); + + expect(defaults).toEqual({}); + }); });