sensemaking 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -297,7 +297,13 @@ function resolveIncremental(db, delta) {
297
297
  return resolveRows(db, _to_consumable_array(candidates.values()), pathSet, byBase);
298
298
  }
299
299
  function linkEdges(db) {
300
- return db.prepare('SELECT DISTINCT src, dst FROM links WHERE dst IS NOT NULL').all().map(function(r) {
300
+ // One edge per row, as 0.12's grain always had it -- two written targets resolving to one
301
+ // dst stay two edges (a weight the fusion evals were gated on). The only exclusion is an
302
+ // embed whose exact (src, target) also exists as a link: that second row is new with the
303
+ // embed grain and would double an edge that used to be one row. The NOT EXISTS probes the
304
+ // primary key and fires only for embed rows; both branches still scan the table.
305
+ var sql = "SELECT src, dst FROM links WHERE dst IS NOT NULL AND embed = 0\n UNION ALL\n SELECT src, dst FROM links l WHERE dst IS NOT NULL AND embed = 1\n AND NOT EXISTS (SELECT 1 FROM links l0 WHERE l0.src = l.src AND l0.target = l.target AND l0.embed = 0)";
306
+ return db.prepare(sql).all().map(function(r) {
301
307
  return [
302
308
  r.src,
303
309
  r.dst
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/links.ts"],"sourcesContent":["import posix from 'node:path/posix';\nimport type { DatabaseSync } from 'node:sqlite';\nimport type { FileStat } from '../scan.ts';\nimport type { Feature, ReconcileDelta } from './types.ts';\n\n// links(src, target, target_base, dst, embed): target as written, target_base its baseKey\n// (indexed, for the incremental resolve below), dst the resolved path or NULL (a\n// queryable dead link), embed whether it's `![[x]]`/`![](x.md)` rather than `[[x]]`/`[](x.md)`\n// -- Obsidian's grain: a target both linked and embedded in the same note is two rows.\n\n// Wikilinks ([[target]], [[target#anchor|alias]], embeds) plus relative markdown links to .md files.\nfunction extract(_raw: string, body: string): Array<{ target: string; embed: boolean }> {\n const seen = new Set<string>();\n const results: Array<{ target: string; embed: boolean }> = [];\n const add = (target: string, embed: boolean) => {\n const key = `${target}\\0${embed ? '1' : '0'}`;\n if (seen.has(key)) return;\n seen.add(key);\n results.push({ target, embed });\n };\n for (const m of body.matchAll(/\\[\\[([^\\]|#]+)(?:#[^\\]|]*)?(?:\\|[^\\]]*)?\\]\\]/g)) {\n const target = m[1].trim();\n if (target) add(target, body[(m.index ?? 0) - 1] === '!');\n }\n for (const m of body.matchAll(/(!)?\\[[^\\]]*\\]\\(([^)]+\\.md)(?:#[^)]*)?\\)/g)) {\n const target = m[2].trim();\n if (target && !/^[a-z]+:\\/\\//i.test(target)) add(target, m[1] === '!');\n }\n return results;\n}\n\nfunction cleanTarget(target: string): string {\n return target.replace(/\\\\/g, '/').replace(/^\\.\\//, '');\n}\n\nfunction baseKey(path: string): string {\n return posix.basename(path).replace(/\\.md$/i, '').toLowerCase();\n}\n\n// Obsidian-style: exact relative path (with/without .md), path relative to the linking\n// note's directory, then basename match (lexicographically first on ties).\nfunction resolveTarget(src: string, target: string, pathSet: Set<string>, byBase: Map<string, string[]>): string | null {\n const clean = cleanTarget(target);\n const fromSrc = posix.normalize(posix.join(posix.dirname(src), clean));\n for (const candidate of [clean, `${clean}.md`, fromSrc, `${fromSrc}.md`]) {\n if (pathSet.has(candidate)) return candidate;\n }\n return byBase.get(baseKey(clean))?.[0] ?? null;\n}\n\nfunction buildByBase(files: FileStat[]): Map<string, string[]> {\n const byBase = new Map<string, string[]>();\n for (const path of files.map((f) => f.relPath).sort()) {\n const key = baseKey(path);\n const list = byBase.get(key);\n if (list) list.push(path);\n else byBase.set(key, [path]);\n }\n return byBase;\n}\n\ninterface LinkRow {\n src: string;\n target: string;\n dst: string | null;\n embed: number;\n}\n\n// Applies resolveTarget to `rows`, writing only rows whose dst actually changes. The UPDATE\n// keys on (src, target): a link/embed sibling pair shares one resolution, so either row's\n// mismatch rewrites both.\nfunction resolveRows(db: DatabaseSync, rows: LinkRow[], pathSet: Set<string>, byBase: Map<string, string[]>): boolean {\n const update = db.prepare('UPDATE links SET dst = ? WHERE src = ? AND target = ?');\n let changed = false;\n for (const row of rows) {\n const dst = resolveTarget(row.src, row.target, pathSet, byBase);\n if (dst !== row.dst) {\n update.run(dst, row.src, row.target);\n changed = true;\n }\n }\n return changed;\n}\n\n// A new or deleted file can change any note's resolution, so re-resolve the whole table.\n// Fallback for cold builds and large deltas -- see afterReconcile's threshold.\nfunction resolveAll(db: DatabaseSync, files: FileStat[]): boolean {\n const pathSet = new Set(files.map((f) => f.relPath));\n const byBase = buildByBase(files);\n const rows = db.prepare('SELECT src, target, dst, embed FROM links').all() as unknown as LinkRow[];\n return resolveRows(db, rows, pathSet, byBase);\n}\n\n// Re-resolve only what this reconcile could have touched: re-stored sources, links whose\n// target basename matches an added/vanished file, and links whose dst just vanished. All\n// three are indexed lookups, so this never reads the whole table.\nfunction resolveIncremental(db: DatabaseSync, delta: ReconcileDelta): boolean {\n const pathSet = new Set(delta.files.map((f) => f.relPath));\n const byBase = buildByBase(delta.files);\n\n const changedBasenames = new Set<string>();\n for (const p of delta.added) changedBasenames.add(baseKey(p));\n for (const p of delta.vanished) changedBasenames.add(baseKey(p));\n\n // \\0 can't appear in a path or target, so the key never collides; a space can.\n const candidates = new Map<string, LinkRow>();\n const collect = (rows: LinkRow[]) => {\n for (const row of rows) candidates.set(`${row.src}\\0${row.target}\\0${row.embed}`, row);\n };\n // Chunked so a large-but-under-threshold delta never exceeds SQLite's bound-variable\n // limit (SQLITE_MAX_VARIABLE_NUMBER, 32766).\n const collectIn = (column: string, keys: string[]) => {\n for (let i = 0; i < keys.length; i += 500) {\n const chunk = keys.slice(i, i + 500);\n const placeholders = chunk.map(() => '?').join(', ');\n collect(db.prepare(`SELECT src, target, dst, embed FROM links WHERE ${column} IN (${placeholders})`).all(...chunk) as unknown as LinkRow[]);\n }\n };\n\n collectIn('src', delta.reparsed);\n collectIn('target_base', [...changedBasenames]);\n collectIn('dst', delta.vanished);\n\n return resolveRows(db, [...candidates.values()], pathSet, byBase);\n}\n\n// Resolved edges, for rank and for search's graph expansion. DISTINCT: a target both linked\n// and embedded is two rows sharing one edge, which must not double its weight.\nexport function linkEdges(db: DatabaseSync): [string, string][] {\n return (db.prepare('SELECT DISTINCT src, dst FROM links WHERE dst IS NOT NULL').all() as Array<{ src: string; dst: string }>).map((r) => [r.src, r.dst]);\n}\n\n// remove() has already deleted the rows by the time afterReconcile runs, so it records here\n// whether they carried edges. Keyed on the delta object, so the state dies with the reconcile.\nconst removedWithLinks = new WeakMap<ReconcileDelta, Set<string>>();\n\nfunction recordRemoved(delta: ReconcileDelta, path: string): void {\n let set = removedWithLinks.get(delta);\n if (!set) {\n set = new Set();\n removedWithLinks.set(delta, set);\n }\n set.add(path);\n}\n\n// remove() runs per path; the vanished list is an array, so cache the Set per delta.\nconst vanishedSets = new WeakMap<ReconcileDelta, Set<string>>();\nfunction vanishedSet(delta: ReconcileDelta): Set<string> {\n let set = vanishedSets.get(delta);\n if (!set) {\n set = new Set(delta.vanished);\n vanishedSets.set(delta, set);\n }\n return set;\n}\n\nexport const links: Feature = {\n name: 'links',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS links (src TEXT, target TEXT, target_base TEXT, dst TEXT, embed INTEGER, PRIMARY KEY (src, target, embed))');\n db.exec('CREATE INDEX IF NOT EXISTS links_dst ON links(dst)');\n db.exec('CREATE INDEX IF NOT EXISTS links_target_base ON links(target_base)');\n },\n extract,\n // Reparsed docs are diffed in store() below instead of wiped here: deleting and\n // re-inserting resets dst to NULL, which made every touch-only reparse read as an edge\n // change and recompute PageRank -- measured as most of the remaining update cost.\n remove(db, path, delta) {\n if (!vanishedSet(delta).has(path)) return;\n const result = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n if (Number(result.changes) > 0) recordRemoved(delta, path);\n },\n store(db, path, extracted, delta) {\n const targets = extracted as Array<{ target: string; embed: boolean }>;\n // Stale rows (target/embed pairs the new parse no longer contains) are real edge removals.\n // \\0 can't appear in a target, so the composite key never collides.\n let stale: ReturnType<ReturnType<DatabaseSync['prepare']>['run']>;\n if (targets.length === 0) {\n stale = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n } else {\n const placeholders = targets.map(() => '?').join(', ');\n const keys = targets.map((t) => `${t.target}\\0${t.embed ? '1' : '0'}`);\n stale = db.prepare(`DELETE FROM links WHERE src = ? AND (target || char(0) || embed) NOT IN (${placeholders})`).run(path, ...keys);\n }\n if (Number(stale.changes) > 0) recordRemoved(delta, path);\n // Upsert preserves dst on surviving rows, so an unchanged link resolves to the same\n // value and reports no change; only genuinely new rows start at NULL.\n const insert = db.prepare('INSERT INTO links (src, target, target_base, dst, embed) VALUES (?, ?, ?, NULL, ?) ON CONFLICT(src, target, embed) DO UPDATE SET target_base = excluded.target_base');\n for (const { target, embed } of targets) insert.run(path, target, baseKey(cleanTarget(target)), embed ? 1 : 0);\n },\n afterReconcile(db, delta) {\n // Any deleted rows that carried edges mean the edge set shrank -- whether the file\n // vanished or was reparsed down to fewer/no links. dstChanged alone misses the\n // reparsed-to-zero-links case (no surviving rows to re-resolve).\n const removeHadLinks = (removedWithLinks.get(delta)?.size ?? 0) > 0;\n\n const churn = delta.reparsed.length + delta.vanished.length;\n // Past this share of the tree, a full pass is the only one guaranteed to match\n // resolveTarget's ambiguity rules. A cold build clears the threshold on its own.\n const large = delta.files.length === 0 || churn > 0.2 * delta.files.length;\n\n const dstChanged = large ? resolveAll(db, delta.files) : resolveIncremental(db, delta);\n delta.linksChanged = dstChanged || removeHadLinks;\n },\n};\n"],"names":["linkEdges","links","extract","_raw","body","seen","Set","results","add","target","embed","key","has","push","matchAll","m","trim","index","test","cleanTarget","replace","baseKey","path","posix","basename","toLowerCase","resolveTarget","src","pathSet","byBase","clean","fromSrc","normalize","join","dirname","candidate","get","buildByBase","files","Map","map","f","relPath","sort","list","set","resolveRows","db","rows","update","prepare","changed","row","dst","run","resolveAll","all","resolveIncremental","delta","changedBasenames","added","p","vanished","candidates","collect","collectIn","column","keys","i","length","chunk","slice","placeholders","reparsed","values","r","removedWithLinks","WeakMap","recordRemoved","vanishedSets","vanishedSet","name","schema","exec","remove","result","Number","changes","store","extracted","targets","stale","t","insert","afterReconcile","removeHadLinks","size","churn","large","dstChanged","linksChanged"],"mappings":"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"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/links.ts"],"sourcesContent":["import posix from 'node:path/posix';\nimport type { DatabaseSync } from 'node:sqlite';\nimport type { FileStat } from '../scan.ts';\nimport type { Feature, ReconcileDelta } from './types.ts';\n\n// links(src, target, target_base, dst, embed): target as written, target_base its baseKey\n// (indexed, for the incremental resolve below), dst the resolved path or NULL (a\n// queryable dead link), embed whether it's `![[x]]`/`![](x.md)` rather than `[[x]]`/`[](x.md)`\n// -- Obsidian's grain: a target both linked and embedded in the same note is two rows.\n\n// Wikilinks ([[target]], [[target#anchor|alias]], embeds) plus relative markdown links to .md files.\nfunction extract(_raw: string, body: string): Array<{ target: string; embed: boolean }> {\n const seen = new Set<string>();\n const results: Array<{ target: string; embed: boolean }> = [];\n const add = (target: string, embed: boolean) => {\n const key = `${target}\\0${embed ? '1' : '0'}`;\n if (seen.has(key)) return;\n seen.add(key);\n results.push({ target, embed });\n };\n for (const m of body.matchAll(/\\[\\[([^\\]|#]+)(?:#[^\\]|]*)?(?:\\|[^\\]]*)?\\]\\]/g)) {\n const target = m[1].trim();\n if (target) add(target, body[(m.index ?? 0) - 1] === '!');\n }\n for (const m of body.matchAll(/(!)?\\[[^\\]]*\\]\\(([^)]+\\.md)(?:#[^)]*)?\\)/g)) {\n const target = m[2].trim();\n if (target && !/^[a-z]+:\\/\\//i.test(target)) add(target, m[1] === '!');\n }\n return results;\n}\n\nfunction cleanTarget(target: string): string {\n return target.replace(/\\\\/g, '/').replace(/^\\.\\//, '');\n}\n\nfunction baseKey(path: string): string {\n return posix.basename(path).replace(/\\.md$/i, '').toLowerCase();\n}\n\n// Obsidian-style: exact relative path (with/without .md), path relative to the linking\n// note's directory, then basename match (lexicographically first on ties).\nfunction resolveTarget(src: string, target: string, pathSet: Set<string>, byBase: Map<string, string[]>): string | null {\n const clean = cleanTarget(target);\n const fromSrc = posix.normalize(posix.join(posix.dirname(src), clean));\n for (const candidate of [clean, `${clean}.md`, fromSrc, `${fromSrc}.md`]) {\n if (pathSet.has(candidate)) return candidate;\n }\n return byBase.get(baseKey(clean))?.[0] ?? null;\n}\n\nfunction buildByBase(files: FileStat[]): Map<string, string[]> {\n const byBase = new Map<string, string[]>();\n for (const path of files.map((f) => f.relPath).sort()) {\n const key = baseKey(path);\n const list = byBase.get(key);\n if (list) list.push(path);\n else byBase.set(key, [path]);\n }\n return byBase;\n}\n\ninterface LinkRow {\n src: string;\n target: string;\n dst: string | null;\n embed: number;\n}\n\n// Applies resolveTarget to `rows`, writing only rows whose dst actually changes. The UPDATE\n// keys on (src, target): a link/embed sibling pair shares one resolution, so either row's\n// mismatch rewrites both.\nfunction resolveRows(db: DatabaseSync, rows: LinkRow[], pathSet: Set<string>, byBase: Map<string, string[]>): boolean {\n const update = db.prepare('UPDATE links SET dst = ? WHERE src = ? AND target = ?');\n let changed = false;\n for (const row of rows) {\n const dst = resolveTarget(row.src, row.target, pathSet, byBase);\n if (dst !== row.dst) {\n update.run(dst, row.src, row.target);\n changed = true;\n }\n }\n return changed;\n}\n\n// A new or deleted file can change any note's resolution, so re-resolve the whole table.\n// Fallback for cold builds and large deltas -- see afterReconcile's threshold.\nfunction resolveAll(db: DatabaseSync, files: FileStat[]): boolean {\n const pathSet = new Set(files.map((f) => f.relPath));\n const byBase = buildByBase(files);\n const rows = db.prepare('SELECT src, target, dst, embed FROM links').all() as unknown as LinkRow[];\n return resolveRows(db, rows, pathSet, byBase);\n}\n\n// Re-resolve only what this reconcile could have touched: re-stored sources, links whose\n// target basename matches an added/vanished file, and links whose dst just vanished. All\n// three are indexed lookups, so this never reads the whole table.\nfunction resolveIncremental(db: DatabaseSync, delta: ReconcileDelta): boolean {\n const pathSet = new Set(delta.files.map((f) => f.relPath));\n const byBase = buildByBase(delta.files);\n\n const changedBasenames = new Set<string>();\n for (const p of delta.added) changedBasenames.add(baseKey(p));\n for (const p of delta.vanished) changedBasenames.add(baseKey(p));\n\n // \\0 can't appear in a path or target, so the key never collides; a space can.\n const candidates = new Map<string, LinkRow>();\n const collect = (rows: LinkRow[]) => {\n for (const row of rows) candidates.set(`${row.src}\\0${row.target}\\0${row.embed}`, row);\n };\n // Chunked so a large-but-under-threshold delta never exceeds SQLite's bound-variable\n // limit (SQLITE_MAX_VARIABLE_NUMBER, 32766).\n const collectIn = (column: string, keys: string[]) => {\n for (let i = 0; i < keys.length; i += 500) {\n const chunk = keys.slice(i, i + 500);\n const placeholders = chunk.map(() => '?').join(', ');\n collect(db.prepare(`SELECT src, target, dst, embed FROM links WHERE ${column} IN (${placeholders})`).all(...chunk) as unknown as LinkRow[]);\n }\n };\n\n collectIn('src', delta.reparsed);\n collectIn('target_base', [...changedBasenames]);\n collectIn('dst', delta.vanished);\n\n return resolveRows(db, [...candidates.values()], pathSet, byBase);\n}\n\n// Resolved edges, for rank and for search's graph expansion.\nexport function linkEdges(db: DatabaseSync): [string, string][] {\n // One edge per row, as 0.12's grain always had it -- two written targets resolving to one\n // dst stay two edges (a weight the fusion evals were gated on). The only exclusion is an\n // embed whose exact (src, target) also exists as a link: that second row is new with the\n // embed grain and would double an edge that used to be one row. The NOT EXISTS probes the\n // primary key and fires only for embed rows; both branches still scan the table.\n const sql = `SELECT src, dst FROM links WHERE dst IS NOT NULL AND embed = 0\n UNION ALL\n SELECT src, dst FROM links l WHERE dst IS NOT NULL AND embed = 1\n AND NOT EXISTS (SELECT 1 FROM links l0 WHERE l0.src = l.src AND l0.target = l.target AND l0.embed = 0)`;\n return (db.prepare(sql).all() as Array<{ src: string; dst: string }>).map((r) => [r.src, r.dst]);\n}\n\n// remove() has already deleted the rows by the time afterReconcile runs, so it records here\n// whether they carried edges. Keyed on the delta object, so the state dies with the reconcile.\nconst removedWithLinks = new WeakMap<ReconcileDelta, Set<string>>();\n\nfunction recordRemoved(delta: ReconcileDelta, path: string): void {\n let set = removedWithLinks.get(delta);\n if (!set) {\n set = new Set();\n removedWithLinks.set(delta, set);\n }\n set.add(path);\n}\n\n// remove() runs per path; the vanished list is an array, so cache the Set per delta.\nconst vanishedSets = new WeakMap<ReconcileDelta, Set<string>>();\nfunction vanishedSet(delta: ReconcileDelta): Set<string> {\n let set = vanishedSets.get(delta);\n if (!set) {\n set = new Set(delta.vanished);\n vanishedSets.set(delta, set);\n }\n return set;\n}\n\nexport const links: Feature = {\n name: 'links',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS links (src TEXT, target TEXT, target_base TEXT, dst TEXT, embed INTEGER, PRIMARY KEY (src, target, embed))');\n db.exec('CREATE INDEX IF NOT EXISTS links_dst ON links(dst)');\n db.exec('CREATE INDEX IF NOT EXISTS links_target_base ON links(target_base)');\n },\n extract,\n // Reparsed docs are diffed in store() below instead of wiped here: deleting and\n // re-inserting resets dst to NULL, which made every touch-only reparse read as an edge\n // change and recompute PageRank -- measured as most of the remaining update cost.\n remove(db, path, delta) {\n if (!vanishedSet(delta).has(path)) return;\n const result = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n if (Number(result.changes) > 0) recordRemoved(delta, path);\n },\n store(db, path, extracted, delta) {\n const targets = extracted as Array<{ target: string; embed: boolean }>;\n // Stale rows (target/embed pairs the new parse no longer contains) are real edge removals.\n // \\0 can't appear in a target, so the composite key never collides.\n let stale: ReturnType<ReturnType<DatabaseSync['prepare']>['run']>;\n if (targets.length === 0) {\n stale = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n } else {\n const placeholders = targets.map(() => '?').join(', ');\n const keys = targets.map((t) => `${t.target}\\0${t.embed ? '1' : '0'}`);\n stale = db.prepare(`DELETE FROM links WHERE src = ? AND (target || char(0) || embed) NOT IN (${placeholders})`).run(path, ...keys);\n }\n if (Number(stale.changes) > 0) recordRemoved(delta, path);\n // Upsert preserves dst on surviving rows, so an unchanged link resolves to the same\n // value and reports no change; only genuinely new rows start at NULL.\n const insert = db.prepare('INSERT INTO links (src, target, target_base, dst, embed) VALUES (?, ?, ?, NULL, ?) ON CONFLICT(src, target, embed) DO UPDATE SET target_base = excluded.target_base');\n for (const { target, embed } of targets) insert.run(path, target, baseKey(cleanTarget(target)), embed ? 1 : 0);\n },\n afterReconcile(db, delta) {\n // Any deleted rows that carried edges mean the edge set shrank -- whether the file\n // vanished or was reparsed down to fewer/no links. dstChanged alone misses the\n // reparsed-to-zero-links case (no surviving rows to re-resolve).\n const removeHadLinks = (removedWithLinks.get(delta)?.size ?? 0) > 0;\n\n const churn = delta.reparsed.length + delta.vanished.length;\n // Past this share of the tree, a full pass is the only one guaranteed to match\n // resolveTarget's ambiguity rules. A cold build clears the threshold on its own.\n const large = delta.files.length === 0 || churn > 0.2 * delta.files.length;\n\n const dstChanged = large ? resolveAll(db, delta.files) : resolveIncremental(db, delta);\n delta.linksChanged = dstChanged || removeHadLinks;\n },\n};\n"],"names":["linkEdges","links","extract","_raw","body","seen","Set","results","add","target","embed","key","has","push","matchAll","m","trim","index","test","cleanTarget","replace","baseKey","path","posix","basename","toLowerCase","resolveTarget","src","pathSet","byBase","clean","fromSrc","normalize","join","dirname","candidate","get","buildByBase","files","Map","map","f","relPath","sort","list","set","resolveRows","db","rows","update","prepare","changed","row","dst","run","resolveAll","all","resolveIncremental","delta","changedBasenames","added","p","vanished","candidates","collect","collectIn","column","keys","i","length","chunk","slice","placeholders","reparsed","values","sql","r","removedWithLinks","WeakMap","recordRemoved","vanishedSets","vanishedSet","name","schema","exec","remove","result","Number","changes","store","extracted","targets","stale","t","insert","afterReconcile","removeHadLinks","size","churn","large","dstChanged","linksChanged"],"mappings":"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"}
@@ -92,9 +92,10 @@ function inlineTags(body) {
92
92
  continue;
93
93
  }
94
94
  if (inFence) continue;
95
- var cleaned = line.replace(INLINE_CODE_RE, function(m) {
95
+ if (!line.includes('#')) continue; // most lines; skip the regex work
96
+ var cleaned = line.includes('`') ? line.replace(INLINE_CODE_RE, function(m) {
96
97
  return ' '.repeat(m.length);
97
- });
98
+ }) : line;
98
99
  var _iteratorNormalCompletion1 = true, _didIteratorError1 = false, _iteratorError1 = undefined;
99
100
  try {
100
101
  for(var _iterator1 = cleaned.matchAll(INLINE_TAG_RE)[Symbol.iterator](), _step1; !(_iteratorNormalCompletion1 = (_step1 = _iterator1.next()).done); _iteratorNormalCompletion1 = true){
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/tags.ts"],"sourcesContent":["import type { Feature } from './types.ts';\n\n// tags(path, tag): Obsidian's file.tags grain -- frontmatter list/string tags plus inline\n// #tags from the prose, deduplicated, source not distinguished. Nested tags store full\n// (book/scifi); `tag = 'book' OR tag LIKE 'book/%'` is how a caller matches the parent too.\n\nconst FENCE_RE = /^(```|~~~)/;\nconst INLINE_CODE_RE = /`[^`]*`/g;\n// Anchors on start-of-line or a preceding whitespace/(/[ so `a#b` and URL fragments don't count.\nconst INLINE_TAG_RE = /(?:^|[\\s([])#([\\p{L}\\p{N}_/-]+)/gu;\n\n// Strips a leading # (frontmatter entries may carry one) and a trailing /; rejects an\n// all-digit result -- a tag needs at least one non-digit character.\nfunction normalizeTag(raw: string): string | null {\n const stripped = raw.replace(/^#/, '').replace(/\\/+$/, '');\n if (!stripped || /^\\d+$/.test(stripped)) return null;\n return stripped;\n}\n\n// data.tags: a YAML list (Obsidian also accepts a bare string). Null members and non-string\n// members are skipped rather than throwing -- `tags:\\n -` parses to [null].\nfunction frontmatterTags(data?: Record<string, unknown>): string[] {\n const raw = data?.tags;\n const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];\n const found: string[] = [];\n for (const item of list) {\n if (typeof item !== 'string') continue;\n const tag = normalizeTag(item);\n if (tag) found.push(tag);\n }\n return found;\n}\n\n// #tag tokens outside fenced code blocks and inline code spans.\nfunction inlineTags(body: string): string[] {\n const found: string[] = [];\n let inFence = false;\n for (const line of body.split('\\n')) {\n if (FENCE_RE.test(line)) {\n inFence = !inFence;\n continue;\n }\n if (inFence) continue;\n const cleaned = line.replace(INLINE_CODE_RE, (m) => ' '.repeat(m.length));\n for (const m of cleaned.matchAll(INLINE_TAG_RE)) {\n const tag = normalizeTag(m[1]);\n if (tag) found.push(tag);\n }\n }\n return found;\n}\n\nfunction extract(_raw: string, body: string, _search?: { title: string; summary: string }, data?: Record<string, unknown>): string[] {\n return [...new Set([...frontmatterTags(data), ...inlineTags(body)])].sort();\n}\n\nexport const tags: Feature = {\n name: 'tags',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS tags (\"path\" TEXT, tag TEXT, PRIMARY KEY (\"path\", tag))');\n db.exec('CREATE INDEX IF NOT EXISTS tags_tag ON tags(tag)');\n },\n extract,\n // Per-file rows with nothing else to resolve, so only a vanished file needs a delete here;\n // store() below handles a reparse's stale rows itself.\n remove(db, path, delta) {\n if (!delta.vanished.includes(path)) return;\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n },\n store(db, path, extracted) {\n const found = extracted as string[];\n if (found.length === 0) {\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n } else {\n const placeholders = found.map(() => '?').join(', ');\n db.prepare(`DELETE FROM tags WHERE \"path\" = ? AND tag NOT IN (${placeholders})`).run(path, ...found);\n }\n const insert = db.prepare('INSERT OR IGNORE INTO tags (\"path\", tag) VALUES (?, ?)');\n for (const tag of found) insert.run(path, tag);\n },\n};\n"],"names":["tags","FENCE_RE","INLINE_CODE_RE","INLINE_TAG_RE","normalizeTag","raw","stripped","replace","test","frontmatterTags","data","list","Array","isArray","found","item","tag","push","inlineTags","body","inFence","split","line","cleaned","m","repeat","length","matchAll","extract","_raw","_search","Set","sort","name","schema","db","exec","remove","path","delta","vanished","includes","prepare","run","store","extracted","placeholders","map","join","insert"],"mappings":";;;;+BAwDaA;;;eAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAtDb,0FAA0F;AAC1F,uFAAuF;AACvF,4FAA4F;AAE5F,IAAMC,WAAW;AACjB,IAAMC,iBAAiB;AACvB,iGAAiG;AACjG,IAAMC,gBAAgB;AAEtB,sFAAsF;AACtF,oEAAoE;AACpE,SAASC,aAAaC,GAAW;IAC/B,IAAMC,WAAWD,IAAIE,OAAO,CAAC,MAAM,IAAIA,OAAO,CAAC,QAAQ;IACvD,IAAI,CAACD,YAAY,QAAQE,IAAI,CAACF,WAAW,OAAO;IAChD,OAAOA;AACT;AAEA,4FAA4F;AAC5F,6EAA6E;AAC7E,SAASG,gBAAgBC,IAA8B;IACrD,IAAML,MAAMK,iBAAAA,2BAAAA,KAAMV,IAAI;IACtB,IAAMW,OAAOC,MAAMC,OAAO,CAACR,OAAOA,MAAM,OAAOA,QAAQ,WAAW;QAACA;KAAI,GAAG,EAAE;IAC5E,IAAMS,QAAkB,EAAE;QACrB,kCAAA,2BAAA;;QAAL,QAAK,YAAcH,yBAAd,SAAA,6BAAA,QAAA,yBAAA,iCAAoB;YAApB,IAAMI,OAAN;YACH,IAAI,OAAOA,SAAS,UAAU;YAC9B,IAAMC,MAAMZ,aAAaW;YACzB,IAAIC,KAAKF,MAAMG,IAAI,CAACD;QACtB;;QAJK;QAAA;;;iBAAA,6BAAA;gBAAA;;;gBAAA;sBAAA;;;;IAKL,OAAOF;AACT;AAEA,gEAAgE;AAChE,SAASI,WAAWC,IAAY;IAC9B,IAAML,QAAkB,EAAE;IAC1B,IAAIM,UAAU;QACT,kCAAA,2BAAA;;QAAL,QAAK,YAAcD,KAAKE,KAAK,CAAC,0BAAzB,SAAA,6BAAA,QAAA,yBAAA,iCAAgC;YAAhC,IAAMC,OAAN;YACH,IAAIrB,SAASO,IAAI,CAACc,OAAO;gBACvBF,UAAU,CAACA;gBACX;YACF;YACA,IAAIA,SAAS;YACb,IAAMG,UAAUD,KAAKf,OAAO,CAACL,gBAAgB,SAACsB;uBAAM,IAAIC,MAAM,CAACD,EAAEE,MAAM;;gBAClE,mCAAA,4BAAA;;gBAAL,QAAK,aAAWH,QAAQI,QAAQ,CAACxB,mCAA5B,UAAA,8BAAA,SAAA,0BAAA,kCAA4C;oBAA5C,IAAMqB,IAAN;oBACH,IAAMR,MAAMZ,aAAaoB,CAAC,CAAC,EAAE;oBAC7B,IAAIR,KAAKF,MAAMG,IAAI,CAACD;gBACtB;;gBAHK;gBAAA;;;yBAAA,8BAAA;wBAAA;;;wBAAA;8BAAA;;;;QAIP;;QAXK;QAAA;;;iBAAA,6BAAA;gBAAA;;;gBAAA;sBAAA;;;;IAYL,OAAOF;AACT;AAEA,SAASc,QAAQC,IAAY,EAAEV,IAAY,EAAEW,OAA4C,EAAEpB,IAA8B;IACvH,OAAO,AAAC,qBAAG,IAAIqB,IAAI,AAAC,qBAAGtB,gBAAgBC,cAAO,qBAAGQ,WAAWC,UAASa,IAAI;AAC3E;AAEO,IAAMhC,OAAgB;IAC3BiC,MAAM;IACNC,QAAAA,SAAAA,OAAOC,EAAE;QACPA,GAAGC,IAAI,CAAC;QACRD,GAAGC,IAAI,CAAC;IACV;IACAR,SAAAA;IACA,2FAA2F;IAC3F,uDAAuD;IACvDS,QAAAA,SAAAA,OAAOF,EAAE,EAAEG,IAAI,EAAEC,KAAK;QACpB,IAAI,CAACA,MAAMC,QAAQ,CAACC,QAAQ,CAACH,OAAO;QACpCH,GAAGO,OAAO,CAAC,qCAAqCC,GAAG,CAACL;IACtD;IACAM,OAAAA,SAAAA,MAAMT,EAAE,EAAEG,IAAI,EAAEO,SAAS;QACvB,IAAM/B,QAAQ+B;QACd,IAAI/B,MAAMY,MAAM,KAAK,GAAG;YACtBS,GAAGO,OAAO,CAAC,qCAAqCC,GAAG,CAACL;QACtD,OAAO;gBAELH;YADA,IAAMW,eAAehC,MAAMiC,GAAG,CAAC;uBAAM;eAAKC,IAAI,CAAC;YAC/Cb,CAAAA,cAAAA,GAAGO,OAAO,CAAC,AAAC,qDAAiE,OAAbI,cAAa,OAAIH,GAAG,OAApFR,aAAAA;gBAAqFG;aAAe,CAApGH,OAA2F,qBAAGrB;QAChG;QACA,IAAMmC,SAASd,GAAGO,OAAO,CAAC;YACrB,kCAAA,2BAAA;;YAAL,QAAK,YAAa5B,0BAAb,SAAA,6BAAA,QAAA,yBAAA;gBAAA,IAAME,MAAN;gBAAoBiC,OAAON,GAAG,CAACL,MAAMtB;;;YAArC;YAAA;;;qBAAA,6BAAA;oBAAA;;;oBAAA;0BAAA;;;;IACP;AACF"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/tags.ts"],"sourcesContent":["import type { Feature } from './types.ts';\n\n// tags(path, tag): Obsidian's file.tags grain -- frontmatter list/string tags plus inline\n// #tags from the prose, deduplicated, source not distinguished. Nested tags store full\n// (book/scifi); `tag = 'book' OR tag LIKE 'book/%'` is how a caller matches the parent too.\n\nconst FENCE_RE = /^(```|~~~)/;\nconst INLINE_CODE_RE = /`[^`]*`/g;\n// Anchors on start-of-line or a preceding whitespace/(/[ so `a#b` and URL fragments don't count.\nconst INLINE_TAG_RE = /(?:^|[\\s([])#([\\p{L}\\p{N}_/-]+)/gu;\n\n// Strips a leading # (frontmatter entries may carry one) and a trailing /; rejects an\n// all-digit result -- a tag needs at least one non-digit character.\nfunction normalizeTag(raw: string): string | null {\n const stripped = raw.replace(/^#/, '').replace(/\\/+$/, '');\n if (!stripped || /^\\d+$/.test(stripped)) return null;\n return stripped;\n}\n\n// data.tags: a YAML list (Obsidian also accepts a bare string). Null members and non-string\n// members are skipped rather than throwing -- `tags:\\n -` parses to [null].\nfunction frontmatterTags(data?: Record<string, unknown>): string[] {\n const raw = data?.tags;\n const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];\n const found: string[] = [];\n for (const item of list) {\n if (typeof item !== 'string') continue;\n const tag = normalizeTag(item);\n if (tag) found.push(tag);\n }\n return found;\n}\n\n// #tag tokens outside fenced code blocks and inline code spans.\nfunction inlineTags(body: string): string[] {\n const found: string[] = [];\n let inFence = false;\n for (const line of body.split('\\n')) {\n if (FENCE_RE.test(line)) {\n inFence = !inFence;\n continue;\n }\n if (inFence) continue;\n if (!line.includes('#')) continue; // most lines; skip the regex work\n const cleaned = line.includes('`') ? line.replace(INLINE_CODE_RE, (m) => ' '.repeat(m.length)) : line;\n for (const m of cleaned.matchAll(INLINE_TAG_RE)) {\n const tag = normalizeTag(m[1]);\n if (tag) found.push(tag);\n }\n }\n return found;\n}\n\nfunction extract(_raw: string, body: string, _search?: { title: string; summary: string }, data?: Record<string, unknown>): string[] {\n return [...new Set([...frontmatterTags(data), ...inlineTags(body)])].sort();\n}\n\nexport const tags: Feature = {\n name: 'tags',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS tags (\"path\" TEXT, tag TEXT, PRIMARY KEY (\"path\", tag))');\n db.exec('CREATE INDEX IF NOT EXISTS tags_tag ON tags(tag)');\n },\n extract,\n // Per-file rows with nothing else to resolve, so only a vanished file needs a delete here;\n // store() below handles a reparse's stale rows itself.\n remove(db, path, delta) {\n if (!delta.vanished.includes(path)) return;\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n },\n store(db, path, extracted) {\n const found = extracted as string[];\n if (found.length === 0) {\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n } else {\n const placeholders = found.map(() => '?').join(', ');\n db.prepare(`DELETE FROM tags WHERE \"path\" = ? AND tag NOT IN (${placeholders})`).run(path, ...found);\n }\n const insert = db.prepare('INSERT OR IGNORE INTO tags (\"path\", tag) VALUES (?, ?)');\n for (const tag of found) insert.run(path, tag);\n },\n};\n"],"names":["tags","FENCE_RE","INLINE_CODE_RE","INLINE_TAG_RE","normalizeTag","raw","stripped","replace","test","frontmatterTags","data","list","Array","isArray","found","item","tag","push","inlineTags","body","inFence","split","line","includes","cleaned","m","repeat","length","matchAll","extract","_raw","_search","Set","sort","name","schema","db","exec","remove","path","delta","vanished","prepare","run","store","extracted","placeholders","map","join","insert"],"mappings":"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"}
@@ -117,10 +117,18 @@ function resolveIncremental(db, delta) {
117
117
  ...candidates.values()
118
118
  ], pathSet, byBase);
119
119
  }
120
- // Resolved edges, for rank and for search's graph expansion. DISTINCT: a target both linked
121
- // and embedded is two rows sharing one edge, which must not double its weight.
120
+ // Resolved edges, for rank and for search's graph expansion.
122
121
  export function linkEdges(db) {
123
- return db.prepare('SELECT DISTINCT src, dst FROM links WHERE dst IS NOT NULL').all().map((r)=>[
122
+ // One edge per row, as 0.12's grain always had it -- two written targets resolving to one
123
+ // dst stay two edges (a weight the fusion evals were gated on). The only exclusion is an
124
+ // embed whose exact (src, target) also exists as a link: that second row is new with the
125
+ // embed grain and would double an edge that used to be one row. The NOT EXISTS probes the
126
+ // primary key and fires only for embed rows; both branches still scan the table.
127
+ const sql = `SELECT src, dst FROM links WHERE dst IS NOT NULL AND embed = 0
128
+ UNION ALL
129
+ SELECT src, dst FROM links l WHERE dst IS NOT NULL AND embed = 1
130
+ AND NOT EXISTS (SELECT 1 FROM links l0 WHERE l0.src = l.src AND l0.target = l.target AND l0.embed = 0)`;
131
+ return db.prepare(sql).all().map((r)=>[
124
132
  r.src,
125
133
  r.dst
126
134
  ]);
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/links.ts"],"sourcesContent":["import posix from 'node:path/posix';\nimport type { DatabaseSync } from 'node:sqlite';\nimport type { FileStat } from '../scan.ts';\nimport type { Feature, ReconcileDelta } from './types.ts';\n\n// links(src, target, target_base, dst, embed): target as written, target_base its baseKey\n// (indexed, for the incremental resolve below), dst the resolved path or NULL (a\n// queryable dead link), embed whether it's `![[x]]`/`![](x.md)` rather than `[[x]]`/`[](x.md)`\n// -- Obsidian's grain: a target both linked and embedded in the same note is two rows.\n\n// Wikilinks ([[target]], [[target#anchor|alias]], embeds) plus relative markdown links to .md files.\nfunction extract(_raw: string, body: string): Array<{ target: string; embed: boolean }> {\n const seen = new Set<string>();\n const results: Array<{ target: string; embed: boolean }> = [];\n const add = (target: string, embed: boolean) => {\n const key = `${target}\\0${embed ? '1' : '0'}`;\n if (seen.has(key)) return;\n seen.add(key);\n results.push({ target, embed });\n };\n for (const m of body.matchAll(/\\[\\[([^\\]|#]+)(?:#[^\\]|]*)?(?:\\|[^\\]]*)?\\]\\]/g)) {\n const target = m[1].trim();\n if (target) add(target, body[(m.index ?? 0) - 1] === '!');\n }\n for (const m of body.matchAll(/(!)?\\[[^\\]]*\\]\\(([^)]+\\.md)(?:#[^)]*)?\\)/g)) {\n const target = m[2].trim();\n if (target && !/^[a-z]+:\\/\\//i.test(target)) add(target, m[1] === '!');\n }\n return results;\n}\n\nfunction cleanTarget(target: string): string {\n return target.replace(/\\\\/g, '/').replace(/^\\.\\//, '');\n}\n\nfunction baseKey(path: string): string {\n return posix.basename(path).replace(/\\.md$/i, '').toLowerCase();\n}\n\n// Obsidian-style: exact relative path (with/without .md), path relative to the linking\n// note's directory, then basename match (lexicographically first on ties).\nfunction resolveTarget(src: string, target: string, pathSet: Set<string>, byBase: Map<string, string[]>): string | null {\n const clean = cleanTarget(target);\n const fromSrc = posix.normalize(posix.join(posix.dirname(src), clean));\n for (const candidate of [clean, `${clean}.md`, fromSrc, `${fromSrc}.md`]) {\n if (pathSet.has(candidate)) return candidate;\n }\n return byBase.get(baseKey(clean))?.[0] ?? null;\n}\n\nfunction buildByBase(files: FileStat[]): Map<string, string[]> {\n const byBase = new Map<string, string[]>();\n for (const path of files.map((f) => f.relPath).sort()) {\n const key = baseKey(path);\n const list = byBase.get(key);\n if (list) list.push(path);\n else byBase.set(key, [path]);\n }\n return byBase;\n}\n\ninterface LinkRow {\n src: string;\n target: string;\n dst: string | null;\n embed: number;\n}\n\n// Applies resolveTarget to `rows`, writing only rows whose dst actually changes. The UPDATE\n// keys on (src, target): a link/embed sibling pair shares one resolution, so either row's\n// mismatch rewrites both.\nfunction resolveRows(db: DatabaseSync, rows: LinkRow[], pathSet: Set<string>, byBase: Map<string, string[]>): boolean {\n const update = db.prepare('UPDATE links SET dst = ? WHERE src = ? AND target = ?');\n let changed = false;\n for (const row of rows) {\n const dst = resolveTarget(row.src, row.target, pathSet, byBase);\n if (dst !== row.dst) {\n update.run(dst, row.src, row.target);\n changed = true;\n }\n }\n return changed;\n}\n\n// A new or deleted file can change any note's resolution, so re-resolve the whole table.\n// Fallback for cold builds and large deltas -- see afterReconcile's threshold.\nfunction resolveAll(db: DatabaseSync, files: FileStat[]): boolean {\n const pathSet = new Set(files.map((f) => f.relPath));\n const byBase = buildByBase(files);\n const rows = db.prepare('SELECT src, target, dst, embed FROM links').all() as unknown as LinkRow[];\n return resolveRows(db, rows, pathSet, byBase);\n}\n\n// Re-resolve only what this reconcile could have touched: re-stored sources, links whose\n// target basename matches an added/vanished file, and links whose dst just vanished. All\n// three are indexed lookups, so this never reads the whole table.\nfunction resolveIncremental(db: DatabaseSync, delta: ReconcileDelta): boolean {\n const pathSet = new Set(delta.files.map((f) => f.relPath));\n const byBase = buildByBase(delta.files);\n\n const changedBasenames = new Set<string>();\n for (const p of delta.added) changedBasenames.add(baseKey(p));\n for (const p of delta.vanished) changedBasenames.add(baseKey(p));\n\n // \\0 can't appear in a path or target, so the key never collides; a space can.\n const candidates = new Map<string, LinkRow>();\n const collect = (rows: LinkRow[]) => {\n for (const row of rows) candidates.set(`${row.src}\\0${row.target}\\0${row.embed}`, row);\n };\n // Chunked so a large-but-under-threshold delta never exceeds SQLite's bound-variable\n // limit (SQLITE_MAX_VARIABLE_NUMBER, 32766).\n const collectIn = (column: string, keys: string[]) => {\n for (let i = 0; i < keys.length; i += 500) {\n const chunk = keys.slice(i, i + 500);\n const placeholders = chunk.map(() => '?').join(', ');\n collect(db.prepare(`SELECT src, target, dst, embed FROM links WHERE ${column} IN (${placeholders})`).all(...chunk) as unknown as LinkRow[]);\n }\n };\n\n collectIn('src', delta.reparsed);\n collectIn('target_base', [...changedBasenames]);\n collectIn('dst', delta.vanished);\n\n return resolveRows(db, [...candidates.values()], pathSet, byBase);\n}\n\n// Resolved edges, for rank and for search's graph expansion. DISTINCT: a target both linked\n// and embedded is two rows sharing one edge, which must not double its weight.\nexport function linkEdges(db: DatabaseSync): [string, string][] {\n return (db.prepare('SELECT DISTINCT src, dst FROM links WHERE dst IS NOT NULL').all() as Array<{ src: string; dst: string }>).map((r) => [r.src, r.dst]);\n}\n\n// remove() has already deleted the rows by the time afterReconcile runs, so it records here\n// whether they carried edges. Keyed on the delta object, so the state dies with the reconcile.\nconst removedWithLinks = new WeakMap<ReconcileDelta, Set<string>>();\n\nfunction recordRemoved(delta: ReconcileDelta, path: string): void {\n let set = removedWithLinks.get(delta);\n if (!set) {\n set = new Set();\n removedWithLinks.set(delta, set);\n }\n set.add(path);\n}\n\n// remove() runs per path; the vanished list is an array, so cache the Set per delta.\nconst vanishedSets = new WeakMap<ReconcileDelta, Set<string>>();\nfunction vanishedSet(delta: ReconcileDelta): Set<string> {\n let set = vanishedSets.get(delta);\n if (!set) {\n set = new Set(delta.vanished);\n vanishedSets.set(delta, set);\n }\n return set;\n}\n\nexport const links: Feature = {\n name: 'links',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS links (src TEXT, target TEXT, target_base TEXT, dst TEXT, embed INTEGER, PRIMARY KEY (src, target, embed))');\n db.exec('CREATE INDEX IF NOT EXISTS links_dst ON links(dst)');\n db.exec('CREATE INDEX IF NOT EXISTS links_target_base ON links(target_base)');\n },\n extract,\n // Reparsed docs are diffed in store() below instead of wiped here: deleting and\n // re-inserting resets dst to NULL, which made every touch-only reparse read as an edge\n // change and recompute PageRank -- measured as most of the remaining update cost.\n remove(db, path, delta) {\n if (!vanishedSet(delta).has(path)) return;\n const result = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n if (Number(result.changes) > 0) recordRemoved(delta, path);\n },\n store(db, path, extracted, delta) {\n const targets = extracted as Array<{ target: string; embed: boolean }>;\n // Stale rows (target/embed pairs the new parse no longer contains) are real edge removals.\n // \\0 can't appear in a target, so the composite key never collides.\n let stale: ReturnType<ReturnType<DatabaseSync['prepare']>['run']>;\n if (targets.length === 0) {\n stale = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n } else {\n const placeholders = targets.map(() => '?').join(', ');\n const keys = targets.map((t) => `${t.target}\\0${t.embed ? '1' : '0'}`);\n stale = db.prepare(`DELETE FROM links WHERE src = ? AND (target || char(0) || embed) NOT IN (${placeholders})`).run(path, ...keys);\n }\n if (Number(stale.changes) > 0) recordRemoved(delta, path);\n // Upsert preserves dst on surviving rows, so an unchanged link resolves to the same\n // value and reports no change; only genuinely new rows start at NULL.\n const insert = db.prepare('INSERT INTO links (src, target, target_base, dst, embed) VALUES (?, ?, ?, NULL, ?) ON CONFLICT(src, target, embed) DO UPDATE SET target_base = excluded.target_base');\n for (const { target, embed } of targets) insert.run(path, target, baseKey(cleanTarget(target)), embed ? 1 : 0);\n },\n afterReconcile(db, delta) {\n // Any deleted rows that carried edges mean the edge set shrank -- whether the file\n // vanished or was reparsed down to fewer/no links. dstChanged alone misses the\n // reparsed-to-zero-links case (no surviving rows to re-resolve).\n const removeHadLinks = (removedWithLinks.get(delta)?.size ?? 0) > 0;\n\n const churn = delta.reparsed.length + delta.vanished.length;\n // Past this share of the tree, a full pass is the only one guaranteed to match\n // resolveTarget's ambiguity rules. A cold build clears the threshold on its own.\n const large = delta.files.length === 0 || churn > 0.2 * delta.files.length;\n\n const dstChanged = large ? resolveAll(db, delta.files) : resolveIncremental(db, delta);\n delta.linksChanged = dstChanged || removeHadLinks;\n },\n};\n"],"names":["posix","extract","_raw","body","seen","Set","results","add","target","embed","key","has","push","m","matchAll","trim","index","test","cleanTarget","replace","baseKey","path","basename","toLowerCase","resolveTarget","src","pathSet","byBase","clean","fromSrc","normalize","join","dirname","candidate","get","buildByBase","files","Map","map","f","relPath","sort","list","set","resolveRows","db","rows","update","prepare","changed","row","dst","run","resolveAll","all","resolveIncremental","delta","changedBasenames","p","added","vanished","candidates","collect","collectIn","column","keys","i","length","chunk","slice","placeholders","reparsed","values","linkEdges","r","removedWithLinks","WeakMap","recordRemoved","vanishedSets","vanishedSet","links","name","schema","exec","remove","result","Number","changes","store","extracted","targets","stale","t","insert","afterReconcile","removeHadLinks","size","churn","large","dstChanged","linksChanged"],"mappings":"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"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/links.ts"],"sourcesContent":["import posix from 'node:path/posix';\nimport type { DatabaseSync } from 'node:sqlite';\nimport type { FileStat } from '../scan.ts';\nimport type { Feature, ReconcileDelta } from './types.ts';\n\n// links(src, target, target_base, dst, embed): target as written, target_base its baseKey\n// (indexed, for the incremental resolve below), dst the resolved path or NULL (a\n// queryable dead link), embed whether it's `![[x]]`/`![](x.md)` rather than `[[x]]`/`[](x.md)`\n// -- Obsidian's grain: a target both linked and embedded in the same note is two rows.\n\n// Wikilinks ([[target]], [[target#anchor|alias]], embeds) plus relative markdown links to .md files.\nfunction extract(_raw: string, body: string): Array<{ target: string; embed: boolean }> {\n const seen = new Set<string>();\n const results: Array<{ target: string; embed: boolean }> = [];\n const add = (target: string, embed: boolean) => {\n const key = `${target}\\0${embed ? '1' : '0'}`;\n if (seen.has(key)) return;\n seen.add(key);\n results.push({ target, embed });\n };\n for (const m of body.matchAll(/\\[\\[([^\\]|#]+)(?:#[^\\]|]*)?(?:\\|[^\\]]*)?\\]\\]/g)) {\n const target = m[1].trim();\n if (target) add(target, body[(m.index ?? 0) - 1] === '!');\n }\n for (const m of body.matchAll(/(!)?\\[[^\\]]*\\]\\(([^)]+\\.md)(?:#[^)]*)?\\)/g)) {\n const target = m[2].trim();\n if (target && !/^[a-z]+:\\/\\//i.test(target)) add(target, m[1] === '!');\n }\n return results;\n}\n\nfunction cleanTarget(target: string): string {\n return target.replace(/\\\\/g, '/').replace(/^\\.\\//, '');\n}\n\nfunction baseKey(path: string): string {\n return posix.basename(path).replace(/\\.md$/i, '').toLowerCase();\n}\n\n// Obsidian-style: exact relative path (with/without .md), path relative to the linking\n// note's directory, then basename match (lexicographically first on ties).\nfunction resolveTarget(src: string, target: string, pathSet: Set<string>, byBase: Map<string, string[]>): string | null {\n const clean = cleanTarget(target);\n const fromSrc = posix.normalize(posix.join(posix.dirname(src), clean));\n for (const candidate of [clean, `${clean}.md`, fromSrc, `${fromSrc}.md`]) {\n if (pathSet.has(candidate)) return candidate;\n }\n return byBase.get(baseKey(clean))?.[0] ?? null;\n}\n\nfunction buildByBase(files: FileStat[]): Map<string, string[]> {\n const byBase = new Map<string, string[]>();\n for (const path of files.map((f) => f.relPath).sort()) {\n const key = baseKey(path);\n const list = byBase.get(key);\n if (list) list.push(path);\n else byBase.set(key, [path]);\n }\n return byBase;\n}\n\ninterface LinkRow {\n src: string;\n target: string;\n dst: string | null;\n embed: number;\n}\n\n// Applies resolveTarget to `rows`, writing only rows whose dst actually changes. The UPDATE\n// keys on (src, target): a link/embed sibling pair shares one resolution, so either row's\n// mismatch rewrites both.\nfunction resolveRows(db: DatabaseSync, rows: LinkRow[], pathSet: Set<string>, byBase: Map<string, string[]>): boolean {\n const update = db.prepare('UPDATE links SET dst = ? WHERE src = ? AND target = ?');\n let changed = false;\n for (const row of rows) {\n const dst = resolveTarget(row.src, row.target, pathSet, byBase);\n if (dst !== row.dst) {\n update.run(dst, row.src, row.target);\n changed = true;\n }\n }\n return changed;\n}\n\n// A new or deleted file can change any note's resolution, so re-resolve the whole table.\n// Fallback for cold builds and large deltas -- see afterReconcile's threshold.\nfunction resolveAll(db: DatabaseSync, files: FileStat[]): boolean {\n const pathSet = new Set(files.map((f) => f.relPath));\n const byBase = buildByBase(files);\n const rows = db.prepare('SELECT src, target, dst, embed FROM links').all() as unknown as LinkRow[];\n return resolveRows(db, rows, pathSet, byBase);\n}\n\n// Re-resolve only what this reconcile could have touched: re-stored sources, links whose\n// target basename matches an added/vanished file, and links whose dst just vanished. All\n// three are indexed lookups, so this never reads the whole table.\nfunction resolveIncremental(db: DatabaseSync, delta: ReconcileDelta): boolean {\n const pathSet = new Set(delta.files.map((f) => f.relPath));\n const byBase = buildByBase(delta.files);\n\n const changedBasenames = new Set<string>();\n for (const p of delta.added) changedBasenames.add(baseKey(p));\n for (const p of delta.vanished) changedBasenames.add(baseKey(p));\n\n // \\0 can't appear in a path or target, so the key never collides; a space can.\n const candidates = new Map<string, LinkRow>();\n const collect = (rows: LinkRow[]) => {\n for (const row of rows) candidates.set(`${row.src}\\0${row.target}\\0${row.embed}`, row);\n };\n // Chunked so a large-but-under-threshold delta never exceeds SQLite's bound-variable\n // limit (SQLITE_MAX_VARIABLE_NUMBER, 32766).\n const collectIn = (column: string, keys: string[]) => {\n for (let i = 0; i < keys.length; i += 500) {\n const chunk = keys.slice(i, i + 500);\n const placeholders = chunk.map(() => '?').join(', ');\n collect(db.prepare(`SELECT src, target, dst, embed FROM links WHERE ${column} IN (${placeholders})`).all(...chunk) as unknown as LinkRow[]);\n }\n };\n\n collectIn('src', delta.reparsed);\n collectIn('target_base', [...changedBasenames]);\n collectIn('dst', delta.vanished);\n\n return resolveRows(db, [...candidates.values()], pathSet, byBase);\n}\n\n// Resolved edges, for rank and for search's graph expansion.\nexport function linkEdges(db: DatabaseSync): [string, string][] {\n // One edge per row, as 0.12's grain always had it -- two written targets resolving to one\n // dst stay two edges (a weight the fusion evals were gated on). The only exclusion is an\n // embed whose exact (src, target) also exists as a link: that second row is new with the\n // embed grain and would double an edge that used to be one row. The NOT EXISTS probes the\n // primary key and fires only for embed rows; both branches still scan the table.\n const sql = `SELECT src, dst FROM links WHERE dst IS NOT NULL AND embed = 0\n UNION ALL\n SELECT src, dst FROM links l WHERE dst IS NOT NULL AND embed = 1\n AND NOT EXISTS (SELECT 1 FROM links l0 WHERE l0.src = l.src AND l0.target = l.target AND l0.embed = 0)`;\n return (db.prepare(sql).all() as Array<{ src: string; dst: string }>).map((r) => [r.src, r.dst]);\n}\n\n// remove() has already deleted the rows by the time afterReconcile runs, so it records here\n// whether they carried edges. Keyed on the delta object, so the state dies with the reconcile.\nconst removedWithLinks = new WeakMap<ReconcileDelta, Set<string>>();\n\nfunction recordRemoved(delta: ReconcileDelta, path: string): void {\n let set = removedWithLinks.get(delta);\n if (!set) {\n set = new Set();\n removedWithLinks.set(delta, set);\n }\n set.add(path);\n}\n\n// remove() runs per path; the vanished list is an array, so cache the Set per delta.\nconst vanishedSets = new WeakMap<ReconcileDelta, Set<string>>();\nfunction vanishedSet(delta: ReconcileDelta): Set<string> {\n let set = vanishedSets.get(delta);\n if (!set) {\n set = new Set(delta.vanished);\n vanishedSets.set(delta, set);\n }\n return set;\n}\n\nexport const links: Feature = {\n name: 'links',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS links (src TEXT, target TEXT, target_base TEXT, dst TEXT, embed INTEGER, PRIMARY KEY (src, target, embed))');\n db.exec('CREATE INDEX IF NOT EXISTS links_dst ON links(dst)');\n db.exec('CREATE INDEX IF NOT EXISTS links_target_base ON links(target_base)');\n },\n extract,\n // Reparsed docs are diffed in store() below instead of wiped here: deleting and\n // re-inserting resets dst to NULL, which made every touch-only reparse read as an edge\n // change and recompute PageRank -- measured as most of the remaining update cost.\n remove(db, path, delta) {\n if (!vanishedSet(delta).has(path)) return;\n const result = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n if (Number(result.changes) > 0) recordRemoved(delta, path);\n },\n store(db, path, extracted, delta) {\n const targets = extracted as Array<{ target: string; embed: boolean }>;\n // Stale rows (target/embed pairs the new parse no longer contains) are real edge removals.\n // \\0 can't appear in a target, so the composite key never collides.\n let stale: ReturnType<ReturnType<DatabaseSync['prepare']>['run']>;\n if (targets.length === 0) {\n stale = db.prepare('DELETE FROM links WHERE src = ?').run(path);\n } else {\n const placeholders = targets.map(() => '?').join(', ');\n const keys = targets.map((t) => `${t.target}\\0${t.embed ? '1' : '0'}`);\n stale = db.prepare(`DELETE FROM links WHERE src = ? AND (target || char(0) || embed) NOT IN (${placeholders})`).run(path, ...keys);\n }\n if (Number(stale.changes) > 0) recordRemoved(delta, path);\n // Upsert preserves dst on surviving rows, so an unchanged link resolves to the same\n // value and reports no change; only genuinely new rows start at NULL.\n const insert = db.prepare('INSERT INTO links (src, target, target_base, dst, embed) VALUES (?, ?, ?, NULL, ?) ON CONFLICT(src, target, embed) DO UPDATE SET target_base = excluded.target_base');\n for (const { target, embed } of targets) insert.run(path, target, baseKey(cleanTarget(target)), embed ? 1 : 0);\n },\n afterReconcile(db, delta) {\n // Any deleted rows that carried edges mean the edge set shrank -- whether the file\n // vanished or was reparsed down to fewer/no links. dstChanged alone misses the\n // reparsed-to-zero-links case (no surviving rows to re-resolve).\n const removeHadLinks = (removedWithLinks.get(delta)?.size ?? 0) > 0;\n\n const churn = delta.reparsed.length + delta.vanished.length;\n // Past this share of the tree, a full pass is the only one guaranteed to match\n // resolveTarget's ambiguity rules. A cold build clears the threshold on its own.\n const large = delta.files.length === 0 || churn > 0.2 * delta.files.length;\n\n const dstChanged = large ? resolveAll(db, delta.files) : resolveIncremental(db, delta);\n delta.linksChanged = dstChanged || removeHadLinks;\n },\n};\n"],"names":["posix","extract","_raw","body","seen","Set","results","add","target","embed","key","has","push","m","matchAll","trim","index","test","cleanTarget","replace","baseKey","path","basename","toLowerCase","resolveTarget","src","pathSet","byBase","clean","fromSrc","normalize","join","dirname","candidate","get","buildByBase","files","Map","map","f","relPath","sort","list","set","resolveRows","db","rows","update","prepare","changed","row","dst","run","resolveAll","all","resolveIncremental","delta","changedBasenames","p","added","vanished","candidates","collect","collectIn","column","keys","i","length","chunk","slice","placeholders","reparsed","values","linkEdges","sql","r","removedWithLinks","WeakMap","recordRemoved","vanishedSets","vanishedSet","links","name","schema","exec","remove","result","Number","changes","store","extracted","targets","stale","t","insert","afterReconcile","removeHadLinks","size","churn","large","dstChanged","linksChanged"],"mappings":"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"}
@@ -37,7 +37,8 @@ function inlineTags(body) {
37
37
  continue;
38
38
  }
39
39
  if (inFence) continue;
40
- const cleaned = line.replace(INLINE_CODE_RE, (m)=>' '.repeat(m.length));
40
+ if (!line.includes('#')) continue; // most lines; skip the regex work
41
+ const cleaned = line.includes('`') ? line.replace(INLINE_CODE_RE, (m)=>' '.repeat(m.length)) : line;
41
42
  for (const m of cleaned.matchAll(INLINE_TAG_RE)){
42
43
  const tag = normalizeTag(m[1]);
43
44
  if (tag) found.push(tag);
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/tags.ts"],"sourcesContent":["import type { Feature } from './types.ts';\n\n// tags(path, tag): Obsidian's file.tags grain -- frontmatter list/string tags plus inline\n// #tags from the prose, deduplicated, source not distinguished. Nested tags store full\n// (book/scifi); `tag = 'book' OR tag LIKE 'book/%'` is how a caller matches the parent too.\n\nconst FENCE_RE = /^(```|~~~)/;\nconst INLINE_CODE_RE = /`[^`]*`/g;\n// Anchors on start-of-line or a preceding whitespace/(/[ so `a#b` and URL fragments don't count.\nconst INLINE_TAG_RE = /(?:^|[\\s([])#([\\p{L}\\p{N}_/-]+)/gu;\n\n// Strips a leading # (frontmatter entries may carry one) and a trailing /; rejects an\n// all-digit result -- a tag needs at least one non-digit character.\nfunction normalizeTag(raw: string): string | null {\n const stripped = raw.replace(/^#/, '').replace(/\\/+$/, '');\n if (!stripped || /^\\d+$/.test(stripped)) return null;\n return stripped;\n}\n\n// data.tags: a YAML list (Obsidian also accepts a bare string). Null members and non-string\n// members are skipped rather than throwing -- `tags:\\n -` parses to [null].\nfunction frontmatterTags(data?: Record<string, unknown>): string[] {\n const raw = data?.tags;\n const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];\n const found: string[] = [];\n for (const item of list) {\n if (typeof item !== 'string') continue;\n const tag = normalizeTag(item);\n if (tag) found.push(tag);\n }\n return found;\n}\n\n// #tag tokens outside fenced code blocks and inline code spans.\nfunction inlineTags(body: string): string[] {\n const found: string[] = [];\n let inFence = false;\n for (const line of body.split('\\n')) {\n if (FENCE_RE.test(line)) {\n inFence = !inFence;\n continue;\n }\n if (inFence) continue;\n const cleaned = line.replace(INLINE_CODE_RE, (m) => ' '.repeat(m.length));\n for (const m of cleaned.matchAll(INLINE_TAG_RE)) {\n const tag = normalizeTag(m[1]);\n if (tag) found.push(tag);\n }\n }\n return found;\n}\n\nfunction extract(_raw: string, body: string, _search?: { title: string; summary: string }, data?: Record<string, unknown>): string[] {\n return [...new Set([...frontmatterTags(data), ...inlineTags(body)])].sort();\n}\n\nexport const tags: Feature = {\n name: 'tags',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS tags (\"path\" TEXT, tag TEXT, PRIMARY KEY (\"path\", tag))');\n db.exec('CREATE INDEX IF NOT EXISTS tags_tag ON tags(tag)');\n },\n extract,\n // Per-file rows with nothing else to resolve, so only a vanished file needs a delete here;\n // store() below handles a reparse's stale rows itself.\n remove(db, path, delta) {\n if (!delta.vanished.includes(path)) return;\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n },\n store(db, path, extracted) {\n const found = extracted as string[];\n if (found.length === 0) {\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n } else {\n const placeholders = found.map(() => '?').join(', ');\n db.prepare(`DELETE FROM tags WHERE \"path\" = ? AND tag NOT IN (${placeholders})`).run(path, ...found);\n }\n const insert = db.prepare('INSERT OR IGNORE INTO tags (\"path\", tag) VALUES (?, ?)');\n for (const tag of found) insert.run(path, tag);\n },\n};\n"],"names":["FENCE_RE","INLINE_CODE_RE","INLINE_TAG_RE","normalizeTag","raw","stripped","replace","test","frontmatterTags","data","tags","list","Array","isArray","found","item","tag","push","inlineTags","body","inFence","line","split","cleaned","m","repeat","length","matchAll","extract","_raw","_search","Set","sort","name","schema","db","exec","remove","path","delta","vanished","includes","prepare","run","store","extracted","placeholders","map","join","insert"],"mappings":"AAEA,0FAA0F;AAC1F,uFAAuF;AACvF,4FAA4F;AAE5F,MAAMA,WAAW;AACjB,MAAMC,iBAAiB;AACvB,iGAAiG;AACjG,MAAMC,gBAAgB;AAEtB,sFAAsF;AACtF,oEAAoE;AACpE,SAASC,aAAaC,GAAW;IAC/B,MAAMC,WAAWD,IAAIE,OAAO,CAAC,MAAM,IAAIA,OAAO,CAAC,QAAQ;IACvD,IAAI,CAACD,YAAY,QAAQE,IAAI,CAACF,WAAW,OAAO;IAChD,OAAOA;AACT;AAEA,4FAA4F;AAC5F,6EAA6E;AAC7E,SAASG,gBAAgBC,IAA8B;IACrD,MAAML,MAAMK,iBAAAA,2BAAAA,KAAMC,IAAI;IACtB,MAAMC,OAAOC,MAAMC,OAAO,CAACT,OAAOA,MAAM,OAAOA,QAAQ,WAAW;QAACA;KAAI,GAAG,EAAE;IAC5E,MAAMU,QAAkB,EAAE;IAC1B,KAAK,MAAMC,QAAQJ,KAAM;QACvB,IAAI,OAAOI,SAAS,UAAU;QAC9B,MAAMC,MAAMb,aAAaY;QACzB,IAAIC,KAAKF,MAAMG,IAAI,CAACD;IACtB;IACA,OAAOF;AACT;AAEA,gEAAgE;AAChE,SAASI,WAAWC,IAAY;IAC9B,MAAML,QAAkB,EAAE;IAC1B,IAAIM,UAAU;IACd,KAAK,MAAMC,QAAQF,KAAKG,KAAK,CAAC,MAAO;QACnC,IAAItB,SAASO,IAAI,CAACc,OAAO;YACvBD,UAAU,CAACA;YACX;QACF;QACA,IAAIA,SAAS;QACb,MAAMG,UAAUF,KAAKf,OAAO,CAACL,gBAAgB,CAACuB,IAAM,IAAIC,MAAM,CAACD,EAAEE,MAAM;QACvE,KAAK,MAAMF,KAAKD,QAAQI,QAAQ,CAACzB,eAAgB;YAC/C,MAAMc,MAAMb,aAAaqB,CAAC,CAAC,EAAE;YAC7B,IAAIR,KAAKF,MAAMG,IAAI,CAACD;QACtB;IACF;IACA,OAAOF;AACT;AAEA,SAASc,QAAQC,IAAY,EAAEV,IAAY,EAAEW,OAA4C,EAAErB,IAA8B;IACvH,OAAO;WAAI,IAAIsB,IAAI;eAAIvB,gBAAgBC;eAAUS,WAAWC;SAAM;KAAE,CAACa,IAAI;AAC3E;AAEA,OAAO,MAAMtB,OAAgB;IAC3BuB,MAAM;IACNC,QAAOC,EAAE;QACPA,GAAGC,IAAI,CAAC;QACRD,GAAGC,IAAI,CAAC;IACV;IACAR;IACA,2FAA2F;IAC3F,uDAAuD;IACvDS,QAAOF,EAAE,EAAEG,IAAI,EAAEC,KAAK;QACpB,IAAI,CAACA,MAAMC,QAAQ,CAACC,QAAQ,CAACH,OAAO;QACpCH,GAAGO,OAAO,CAAC,qCAAqCC,GAAG,CAACL;IACtD;IACAM,OAAMT,EAAE,EAAEG,IAAI,EAAEO,SAAS;QACvB,MAAM/B,QAAQ+B;QACd,IAAI/B,MAAMY,MAAM,KAAK,GAAG;YACtBS,GAAGO,OAAO,CAAC,qCAAqCC,GAAG,CAACL;QACtD,OAAO;YACL,MAAMQ,eAAehC,MAAMiC,GAAG,CAAC,IAAM,KAAKC,IAAI,CAAC;YAC/Cb,GAAGO,OAAO,CAAC,CAAC,kDAAkD,EAAEI,aAAa,CAAC,CAAC,EAAEH,GAAG,CAACL,SAASxB;QAChG;QACA,MAAMmC,SAASd,GAAGO,OAAO,CAAC;QAC1B,KAAK,MAAM1B,OAAOF,MAAOmC,OAAON,GAAG,CAACL,MAAMtB;IAC5C;AACF,EAAE"}
1
+ {"version":3,"sources":["/Users/kevin/Dev/OpenSource/ai/sensemaking/src/features/tags.ts"],"sourcesContent":["import type { Feature } from './types.ts';\n\n// tags(path, tag): Obsidian's file.tags grain -- frontmatter list/string tags plus inline\n// #tags from the prose, deduplicated, source not distinguished. Nested tags store full\n// (book/scifi); `tag = 'book' OR tag LIKE 'book/%'` is how a caller matches the parent too.\n\nconst FENCE_RE = /^(```|~~~)/;\nconst INLINE_CODE_RE = /`[^`]*`/g;\n// Anchors on start-of-line or a preceding whitespace/(/[ so `a#b` and URL fragments don't count.\nconst INLINE_TAG_RE = /(?:^|[\\s([])#([\\p{L}\\p{N}_/-]+)/gu;\n\n// Strips a leading # (frontmatter entries may carry one) and a trailing /; rejects an\n// all-digit result -- a tag needs at least one non-digit character.\nfunction normalizeTag(raw: string): string | null {\n const stripped = raw.replace(/^#/, '').replace(/\\/+$/, '');\n if (!stripped || /^\\d+$/.test(stripped)) return null;\n return stripped;\n}\n\n// data.tags: a YAML list (Obsidian also accepts a bare string). Null members and non-string\n// members are skipped rather than throwing -- `tags:\\n -` parses to [null].\nfunction frontmatterTags(data?: Record<string, unknown>): string[] {\n const raw = data?.tags;\n const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];\n const found: string[] = [];\n for (const item of list) {\n if (typeof item !== 'string') continue;\n const tag = normalizeTag(item);\n if (tag) found.push(tag);\n }\n return found;\n}\n\n// #tag tokens outside fenced code blocks and inline code spans.\nfunction inlineTags(body: string): string[] {\n const found: string[] = [];\n let inFence = false;\n for (const line of body.split('\\n')) {\n if (FENCE_RE.test(line)) {\n inFence = !inFence;\n continue;\n }\n if (inFence) continue;\n if (!line.includes('#')) continue; // most lines; skip the regex work\n const cleaned = line.includes('`') ? line.replace(INLINE_CODE_RE, (m) => ' '.repeat(m.length)) : line;\n for (const m of cleaned.matchAll(INLINE_TAG_RE)) {\n const tag = normalizeTag(m[1]);\n if (tag) found.push(tag);\n }\n }\n return found;\n}\n\nfunction extract(_raw: string, body: string, _search?: { title: string; summary: string }, data?: Record<string, unknown>): string[] {\n return [...new Set([...frontmatterTags(data), ...inlineTags(body)])].sort();\n}\n\nexport const tags: Feature = {\n name: 'tags',\n schema(db) {\n db.exec('CREATE TABLE IF NOT EXISTS tags (\"path\" TEXT, tag TEXT, PRIMARY KEY (\"path\", tag))');\n db.exec('CREATE INDEX IF NOT EXISTS tags_tag ON tags(tag)');\n },\n extract,\n // Per-file rows with nothing else to resolve, so only a vanished file needs a delete here;\n // store() below handles a reparse's stale rows itself.\n remove(db, path, delta) {\n if (!delta.vanished.includes(path)) return;\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n },\n store(db, path, extracted) {\n const found = extracted as string[];\n if (found.length === 0) {\n db.prepare('DELETE FROM tags WHERE \"path\" = ?').run(path);\n } else {\n const placeholders = found.map(() => '?').join(', ');\n db.prepare(`DELETE FROM tags WHERE \"path\" = ? AND tag NOT IN (${placeholders})`).run(path, ...found);\n }\n const insert = db.prepare('INSERT OR IGNORE INTO tags (\"path\", tag) VALUES (?, ?)');\n for (const tag of found) insert.run(path, tag);\n },\n};\n"],"names":["FENCE_RE","INLINE_CODE_RE","INLINE_TAG_RE","normalizeTag","raw","stripped","replace","test","frontmatterTags","data","tags","list","Array","isArray","found","item","tag","push","inlineTags","body","inFence","line","split","includes","cleaned","m","repeat","length","matchAll","extract","_raw","_search","Set","sort","name","schema","db","exec","remove","path","delta","vanished","prepare","run","store","extracted","placeholders","map","join","insert"],"mappings":"AAEA,0FAA0F;AAC1F,uFAAuF;AACvF,4FAA4F;AAE5F,MAAMA,WAAW;AACjB,MAAMC,iBAAiB;AACvB,iGAAiG;AACjG,MAAMC,gBAAgB;AAEtB,sFAAsF;AACtF,oEAAoE;AACpE,SAASC,aAAaC,GAAW;IAC/B,MAAMC,WAAWD,IAAIE,OAAO,CAAC,MAAM,IAAIA,OAAO,CAAC,QAAQ;IACvD,IAAI,CAACD,YAAY,QAAQE,IAAI,CAACF,WAAW,OAAO;IAChD,OAAOA;AACT;AAEA,4FAA4F;AAC5F,6EAA6E;AAC7E,SAASG,gBAAgBC,IAA8B;IACrD,MAAML,MAAMK,iBAAAA,2BAAAA,KAAMC,IAAI;IACtB,MAAMC,OAAOC,MAAMC,OAAO,CAACT,OAAOA,MAAM,OAAOA,QAAQ,WAAW;QAACA;KAAI,GAAG,EAAE;IAC5E,MAAMU,QAAkB,EAAE;IAC1B,KAAK,MAAMC,QAAQJ,KAAM;QACvB,IAAI,OAAOI,SAAS,UAAU;QAC9B,MAAMC,MAAMb,aAAaY;QACzB,IAAIC,KAAKF,MAAMG,IAAI,CAACD;IACtB;IACA,OAAOF;AACT;AAEA,gEAAgE;AAChE,SAASI,WAAWC,IAAY;IAC9B,MAAML,QAAkB,EAAE;IAC1B,IAAIM,UAAU;IACd,KAAK,MAAMC,QAAQF,KAAKG,KAAK,CAAC,MAAO;QACnC,IAAItB,SAASO,IAAI,CAACc,OAAO;YACvBD,UAAU,CAACA;YACX;QACF;QACA,IAAIA,SAAS;QACb,IAAI,CAACC,KAAKE,QAAQ,CAAC,MAAM,UAAU,kCAAkC;QACrE,MAAMC,UAAUH,KAAKE,QAAQ,CAAC,OAAOF,KAAKf,OAAO,CAACL,gBAAgB,CAACwB,IAAM,IAAIC,MAAM,CAACD,EAAEE,MAAM,KAAKN;QACjG,KAAK,MAAMI,KAAKD,QAAQI,QAAQ,CAAC1B,eAAgB;YAC/C,MAAMc,MAAMb,aAAasB,CAAC,CAAC,EAAE;YAC7B,IAAIT,KAAKF,MAAMG,IAAI,CAACD;QACtB;IACF;IACA,OAAOF;AACT;AAEA,SAASe,QAAQC,IAAY,EAAEX,IAAY,EAAEY,OAA4C,EAAEtB,IAA8B;IACvH,OAAO;WAAI,IAAIuB,IAAI;eAAIxB,gBAAgBC;eAAUS,WAAWC;SAAM;KAAE,CAACc,IAAI;AAC3E;AAEA,OAAO,MAAMvB,OAAgB;IAC3BwB,MAAM;IACNC,QAAOC,EAAE;QACPA,GAAGC,IAAI,CAAC;QACRD,GAAGC,IAAI,CAAC;IACV;IACAR;IACA,2FAA2F;IAC3F,uDAAuD;IACvDS,QAAOF,EAAE,EAAEG,IAAI,EAAEC,KAAK;QACpB,IAAI,CAACA,MAAMC,QAAQ,CAAClB,QAAQ,CAACgB,OAAO;QACpCH,GAAGM,OAAO,CAAC,qCAAqCC,GAAG,CAACJ;IACtD;IACAK,OAAMR,EAAE,EAAEG,IAAI,EAAEM,SAAS;QACvB,MAAM/B,QAAQ+B;QACd,IAAI/B,MAAMa,MAAM,KAAK,GAAG;YACtBS,GAAGM,OAAO,CAAC,qCAAqCC,GAAG,CAACJ;QACtD,OAAO;YACL,MAAMO,eAAehC,MAAMiC,GAAG,CAAC,IAAM,KAAKC,IAAI,CAAC;YAC/CZ,GAAGM,OAAO,CAAC,CAAC,kDAAkD,EAAEI,aAAa,CAAC,CAAC,EAAEH,GAAG,CAACJ,SAASzB;QAChG;QACA,MAAMmC,SAASb,GAAGM,OAAO,CAAC;QAC1B,KAAK,MAAM1B,OAAOF,MAAOmC,OAAON,GAAG,CAACJ,MAAMvB;IAC5C;AACF,EAAE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sensemaking",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "description": "Query and search your markdown notes with context-aware progressive disclosure: SQL over frontmatter, links, and text, plus semantic search and link-graph ranking. No server, no build step",
5
5
  "keywords": [
6
6
  "markdown",
@@ -26,6 +26,7 @@
26
26
  "document-os",
27
27
  "notes",
28
28
  "obsidian",
29
+ "obsidian-bases",
29
30
  "markdowndb",
30
31
  "mdbase",
31
32
  "iwe",