From e19e5e5420e4f618071e6752506e9dfb18b2eff7 Mon Sep 17 00:00:00 2001 From: Andrii Oriekhov Date: Sun, 11 Oct 2026 13:45:59 +0300 Subject: [PATCH] Strip leading dot from file.ext to match Obsidian Obsidian Bases exposes file.ext as "md", but mdbasequery used path.extname and returned ".md", so `file.ext == "md"` matched nothing. Strip the dot in core for indexed notes, this.file and link-derived file records; adapters keep returning the dotted extname. Fixes #26 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + src/core/expression/evaluator.ts | 2 +- src/core/vault-index.ts | 2 +- src/query.ts | 2 +- test-runtime/conformance-tests.mjs | 2 +- tests/expression-evaluator.test.ts | 4 +-- tests/query-engine.test.ts | 39 +++++++++++++++++++++++++++--- tests/serialize.test.ts | 2 +- 8 files changed, 43 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d6fa23f..d3794b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ When implementation decisions affect compatibility, runtime behaviour, performan - `this` is a file-like record. With `--base`/`basePath` it is a synthetic record of the base file itself (path/folder resolved relative to the vault root; no file read); in `--yaml`/flag/library mode without a base path it is an empty record at the vault root. This diverges from Obsidian embed-location semantics but supports the documented patterns (`this.file.folder`, `file.hasLink(this.file)`, `author == this`). - `file.folder` is supported and resolves to the vault-relative parent directory (`""` for root-level notes). +- `file.ext` is the extension without a leading dot (`"md"`, `"base"`), matching Obsidian, so `file.ext == "md"` works. Applies to indexed notes, `this.file`, and link-derived file records. Runtime adapters' `extname` still returns the dotted form; the dot is stripped in core. - `file.backlinks` is computed during indexing from resolved `file.links` targets using unambiguous basename / exact path matching. - `random()` is deliberately not implemented for deterministic query evaluation. - Non-standard extension globals beyond the Obsidian function surface: `sum(values)`, `avg(values)`, `count(values)`, `contains(container, needle)`. List stat methods `values.mean()`, `values.sum()`, `values.min()`, `values.max()` are supported per official docs. diff --git a/src/core/expression/evaluator.ts b/src/core/expression/evaluator.ts index 98f409b..38097f7 100644 --- a/src/core/expression/evaluator.ts +++ b/src/core/expression/evaluator.ts @@ -923,7 +923,7 @@ function createSyntheticFile(pathLike: string): Record { const folderIndex = path.lastIndexOf("/"); const folder = folderIndex === -1 ? "" : path.slice(0, folderIndex); const dotIndex = name.lastIndexOf("."); - const ext = dotIndex === -1 ? "" : name.slice(dotIndex); + const ext = dotIndex === -1 ? "" : name.slice(dotIndex + 1); return { name, diff --git a/src/core/vault-index.ts b/src/core/vault-index.ts index f138438..fec0672 100644 --- a/src/core/vault-index.ts +++ b/src/core/vault-index.ts @@ -170,7 +170,7 @@ export async function indexVault(options: VaultIndexOptions): Promise { basename: "note", path: "projects/note.md", folder: "projects", - ext: ".md", + ext: "md", size: 10, ctime: new Date("2024-01-01"), mtime: new Date("2024-01-02"), @@ -161,7 +161,7 @@ describe("expression evaluator", () => { basename: "beta", path: "beta.md", folder: "", - ext: ".md", + ext: "md", size: 10, ctime: new Date("2024-01-01"), mtime: new Date("2024-01-02"), diff --git a/tests/query-engine.test.ts b/tests/query-engine.test.ts index e382877..7cbf69e 100644 --- a/tests/query-engine.test.ts +++ b/tests/query-engine.test.ts @@ -19,7 +19,7 @@ describe("query engine", () => { basename: (path.split("/").pop() ?? path).replace(/\.md$/, ""), path, folder: path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "", - ext: ".md", + ext: "md", size: 1, ctime: new Date(0), mtime: new Date(0), @@ -287,6 +287,37 @@ views: expect(result.diagnostics.errors).toHaveLength(0); }); + test("file.ext is the extension without a leading dot, as in Obsidian", async () => { + const spec = parseBaseYaml( + ` +filters: file.ext == "md" +views: + - type: table + name: exts + properties: + - file.name + - file.ext +`.trim(), + ); + + const compiled = compileQuery(spec); + const indexed = await indexVault({ + rootDir: vaultDir, + include: ["**/*.md"], + exclude: [], + adapter: nodeAdapter, + }); + + const result = executeCompiledQuery({ + compiled, + documents: indexed.documents, + view: "exts", + }); + + expect(result.rows).toHaveLength(indexed.documents.length); + expect(result.rows[0].projected["file.ext"]).toBe("md"); + }); + test("strict mode rejects identifiers that exist nowhere in the vault", async () => { const spec = parseBaseYaml( ` @@ -456,7 +487,7 @@ views: test("this is a file-like object, stable across rows", async () => { const spec = parseBaseYaml( ` -filters: this.file.ext == ".base" +filters: this.file.ext == "base" views: - type: table name: default @@ -482,7 +513,7 @@ views: basename: "base", path: "queries/base.base", folder: "queries", - ext: ".base", + ext: "base", size: 0, ctime: new Date(0), mtime: new Date(0), @@ -528,7 +559,7 @@ views: basename: "beta", path: "beta.md", folder: "", - ext: ".md", + ext: "md", size: 0, ctime: new Date(0), mtime: new Date(0), diff --git a/tests/serialize.test.ts b/tests/serialize.test.ts index f740531..e34eccf 100644 --- a/tests/serialize.test.ts +++ b/tests/serialize.test.ts @@ -11,7 +11,7 @@ const fixtureResult: QueryResult = { name: "alpha.md", path: "alpha.md", folder: "", - ext: ".md", + ext: "md", size: 10, ctime: new Date("2024-01-01"), mtime: new Date("2024-01-01"),