benjivers/notes Public Public: anyone can read this; nobody can change it.

Files shared
205 lines · 7.2 KB · ~2.1k tok Raw History
1
/**
2
 * What a vault knows about itself, from its notes alone: which note links
3
 * to which, what links back, which tags are in use, how the whole thing
4
 * hangs together as a graph, and what a search finds. Pure functions over a
5
 * list of notes — no database, no identity — so every door that shows a
6
 * vault shows the same one, and a test needs nothing but notes.
7
 *
8
 * Links are by title, the way a person writes them: `[[Some note]]`,
9
 * matched without regard to case. The markdown itself is `static/md.js`,
10
 * shared with the browser.
11
 */
12
13
// @ts-ignore — plain JS module, shared verbatim with the browser.
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
/** Title (any case) → note. */
37
export const byTitle = <T extends VaultNote>(notes: readonly T[]) =>
38
  new Map(notes.map((n) => [key(n.title), n]));
39
40
/** A note's address. */
41
export const hrefOf = (note: { readonly id: number }): string => `/n/${note.id}`;
42
43
/** A note's markdown as HTML, its links pointed at the notes they name. */
44
export function renderNote(
45
  body: string,
46
  notes: readonly VaultNote[],
47
  base = "",
48
): string {
49
  const titles = byTitle(notes);
50
  return md.render(body, {
51
    base,
52
    resolve: (title: string) => {
53
      const found = titles.get(key(title));
54
      return found === undefined ? null : hrefOf(found);
55
    },
56
  });
57
}
58
59
/** The notes that link to `note`, by title. */
60
export function backlinks<T extends VaultNote>(
61
  note: VaultNote,
62
  notes: readonly T[],
63
): T[] {
64
  const me = key(note.title);
65
  return notes.filter(
66
    (n) => n.id !== note.id && links(n.body).some((l) => key(l) === me),
67
  );
68
}
69
70
/** What `note` links to: the notes that exist, and the titles that do not yet. */
71
export function outgoing<T extends VaultNote>(
72
  note: VaultNote,
73
  notes: readonly T[],
74
): { found: T[]; missing: string[] } {
75
  const titles = byTitle(notes);
76
  const found: T[] = [];
77
  const missing: string[] = [];
78
  for (const title of links(note.body)) {
79
    const hit = titles.get(key(title));
80
    if (hit === undefined) missing.push(title);
81
    else if (hit.id !== note.id) found.push(hit);
82
  }
83
  return { found, missing };
84
}
85
86
/** Every tag in the vault with how many notes carry it, most used first. */
87
export function tagCounts(notes: readonly VaultNote[]): { tag: string; count: number }[] {
88
  const counts = new Map<string, number>();
89
  for (const n of notes)
90
    for (const t of tags(n.body)) counts.set(t, (counts.get(t) ?? 0) + 1);
91
  return [...counts]
92
    .map(([tag, count]) => ({ tag, count }))
93
    .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
94
}
95
96
export interface Graph {
97
  readonly nodes: { id: number; title: string; links: number }[];
98
  readonly edges: [number, number][];
99
}
100
101
/** The vault as nodes and undirected edges, for the graph view. */
102
export function graph(notes: readonly VaultNote[]): Graph {
103
  const titles = byTitle(notes);
104
  const seen = new Set<string>();
105
  const edges: [number, number][] = [];
106
  const degree = new Map<number, number>();
107
  for (const n of notes)
108
    for (const title of links(n.body)) {
109
      const to = titles.get(key(title));
110
      if (to === undefined || to.id === n.id) continue;
111
      const pair = n.id < to.id ? `${n.id}-${to.id}` : `${to.id}-${n.id}`;
112
      if (seen.has(pair)) continue;
113
      seen.add(pair);
114
      edges.push([n.id, to.id]);
115
      degree.set(n.id, (degree.get(n.id) ?? 0) + 1);
116
      degree.set(to.id, (degree.get(to.id) ?? 0) + 1);
117
    }
118
  return {
119
    nodes: notes.map((n) => ({ id: n.id, title: n.title, links: degree.get(n.id) ?? 0 })),
120
    edges,
121
  };
122
}
123
124
export interface Hit<T> {
125
  readonly note: T;
126
  /** A line of the body around the first match, plain text. */
127
  readonly snippet: string;
128
}
129
130
/**
131
 * Search: every word must appear in the title or body (any case); `#tag`
132
 * or `tag:name` must be a tag the note carries; a phrase in quotes is
133
 * matched whole. Title matches rank first, then the most recently changed.
134
 */
135
export function search<T extends VaultNote>(
136
  notes: readonly T[],
137
  query: string,
138
): Hit<T>[] {
139
  const wanted: string[] = [];
140
  const tagged: string[] = [];
141
  for (const m of query.matchAll(/"([^"]+)"|(\S+)/g)) {
142
    const word = (m[1] ?? m[2] ?? "").toLowerCase();
143
    const tag = /^(?:#|tag:)(.+)$/.exec(word)?.[1];
144
    if (tag !== undefined && m[1] === undefined) tagged.push(tag);
145
    else if (word !== "") wanted.push(word);
146
  }
147
  if (wanted.length === 0 && tagged.length === 0) return [];
148
  const hits: (Hit<T> & { title: boolean })[] = [];
149
  for (const note of notes) {
150
    const title = note.title.toLowerCase();
151
    const body = note.body.toLowerCase();
152
    if (!wanted.every((w) => title.includes(w) || body.includes(w))) continue;
153
    if (tagged.length > 0) {
154
      const has = tags(note.body);
155
      // A tag matches itself and its children: #work finds #work/acme.
156
      if (!tagged.every((t) => has.some((h) => h === t || h.startsWith(`${t}/`)))) continue;
157
    }
158
    hits.push({
159
      note,
160
      snippet: snippet(note.body, wanted[0] ?? (tagged[0] === undefined ? "" : `#${tagged[0]}`)),
161
      title: wanted.length > 0 && wanted.every((w) => title.includes(w)),
162
    });
163
  }
164
  hits.sort(
165
    (a, b) =>
166
      Number(b.title) - Number(a.title) ||
167
      b.note.updated_at.localeCompare(a.note.updated_at),
168
  );
169
  return hits.map(({ note, snippet }) => ({ note, snippet }));
170
}
171
172
function snippet(body: string, word: string): string {
173
  const at = word === "" ? -1 : body.toLowerCase().indexOf(word);
174
  if (at < 0) return body.split("\n").find((l) => l.trim() !== "")?.trim().slice(0, 160) ?? "";
175
  const start = Math.max(0, body.lastIndexOf("\n", at) + 1, at - 70);
176
  const endLine = body.indexOf("\n", at);
177
  const end = Math.min(endLine < 0 ? body.length : endLine, at + 90);
178
  return `${start > 0 && body[start - 1] !== "\n" ? "…" : ""}${body.slice(start, end).trim()}${end < (endLine < 0 ? body.length : endLine) ? "…" : ""}`;
179
}
180
181
/**
182
 * The body with every link to `from` pointed at `to` instead, keeping any
183
 * alias and heading: what a rename does to the notes that link to it.
184
 */
185
export function relink(body: string, from: string, to: string): string {
186
  const old = key(from);
187
  return body.replace(
188
    /(!?)\[\[([^\[\]\n|#]+?)((?:#[^\[\]\n|]*)?)((?:\|[^\[\]\n]+?)?)\]\]/g,
189
    (whole, embed: string, title: string, heading: string, alias: string) =>
190
      key(title) === old ? `${embed}[[${to}${heading}${alias}]]` : whole,
191
  );
192
}
193
194
/** A plain-text preview of a note's first words, for lists. */
195
export function excerpt(body: string, max = 120): string {
196
  const text = (md.frontmatter(body)?.body ?? body)
197
    .replace(/^(```|~~~)[\s\S]*?^\1/gm, "")
198
    .replace(/^#{1,6}\s+/gm, "")
199
    .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2")
200
    .replace(/\[\[([^\]]+)\]\]/g, "$1")
201
    .replace(/[*_=~`>]/g, "")
202
    .replace(/\s+/g, " ")
203
    .trim();
204
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
205
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts