From f87570351225e0e05edd360a293fad976c555f42 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 17:57:32 +0530 Subject: [PATCH 01/16] Update: Moved resolver to core, as editor depends on core --- packages/core/src/index.ts | 1 + packages/core/src/links/index.ts | 2 ++ packages/core/src/links/resolver.ts | 23 +++++++++++++++++ packages/editor/package.json | 3 ++- .../src/extensions/resolver/resolver.ts | 25 ++----------------- pnpm-lock.yaml | 3 +++ 6 files changed, 33 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/links/index.ts create mode 100644 packages/core/src/links/resolver.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 069426b..affa483 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,3 +6,4 @@ export * from "./path.js"; export * from "./doctor/index.js"; export * from "./index/index.js"; export * from "./search/index.js"; +export * from "./links/index.js"; diff --git a/packages/core/src/links/index.ts b/packages/core/src/links/index.ts new file mode 100644 index 0000000..c3f3d92 --- /dev/null +++ b/packages/core/src/links/index.ts @@ -0,0 +1,2 @@ +export type { ParsedLinkTarget } from "./resolver.js"; +export { parseLinkTarget, resolveWikilink } from "./resolver.js"; diff --git a/packages/core/src/links/resolver.ts b/packages/core/src/links/resolver.ts new file mode 100644 index 0000000..5b0bf39 --- /dev/null +++ b/packages/core/src/links/resolver.ts @@ -0,0 +1,23 @@ +export interface ParsedLinkTarget { + note: string; + blockId: string | null; +} + +export function parseLinkTarget(raw: string): ParsedLinkTarget { + const target = raw.trim(); + const hashIdx = target.indexOf("#^"); + if (hashIdx === -1) return { note: target, blockId: null }; + return { note: target.slice(0, hashIdx), blockId: target.slice(hashIdx + 2) }; +} + +export function resolveWikilink( + raw: string, + titleToPath: ReadonlyMap, +): string | null { + const { note } = parseLinkTarget(raw); + if (note.length === 0) return null; + if (note.includes("/") || note.toLowerCase().endsWith(".md")) { + return note.replace(/^\.\//, ""); + } + return titleToPath.get(note) ?? null; +} diff --git a/packages/editor/package.json b/packages/editor/package.json index 9e6d703..ba18e1f 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -15,7 +15,8 @@ "@codemirror/lang-markdown": "^6.5.1", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.43.6", - "@codemirror/commands": "^6.8.2" + "@codemirror/commands": "^6.8.2", + "@trachyte/core": "workspace:*" }, "peerDependencies": { "react": "^19.0.0" diff --git a/packages/editor/src/extensions/resolver/resolver.ts b/packages/editor/src/extensions/resolver/resolver.ts index 5b0bf39..db56c58 100644 --- a/packages/editor/src/extensions/resolver/resolver.ts +++ b/packages/editor/src/extensions/resolver/resolver.ts @@ -1,23 +1,2 @@ -export interface ParsedLinkTarget { - note: string; - blockId: string | null; -} - -export function parseLinkTarget(raw: string): ParsedLinkTarget { - const target = raw.trim(); - const hashIdx = target.indexOf("#^"); - if (hashIdx === -1) return { note: target, blockId: null }; - return { note: target.slice(0, hashIdx), blockId: target.slice(hashIdx + 2) }; -} - -export function resolveWikilink( - raw: string, - titleToPath: ReadonlyMap, -): string | null { - const { note } = parseLinkTarget(raw); - if (note.length === 0) return null; - if (note.includes("/") || note.toLowerCase().endsWith(".md")) { - return note.replace(/^\.\//, ""); - } - return titleToPath.get(note) ?? null; -} +export { parseLinkTarget, resolveWikilink } from "@trachyte/core"; +export type { ParsedLinkTarget } from "@trachyte/core"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8c3fda..3536a87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,6 +165,9 @@ importers: '@codemirror/view': specifier: ^6.43.6 version: 6.43.6 + '@trachyte/core': + specifier: workspace:* + version: link:../core codemirror: specifier: ^6.0.2 version: 6.0.2 From 68c71c70ddfb95cdb8c6c593edff8b118d002b5e Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 18:04:01 +0530 Subject: [PATCH 02/16] Feat: Added functions for extracting links and headings --- packages/core/src/index/parser/md.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/core/src/index/parser/md.ts b/packages/core/src/index/parser/md.ts index 6500aa4..46ac40d 100644 --- a/packages/core/src/index/parser/md.ts +++ b/packages/core/src/index/parser/md.ts @@ -14,3 +14,28 @@ export function extractIndexContent(markdown: string): string { return trimmed.slice(end + 4); } + +export function extractLinks(markdown: string): { target: string; positionChar: number }[] { + const links: { target: string; positionChar: number }[] = []; + const re = /\[\[([^\[\]\n]+)\]\]/g; + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + links.push({ target: match[1]!, positionChar: match.index }); + } + return links; +} + +export function extractHeadings( + markdown: string, +): { level: number; text: string; position: number }[] { + const headings: { level: number; text: string; position: number }[] = []; + const re = /^(#{1,6})\s+(.+)$/gm; + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const text = match[2]!.trim(); + const level = match[1]!.length; + const position = match.index + match[1]!.length + 1; + headings.push({ level, text, position }); + } + return headings; +} From ab7e31e92f93da2e90e967e1f5d8aeea1109fdb2 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 18:04:23 +0530 Subject: [PATCH 03/16] Feat: Added functions for extracting tags --- packages/core/src/index/parser/md.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/core/src/index/parser/md.ts b/packages/core/src/index/parser/md.ts index 46ac40d..870f384 100644 --- a/packages/core/src/index/parser/md.ts +++ b/packages/core/src/index/parser/md.ts @@ -39,3 +39,15 @@ export function extractHeadings( } return headings; } + +export function extractTags(markdown: string): string[] { + const seen = new Set(); + const re = /#([A-Za-z0-9_/.-]+)/g; + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const tag = match[1]!; + if (tag.endsWith(".")) continue; + seen.add(tag); + } + return [...seen]; +} From bd78e2fa7c2f193c200eb66bbdc0f2beb0e156b1 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 18:05:07 +0530 Subject: [PATCH 04/16] Feat: Added interface for extracted node --- packages/core/src/index/types.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/index/types.ts b/packages/core/src/index/types.ts index b48162a..442f2ac 100644 --- a/packages/core/src/index/types.ts +++ b/packages/core/src/index/types.ts @@ -9,3 +9,9 @@ export type IndexEvent = | { type: "index:upserted"; path: string } | { type: "index:deleted"; path: string } | { type: "index:rebuilt" }; + +export interface ExtractedNote { + links: { target: string; positionChar: number }[]; + headings: { level: number; text: string; position: number }[]; + tags: string[]; +} From f87c2d2770704017368e196d551111aefeb04a48 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 18:19:41 +0530 Subject: [PATCH 05/16] Feat: Updated driver with link functions and wired up index for export --- packages/core/src/index/driver.ts | 34 ++++++++++++++++++++++++++++++- packages/core/src/index/index.ts | 4 ++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/core/src/index/driver.ts b/packages/core/src/index/driver.ts index 4242fb4..bde4317 100644 --- a/packages/core/src/index/driver.ts +++ b/packages/core/src/index/driver.ts @@ -1,4 +1,4 @@ -import type { IndexedFileMeta } from "./types.js"; +import type { IndexedFileMeta, ExtractedNote } from "./types.js"; export interface IndexDriver { upsertFile(vaultPath: string, relPath: string, content: string, mtime: number): Promise; @@ -9,6 +9,12 @@ export interface IndexDriver { files: { path: string; content: string; mtime: number }[], ): Promise; search(vaultPath: string, query: string): Promise; + storeExtracted(vaultPath: string, relPath: string, note: ExtractedNote): Promise; + + backlinks( + vaultPath: string, + targetRelPath: string, + ): Promise<{ sourcePath: string; positionChar: number }[]>; } export class MemoryIndexDriver implements IndexDriver { @@ -16,6 +22,7 @@ export class MemoryIndexDriver implements IndexDriver { private meta = new Map(); private ids = new Map(); private nextId = 1; + private extracted = new Map(); upsertFile(_vaultPath: string, relPath: string, content: string, mtime: number): Promise { this.files.set(relPath, content); @@ -41,6 +48,7 @@ export class MemoryIndexDriver implements IndexDriver { this.files.clear(); this.meta.clear(); this.ids.clear(); + this.extracted.clear(); this.nextId = 1; for (const f of files) { this.files.set(f.path, f.content); @@ -65,10 +73,34 @@ export class MemoryIndexDriver implements IndexDriver { return Promise.resolve(paths); } + storeExtracted(_vaultPath: string, relPath: string, note: ExtractedNote): Promise { + this.extracted.set(relPath, note); + return Promise.resolve(); + } + + backlinks( + _vaultPath: string, + targetRelPath: string, + ): Promise<{ sourcePath: string; positionChar: number }[]> { + const results: { sourcePath: string; positionChar: number }[] = []; + for (const [sourcePath, note] of this.extracted) { + for (const link of note.links) { + if (link.target === targetRelPath) { + results.push({ sourcePath, positionChar: link.positionChar }); + } + } + } + results.sort( + (a, b) => a.sourcePath.localeCompare(b.sourcePath) || a.positionChar - b.positionChar, + ); + return Promise.resolve(results); + } + deleteFile(_vaultPath: string, relPath: string): Promise { this.files.delete(relPath); this.meta.delete(relPath); this.ids.delete(relPath); + this.extracted.delete(relPath); return Promise.resolve(); } diff --git a/packages/core/src/index/index.ts b/packages/core/src/index/index.ts index da5b393..679d807 100644 --- a/packages/core/src/index/index.ts +++ b/packages/core/src/index/index.ts @@ -1,4 +1,4 @@ export { Indexer, type IndexerDeps } from "./indexer.js"; export { MemoryIndexDriver, type IndexDriver } from "./driver.js"; -export { extractIndexContent } from "./parser/md.js"; -export { type IndexedFileMeta, type IndexEvent } from "./types.js"; +export { extractIndexContent, extractHeadings, extractLinks, extractTags } from "./parser/md.js"; +export { type IndexedFileMeta, type IndexEvent, type ExtractedNote } from "./types.js"; From 1045c39c29f1df2ee83e56d0c9fbae096d291cdc Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 18:36:03 +0530 Subject: [PATCH 06/16] Feat: Updated indexer with the new link features. Updated upsert, rebuild to check for tags, links --- packages/core/src/index/indexer.ts | 49 ++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/core/src/index/indexer.ts b/packages/core/src/index/indexer.ts index 18b3a91..d8e4c08 100644 --- a/packages/core/src/index/indexer.ts +++ b/packages/core/src/index/indexer.ts @@ -2,8 +2,9 @@ import { joinPath } from "../path.js"; import { type FSAdapter } from "../storage/adapter.js"; import { type VaultEvent } from "../events/types.js"; import { type IndexDriver } from "./driver.js"; -import { extractIndexContent } from "./parser/md.js"; -import { type IndexEvent } from "./types.js"; +import { extractIndexContent, extractHeadings, extractLinks, extractTags } from "./parser/md.js"; +import { type IndexEvent, type ExtractedNote } from "./types.js"; +import { resolveWikilink } from "../links/resolver.js"; export interface IndexerDeps { fs: FSAdapter; @@ -25,6 +26,7 @@ function isIndexablePath(relPath: string): boolean { */ export class Indexer { private unsubscribe: (() => void) | null = null; + private titleToPath = new Map(); constructor(private readonly deps: IndexerDeps) {} @@ -56,11 +58,18 @@ export class Indexer { async rebuild(): Promise { const rels = await this.walkVault(this.deps.vaultPath); const entries: { path: string; content: string; mtime: number }[] = []; + const strippedByPath = new Map(); for (const rel of rels) { const content = await this.deps.fs.readFile(joinPath(this.deps.vaultPath, rel)); - entries.push({ path: rel, content: extractIndexContent(content), mtime: Date.now() }); + const stripped = extractIndexContent(content); + entries.push({ path: rel, content: stripped, mtime: Date.now() }); + strippedByPath.set(rel, stripped); } await this.deps.index.rebuild(this.deps.vaultPath, entries); + await this.refreshTitleIndex(); + for (const rel of rels) { + await this.storeExtractedFor(rel, strippedByPath.get(rel)!); + } this.deps.onIndex?.({ type: "index:rebuilt" }); } @@ -88,15 +97,37 @@ export class Indexer { } } + private async refreshTitleIndex(): Promise { + const metas = await this.deps.index.listFiles(this.deps.vaultPath); + this.titleToPath.clear(); + for (const meta of metas) { + const base = meta.path.split("/").pop() ?? meta.path; + const title = base.replace(/\.md$/i, ""); + this.titleToPath.set(title, meta.path); + } + } + + private async storeExtractedFor(relPath: string, stripped: string): Promise { + const note: ExtractedNote = { + links: extractLinks(stripped) + .map((l) => { + const target = resolveWikilink(l.target, this.titleToPath); + return target === null ? null : { target, positionChar: l.positionChar }; + }) + .filter((l): l is { target: string; positionChar: number } => l !== null), + headings: extractHeadings(stripped), + tags: extractTags(stripped), + }; + await this.deps.index.storeExtracted(this.deps.vaultPath, relPath, note); + } + private async upsertFile(relPath: string): Promise { if (!isIndexablePath(relPath)) return; const content = await this.deps.fs.readFile(joinPath(this.deps.vaultPath, relPath)); - await this.deps.index.upsertFile( - this.deps.vaultPath, - relPath, - extractIndexContent(content), - Date.now(), - ); + const stripped = extractIndexContent(content); + await this.deps.index.upsertFile(this.deps.vaultPath, relPath, stripped, Date.now()); + await this.refreshTitleIndex(); + await this.storeExtractedFor(relPath, stripped); this.deps.onIndex?.({ type: "index:upserted", path: relPath }); } From 14b7d77ed3641846ab38182efd0968a12fab1936 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 18:37:18 +0530 Subject: [PATCH 07/16] Feat: Split path and refresh title index --- packages/core/src/index/indexer.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/core/src/index/indexer.ts b/packages/core/src/index/indexer.ts index d8e4c08..06858f2 100644 --- a/packages/core/src/index/indexer.ts +++ b/packages/core/src/index/indexer.ts @@ -97,16 +97,20 @@ export class Indexer { } } - private async refreshTitleIndex(): Promise { - const metas = await this.deps.index.listFiles(this.deps.vaultPath); + private setTitleToPath(paths: string[]): void { this.titleToPath.clear(); - for (const meta of metas) { - const base = meta.path.split("/").pop() ?? meta.path; + for (const path of paths) { + const base = path.split("/").pop() ?? path; const title = base.replace(/\.md$/i, ""); - this.titleToPath.set(title, meta.path); + this.titleToPath.set(title, path); } } + private async refreshTitleIndex(): Promise { + const metas = await this.deps.index.listFiles(this.deps.vaultPath); + this.setTitleToPath(metas.map((m) => m.path)); + } + private async storeExtractedFor(relPath: string, stripped: string): Promise { const note: ExtractedNote = { links: extractLinks(stripped) From e5605e2f766e996881ca3493e8a597142dac08c3 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 18:38:53 +0530 Subject: [PATCH 08/16] Feat: Updated sweep to build once ad then pass for fater execution, upsertfile updated for optional override --- packages/core/src/index/indexer.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/core/src/index/indexer.ts b/packages/core/src/index/indexer.ts index 06858f2..a28e006 100644 --- a/packages/core/src/index/indexer.ts +++ b/packages/core/src/index/indexer.ts @@ -50,8 +50,13 @@ export class Indexer { await this.deps.index.deleteFile(this.deps.vaultPath, meta.path); } } + const dmMap = new Map(); + for (const p of diskMd) { + const base = p.split("/").pop() ?? p; + dmMap.set(base.replace(/\.md$/i, ""), p); + } for (const relPath of diskMd) { - await this.upsertFile(relPath); + await this.upsertFile(relPath, dmMap); } } @@ -125,12 +130,19 @@ export class Indexer { await this.deps.index.storeExtracted(this.deps.vaultPath, relPath, note); } - private async upsertFile(relPath: string): Promise { + private async upsertFile( + relPath: string, + titleToPathOverride?: Map, + ): Promise { if (!isIndexablePath(relPath)) return; const content = await this.deps.fs.readFile(joinPath(this.deps.vaultPath, relPath)); const stripped = extractIndexContent(content); await this.deps.index.upsertFile(this.deps.vaultPath, relPath, stripped, Date.now()); - await this.refreshTitleIndex(); + if (titleToPathOverride !== undefined) { + this.titleToPath = titleToPathOverride; + } else { + await this.refreshTitleIndex(); + } await this.storeExtractedFor(relPath, stripped); this.deps.onIndex?.({ type: "index:upserted", path: relPath }); } From 1e008cfc1c15d6157c8a1e7d080988a0ee9fbf53 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 20:10:34 +0530 Subject: [PATCH 09/16] Feat: Added tests for md parser --- .../src/index/parser/__tests__/md.spec.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 packages/core/src/index/parser/__tests__/md.spec.ts diff --git a/packages/core/src/index/parser/__tests__/md.spec.ts b/packages/core/src/index/parser/__tests__/md.spec.ts new file mode 100644 index 0000000..036de6e --- /dev/null +++ b/packages/core/src/index/parser/__tests__/md.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { extractLinks, extractHeadings, extractTags } from "../md.js"; + +describe("extractLinks", () => { + it("returns each [[link]] with its absolute char offset", () => { + expect(extractLinks("See [[Java]] and [[Notes/JAVA.md]] here")).toEqual([ + { target: "Java", positionChar: 4 }, + { target: "Notes/JAVA.md", positionChar: 17 }, + ]); + }); + + it("computes positions across line breaks", () => { + expect(extractLinks("A\n[[Java]]\n[[Go]]")).toEqual([ + { target: "Java", positionChar: 2 }, + { target: "Go", positionChar: 11 }, + ]); + }); + + it("keeps block-ref suffixes in the raw target", () => { + expect(extractLinks("[[Java#^abc]]")).toEqual([{ target: "Java#^abc", positionChar: 0 }]); + }); + + it("returns [] for text without links", () => { + expect(extractLinks("no links here")).toEqual([]); + expect(extractLinks("")).toEqual([]); + }); +}); + +describe("extractHeadings", () => { + it("extracts level, text and text-start position", () => { + expect(extractHeadings("# Big\n## Small\n\nMore\n")).toEqual([ + { level: 1, text: "Big", position: 2 }, + { level: 2, text: "Small", position: 9 }, + ]); + }); + + it("trims surrounding whitespace from heading text", () => { + expect(extractHeadings("## Hello ")).toEqual([{ level: 2, text: "Hello", position: 3 }]); + }); + + it("returns [] when there are no headings", () => { + expect(extractHeadings("plain text")).toEqual([]); + expect(extractHeadings("")).toEqual([]); + }); +}); + +describe("extractTags", () => { + it("collects #tags and dedupes", () => { + expect(extractTags("#java and #rust and #java")).toEqual(["java", "rust"]); + }); + + it("skips trailing sentence periods", () => { + expect(extractTags("See #go.")).toEqual([]); + }); + + it("does not treat headings as tags", () => { + expect(extractTags("# Heading")).toEqual([]); + }); + + it("keeps tag case and allows hyphens/underscores", () => { + expect(extractTags("#Java #my-tag #my_tag")).toEqual(["Java", "my-tag", "my_tag"]); + }); + + it("returns [] when no tags present", () => { + expect(extractTags("no tags")).toEqual([]); + expect(extractTags("")).toEqual([]); + }); +}); From b178b05dced9543366631e6db79fc06308095b99 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 20:53:27 +0530 Subject: [PATCH 10/16] Big Feat: Added structs for backlinks, tags. Added features for indexing for tags, backlinks --- crates/trachyte-db/src/lib.rs | 217 ++++++++++++++++++++++++++++++++-- 1 file changed, 210 insertions(+), 7 deletions(-) diff --git a/crates/trachyte-db/src/lib.rs b/crates/trachyte-db/src/lib.rs index 218022a..7853b55 100644 --- a/crates/trachyte-db/src/lib.rs +++ b/crates/trachyte-db/src/lib.rs @@ -27,6 +27,46 @@ pub struct FileMeta { pub hash: String, } +/// A wikilink found in a source note, already resolved to a target path. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredLink { + /// Vault-relative path of the link target. + pub target_path: String, + /// Char offset of the `[[` in the (frontmatter-stripped) source content. + pub position_char: i64, +} + +/// A markdown heading in a source note. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredHeading { + /// Heading level (1..=6). + pub level: i64, + /// Heading text, trimmed. + pub text: String, + /// Char offset of the heading text start. + pub position: i64, +} + +/// A `#tag` in a source note. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredTag { + pub tag: String, +} + +/// A note that points at a given target note. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] + +pub struct Backlink { + /// Vault-relative path of the source note. + pub source_path: String, + /// Char offset of the link in the source note. + pub position_char: i64, +} + /// A single entry fed into a transactional index rebuild. pub struct RebuildEntry { /// Vault-relative path of the indexed file. @@ -43,9 +83,7 @@ pub struct Database { } impl Database { - /// Open (or create) an index database at `path`, applying pragmas and - /// running pending migrations. - /// + /// Open (or create) an index database at `path`, applying pragmas and running pending migrations. /// The parent directory must already exist (the vault's `.trachyte/` /// guarantees this); it is not created. pub fn open(path: impl AsRef) -> Result { @@ -55,10 +93,9 @@ impl Database { Ok(Database { conn }) } - /// Insert a file and its content into the index, returning its row id. - /// + /// Insert a file and its content into the index, returning its row id /// `hash` should be the BLAKE3 hex digest of `content` - /// (see [`hash::hash_content`]); the caller computes `size`. + /// (see [`hash::hash_content`]); the caller computes `size` pub fn insert_file( &self, path: &str, @@ -110,7 +147,7 @@ impl Database { } /// Remove a file and its FTS mirror row from the index. - /// Headings, tags, and backlinks are removed by `ON DELETE CASCADE`. + /// Headings, tags, and backlinks are removed by `ON DELETE CASCADE` pub fn delete_file(&self, path: &str) -> Result<(), DbError> { self.conn.execute( "DELETE FROM content_fts WHERE path = ?1", @@ -164,6 +201,88 @@ impl Database { pub fn search(&self, query: &str) -> Result, DbError> { fts::search(&self.conn, query) } + + /// Replace a file's headings, tags, and backlink rows (delete-then-insert) + /// The file must already exist ([`Database::upsert_file`] / `rebuild` + /// guarantee this). Link targets that don't resolve to an indexed file + /// are skipped — no orphan `backlinks` row is created + pub fn store_extracted( + &self, + source_path: &str, + links: &[StoredLink], + headings: &[StoredHeading], + tags: &[StoredTag], + ) -> Result<(), DbError> { + let tx = self.conn.unchecked_transaction()?; + let source_id: i64 = tx.query_row( + "SELECT id FROM files WHERE path = ?1", + rusqlite::params![source_path], + |row| row.get(0), + )?; + tx.execute( + "DELETE FROM backlinks WHERE source_file_id = ?1", + rusqlite::params![source_id], + )?; + tx.execute( + "DELETE FROM headings WHERE file_id = ?1", + rusqlite::params![source_id], + )?; + tx.execute( + "DELETE FROM tags WHERE file_id = ?1", + rusqlite::params![source_id], + )?; + { + let mut stmt = tx.prepare( + "INSERT INTO headings (file_id, level, text, position) VALUES (?1, ?2, ?3, ?4)", + )?; + for h in headings { + stmt.execute(rusqlite::params![source_id, h.level, h.text, h.position])?; + } + } + { + let mut stmt = tx.prepare("INSERT INTO tags (file_id, tag) VALUES (?1, ?2)")?; + for t in tags { + stmt.execute(rusqlite::params![source_id, t.tag])?; + } + } + { + let mut stmt = tx.prepare( + "INSERT INTO backlinks (target_file_id, source_file_id, position_char) + VALUES (?1, ?2, ?3)", + )?; + for link in links { + let target_id: Result = tx.query_row( + "SELECT id FROM files WHERE path = ?1", + rusqlite::params![link.target_path], + |row| row.get(0), + ); + if let Ok(target_id) = target_id { + stmt.execute(rusqlite::params![target_id, source_id, link.position_char])?; + } + } + } + tx.commit()?; + Ok(()) + } + + /// Return all notes that link to `target_path`, ordered by source then position + pub fn list_backlinks(&self, target_path: &str) -> Result, DbError> { + let mut stmt = self.conn.prepare( + "SELECT f.path, b.position_char + FROM backlinks b + JOIN files f ON f.id = b.source_file_id + JOIN files t ON t.id = b.target_file_id + WHERE t.path = ?1 + ORDER BY f.path, b.position_char", + )?; + let rows = stmt.query_map(rusqlite::params![target_path], |row| { + Ok(Backlink { + source_path: row.get(0)?, + position_char: row.get(1)?, + }) + })?; + rows.collect::, _>>().map_err(DbError::from) + } } #[cfg(test)] @@ -177,6 +296,13 @@ mod tests { (dir, db) } + fn link(target: &str, pos: i64) -> StoredLink { + StoredLink { + target_path: target.into(), + position_char: pos, + } + } + #[test] fn insert_and_search_roundtrip() { let (_dir, db) = open_db(); @@ -322,4 +448,81 @@ mod tests { assert_eq!(db.list_meta().unwrap().len(), 1); assert_eq!(db.search("alpha").unwrap(), vec!["Notes/A.md".to_string()]); } + + #[test] + fn store_extracted_then_list_backlinks_roundtrip() { + let (_dir, db) = open_db(); + db.insert_file("Notes/A.md", "See [[Java]]", 0, 12, "h-a") + .unwrap(); + db.insert_file("Notes/Java.md", "content", 0, 7, "h-j") + .unwrap(); + + db.store_extracted( + "Notes/A.md", + &[link("Notes/Java.md", 4)], + &[StoredHeading { + level: 1, + text: "A".into(), + position: 2, + }], + &[StoredTag { tag: "rust".into() }], + ) + .unwrap(); + + assert_eq!( + db.list_backlinks("Notes/Java.md").unwrap(), + vec![Backlink { + source_path: "Notes/A.md".into(), + position_char: 4 + }] + ); + } + + #[test] + fn unresolved_link_target_skipped() { + let (_dir, db) = open_db(); + db.insert_file("Notes/A.md", "see [[Nope]]", 0, 12, "h-a") + .unwrap(); + + db.store_extracted("Notes/A.md", &[link("Notes/Nope.md", 4)], &[], &[]) + .unwrap(); + + assert!(db.list_backlinks("Notes/Nope.md").unwrap().is_empty()); + } + + #[test] + fn reindex_does_not_duplicate_rows() { + let (_dir, db) = open_db(); + db.insert_file("Notes/A.md", "see [[Java]]", 0, 12, "h-a") + .unwrap(); + db.insert_file("Notes/Java.md", "x", 0, 1, "h-j").unwrap(); + + db.store_extracted("Notes/A.md", &[link("Notes/Java.md", 4)], &[], &[]) + .unwrap(); + db.store_extracted("Notes/A.md", &[link("Notes/Java.md", 9)], &[], &[]) + .unwrap(); + + let links = db.list_backlinks("Notes/Java.md").unwrap(); + assert_eq!( + links, + vec![Backlink { + source_path: "Notes/A.md".into(), + position_char: 9 + }] + ); + } + + #[test] + fn delete_cascades_to_backlinks() { + let (_dir, db) = open_db(); + db.insert_file("Notes/A.md", "see [[Java]]", 0, 12, "h-a") + .unwrap(); + db.insert_file("Notes/Java.md", "x", 0, 1, "h-j").unwrap(); + db.store_extracted("Notes/A.md", &[link("Notes/Java.md", 4)], &[], &[]) + .unwrap(); + + db.delete_file("Notes/A.md").unwrap(); + + assert!(db.list_backlinks("Notes/Java.md").unwrap().is_empty()); + } } From 3459899a79cdab69636736974f3380744858b2b3 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 21:01:33 +0530 Subject: [PATCH 11/16] Feat: Added extracted baklink lister storing --- crates/trachyte-ipc/src/commands.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/trachyte-ipc/src/commands.rs b/crates/trachyte-ipc/src/commands.rs index 95f66ec..b24fb0f 100644 --- a/crates/trachyte-ipc/src/commands.rs +++ b/crates/trachyte-ipc/src/commands.rs @@ -113,8 +113,33 @@ pub struct DoctorVaultReport { pub schema_version: Option, } +/// Replace a file's headings, tags, and backlink rows in the vault index +#[tauri::command] +#[specta::specta] +pub fn index_store_extracted( + vault_path: String, + rel_path: String, + links: Vec, + headings: Vec, + tags: Vec, +) -> Result<(), IpcError> { + let db = open_index(&vault_path)?; + db.store_extracted(&rel_path, &links, &headings, &tags) + .map_err(db_to_ipc) +} + +/// Return all notes that link to a target note, with char-offset positions +#[tauri::command] +#[specta::specta] +pub fn index_list_backlinks( + vault_path: String, + target_path: String, +) -> Result, IpcError> { + let db = open_index(&vault_path)?; + db.list_backlinks(&target_path).map_err(db_to_ipc) +} + /// Aggregate health check for a vault: validity plus schema version value. -/// /// Reuses `trachyte_fs::validate_vault` for the layout and additionally reads the schema /// version *value* — the validator only checks the file's existence. #[tauri::command] From f67bb01240085947080123b8682deb6f163039ae Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 21:03:51 +0530 Subject: [PATCH 12/16] Feat: Wired up commands to src-ipc, added test for commands --- apps/desktop/src-tauri/src/lib.rs | 2 ++ crates/trachyte-ipc/src/commands.rs | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1f1631b..6114d4b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -22,6 +22,8 @@ pub fn run() { trachyte_ipc::commands::index_delete_file, trachyte_ipc::commands::index_list_files, trachyte_ipc::commands::index_rebuild, + trachyte_ipc::commands::index_store_extracted, + trachyte_ipc::commands::index_list_backlinks, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/crates/trachyte-ipc/src/commands.rs b/crates/trachyte-ipc/src/commands.rs index b24fb0f..d5b8fed 100644 --- a/crates/trachyte-ipc/src/commands.rs +++ b/crates/trachyte-ipc/src/commands.rs @@ -465,4 +465,29 @@ mod tests { assert_eq!(f.content, "hi"); assert_eq!(f.mtime, 1); } + + #[test] + fn index_store_extracted_and_list_backlinks_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = create_vault(dir.path()); + index_upsert_file(path.clone(), "Notes/A.md".into(), "see [[Java]]".into(), 0).unwrap(); + index_upsert_file(path.clone(), "Notes/Java.md".into(), "content".into(), 0).unwrap(); + + index_store_extracted( + path.clone(), + "Notes/A.md".into(), + vec![trachyte_db::StoredLink { + target_path: "Notes/Java.md".into(), + position_char: 4, + }], + vec![], + vec![], + ) + .unwrap(); + + let backlinks = index_list_backlinks(path, "Notes/Java.md".into()).unwrap(); + assert_eq!(backlinks.len(), 1); + assert_eq!(backlinks[0].source_path, "Notes/A.md"); + assert_eq!(backlinks[0].position_char, 4); + } } From c3d68cb8994af2a0e28e061b75553262c4b68625 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 21:08:17 +0530 Subject: [PATCH 13/16] Feat: Added extacted notes into db, with interfaces and functions --- apps/desktop/src/ipc/db.ts | 43 +++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/ipc/db.ts b/apps/desktop/src/ipc/db.ts index de3fc48..40fa842 100644 --- a/apps/desktop/src/ipc/db.ts +++ b/apps/desktop/src/ipc/db.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { IndexedFileMeta } from "@trachyte/core"; +import type { IndexedFileMeta, ExtractedNote } from "@trachyte/core"; export async function indexInsertFile( vaultPath: string, @@ -40,3 +40,44 @@ export interface RebuildFile { export async function indexRebuild(vaultPath: string, files: RebuildFile[]): Promise { return invoke("index_rebuild", { vaultPath, files }); } + +export interface StoredLink { + targetPath: string; + positionChar: number; +} + +export interface StoredHeading { + level: number; + text: string; + position: number; +} + +export interface StoredTag { + tag: string; +} + +export async function indexStoreExtracted( + vaultPath: string, + relPath: string, + note: ExtractedNote, +): Promise { + return invoke("index_store_extracted", { + vaultPath, + relPath, + links: note.links.map((l) => ({ targetPath: l.target, positionChar: l.positionChar })), + headings: note.headings, + tags: note.tags.map((tag) => ({ tag })), + }); +} + +export interface Backlink { + sourcePath: string; + positionChar: number; +} + +export async function indexListBacklinks( + vaultPath: string, + targetPath: string, +): Promise { + return invoke("index_list_backlinks", { vaultPath, targetPath }); +} From 5077e55230a6bd14191abdf764c815dad8e1dd42 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 21:10:55 +0530 Subject: [PATCH 14/16] Feat: Updated index-driver with extracted note, backlink features --- apps/desktop/src/ipc/index-driver.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/ipc/index-driver.ts b/apps/desktop/src/ipc/index-driver.ts index 3478025..e4368b9 100644 --- a/apps/desktop/src/ipc/index-driver.ts +++ b/apps/desktop/src/ipc/index-driver.ts @@ -1,11 +1,14 @@ import type { IndexDriver } from "@trachyte/core"; import { indexDeleteFile, + indexListBacklinks, indexListFiles, indexRebuild, indexSearch, + indexStoreExtracted, indexUpsertFile, } from "./db.js"; + export const tauriIndexDriver: IndexDriver = { upsertFile: indexUpsertFile, deleteFile: indexDeleteFile, @@ -16,4 +19,6 @@ export const tauriIndexDriver: IndexDriver = { files.map((f) => ({ relPath: f.path, content: f.content, mtime: f.mtime })), ), search: indexSearch, + storeExtracted: (vaultPath, relPath, note) => indexStoreExtracted(vaultPath, relPath, note), + backlinks: (vaultPath, targetRelPath) => indexListBacklinks(vaultPath, targetRelPath), }; From 5dd6939c6470369dfe08bbdf0ef18f275fc08d89 Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 23:03:25 +0530 Subject: [PATCH 15/16] Feat: Consolidated imports, uses, fixed stale docs --- apps/desktop/src/ipc/index-driver.ts | 4 ++-- crates/trachyte-db/src/lib.rs | 1 - packages/core/src/index/indexer.ts | 12 +++++++----- packages/core/src/index/parser/md.ts | 6 +++--- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/ipc/index-driver.ts b/apps/desktop/src/ipc/index-driver.ts index e4368b9..2f8fcef 100644 --- a/apps/desktop/src/ipc/index-driver.ts +++ b/apps/desktop/src/ipc/index-driver.ts @@ -19,6 +19,6 @@ export const tauriIndexDriver: IndexDriver = { files.map((f) => ({ relPath: f.path, content: f.content, mtime: f.mtime })), ), search: indexSearch, - storeExtracted: (vaultPath, relPath, note) => indexStoreExtracted(vaultPath, relPath, note), - backlinks: (vaultPath, targetRelPath) => indexListBacklinks(vaultPath, targetRelPath), + storeExtracted: indexStoreExtracted, + backlinks: indexListBacklinks, }; diff --git a/crates/trachyte-db/src/lib.rs b/crates/trachyte-db/src/lib.rs index 7853b55..1717530 100644 --- a/crates/trachyte-db/src/lib.rs +++ b/crates/trachyte-db/src/lib.rs @@ -59,7 +59,6 @@ pub struct StoredTag { /// A note that points at a given target note. #[derive(Debug, Clone, PartialEq, serde::Serialize)] #[serde(rename_all = "camelCase")] - pub struct Backlink { /// Vault-relative path of the source note. pub source_path: String, diff --git a/packages/core/src/index/indexer.ts b/packages/core/src/index/indexer.ts index a28e006..4ee2298 100644 --- a/packages/core/src/index/indexer.ts +++ b/packages/core/src/index/indexer.ts @@ -14,6 +14,11 @@ export interface IndexerDeps { onIndex?: (event: IndexEvent) => void; } +function titleOfPath(path: string): string { + const base = path.split("/").pop() ?? path; + return base.replace(/\.md$/i, ""); +} + function isIndexablePath(relPath: string): boolean { return relPath.endsWith(".md") && !relPath.startsWith(".trachyte"); } @@ -52,8 +57,7 @@ export class Indexer { } const dmMap = new Map(); for (const p of diskMd) { - const base = p.split("/").pop() ?? p; - dmMap.set(base.replace(/\.md$/i, ""), p); + dmMap.set(titleOfPath(p), p); } for (const relPath of diskMd) { await this.upsertFile(relPath, dmMap); @@ -105,9 +109,7 @@ export class Indexer { private setTitleToPath(paths: string[]): void { this.titleToPath.clear(); for (const path of paths) { - const base = path.split("/").pop() ?? path; - const title = base.replace(/\.md$/i, ""); - this.titleToPath.set(title, path); + this.titleToPath.set(titleOfPath(path), path); } } diff --git a/packages/core/src/index/parser/md.ts b/packages/core/src/index/parser/md.ts index 870f384..133579f 100644 --- a/packages/core/src/index/parser/md.ts +++ b/packages/core/src/index/parser/md.ts @@ -1,9 +1,9 @@ /** * Extract the indexable text from a markdown file * - * For v0.0.1 this only strips a leading YAML frontmatter block - * (`---\n…\n---`) so FTS doesn't index frontmatter keys. Heading/tag/link - * extraction is deferred to later PRs. + * Strips a leading YAML frontmatter block (`---\n…\n---`) so FTS doesn't + * index frontmatter keys. Link/heading/tag extraction is provided by + * `extractLinks`, `extractHeadings`, and `extractTags` below. */ export function extractIndexContent(markdown: string): string { const trimmed = markdown.startsWith("\uFEFF") ? markdown.slice(1) : markdown; From 1bc1208ad1e671f3dfe0b9524b43ff53f218983d Mon Sep 17 00:00:00 2001 From: Debankan Roy Date: Wed, 12 Aug 2026 23:03:42 +0530 Subject: [PATCH 16/16] Updated Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 930f554..ab34402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Wikilinks + block refs in CM6: `wikilinks()` extension (`[[target]]` highlighted, title-first + `path/`/`.md` escape-hatch resolution, resolved vs faint-unresolved styling, click-to-open note via `handleWikilinkClick`); `blockRefs()` extension (`^id` line-end definitions decorated, `#^id` decoded in link targets); `CodeMirrorEditor` gains `links`/`onOpenLink` props (resolution data injected from the app — editor stays Tauri-free); `Home.tsx` builds the title→path index from `VaultManager.list()` and wires link navigation; first `@trachyte/editor` vitest harness (resolver + wikilink extension specs) +- Link/heading/tag data layer: MD parser extracts `[[wikilinks]]` (char-offset positions), `#headings`, and `#tags`; `Indexer` persists them to the `headings`/`tags`/`backlinks` tables via the new `IndexDriver.storeExtracted` method; `IndexDriver.backlinks(vaultPath, target)` query returns source notes + char positions (Rust `store_extracted`/`list_backlinks`, IPC `index_store_extracted`/`index_list_backlinks`, desktop wrappers); wikilink resolver relocated into core and shared with the editor + ### Changed - CI Hardening for `ci.yml` now checks inside app/desktop to confirm build ablility