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

Files 48459319b3fcac902928ea7f5ec007a0d1a287bc
359 lines · 16.3 KB · ~4.8k tok Raw History
1
/**
2
 * A shared folder and its repository, kept in step. The notes live here,
3
 * so the page is as quick as a personal vault; the repository is where
4
 * they are kept, so they are plain markdown files anyone can clone, push
5
 * to, or open in another editor.
6
 *
7
 * One pass (`syncSpace`) reads, then writes:
8
 *
9
 *   read   when the branch has moved since the last read, walk the folder
10
 *          and take every file that changed. A note nobody has touched
11
 *          here takes the repository's words; a note edited here *and*
12
 *          there keeps ours, and the repository's version is kept beside
13
 *          it as "<title> (repo copy)" — nothing is ever lost.
14
 *   write  whatever was written, renamed, moved or removed here since,
15
 *          as one commit.
16
 *
17
 * A note knows what the repository last had (synced_path and the git blob
18
 * of its words, synced_oid), so "changed here" is a comparison, never a
19
 * flag to forget. Commits go through the forge's API with a token
20
 * (NOTES_REPO_TOKEN): the app's own token reads only.
21
 *
22
 * Writes are owed, not immediate: a change waits a few seconds for the
23
 * next (typing is many saves), and a slow trickle still commits within
24
 * MAX_WAIT_MS. Every space is also read once a minute.
25
 */
26
27
import type { Database } from "bun:sqlite";
28
import { createHash } from "node:crypto";
29
import type { Note } from "./actions.ts";
30
import { BODY_MAX } from "./actions.ts";
31
import { findSpace, spaceVault, tellSpace, titleOfPath, whenEdited, type Space } from "./spaces.ts";
32
33
/** The git blob id of some words: what `git hash-object` says of the file. */
34
export function blobOid(text: string): string {
35
  const bytes = Buffer.from(text, "utf8");
36
  return createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
37
}
38
39
export interface Entry {
40
  readonly name: string;
41
  readonly type: "blob" | "tree" | string;
42
  readonly oid: string;
43
  readonly size: number;
44
}
45
46
/** The forge, as much of it as a shared folder needs. */
47
export interface Forge {
48
  /** The commit at the tip of a branch; null when there is none yet. */
49
  head(repo: string, branch: string): Promise<string | null>;
50
  /** A folder's entries at a commit; null when it doesn't exist. */
51
  list(repo: string, ref: string, path: string): Promise<Entry[] | null>;
52
  /** A file's words at a commit. */
53
  read(repo: string, ref: string, path: string): Promise<string>;
54
  /** Write and remove files (`content: null`) as one commit on `branch`. */
55
  commit(
56
    repo: string,
57
    branch: string,
58
    message: string,
59
    files: { path: string; content: string | null }[],
60
  ): Promise<string>;
61
}
62
63
type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
64
65
/** The forge's JSON API (`<forge>/api/v1`), as a token that may write the repositories. */
66
export function httpForge(api: string, token: string, fetch: Fetch = globalThis.fetch): Forge {
67
  const base = api.replace(/\/+$/, "");
68
  const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
69
  const at = (repo: string, rest: string) => `${base}/repos/${repo}/${rest}`;
70
  const path = (p: string) => p.split("/").map(encodeURIComponent).join("/");
71
  const failed = async (r: Response, what: string): Promise<never> => {
72
    throw new Error(`${what}: HTTP ${r.status} ${(await r.text()).slice(0, 200)}`);
73
  };
74
  return {
75
    async head(repo, branch) {
76
      const r = await fetch(at(repo, `commits?ref=${encodeURIComponent(branch)}&limit=1`), { headers });
77
      if (r.status === 404) throw new Error(`No repository ${repo} — or the token can't see it.`);
78
      if (!r.ok) return failed(r, "read the branch");
79
      const log = (await r.json()) as { oid?: string }[];
80
      return log[0]?.oid ?? null;
81
    },
82
    async list(repo, ref, p) {
83
      const r = await fetch(at(repo, `contents${p === "" ? "" : `/${path(p)}`}?ref=${encodeURIComponent(ref)}`), {
84
        headers,
85
      });
86
      if (r.status === 404) return null;
87
      if (!r.ok) return failed(r, `list ${p || "/"}`);
88
      const answer = (await r.json()) as { type?: string; entries?: Entry[] };
89
      return answer.type === "dir" ? (answer.entries ?? []) : null;
90
    },
91
    async read(repo, ref, p) {
92
      const r = await fetch(at(repo, `contents/${path(p)}?ref=${encodeURIComponent(ref)}`), { headers });
93
      if (!r.ok) return failed(r, `read ${p}`);
94
      const answer = (await r.json()) as { encoding?: string; content?: string };
95
      const content = answer.content ?? "";
96
      return answer.encoding === "base64" ? Buffer.from(content, "base64").toString("utf8") : content;
97
    },
98
    async commit(repo, branch, message, files) {
99
      const r = await fetch(at(repo, "edits"), {
100
        method: "POST",
101
        headers,
102
        body: JSON.stringify({ branch, message, files }),
103
      });
104
      if (!r.ok) return failed(r, "commit");
105
      const answer = (await r.json()) as { commit?: { commitOid?: string; oid?: string } | string };
106
      const c = answer.commit;
107
      return typeof c === "string" ? c : (c?.commitOid ?? c?.oid ?? "");
108
    },
109
  };
110
}
111
112
/** Folders a vault never holds: git's, and editors' own settings (`.obsidian`, `.vscode`). */
113
const hidden = (name: string): boolean => name.startsWith(".") || name === "node_modules";
114
115
/** Every markdown file under `folder`, path → blob id. */
116
async function walk(forge: Forge, space: Space, ref: string): Promise<Map<string, Entry>> {
117
  const files = new Map<string, Entry>();
118
  const visit = async (dir: string, depth: number): Promise<void> => {
119
    if (depth > 12) return;
120
    const entries = await forge.list(space.repo, ref, dir);
121
    for (const e of entries ?? []) {
122
      if (hidden(e.name)) continue;
123
      const p = dir === "" ? e.name : `${dir}/${e.name}`;
124
      if (e.type === "tree") await visit(p, depth + 1);
125
      else if (e.type === "blob" && /\.md$/i.test(e.name) && e.size <= BODY_MAX) files.set(p, e);
126
    }
127
  };
128
  await visit(space.folder, 0);
129
  return files;
130
}
131
132
const NOW = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
133
const notesOf = (db: Database, space: Space): Note[] =>
134
  db.query<Note, [string]>("SELECT * FROM note WHERE author_id = ?1").all(spaceVault(space.id));
135
136
/** Whether the note has words, a name or a place the repository hasn't been given yet. */
137
export const unsynced = (n: Note): boolean =>
138
  n.path !== null && (n.synced_path === null || n.synced_path !== n.path || n.synced_oid !== blobOid(n.body));
139
140
/** A title nobody in the vault has yet: `base`, else `base 2`, `base 3`, … */
141
function freeTitle(db: Database, vault: string, base: string): string {
142
  const taken = (t: string) =>
143
    db
144
      .query<{ id: number }, [string, string]>("SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE")
145
      .get(vault, t) !== null;
146
  if (!taken(base)) return base;
147
  for (let n = 2; ; n++) if (!taken(`${base} ${n}`)) return `${base} ${n}`;
148
}
149
150
export interface Report {
151
  /** Notes taken from the repository: new, changed, removed. */
152
  readonly pulled: number;
153
  /** Files the commit wrote or removed. */
154
  readonly pushed: number;
155
  /** Repository versions kept beside a note edited in both places. */
156
  readonly copies: number;
157
  readonly commit?: string;
158
}
159
160
/** Read what the repository has that we don't — when the branch has moved. */
161
async function pull(db: Database, forge: Forge, space: Space, head: string | null): Promise<{ pulled: number; copies: number }> {
162
  if (head === null || head === space.head) return { pulled: 0, copies: 0 };
163
  const vault = spaceVault(space.id);
164
  const files = await walk(forge, space, head);
165
  const notes = notesOf(db, space);
166
  const bySynced = new Map(notes.filter((n) => n.synced_path !== null).map((n) => [n.synced_path!, n]));
167
  const byPath = new Map(notes.filter((n) => n.path !== null).map((n) => [n.path!.toLowerCase(), n]));
168
  const gone = new Set(
169
    db
170
      .query<{ path: string }, [number]>("SELECT path FROM space_gone WHERE space_id = ?1")
171
      .all(space.id)
172
      .map((g) => g.path),
173
  );
174
  // Read every changed file first, then write them all at once.
175
  const wanted = [...files].filter(([p, e]) => bySynced.get(p)?.synced_oid !== e.oid && !gone.has(p));
176
  const words = new Map<string, string>();
177
  for (const [p] of wanted) words.set(p, await forge.read(space.repo, head, p));
178
179
  let pulled = 0;
180
  let copies = 0;
181
  const changed: Note[] = [];
182
  const removed: number[] = [];
183
  db.transaction(() => {
184
    const keepCopy = (note: Note, body: string) => {
185
      const title = freeTitle(db, vault, `${note.title} (repo copy)`);
186
      const dir = note.path!.includes("/") ? note.path!.slice(0, note.path!.lastIndexOf("/") + 1) : "";
187
      changed.push(
188
        db
189
          .query<Note, [string, string, string, string]>(
190
            "INSERT INTO note (title, body, author_id, author_name, path) VALUES (?1, ?2, ?3, 'the repository', ?4) RETURNING *",
191
          )
192
          .get(title, body, vault, `${dir}${title}.md`)!,
193
      );
194
      copies++;
195
    };
196
    for (const [p, entry] of files) {
197
      if (gone.has(p)) continue;
198
      const known = bySynced.get(p);
199
      if (known !== undefined && known.synced_oid === entry.oid) continue;
200
      const body = words.get(p)!;
201
      // Made here and there under one name before either had seen the other.
202
      const twin = known ?? (byPath.get(p.toLowerCase())?.synced_path === null ? byPath.get(p.toLowerCase()) : undefined);
203
      if (twin !== undefined) {
204
        // Ours wins only when its words changed here too; a rename alone takes theirs.
205
        const ours = blobOid(twin.body) !== twin.synced_oid && twin.body !== body;
206
        if (ours) keepCopy(twin, body);
207
        changed.push(
208
          db
209
            .query<Note, [string, string, string, number]>(
210
              ours
211
                ? "UPDATE note SET synced_path = ?2, synced_oid = ?3 WHERE id = ?4 RETURNING *"
212
                : `UPDATE note SET body = ?1, synced_path = ?2, synced_oid = ?3, author_name = 'the repository', updated_at = ${NOW} WHERE id = ?4 RETURNING *`,
213
            )
214
            .get(body, p, entry.oid, twin.id)!,
215
        );
216
        if (!ours) pulled++;
217
        continue;
218
      }
219
      // A note here moved onto this name while the repository wrote its own
220
      // file there: keep theirs beside it.
221
      const squatter = byPath.get(p.toLowerCase());
222
      if (squatter !== undefined) {
223
        keepCopy(squatter, body);
224
        continue;
225
      }
226
      // A file new to us: a note, titled by its name (or, when another
227
      // folder already has that name, by its name and a number).
228
      const title = freeTitle(db, vault, titleOfPath(p).replace(/[\[\]|#^\\]/g, " ").trim() || "Untitled");
229
      changed.push(
230
        db
231
          .query<Note, [string, string, string, string]>(
232
            "INSERT INTO note (title, body, author_id, author_name, path, synced_path, synced_oid) VALUES (?1, ?2, ?3, 'the repository', ?4, ?4, ?5) RETURNING *",
233
          )
234
          .get(title, body, vault, p, entry.oid)!,
235
      );
236
      pulled++;
237
    }
238
    // Files the repository no longer has: gone here too, unless edited here
239
    // since — then they are written back.
240
    for (const n of notes) {
241
      if (n.synced_path === null || files.has(n.synced_path)) continue;
242
      if (unsynced(n)) db.query("UPDATE note SET synced_path = NULL, synced_oid = NULL WHERE id = ?1").run(n.id);
243
      else {
244
        db.query("DELETE FROM note WHERE id = ?1").run(n.id);
245
        removed.push(n.id);
246
        pulled++;
247
      }
248
    }
249
    for (const p of gone) if (!files.has(p)) db.query("DELETE FROM space_gone WHERE space_id = ?1 AND path = ?2").run(space.id, p);
250
    db.query("UPDATE space SET head = ?1 WHERE id = ?2").run(head, space.id);
251
  })();
252
  for (const n of changed) tellSpace(db, space.id, "note.changed", { id: n.id, title: n.title, updated_at: n.updated_at });
253
  for (const id of removed) tellSpace(db, space.id, "note.removed", { id });
254
  return { pulled, copies };
255
}
256
257
/** Commit whatever was written here since the repository last heard. */
258
async function push(db: Database, forge: Forge, space: Space): Promise<{ pushed: number; commit?: string }> {
259
  const notes = notesOf(db, space).filter(unsynced);
260
  const gone = db
261
    .query<{ path: string }, [number]>("SELECT path FROM space_gone WHERE space_id = ?1")
262
    .all(space.id)
263
    .map((g) => g.path);
264
  if (notes.length === 0 && gone.length === 0) return { pushed: 0 };
265
  const writes = new Map<string, string | null>();
266
  for (const n of notes) writes.set(n.path!, n.body);
267
  // A rename or a move leaves its old file behind; a removal takes its file.
268
  for (const n of notes) if (n.synced_path !== null && n.synced_path !== n.path && !writes.has(n.synced_path)) writes.set(n.synced_path, null);
269
  for (const p of gone) if (!writes.has(p)) writes.set(p, null);
270
  const files = [...writes].map(([path, content]) => ({ path, content }));
271
272
  const written = notes.map((n) => n.title);
273
  const who = [...new Set(notes.map((n) => n.author_name))].filter((n) => n !== "the repository");
274
  const subject =
275
    written.length === 0
276
      ? `notes: remove ${gone.length} ${gone.length === 1 ? "note" : "notes"}`
277
      : `notes: ${written.slice(0, 3).join(", ")}${written.length > 3 ? ` and ${written.length - 3} more` : ""}${gone.length > 0 ? `, removed ${gone.length}` : ""}`;
278
  const message = `${subject}${who.length > 0 ? `\n\nWritten in notes by ${who.join(", ")}.` : ""}`;
279
280
  const commit = await forge.commit(space.repo, space.branch, message, files);
281
  db.transaction(() => {
282
    for (const n of notes)
283
      db.query("UPDATE note SET synced_path = ?1, synced_oid = ?2 WHERE id = ?3").run(n.path, blobOid(n.body), n.id);
284
    for (const p of gone) db.query("DELETE FROM space_gone WHERE space_id = ?1 AND path = ?2").run(space.id, p);
285
  })();
286
  // The head stays where it was read: someone may have pushed just before
287
  // this commit landed on top of theirs, and the next read must see it.
288
  // Files of ours it finds unchanged (their blob is the one we wrote).
289
  return { pushed: files.length, commit };
290
}
291
292
const running = new Map<number, Promise<Report>>();
293
294
/** One pass for one space: read, then write. Passes for a space never overlap. */
295
export function syncSpace(db: Database, forge: Forge, id: number): Promise<Report> {
296
  const before = running.get(id) ?? Promise.resolve(undefined);
297
  const pass = before
298
    .catch(() => undefined)
299
    .then(async (): Promise<Report> => {
300
      const space = findSpace(db, id);
301
      if (space === undefined) return { pulled: 0, pushed: 0, copies: 0 };
302
      try {
303
        const head = await forge.head(space.repo, space.branch);
304
        const read = await pull(db, forge, space, head);
305
        const wrote = await push(db, forge, findSpace(db, id) ?? space);
306
        db.query(`UPDATE space SET synced_at = ${NOW}, sync_error = NULL WHERE id = ?1`).run(id);
307
        if (read.pulled + read.copies + wrote.pushed > 0) tellSpace(db, id, "space.synced", { id });
308
        return { ...read, pushed: wrote.pushed, ...(wrote.commit === undefined ? {} : { commit: wrote.commit }) };
309
      } catch (e) {
310
        const message = e instanceof Error ? e.message : String(e);
311
        db.query(`UPDATE space SET synced_at = ${NOW}, sync_error = ?1 WHERE id = ?2`).run(message.slice(0, 500), id);
312
        tellSpace(db, id, "space.synced", { id });
313
        throw e;
314
      }
315
    });
316
  running.set(id, pass);
317
  void pass.finally(() => {
318
    if (running.get(id) === pass) running.delete(id);
319
  }).catch(() => {});
320
  return pass;
321
}
322
323
export const WAIT_MS = 4_000;
324
export const MAX_WAIT_MS = 20_000;
325
export const EVERY_MS = 60_000;
326
327
/**
328
 * Keep every space in step from now on: a pass a moment after each edit,
329
 * and one a minute for whatever was pushed to the repository. Returns the
330
 * way to stop.
331
 */
332
export function startSync(db: Database, forge: Forge | undefined): () => void {
333
  if (forge === undefined) return () => {};
334
  const timers = new Map<number, { timer: ReturnType<typeof setTimeout>; first: number }>();
335
  const quiet = (id: number) => syncSpace(db, forge, id).catch(() => {});
336
  whenEdited((id) => {
337
    const owed = timers.get(id);
338
    const first = owed?.first ?? Date.now();
339
    if (owed !== undefined) clearTimeout(owed.timer);
340
    const wait = Math.max(0, Math.min(WAIT_MS, first + MAX_WAIT_MS - Date.now()));
341
    timers.set(id, {
342
      first,
343
      timer: setTimeout(() => {
344
        timers.delete(id);
345
        void quiet(id);
346
      }, wait),
347
    });
348
  });
349
  const all = () => {
350
    for (const { id } of db.query<{ id: number }, []>("SELECT id FROM space").all()) void quiet(id);
351
  };
352
  const minute = setInterval(all, EVERY_MS);
353
  setTimeout(all, 1_000);
354
  return () => {
355
    clearInterval(minute);
356
    for (const t of timers.values()) clearTimeout(t.timer);
357
    whenEdited(() => {});
358
  };
359
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts