| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
import * as md from "../static/md.js"; |
| 15 |
|
| 16 |
export interface VaultNote { |
| 17 |
readonly id: number; |
| 18 |
readonly title: string; |
| 19 |
readonly body: string; |
| 20 |
readonly pinned: number; |
| 21 |
readonly updated_at: string; |
| 22 |
} |
| 23 |
|
| 24 |
export interface Heading { |
| 25 |
readonly level: number; |
| 26 |
readonly text: string; |
| 27 |
readonly id: string; |
| 28 |
} |
| 29 |
|
| 30 |
export const links = (body: string): string[] => md.links(body); |
| 31 |
export const tags = (body: string): string[] => md.tags(body); |
| 32 |
export const headings = (body: string): Heading[] => md.headings(body); |
| 33 |
|
| 34 |
const key = (title: string): string => title.trim().toLowerCase(); |
| 35 |
|
| 36 |
|
| 37 |
export const byTitle = <T extends VaultNote>(notes: readonly T[]) => |
| 38 |
new Map(notes.map((n) => [key(n.title), n])); |
| 39 |
|
| 40 |
|
| 41 |
export const hrefOf = (note: { readonly id: number }): string => `/n/${note.id}`; |
| 42 |
|
| 43 |
|
| 44 |
export function renderNote( |
| 45 |
body: string, |
| 46 |
notes: readonly VaultNote[], |
| 47 |
): string { |
| 48 |
const titles = byTitle(notes); |
| 49 |
return md.render(body, { |
| 50 |
resolve: (title: string) => { |
| 51 |
const found = titles.get(key(title)); |
| 52 |
return found === undefined ? null : hrefOf(found); |
| 53 |
}, |
| 54 |
}); |
| 55 |
} |
| 56 |
|
| 57 |
|
| 58 |
export function backlinks<T extends VaultNote>( |
| 59 |
note: VaultNote, |
| 60 |
notes: readonly T[], |
| 61 |
): T[] { |
| 62 |
const me = key(note.title); |
| 63 |
return notes.filter( |
| 64 |
(n) => n.id !== note.id && links(n.body).some((l) => key(l) === me), |
| 65 |
); |
| 66 |
} |
| 67 |
|
| 68 |
|
| 69 |
export function outgoing<T extends VaultNote>( |
| 70 |
note: VaultNote, |
| 71 |
notes: readonly T[], |
| 72 |
): { found: T[]; missing: string[] } { |
| 73 |
const titles = byTitle(notes); |
| 74 |
const found: T[] = []; |
| 75 |
const missing: string[] = []; |
| 76 |
for (const title of links(note.body)) { |
| 77 |
const hit = titles.get(key(title)); |
| 78 |
if (hit === undefined) missing.push(title); |
| 79 |
else if (hit.id !== note.id) found.push(hit); |
| 80 |
} |
| 81 |
return { found, missing }; |
| 82 |
} |
| 83 |
|
| 84 |
|
| 85 |
export function tagCounts(notes: readonly VaultNote[]): { tag: string; count: number }[] { |
| 86 |
const counts = new Map<string, number>(); |
| 87 |
for (const n of notes) |
| 88 |
for (const t of tags(n.body)) counts.set(t, (counts.get(t) ?? 0) + 1); |
| 89 |
return [...counts] |
| 90 |
.map(([tag, count]) => ({ tag, count })) |
| 91 |
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); |
| 92 |
} |
| 93 |
|
| 94 |
export interface Graph { |
| 95 |
readonly nodes: { id: number; title: string; links: number }[]; |
| 96 |
readonly edges: [number, number][]; |
| 97 |
} |
| 98 |
|
| 99 |
|
| 100 |
export function graph(notes: readonly VaultNote[]): Graph { |
| 101 |
const titles = byTitle(notes); |
| 102 |
const seen = new Set<string>(); |
| 103 |
const edges: [number, number][] = []; |
| 104 |
const degree = new Map<number, number>(); |
| 105 |
for (const n of notes) |
| 106 |
for (const title of links(n.body)) { |
| 107 |
const to = titles.get(key(title)); |
| 108 |
if (to === undefined || to.id === n.id) continue; |
| 109 |
const pair = n.id < to.id ? `${n.id}-${to.id}` : `${to.id}-${n.id}`; |
| 110 |
if (seen.has(pair)) continue; |
| 111 |
seen.add(pair); |
| 112 |
edges.push([n.id, to.id]); |
| 113 |
degree.set(n.id, (degree.get(n.id) ?? 0) + 1); |
| 114 |
degree.set(to.id, (degree.get(to.id) ?? 0) + 1); |
| 115 |
} |
| 116 |
return { |
| 117 |
nodes: notes.map((n) => ({ id: n.id, title: n.title, links: degree.get(n.id) ?? 0 })), |
| 118 |
edges, |
| 119 |
}; |
| 120 |
} |
| 121 |
|
| 122 |
export interface Hit<T> { |
| 123 |
readonly note: T; |
| 124 |
|
| 125 |
readonly snippet: string; |
| 126 |
} |
| 127 |
|
| 128 |
|
| 129 |
|
| 130 |
|
| 131 |
|
| 132 |
|
| 133 |
export function search<T extends VaultNote>( |
| 134 |
notes: readonly T[], |
| 135 |
query: string, |
| 136 |
): Hit<T>[] { |
| 137 |
const wanted: string[] = []; |
| 138 |
const tagged: string[] = []; |
| 139 |
for (const m of query.matchAll(/"([^"]+)"|(\S+)/g)) { |
| 140 |
const word = (m[1] ?? m[2] ?? "").toLowerCase(); |
| 141 |
const tag = /^(?:#|tag:)(.+)$/.exec(word)?.[1]; |
| 142 |
if (tag !== undefined && m[1] === undefined) tagged.push(tag); |
| 143 |
else if (word !== "") wanted.push(word); |
| 144 |
} |
| 145 |
if (wanted.length === 0 && tagged.length === 0) return []; |
| 146 |
const hits: (Hit<T> & { title: boolean })[] = []; |
| 147 |
for (const note of notes) { |
| 148 |
const title = note.title.toLowerCase(); |
| 149 |
const body = note.body.toLowerCase(); |
| 150 |
if (!wanted.every((w) => title.includes(w) || body.includes(w))) continue; |
| 151 |
if (tagged.length > 0) { |
| 152 |
const has = tags(note.body); |
| 153 |
|
| 154 |
if (!tagged.every((t) => has.some((h) => h === t || h.startsWith(`${t}/`)))) continue; |
| 155 |
} |
| 156 |
hits.push({ |
| 157 |
note, |
| 158 |
snippet: snippet(note.body, wanted[0] ?? (tagged[0] === undefined ? "" : `#${tagged[0]}`)), |
| 159 |
title: wanted.length > 0 && wanted.every((w) => title.includes(w)), |
| 160 |
}); |
| 161 |
} |
| 162 |
hits.sort( |
| 163 |
(a, b) => |
| 164 |
Number(b.title) - Number(a.title) || |
| 165 |
b.note.updated_at.localeCompare(a.note.updated_at), |
| 166 |
); |
| 167 |
return hits.map(({ note, snippet }) => ({ note, snippet })); |
| 168 |
} |
| 169 |
|
| 170 |
function snippet(body: string, word: string): string { |
| 171 |
const at = word === "" ? -1 : body.toLowerCase().indexOf(word); |
| 172 |
if (at < 0) return body.split("\n").find((l) => l.trim() !== "")?.trim().slice(0, 160) ?? ""; |
| 173 |
const start = Math.max(0, body.lastIndexOf("\n", at) + 1, at - 70); |
| 174 |
const endLine = body.indexOf("\n", at); |
| 175 |
const end = Math.min(endLine < 0 ? body.length : endLine, at + 90); |
| 176 |
return `${start > 0 && body[start - 1] !== "\n" ? "…" : ""}${body.slice(start, end).trim()}${end < (endLine < 0 ? body.length : endLine) ? "…" : ""}`; |
| 177 |
} |
| 178 |
|
| 179 |
|
| 180 |
|
| 181 |
|
| 182 |
|
| 183 |
export function relink(body: string, from: string, to: string): string { |
| 184 |
const old = key(from); |
| 185 |
return body.replace( |
| 186 |
/(!?)\[\[([^\[\]\n|#]+?)((?:#[^\[\]\n|]*)?)((?:\|[^\[\]\n]+?)?)\]\]/g, |
| 187 |
(whole, embed: string, title: string, heading: string, alias: string) => |
| 188 |
key(title) === old ? `${embed}[[${to}${heading}${alias}]]` : whole, |
| 189 |
); |
| 190 |
} |
| 191 |
|
| 192 |
|
| 193 |
export function excerpt(body: string, max = 120): string { |
| 194 |
const text = body |
| 195 |
.replace(/^(```|~~~)[\s\S]*?^\1/gm, "") |
| 196 |
.replace(/^#{1,6}\s+/gm, "") |
| 197 |
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2") |
| 198 |
.replace(/\[\[([^\]]+)\]\]/g, "$1") |
| 199 |
.replace(/[*_=~`>]/g, "") |
| 200 |
.replace(/\s+/g, " ") |
| 201 |
.trim(); |
| 202 |
return text.length > max ? `${text.slice(0, max - 1)}…` : text; |
| 203 |
} |