diff --git a/docs-builder/build.js b/docs-builder/build.js index 5b868ba46..9530b1009 100644 --- a/docs-builder/build.js +++ b/docs-builder/build.js @@ -449,6 +449,17 @@ async function main() { cpSync(assetsIn, path.join(DIST, 'assets'), { recursive: true }); } + // Copy `media/docs/` so relative image references like + // `../media/docs/.png` from top-level markdown files resolve at + // `/media/docs/.png` once `resolveAssetPaths` rewrites them. Without + // this, images that live in `media/docs/` (used by docs/workflows.md and + // docs/calendar-setup.md) 404 on the published site even though the source + // files exist in the repo. Fixes #2039. + const mediaDocsIn = path.resolve(__dir, '..', 'media', 'docs'); + if (existsSync(mediaDocsIn)) { + cpSync(mediaDocsIn, path.join(DIST, 'media', 'docs'), { recursive: true }); + } + // Copy CNAME for GitHub Pages custom domain const cname = path.join(DOCS, 'CNAME'); if (existsSync(cname)) { 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({}); + }); });