/** * What a vault knows about itself, from its notes alone: which note links * to which, what links back, which tags are in use, how the whole thing * hangs together as a graph, and what a search finds. Pure functions over a * list of notes — no database, no identity — so every door that shows a * vault shows the same one, and a test needs nothing but notes. * * Links are by title, the way a person writes them: `[[Some note]]`, * matched without regard to case. The markdown itself is `static/md.js`, * shared with the browser. */ // @ts-ignore — plain JS module, shared verbatim with the browser. import * as md from "../static/md.js"; export interface VaultNote { readonly id: number; readonly title: string; readonly body: string; readonly pinned: number; readonly updated_at: string; } export interface Heading { readonly level: number; readonly text: string; readonly id: string; } export const links = (body: string): string[] => md.links(body); export const tags = (body: string): string[] => md.tags(body); export const headings = (body: string): Heading[] => md.headings(body); const key = (title: string): string => title.trim().toLowerCase(); /** Title (any case) → note. */ export const byTitle = (notes: readonly T[]) => new Map(notes.map((n) => [key(n.title), n])); /** A note's address. */ export const hrefOf = (note: { readonly id: number }): string => `/n/${note.id}`; /** A note's markdown as HTML, its links pointed at the notes they name. */ export function renderNote( body: string, notes: readonly VaultNote[], ): string { const titles = byTitle(notes); return md.render(body, { resolve: (title: string) => { const found = titles.get(key(title)); return found === undefined ? null : hrefOf(found); }, }); } /** The notes that link to `note`, by title. */ export function backlinks( note: VaultNote, notes: readonly T[], ): T[] { const me = key(note.title); return notes.filter( (n) => n.id !== note.id && links(n.body).some((l) => key(l) === me), ); } /** What `note` links to: the notes that exist, and the titles that do not yet. */ export function outgoing( note: VaultNote, notes: readonly T[], ): { found: T[]; missing: string[] } { const titles = byTitle(notes); const found: T[] = []; const missing: string[] = []; for (const title of links(note.body)) { const hit = titles.get(key(title)); if (hit === undefined) missing.push(title); else if (hit.id !== note.id) found.push(hit); } return { found, missing }; } /** Every tag in the vault with how many notes carry it, most used first. */ export function tagCounts(notes: readonly VaultNote[]): { tag: string; count: number }[] { const counts = new Map(); for (const n of notes) for (const t of tags(n.body)) counts.set(t, (counts.get(t) ?? 0) + 1); return [...counts] .map(([tag, count]) => ({ tag, count })) .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); } export interface Graph { readonly nodes: { id: number; title: string; links: number }[]; readonly edges: [number, number][]; } /** The vault as nodes and undirected edges, for the graph view. */ export function graph(notes: readonly VaultNote[]): Graph { const titles = byTitle(notes); const seen = new Set(); const edges: [number, number][] = []; const degree = new Map(); for (const n of notes) for (const title of links(n.body)) { const to = titles.get(key(title)); if (to === undefined || to.id === n.id) continue; const pair = n.id < to.id ? `${n.id}-${to.id}` : `${to.id}-${n.id}`; if (seen.has(pair)) continue; seen.add(pair); edges.push([n.id, to.id]); degree.set(n.id, (degree.get(n.id) ?? 0) + 1); degree.set(to.id, (degree.get(to.id) ?? 0) + 1); } return { nodes: notes.map((n) => ({ id: n.id, title: n.title, links: degree.get(n.id) ?? 0 })), edges, }; } export interface Hit { readonly note: T; /** A line of the body around the first match, plain text. */ readonly snippet: string; } /** * Search: every word must appear in the title or body (any case); `#tag` * or `tag:name` must be a tag the note carries; a phrase in quotes is * matched whole. Title matches rank first, then the most recently changed. */ export function search( notes: readonly T[], query: string, ): Hit[] { const wanted: string[] = []; const tagged: string[] = []; for (const m of query.matchAll(/"([^"]+)"|(\S+)/g)) { const word = (m[1] ?? m[2] ?? "").toLowerCase(); const tag = /^(?:#|tag:)(.+)$/.exec(word)?.[1]; if (tag !== undefined && m[1] === undefined) tagged.push(tag); else if (word !== "") wanted.push(word); } if (wanted.length === 0 && tagged.length === 0) return []; const hits: (Hit & { title: boolean })[] = []; for (const note of notes) { const title = note.title.toLowerCase(); const body = note.body.toLowerCase(); if (!wanted.every((w) => title.includes(w) || body.includes(w))) continue; if (tagged.length > 0) { const has = tags(note.body); // A tag matches itself and its children: #work finds #work/acme. if (!tagged.every((t) => has.some((h) => h === t || h.startsWith(`${t}/`)))) continue; } hits.push({ note, snippet: snippet(note.body, wanted[0] ?? (tagged[0] === undefined ? "" : `#${tagged[0]}`)), title: wanted.length > 0 && wanted.every((w) => title.includes(w)), }); } hits.sort( (a, b) => Number(b.title) - Number(a.title) || b.note.updated_at.localeCompare(a.note.updated_at), ); return hits.map(({ note, snippet }) => ({ note, snippet })); } function snippet(body: string, word: string): string { const at = word === "" ? -1 : body.toLowerCase().indexOf(word); if (at < 0) return body.split("\n").find((l) => l.trim() !== "")?.trim().slice(0, 160) ?? ""; const start = Math.max(0, body.lastIndexOf("\n", at) + 1, at - 70); const endLine = body.indexOf("\n", at); const end = Math.min(endLine < 0 ? body.length : endLine, at + 90); return `${start > 0 && body[start - 1] !== "\n" ? "…" : ""}${body.slice(start, end).trim()}${end < (endLine < 0 ? body.length : endLine) ? "…" : ""}`; } /** * The body with every link to `from` pointed at `to` instead, keeping any * alias and heading: what a rename does to the notes that link to it. */ export function relink(body: string, from: string, to: string): string { const old = key(from); return body.replace( /(!?)\[\[([^\[\]\n|#]+?)((?:#[^\[\]\n|]*)?)((?:\|[^\[\]\n]+?)?)\]\]/g, (whole, embed: string, title: string, heading: string, alias: string) => key(title) === old ? `${embed}[[${to}${heading}${alias}]]` : whole, ); } /** A plain-text preview of a note's first words, for lists. */ export function excerpt(body: string, max = 120): string { const text = body .replace(/^(```|~~~)[\s\S]*?^\1/gm, "") .replace(/^#{1,6}\s+/gm, "") .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2") .replace(/\[\[([^\]]+)\]\]/g, "$1") .replace(/[*_=~`>]/g, "") .replace(/\s+/g, " ") .trim(); return text.length > max ? `${text.slice(0, max - 1)}…` : text; }