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

Files b1c728e7eb4bf51bf75a6af39ebff6d562839a67
203 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
): 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
/** The notes that link to `note`, by title. */
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
/** What `note` links to: the notes that exist, and the titles that do not yet. */
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
/** Every tag in the vault with how many notes carry it, most used first. */
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
/** The vault as nodes and undirected edges, for the graph view. */
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
  /** A line of the body around the first match, plain text. */
125
  readonly snippet: string;
126
}
127
128
/**
129
 * Search: every word must appear in the title or body (any case); `#tag`
130
 * or `tag:name` must be a tag the note carries; a phrase in quotes is
131
 * matched whole. Title matches rank first, then the most recently changed.
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
      // A tag matches itself and its children: #work finds #work/acme.
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
 * The body with every link to `from` pointed at `to` instead, keeping any
181
 * alias and heading: what a rename does to the notes that link to it.
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
/** A plain-text preview of a note's first words, for lists. */
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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts