diff --git a/src/bases/basesFilterDefaults.ts b/src/bases/basesFilterDefaults.ts index 7b127018..d9debf42 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 d4833dc6..febc2507 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({}); + }); });