'use strict'; // [[Wiki links]] on disk: find them, resolve them, rewrite them after a rename or move, // and list backlinks. Lookup rules match src/lib/links.js. const refPage = (ref) => String(ref || '').split('#')[0].replace(/\.md$/i, '').trim(); // Lookups over a page list, built once per list (a vault of thousands of pages is searched for every link). const indexes = new WeakMap(); function indexOf(pages) { let ix = indexes.get(pages); if (ix && ix.size === pages.length) return ix; const exact = new Map(); const byName = new Map(); for (const p of pages) { const id = p.id.toLowerCase(); if (!exact.has(id)) exact.set(id, p.id); const name = id.slice(id.lastIndexOf('/') + 1); const list = byName.get(name); if (list) list.push(p); else byName.set(name, [p]); } // by name: a note's title, or a canvas's file name ("Map.canvas"), so [[Map]] never means a canvas; shortest path wins for (const list of byName.values()) if (list.length > 1) list.sort((a, b) => a.id.length - b.id.length); ix = { size: pages.length, exact, byName }; indexes.set(pages, ix); return ix; } function resolveLink(ref, pages) { const want = refPage(ref).toLowerCase(); if (!want) return null; const ix = indexOf(pages); const hit = ix.exact.get(want); if (hit) return hit; const named = ix.byName.get(want); return named ? named[0].id : null; } const normHeading = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); const linkHeading = (s) => String(s || '').replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim(); const matchesHeading = (linkText, headingText) => { const a = normHeading(linkText); return !!a && (a === normHeading(headingText) || a === normHeading(linkHeading(headingText))); }; // The heading a link points at ('' if none): the last #part, and #^blockid isn't a heading. const headingOf = (ref) => { const s = String(ref || ''); const i = s.lastIndexOf('#'); if (i < 0) return ''; const h = s.slice(i + 1).trim(); return h.startsWith('^') ? '' : h; }; function refFor(id, pages) { const title = id.slice(id.lastIndexOf('/') + 1); const same = indexOf(pages).byName.get(title.toLowerCase()) || []; return same.length <= 1 ? title : id; } // Blank out code (fenced and inline) so links inside it are left alone; offsets stay the same. function maskCode(body) { const blank = (s) => s.replace(/[^\n]/g, ' '); let fence = false; return body .split('\n') .map((line) => { if (/^```/.test(line)) { fence = !fence; return blank(line); } return fence ? blank(line) : line.replace(/`[^`\n]+`/g, blank); }) .join('\n'); } // [[Page]] links and ![[Page]] embeds (groups: 1 = "!" for an embed, 2 = target, 3 = |alias). const LINK = /(!?)\[\[([^\[\]\n|]+)(\|[^\[\]\n]*)?\]\]/g; // ![[photo.png]] and ![[slides.pdf]] embed files, not pages. (Same lists as src/lib/links.js.) const FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i; // [{ index, length, ref, alias, embed }] for every page link and page embed in a Markdown body. function findLinks(body) { const masked = maskCode(String(body || '')); const out = []; for (const m of masked.matchAll(LINK)) { if (m[1] && FILE.test(m[2].trim())) continue; out.push({ index: m.index, length: m[0].length, embed: !!m[1], ref: body.substr(m.index + m[1].length + 2, m[2].length), alias: m[3] || '' }); } return out; } /* database properties A database record's property values are front matter (page.props). Links written in them ([[Page]]) count as links from the record, and a relation property (src/lib/database.js) points at sibling records by title. */ const NOT_PROPS = new Set(['icon', 'cover', 'tags', 'created', 'ink-database', 'kanban-plugin']); const valuesOf = (v) => (Array.isArray(v) ? v : [v]).filter((x) => typeof x === 'string'); // The property values of a page that hold [[links]], one per line. function propText(p) { const out = []; for (const [k, v] of Object.entries(p.props || {})) { if (NOT_PROPS.has(k)) continue; for (const x of valuesOf(v)) if (x.includes('[[')) out.push(x); } return out.join('\n'); } // [{ key, name }] of the relation properties in database page `db` (read from the schema comment in its body). function relationKeys(db) { const m = String((db && db.body) || '').match(/^[ \t]*%%[ \t]*ink-database[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*%%[ \t]*$/m); if (!m) return []; try { return (JSON.parse(m[1]).properties || []).filter((p) => p && p.type === 'relation' && p.key).map((p) => ({ key: p.key, name: p.name || p.key })); } catch { return []; } } // [{ id, name }]: the sibling records that record `rec` names in its relation properties. function relatedIds(rec, pages) { if (!rec.parent || !rec.props) return []; const db = pages.find((x) => x.id === rec.parent); const keys = db ? relationKeys(db) : []; if (!keys.length) return []; const siblings = pages.filter((x) => x.parent === rec.parent && x.exists && x.id !== rec.id); const out = []; for (const { key, name } of keys) { for (const title of valuesOf(rec.props[key])) { const hit = siblings.find((x) => x.title.toLowerCase() === title.trim().toLowerCase()); if (hit) out.push({ id: hit.id, name }); } } return out; } // { key: newValue } for the property values whose [[links]] moved, or null. function rewriteProps(props, before, after, moved) { const patch = {}; for (const [k, v] of Object.entries(props || {})) { if (NOT_PROPS.has(k)) continue; const next = (x) => (typeof x === 'string' && x.includes('[[') ? rewriteLinks(x, before, after, moved) : null); if (Array.isArray(v)) { const out = v.map((x) => next(x)); if (out.some((x) => x !== null)) patch[k] = v.map((x, i) => (out[i] === null ? x : out[i])); } else { const n = next(v); if (n !== null) patch[k] = n; } } return Object.keys(patch).length ? patch : null; } // Rewrite links whose target moved. `before` is the page list before the change, `after` the list after, // `moved(oldId)` returns the new id or null. Returns the new body, or null when nothing changed. function rewriteLinks(body, before, after, moved) { const links = findLinks(body); let out = '', at = 0, changed = false; for (const l of links) { const was = resolveLink(l.ref, before); const now = was && moved(was); if (!now || now === was) continue; const hash = l.ref.includes('#') ? l.ref.slice(l.ref.indexOf('#')) : ''; out += body.slice(at, l.index) + (l.embed ? '!' : '') + '[[' + refFor(now, after) + hash + l.alias + ']]'; at = l.index + l.length; changed = true; } return changed ? out + body.slice(at) : null; } // A heading of page `pageId` was renamed from `from` to `to`: point the [[Page#from]] links and ![[Page#from]] // embeds in `body` at the new name (page part and |alias stay). Returns { body, count }, or null if none matched. // ([[#from]] with no page part points inside its own page, so it belongs to that page and is left alone.) function renameHeadingRefs(body, pages, pageId, from, to) { const safe = linkHeading(to); if (!safe) return null; let out = '', at = 0, count = 0; for (const l of findLinks(body)) { if (!refPage(l.ref) || resolveLink(l.ref, pages) !== pageId || !matchesHeading(headingOf(l.ref), from)) continue; const ref = l.ref.slice(0, l.ref.lastIndexOf('#') + 1) + safe; out += body.slice(at, l.index) + (l.embed ? '!' : '') + '[[' + ref + l.alias + ']]'; at = l.index + l.length; count++; } return count ? { body: out + body.slice(at), count } : null; } // Pages that link to `id`, each with the line the first link sits on. function backlinks(id, pages) { const out = []; for (const p of pages) { if (!p.exists || p.id === id) continue; const text = p.body + '\n' + propText(p); // links in property values count too const hit = findLinks(text).find((l) => resolveLink(l.ref, pages) === id); if (!hit) { const rel = relatedIds(p, pages).find((r) => r.id === id); if (rel) out.push({ id: p.id, title: p.title, icon: p.icon, snip: rel.name }); continue; } const start = text.lastIndexOf('\n', hit.index) + 1; let end = text.indexOf('\n', hit.index); if (end < 0) end = text.length; const line = text.slice(start, end) .replace(LINK, (_, _bang, ref, alias) => (alias ? alias.slice(1) : refPage(ref).split('/').pop())) .replace(/^\s*(?:[-*+]\s+(?:\[[ xX]\]\s*)?|#{1,6}\s+|>\s?(?:\[!\w+\]\s*)?)/, '') .replace(/\*\*|__|[*_`]/g, '') .trim(); out.push({ id: p.id, title: p.title, icon: p.icon, snip: line.length > 160 ? line.slice(0, 159) + '…' : line }); } return out.sort((a, b) => a.title.localeCompare(b.title)); } /* unlinked mentions: pages that say a page's title in plain text without linking to it */ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Blank out what is not plain prose (code, [[links]], [text](url), bare URLs); offsets stay the same. function maskProse(body) { const blank = (s) => s.replace(/[^\n]/g, ' '); return maskCode(body) .replace(/!?\[\[[^\]\n]*\]\]/g, blank) .replace(/!?\[[^\]\n]*\]\([^)\n]*\)/g, blank) .replace(/\b(?:https?:\/\/|mailto:)\S+/g, blank); } const mentionRe = (title) => new RegExp('(? p.id === id); if (!me || !me.exists || /\.canvas$/i.test(id)) return []; const out = []; for (const p of pages) { if (!p.exists || p.id === id || p.kind === 'canvas' || /\.canvas$/i.test(p.id)) continue; if (p.props && p.props['kanban-plugin']) continue; const hit = firstMention(p.body, me.title); if (!hit) continue; if (findLinks(p.body + '\n' + propText(p)).some((l) => resolveLink(l.ref, pages) === id)) continue; const start = p.body.lastIndexOf('\n', hit.index) + 1; let end = p.body.indexOf('\n', hit.index); if (end < 0) end = p.body.length; const line = p.body.slice(start, end) .replace(/^\s*(?:[-*+]\s+(?:\[[ xX]\]\s*)?|#{1,6}\s+|>\s?(?:\[!\w+\]\s*)?)/, '') .replace(/\*\*|__|[*_`]/g, '').trim(); out.push({ id: p.id, title: p.title, icon: p.icon, snip: line.length > 160 ? line.slice(0, 159) + '…' : line }); } return out.sort((a, b) => a.title.localeCompare(b.title)).slice(0, 200); } // `body` with the first plain mention of page `id`'s title turned into a link; null if there is none. function linkFirstMention(body, id, pages) { const me = pages.find((p) => p.id === id); if (!me) return null; const hit = firstMention(body, me.title); if (!hit) return null; const said = body.substr(hit.index, hit.length); const ref = refFor(id, pages); const link = said === me.title.trim() && ref.toLowerCase() === said.toLowerCase() ? `[[${ref}]]` : `[[${ref}|${said}]]`; return body.slice(0, hit.index) + link + body.slice(hit.index + hit.length); } // Everything the graph view draws: pages, links between them, and links to pages that don't exist yet. // nodes: [{ id, title, icon, parent }] edges: [[from, to]] (each pair once) ghosts: [{ id: '?name', title }] function graph(pages) { const nodes = pages.filter((p) => p.exists).map((p) => ({ id: p.id, title: p.title, icon: p.icon || null, parent: p.parent || null, tags: p.tags || [] })); const seen = new Set(); const edges = []; const ghosts = new Map(); const add = (from, to) => { const key = from + '\u0000' + to; if (to === from || seen.has(key)) return; seen.add(key); edges.push([from, to]); }; for (const p of pages) { if (!p.exists) continue; for (const l of findLinks(p.body + '\n' + propText(p))) { let to = resolveLink(l.ref, pages); if (!to) { const name = refPage(l.ref); if (!name) continue; to = '?' + name.toLowerCase(); if (!ghosts.has(to)) ghosts.set(to, { id: to, title: name.split('/').pop() }); } add(p.id, to); } for (const r of relatedIds(p, pages)) add(p.id, r.id); } return { nodes, edges, ghosts: [...ghosts.values()] }; } module.exports = { unlinked, linkFirstMention, refPage, resolveLink, refFor, findLinks, rewriteLinks, rewriteProps, propText, relatedIds, renameHeadingRefs, backlinks, graph, headingOf, linkHeading, matchesHeading };