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

Shared folders: a repo folder you write in together, kept in git (#3)

Co-authored-by: benjivers <[email protected]>
benjivers committed 9965609 Browse files
18 files changed +2116 −196
README.md +33
@@ -45,6 +45,39 @@ the MCP endpoint and the terminal are for forge accounts.
4545 Everything is real: notes live in SQLite under `FORGE_APP_DATA`. A preview
4646 of a pull gets a copy of production data; production is the default branch.
4747
48 ## Shared folders
49
50 A **shared folder** is a folder of a git repository on the forge — say
51 `docs/` of `ben/brainstorm` — that you and the people you invite write in
52 together. Everything else in the app stays yours alone: a shared folder's
53 notes never appear in anyone's own vault, search or graph, and its members
54 never see yours.
55
56 - **Connect one** (app admins): sidebar → *Vaults* → *Share a repo folder*,
57 then name, `owner/repo`, branch and folder. It reads the folder at once.
58 Use a plain repository, not an app's: every save is a commit.
59 - **Invite**: the folder's people icon → *Join link*. Anyone signed in (forge
60 or email account) who opens it joins. Turn the link off afterwards, make a
61 new one, or remove people there. Members can leave.
62 - **Inside**: the sidebar shows the repo's folder tree. Links, backlinks,
63 tags, search, the graph and daily notes work as in your vault, within the
64 folder. Name a new note `ideas/Pricing` to put it in `ideas/`; the File
65 panel moves a note to another folder. Front matter (`---` … `---`) reads
66 as properties, and its `tags` are tags.
67 - **Git**: every note is a `.md` file. Edits commit a few seconds after you
68 stop typing (one commit for a burst, naming who wrote); renames and moves
69 move the file; removing a note removes it. Anything pushed to the folder
70 (from a laptop, VS Code, Obsidian) shows up within a minute, or at once
71 with *Sync now*. A note changed in both places keeps the app's words and
72 saves the repository's beside it as `<title> (repo copy)` — nothing is
73 lost.
74 - **Two people, one note**: the second save is refused when the note moved
75 on underneath it, and the editor asks: *Load theirs* or *Keep mine*.
76
77 Commits are made with `NOTES_REPO_TOKEN` (Settings → App → Environment), a
78 forge token that may write those repositories; without it there are no
79 shared folders. Commits are authored as that token's owner.
80
4881 ## Claude can use it too
4982
5083 `POST /mcp` is an MCP server with `list_notes`, `search_notes`,
contract.json +6 −5
@@ -2,7 +2,8 @@
22 "roles": {
33 "anon": { "above": [], "held": { "by": "everyone" } },
44 "user": { "above": ["anon"], "held": { "by": "identity", "role": "user" } },
5 "author": { "above": ["user"], "held": { "by": "row", "column": "author_id" } },
5 "member": { "above": ["user"], "held": { "by": "row", "column": "member_id" } },
6 "author": { "above": ["member"], "held": { "by": "row", "column": "author_id" } },
67 "admin": { "above": ["user"], "held": { "by": "identity", "role": "admin" } }
78 },
89 "resources": {
@@ -10,10 +11,10 @@
1011 "api": "/api/notes",
1112 "can": {
1213 "create": "user",
13 "read": "author",
14 "update": "author",
15 "delete": "author",
16 "list": "author"
14 "read": "member",
15 "update": "member",
16 "delete": "member",
17 "list": "member"
1718 }
1819 },
1920 "audit": {
contract/openapi.json +44 −6
@@ -8,7 +8,7 @@
88 "paths": {
99 "/api/notes": {
1010 "get": {
11 "summary": "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first.",
11 "summary": "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first. With `space`, a shared folder's instead.",
1212 "operationId": "getApiNotes",
1313 "parameters": [
1414 {
@@ -20,6 +20,13 @@
2020 "maximum": 5000
2121 }
2222 },
23 {
24 "name": "space",
25 "in": "query",
26 "schema": {
27 "type": "integer"
28 }
29 },
2330 {
2431 "name": "q",
2532 "in": "query",
@@ -31,7 +38,7 @@
3138 ],
3239 "responses": {
3340 "200": {
34 "description": "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first.",
41 "description": "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first. With `space`, a shared folder's instead.",
3542 "content": {
3643 "application/json": {
3744 "schema": {
@@ -57,7 +64,7 @@
5764 "x-contract": {
5865 "resource": "note",
5966 "verb": "list",
60 "least": "author"
67 "least": "member"
6168 }
6269 },
6370 "post": {
@@ -79,6 +86,14 @@
7986 "type": "string",
8087 "maxLength": 200000,
8188 "description": "Markdown."
89 },
90 "space": {
91 "type": "integer",
92 "description": "A shared folder you are in, to add the note there instead of your vault."
93 },
94 "folder": {
95 "type": "string",
96 "description": "In a shared folder: the folder to put it in (`ideas/q4`)."
8297 }
8398 }
8499 }
@@ -153,7 +168,7 @@
153168 "x-contract": {
154169 "resource": "note",
155170 "verb": "read",
156 "least": "author"
171 "least": "member"
157172 }
158173 },
159174 "patch": {
@@ -188,6 +203,15 @@
188203 },
189204 "pinned": {
190205 "type": "boolean"
206 },
207 "folder": {
208 "type": "string",
209 "description": "A shared note's folder: moves its file."
210 },
211 "base": {
212 "type": "string",
213 "format": "date-time",
214 "description": "The updated_at you edited from; when someone has saved since, new words are refused with 409."
191215 }
192216 }
193217 }
@@ -235,6 +259,13 @@
235259 "updated_at": {
236260 "type": "string",
237261 "format": "date-time"
262 },
263 "path": {
264 "type": [
265 "string",
266 "null"
267 ],
268 "description": "A shared folder's note: its file in the repository. Null for a note of your own."
238269 }
239270 },
240271 "required": [
@@ -278,7 +309,7 @@
278309 "x-contract": {
279310 "resource": "note",
280311 "verb": "update",
281 "least": "author"
312 "least": "member"
282313 }
283314 },
284315 "delete": {
@@ -327,7 +358,7 @@
327358 "x-contract": {
328359 "resource": "note",
329360 "verb": "delete",
330 "least": "author"
361 "least": "member"
331362 }
332363 }
333364 },
@@ -570,6 +601,13 @@
570601 "updated_at": {
571602 "type": "string",
572603 "format": "date-time"
604 },
605 "path": {
606 "type": [
607 "string",
608 "null"
609 ],
610 "description": "A shared folder's note: its file in the repository. Null for a note of your own."
573611 }
574612 },
575613 "required": [
src/accounts.ts +1
@@ -185,6 +185,7 @@ export async function deleteAccount(
185185 const notes = db.transaction(() => {
186186 const gone = db.query("DELETE FROM note WHERE author_id = ?1").run(`acct:${id}`).changes;
187187 db.query("DELETE FROM session WHERE account_id = ?1").run(id);
188 db.query("DELETE FROM space_member WHERE who_id = ?1").run(`acct:${id}`);
188189 db.query("DELETE FROM account WHERE id = ?1").run(id);
189190 return gone;
190191 })();
src/actions.ts +192 −48
@@ -10,6 +10,19 @@ import type { Database } from "bun:sqlite";
1010 import { record, type AuditRow } from "./audit.ts";
1111 import { permit } from "./contract.ts";
1212 import { emit } from "./live.ts";
13 import {
14 cleanFolder,
15 cleanSegment,
16 edited,
17 fileOf,
18 folderOf,
19 getSpace,
20 isMember,
21 spaceOf,
22 spaceVault,
23 tellSpace,
24 type Space,
25 } from "./spaces.ts";
1326 import { relink, search } from "./vault.ts";
1427 import type { Who } from "./who.ts";
1528
@@ -22,9 +35,14 @@ export interface Note {
2235 readonly author_name: string;
2336 readonly created_at: string;
2437 readonly updated_at: string;
38 /** A shared note's file in its repository; null for a note of your own. */
39 readonly path: string | null;
40 /** Where the repository last had it, and the git blob it had (src/sync.ts). */
41 readonly synced_path: string | null;
42 readonly synced_oid: string | null;
2543 }
2644
27 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
45 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid" | "conflict";
2846 export type Result<T> =
2947 | { ok: true; value: T }
3048 | { ok: false; error: Failure; message: string };
@@ -67,11 +85,31 @@ const audited = <T extends { readonly id: number }>(
6785 return result;
6886 };
6987
70 /** Whose vault a change belongs to: its live events reach only them. */
71 const tell = (type: string, note: Note): void => {
72 emit(type, { id: note.id, title: note.title, updated_at: note.updated_at }, note.author_id);
88 /** Whose vault a change belongs to: its live events reach only them — or, in a shared folder, its members. */
89 const tell = (db: Database, type: string, note: Note): void => {
90 const data = { id: note.id, title: note.title, updated_at: note.updated_at };
91 const space = spaceOf(note.author_id);
92 if (space === undefined) emit(type, data, note.author_id);
93 else tellSpace(db, space, type, data);
7394 };
7495
96 /**
97 * The vault `who` means: their own, or — named by `space` — a shared
98 * folder they are in. Every vault query takes the answer's `vault` where
99 * it once took `who.id`, which is all a shared folder needs.
100 */
101 function vaultFor(
102 db: Database,
103 who: Who,
104 space: number | undefined,
105 ): Result<{ vault: string; space?: Space }> {
106 if (who.role === "anon") return fail("signin_required", "Sign in to open your notes.");
107 if (space === undefined) return ok({ vault: who.id });
108 const found = getSpace(db, who, space);
109 if (!found.ok) return found;
110 return ok({ vault: spaceVault(space), space: found.value });
111 }
112
75113 /**
76114 * A title, as the vault keeps it: trimmed, one line, and free of the
77115 * characters a [[link]] uses to say something else (`[ ] | # ^`).
@@ -99,7 +137,7 @@ const checkBody = (body: string): Result<string> =>
99137 export function listNotes(
100138 db: Database,
101139 who: Who,
102 input: { limit?: number } = {},
140 input: { limit?: number; space?: number } = {},
103141 ): Result<Note[]> {
104142 const limit = Math.min(
105143 Math.max(Math.trunc(input.limit ?? VAULT_MAX) || VAULT_MAX, 1),
@@ -108,12 +146,14 @@ export function listNotes(
108146 const may = permit(who, "note", "list");
109147 if (may === "no" || who.role === "anon")
110148 return refuse(who, "Sign in to open your notes.", "You may not see these notes.");
149 const vault = vaultFor(db, who, input.space);
150 if (!vault.ok) return vault;
111151 return ok(
112152 db
113153 .query<Note, [string, number]>(
114154 "SELECT * FROM note WHERE author_id = ?1 ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT ?2",
115155 )
116 .all(who.id, limit),
156 .all(vault.value.vault, limit),
117157 );
118158 }
119159
@@ -136,48 +176,85 @@ const titleTaken = (db: Database, authorId: string, title: string, except = -1):
136176 )
137177 .get(authorId, title, except) !== null;
138178
139 export const addNote = (
140 db: Database,
141 who: Who,
142 input: { title?: string; body?: string },
143 ): Result<Note> =>
179 /** What a new note may say: in a shared folder, also which folder (or a title `folder/Title`). */
180 export interface NewNote {
181 readonly title?: string;
182 readonly body?: string;
183 readonly space?: number;
184 readonly folder?: string;
185 }
186
187 export const addNote = (db: Database, who: Who, input: NewNote): Result<Note> =>
144188 audited(db, who, "note", "create", undefined, add(db, who, input));
145189
146 function add(
147 db: Database,
148 who: Who,
149 input: { title?: string; body?: string },
150 ): Result<Note> {
190 /** A shared note's title and folder: its title is a file name, and `a/b/Title` puts it in a folder. */
191 function placeOf(input: { title: string; folder?: string }): Result<{ title: string; folder: string }> {
192 const cut = input.title.lastIndexOf("/");
193 const title = cleanSegment(cut < 0 ? input.title : input.title.slice(cut + 1));
194 if (!title.ok) return title;
195 const folder = cleanFolder(
196 [input.folder ?? "", cut < 0 ? "" : input.title.slice(0, cut)].filter((f) => f !== "").join("/"),
197 );
198 if (!folder.ok) return folder;
199 return ok({ title: title.value, folder: folder.value });
200 }
201
202 function add(db: Database, who: Who, input: NewNote): Result<Note> {
151203 // The table decides; the type only learns it here: whoever may create is
152204 // signed in, and carries a name to write beside the note.
153205 if (who.role === "anon" || permit(who, "note", "create") !== "yes")
154206 return refuse(who, "Sign in to add a note.", "You may not add a note.");
207 const where = vaultFor(db, who, input.space);
208 if (!where.ok) return where;
209 const { vault, space } = where.value;
155210 const body = checkBody(input.body ?? "");
156211 if (!body.ok) return body;
157212 const count = db
158213 .query<{ n: number }, [string]>("SELECT count(*) AS n FROM note WHERE author_id = ?1")
159 .get(who.id)!.n;
214 .get(vault)!.n;
160215 if (count >= VAULT_MAX)
161216 return fail("invalid", `A vault holds at most ${VAULT_MAX.toLocaleString("en")} notes.`);
162217 let title: string;
163 if (input.title === undefined || input.title.trim() === "")
164 title = freeTitle(db, who.id, "Untitled");
165 else {
218 let folder = "";
219 if (input.title === undefined || input.title.trim() === "" || input.title.trim().endsWith("/")) {
220 if (space !== undefined) {
221 const at = cleanFolder(`${input.folder ?? ""}/${input.title ?? ""}`);
222 if (!at.ok) return at;
223 folder = at.value;
224 }
225 title = freeTitle(db, vault, "Untitled");
226 } else if (space !== undefined) {
227 const place = placeOf({ title: input.title, ...(input.folder === undefined ? {} : { folder: input.folder }) });
228 if (!place.ok) return place;
229 title = place.value.title;
230 folder = place.value.folder;
231 } else {
166232 const clean = cleanTitle(input.title);
167233 if (!clean.ok) return clean;
168 if (titleTaken(db, who.id, clean.value))
169 return fail("invalid", `A note called “${clean.value}” already exists.`);
170234 title = clean.value;
171235 }
236 if (titleTaken(db, vault, title))
237 return fail("invalid", `A note called “${title}” already exists.`);
238 const path = space === undefined ? null : fileOf(space, folder, title);
239 if (path !== null && pathTaken(db, vault, path))
240 return fail("invalid", `There is already a file at ${path}.`);
172241 const note = db
173 .query<Note, [string, string, string, string]>(
174 "INSERT INTO note (title, body, author_id, author_name) VALUES (?1, ?2, ?3, ?4) RETURNING *",
242 .query<Note, [string, string, string, string, string | null]>(
243 "INSERT INTO note (title, body, author_id, author_name, path) VALUES (?1, ?2, ?3, ?4, ?5) RETURNING *",
175244 )
176 .get(title, body.value, who.id, who.name)!;
177 tell("note.added", note);
245 .get(title, body.value, vault, who.name, path)!;
246 tell(db, "note.added", note);
247 if (space !== undefined) edited(space.id);
178248 return ok(note);
179249 }
180250
251 const pathTaken = (db: Database, vault: string, path: string, except = -1): boolean =>
252 db
253 .query<{ id: number }, [string, string, number]>(
254 "SELECT id FROM note WHERE author_id = ?1 AND path = ?2 COLLATE NOCASE AND id != ?3",
255 )
256 .get(vault, path, except) !== null;
257
181258 /**
182259 * One note, for `who` to do `verb` to. The door is asked before the row is
183260 * looked for, so whoever may not is told that and never learns whether the
@@ -196,8 +273,17 @@ function noteFor(
196273 // With no row to show, the door is asked about a row of their own: whoever
197274 // could not pass even then is refused, and learns nothing about what
198275 // exists; whoever could is told there is no such note.
276 // A shared note is its folder's: whoever is in the folder is the row's
277 // member, which is what the table gives a shared note's verbs to.
278 const space = note === null ? undefined : spaceOf(note.author_id);
199279 const row: Readonly<Record<string, unknown>> =
200 note === null ? { author_id: who.role === "anon" ? null : who.id } : { ...note };
280 note === null
281 ? { author_id: who.role === "anon" ? null : who.id }
282 : {
283 ...note,
284 member_id:
285 space !== undefined && who.role !== "anon" && isMember(db, space, who) ? who.id : null,
286 };
201287 if (permit(who, "note", verb, row) !== "yes")
202288 return refuse(who, said.signIn, said.no);
203289 if (note !== null) return ok(note);
@@ -217,20 +303,22 @@ export function getNote(
217303 });
218304 }
219305
220 /** A note in the asker's vault by its title, any case. */
306 /** A note in the asker's vault (or a shared folder of theirs) by its title, any case. */
221307 export function findNote(
222308 db: Database,
223309 who: Who,
224 input: { title: string },
310 input: { title: string; space?: number },
225311 ): Result<Note> {
226 if (who.role === "anon")
227 return fail("signin_required", "Sign in to open your notes.");
228 const title = input.title.replace(/\s+/g, " ").trim();
312 const where = vaultFor(db, who, input.space);
313 if (!where.ok) return where;
314 // In a shared folder `ideas/Pricing` names the note Pricing.
315 const raw = input.space === undefined ? input.title : input.title.slice(input.title.lastIndexOf("/") + 1);
316 const title = raw.replace(/\s+/g, " ").trim();
229317 const note = db
230318 .query<Note, [string, string]>(
231319 "SELECT * FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE",
232320 )
233 .get(who.id, title);
321 .get(where.value.vault, title);
234322 if (note === null) return fail("not_found", `No note called “${title}”.`);
235323 return getNote(db, who, { id: note.id });
236324 }
@@ -239,12 +327,12 @@ export function findNote(
239327 export function openOrCreate(
240328 db: Database,
241329 who: Who,
242 input: { title: string; body?: string },
330 input: { title: string; body?: string; space?: number; folder?: string },
243331 ): Result<Note & { created: boolean }> {
244332 const found = findNote(db, who, input);
245333 if (found.ok) return ok({ ...found.value, created: false });
246334 if (found.error !== "not_found") return found;
247 const made = addNote(db, who, { title: input.title, body: input.body ?? "" });
335 const made = addNote(db, who, { ...input, body: input.body ?? "" });
248336 return made.ok ? ok({ ...made.value, created: true }) : made;
249337 }
250338
@@ -253,6 +341,14 @@ export interface Change {
253341 readonly title?: string;
254342 readonly body?: string;
255343 readonly pinned?: boolean;
344 /** A shared note's folder, relative to its space's: a move. */
345 readonly folder?: string;
346 /**
347 * The `updated_at` the change was written against. When the note has
348 * moved on since — someone else saved it — new words or a new title are
349 * refused as a `conflict` rather than written over theirs.
350 */
351 readonly base?: string;
256352 }
257353
258354 export const updateNote = (
@@ -278,9 +374,19 @@ function update(
278374 });
279375 if (!found.ok) return found;
280376 const before = found.value;
377 const spaceId = spaceOf(before.author_id);
378 const space = spaceId === undefined ? undefined : getSpace(db, who, spaceId);
379 if (space !== undefined && !space.ok) return space;
380 if (
381 input.base !== undefined &&
382 input.base !== before.updated_at &&
383 ((input.body !== undefined && input.body !== before.body) ||
384 (input.title !== undefined && input.title !== before.title))
385 )
386 return fail("conflict", `${before.author_name} saved this note while you were writing.`);
281387 let title = before.title;
282388 if (input.title !== undefined) {
283 const clean = cleanTitle(input.title);
389 const clean = space === undefined ? cleanTitle(input.title) : cleanSegment(input.title);
284390 if (!clean.ok) return clean;
285391 if (titleTaken(db, before.author_id, clean.value, before.id))
286392 return fail("invalid", `A note called “${clean.value}” already exists.`);
@@ -293,17 +399,43 @@ function update(
293399 body = checked.value;
294400 }
295401 const pinned = input.pinned === undefined ? before.pinned : input.pinned ? 1 : 0;
296 if (title === before.title && body === before.body && pinned === before.pinned)
402 // A shared note's file follows its title, and its folder when it moves.
403 let path = before.path;
404 if (space !== undefined && before.path !== null) {
405 let folder = folderOf(space.value, before.path);
406 if (input.folder !== undefined) {
407 const clean = cleanFolder(input.folder);
408 if (!clean.ok) return clean;
409 folder = clean.value;
410 }
411 // A file whose name was never its title (two files of one name, read
412 // from the repository) keeps its name until it is renamed or moved.
413 path =
414 title === before.title && input.folder === undefined ? before.path : fileOf(space.value, folder, title);
415 if (path !== before.path && pathTaken(db, before.author_id, path, before.id))
416 return fail("invalid", `There is already a file at ${path}.`);
417 }
418 if (title === before.title && body === before.body && pinned === before.pinned && path === before.path)
297419 return ok({ ...before, relinked: 0 });
298420 const renamed = title.toLowerCase() !== before.title.toLowerCase();
299421 const touched: Note[] = [];
422 // In a shared folder the name beside a note is whoever wrote in it last.
423 const by = space !== undefined && who.role !== "anon" ? who.name : before.author_name;
300424 const note = db.transaction(() => {
301425 const saved = db
302 .query<Note, [string, string, number, number, number]>(
303 // A pin is not an edit: only new words or a new title move a note up by recency.
304 "UPDATE note SET title = ?1, body = ?2, pinned = ?3, updated_at = CASE WHEN ?5 THEN strftime('%Y-%m-%dT%H:%M:%fZ', 'now') ELSE updated_at END WHERE id = ?4 RETURNING *",
426 .query<Note, [string, string, number, number, number, string | null, string]>(
427 // A pin is not an edit: only new words, a new title or a move move a note up by recency.
428 "UPDATE note SET title = ?1, body = ?2, pinned = ?3, path = ?6, author_name = CASE WHEN ?5 THEN ?7 ELSE author_name END, updated_at = CASE WHEN ?5 THEN strftime('%Y-%m-%dT%H:%M:%fZ', 'now') ELSE updated_at END WHERE id = ?4 RETURNING *",
305429 )
306 .get(title, body, pinned, before.id, title !== before.title || body !== before.body ? 1 : 0)!;
430 .get(
431 title,
432 body,
433 pinned,
434 before.id,
435 title !== before.title || body !== before.body || path !== before.path ? 1 : 0,
436 path,
437 by,
438 )!;
307439 if (renamed || title !== before.title)
308440 for (const other of db
309441 .query<Note, [string, number]>("SELECT * FROM note WHERE author_id = ?1 AND id != ?2")
@@ -320,8 +452,9 @@ function update(
320452 }
321453 return saved;
322454 })();
323 tell("note.changed", note);
324 for (const other of touched) tell("note.changed", other);
455 tell(db, "note.changed", note);
456 for (const other of touched) tell(db, "note.changed", other);
457 if (spaceId !== undefined) edited(spaceId);
325458 return ok({ ...note, relinked: touched.length });
326459 }
327460
@@ -342,18 +475,29 @@ function remove(
342475 no: "Only the author can remove a note.",
343476 });
344477 if (!found.ok) return found;
345 db.query("DELETE FROM note WHERE id = ?1").run(found.value.id);
346 emit("note.removed", { id: found.value.id }, found.value.author_id);
347 return ok({ id: found.value.id });
478 const note = found.value;
479 const space = spaceOf(note.author_id);
480 db.transaction(() => {
481 db.query("DELETE FROM note WHERE id = ?1").run(note.id);
482 // The repository still has its file until the next commit takes it away.
483 if (space !== undefined && note.synced_path !== null)
484 db.query("INSERT OR IGNORE INTO space_gone (space_id, path) VALUES (?1, ?2)").run(space, note.synced_path);
485 })();
486 if (space === undefined) emit("note.removed", { id: note.id }, note.author_id);
487 else {
488 tellSpace(db, space, "note.removed", { id: note.id });
489 edited(space);
490 }
491 return ok({ id: note.id });
348492 }
349493
350494 /** Search the asker's vault (see `vault.search` for the language). */
351495 export function searchNotes(
352496 db: Database,
353497 who: Who,
354 input: { q: string; limit?: number },
498 input: { q: string; limit?: number; space?: number },
355499 ): Result<{ id: number; title: string; snippet: string; updated_at: string }[]> {
356 const vault = listNotes(db, who);
500 const vault = listNotes(db, who, input.space === undefined ? {} : { space: input.space });
357501 if (!vault.ok) return vault;
358502 const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 500);
359503 return ok(
src/db.ts +39
@@ -73,6 +73,45 @@ export const migrations: string[] = [
7373 expires_at TEXT NOT NULL
7474 )`,
7575 `CREATE INDEX session_account ON session (account_id)`,
76 // Shared folders (src/spaces.ts): a folder of a git repository that its
77 // members write in together. Its notes are ordinary notes whose vault is
78 // the space — author_id `space:<id>` — so every vault query holds as it
79 // is; `path` is the note's file in the repository, and the synced_* pair
80 // is what the repository last had (src/sync.ts), so a difference is an
81 // edit not committed yet.
82 `CREATE TABLE space (
83 id INTEGER PRIMARY KEY,
84 name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
85 repo TEXT NOT NULL,
86 branch TEXT NOT NULL,
87 folder TEXT NOT NULL,
88 owner_id TEXT NOT NULL,
89 owner_name TEXT NOT NULL,
90 join_code TEXT UNIQUE,
91 head TEXT,
92 synced_at TEXT,
93 sync_error TEXT,
94 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
95 )`,
96 `CREATE TABLE space_member (
97 space_id INTEGER NOT NULL REFERENCES space (id) ON DELETE CASCADE,
98 who_id TEXT NOT NULL,
99 who_name TEXT NOT NULL,
100 joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
101 PRIMARY KEY (space_id, who_id)
102 )`,
103 `CREATE INDEX space_member_who ON space_member (who_id)`,
104 `ALTER TABLE note ADD COLUMN path TEXT`,
105 `ALTER TABLE note ADD COLUMN synced_path TEXT`,
106 `ALTER TABLE note ADD COLUMN synced_oid TEXT`,
107 `CREATE UNIQUE INDEX note_path ON note (author_id, path) WHERE path IS NOT NULL`,
108 // A shared note removed here whose file the repository still has: the
109 // next commit deletes it.
110 `CREATE TABLE space_gone (
111 space_id INTEGER NOT NULL REFERENCES space (id) ON DELETE CASCADE,
112 path TEXT NOT NULL,
113 PRIMARY KEY (space_id, path)
114 )`,
76115 ];
77116
78117 export function openDb(dir = dataDir): Database {
src/env.ts +8
@@ -30,3 +30,11 @@ export const publicOrigin: string = new URL(
3030 export const forgeUrl: string | undefined = process.env.FORGE_URL;
3131 /** The key the forge signs identities with (`who.ts`). Unset, everyone is anonymous. */
3232 export const identityKey: string = process.env.FORGE_APP_IDENTITY_KEY ?? "";
33 /** The forge's JSON API, for shared folders (`src/sync.ts`). */
34 export const forgeApi: string | undefined =
35 process.env.FORGE_API_URL ?? (forgeUrl === undefined ? undefined : `${forgeUrl.replace(/\/+$/, "")}/api/v1`);
36 /**
37 * A forge token that may write the repositories shared folders keep their
38 * notes in (Settings → App → Environment). Unset, there are no shared folders.
39 */
40 export const repoToken: string | undefined = process.env.NOTES_REPO_TOKEN?.trim() || undefined;
src/page.ts +222 −30
@@ -8,8 +8,10 @@
88
99 import { isAccount, type Account } from "./accounts.ts";
1010 import { canDelete, type Note } from "./actions.ts";
11 import { commit, description, env, publicOrigin, title, where } from "./env.ts";
11 import { commit, description, env, forgeUrl, publicOrigin, title, where } from "./env.ts";
1212 import { asset, escape } from "./http.ts";
13 import { folderOf, spaceHref, type Member, type Space } from "./spaces.ts";
14 import { unsynced } from "./sync.ts";
1315 import { token, tokensHref } from "./tokens.ts";
1416 import {
1517 backlinks,
@@ -29,7 +31,7 @@ import type { Who } from "./who.ts";
2931 const data = (id: string, value: unknown, live = ""): string =>
3032 `<script type="application/json" id="${id}"${live ? ` data-live="${live}"` : ""}>${JSON.stringify(value).replace(/</g, "\\u003c")}</script>`;
3133
32 const CHANGES = "note.added note.removed note.changed";
34 const CHANGES = "note.added note.removed note.changed space.changed space.synced";
3335
3436 /**
3537 * The frame every page shares: the tokens and the look, and what a link
@@ -73,7 +75,8 @@ ${body}
7375 }
7476
7577 /** Signed out: what this is, and the ways in. */
76 export function landing(notice?: string): string {
78 export function landing(notice?: string, to = "/"): string {
79 const next = to === "/" ? "" : `?to=${encodeURIComponent(to)}`;
7780 return layout(
7881 `<div class="landing">
7982 <div class="landing-card">
@@ -81,10 +84,10 @@ export function landing(notice?: string): string {
8184 <h1>${escape(title)}</h1>
8285 <p class="lede">A private vault for your thinking. Markdown notes, linked with <code>[[double brackets]]</code>, with backlinks, tags and a graph of how it all connects.</p>
8386 <div class="row center">
84 <a class="button button-big" href="/signup">Create your vault</a>
85 <a class="button button-big button-quiet" href="/login">Log in</a>
87 <a class="button button-big" href="/signup${next}">Create your vault</a>
88 <a class="button button-big button-quiet" href="/login${next}">Log in</a>
8689 </div>
87 <p class="muted small">Have a forge account? <a href="/__forge/signin?to=/">Sign in with the forge</a>.</p>
90 <p class="muted small">Have a forge account? <a href="/__forge/signin?to=${escape(to)}">Sign in with the forge</a>.</p>
8891 <p class="muted small">Your notes are yours alone — nobody else who uses this app can read them.</p>
8992 </div>
9093 ${toast(notice)}
@@ -101,11 +104,16 @@ export function authPage(input: {
101104 email?: string;
102105 name?: string;
103106 needsCode: boolean;
107 /** Where to land afterwards: a join link, when that is what brought them. */
108 to?: string;
104109 }): string {
105110 const signup = input.mode === "signup";
111 const to = input.to ?? "/";
112 const next = to === "/" ? "" : `?to=${encodeURIComponent(to)}`;
106113 return layout(
107114 `<div class="landing">
108115 <form class="landing-card auth" method="post" action="/${input.mode}">
116 ${to === "/" ? "" : `<input type="hidden" name="to" value="${escape(to)}">`}
109117 <a class="mark" href="/" aria-label="Back">${glyph}</a>
110118 <h1>${signup ? "Create your vault" : "Welcome back"}</h1>
111119 ${input.notice === undefined ? "" : `<p class="auth-error" role="alert">${escape(input.notice)}</p>`}
@@ -115,7 +123,7 @@ export function authPage(input: {
115123 ${signup && input.needsCode ? `<label class="field"><span>Sign-up code</span><input class="input" name="code" required autocomplete="off"></label>` : ""}
116124 ${signup ? `<p class="muted small">At least 8 characters. There's no email reset, so keep it somewhere safe.</p>` : ""}
117125 <button class="button button-big button-wide">${signup ? "Create account" : "Log in"}</button>
118 <p class="muted small">${signup ? `Already have one? <a href="/login">Log in</a>` : `New here? <a href="/signup">Create a vault</a>`} · <a href="/__forge/signin?to=/">Forge sign-in</a></p>
126 <p class="muted small">${signup ? `Already have one? <a href="/login${next}">Log in</a>` : `New here? <a href="/signup${next}">Create a vault</a>`} · <a href="/__forge/signin?to=${escape(to)}">Forge sign-in</a></p>
119127 </form>
120128 </div>`,
121129 `/${input.mode}`,
@@ -140,14 +148,70 @@ const icon = {
140148 theme: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 0 0 16z" fill="currentColor"/></svg>`,
141149 user: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6"/></svg>`,
142150 download: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>`,
151 folder: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>`,
152 people: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3.2"/><circle cx="17" cy="9" r="2.6"/><path d="M3 20c1-3.5 3.4-5 6-5s5 1.5 6 5M15 15.3c2.6-.4 4.8 1 5.6 3.7"/></svg>`,
153 gear: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/></svg>`,
143154 };
144155
145 interface Shell {
156 export interface Shell {
146157 readonly who: Who & { role: "user" | "admin" };
147158 readonly vault: readonly Note[];
148159 readonly active?: Note | undefined;
149160 readonly notice?: string | undefined;
150161 readonly query?: string;
162 /** The shared folder this page is in; none for your own vault. */
163 readonly space?: Space | undefined;
164 /** Every shared folder you are in, for the switcher. */
165 readonly spaces: readonly Space[];
166 }
167
168 /** Where a vault's own pages are: `""` for yours, `/s/<id>` for a shared folder. */
169 const baseOf = (s: Pick<Shell, "space">): string => (s.space === undefined ? "" : spaceHref(s.space.id));
170 /** What the vault is called in a page's crumbs. */
171 const vaultName = (s: Pick<Shell, "space">): string => (s.space === undefined ? "Vault" : s.space.name);
172
173 /** The switcher: your vault, then every shared folder you are in. */
174 function vaults(s: Shell): string {
175 if (s.spaces.length === 0 && s.who.role !== "admin") return "";
176 const row = (href: string, label: string, here: boolean, glyphs: string) =>
177 `<li><a class="file${here ? " is-active" : ""}" href="${href}"${here ? ' aria-current="true"' : ""}><span class="file-title">${glyphs}${escape(label)}</span></a></li>`;
178 return `<p class="section">Vaults</p><ul class="file-list vaults">
179 ${row("/", "My notes", s.space === undefined, "")}
180 ${s.spaces.map((sp) => row(spaceHref(sp.id), sp.name, s.space?.id === sp.id, icon.folder)).join("")}
181 ${s.who.role === "admin" ? `<li><a class="file muted" href="/spaces/new"><span class="file-title">${icon.plus}Share a repo folder</span></a></li>` : ""}
182 </ul>`;
183 }
184
185 /** A shared folder's notes as its folders hold them: a tree, folders first, A to Z. */
186 function tree(s: Shell, row: (n: Note) => string): string {
187 interface Dir { dirs: Map<string, Dir>; notes: Note[] }
188 const root: Dir = { dirs: new Map(), notes: [] };
189 for (const n of s.vault) {
190 let at = root;
191 const folder = n.path === null || s.space === undefined ? "" : folderOf(s.space, n.path);
192 for (const part of folder === "" ? [] : folder.split("/")) {
193 if (!at.dirs.has(part)) at.dirs.set(part, { dirs: new Map(), notes: [] });
194 at = at.dirs.get(part)!;
195 }
196 at.notes.push(n);
197 }
198 const holds = (d: Dir): boolean => d.notes.some((n) => n.id === s.active?.id) || [...d.dirs.values()].some(holds);
199 const draw = (d: Dir, path: string): string =>
200 `${[...d.dirs]
201 .sort(([a], [b]) => a.localeCompare(b))
202 .map(
203 ([name, sub]) =>
204 `<li><details class="folder" data-folder="${escape(path + name)}"${holds(sub) || sub.dirs.size + sub.notes.length < 12 ? " open" : ""}><summary>${icon.folder}<span>${escape(name)}</span></summary><ul class="file-list">${draw(sub, `${path}${name}/`)}</ul></details></li>`,
205 )
206 .join("")}${[...d.notes].sort((a, b) => a.title.localeCompare(b.title)).map(row).join("")}`;
207 return `<ul class="file-list">${draw(root, "")}</ul>`;
208 }
209
210 /** How a shared folder stands with its repository, for the foot of the sidebar. */
211 function syncLine(space: Space): string {
212 const repo = forgeUrl === undefined ? escape(space.repo) : `<a href="${escape(`${forgeUrl.replace(/\/+$/, "")}/${space.repo}`)}" target="_blank" rel="noopener noreferrer">${escape(space.repo)}</a>`;
213 const when = space.synced_at === null ? "not synced yet" : `synced ${ago(space.synced_at)}${ago(space.synced_at) === "now" ? "" : " ago"}`;
214 return `<p class="sync-line small${space.sync_error === null ? " muted" : " is-error"}" title="${escape(space.sync_error ?? `${space.branch}:${space.folder || "/"}`)}">${repo}/${escape(space.folder)} · ${space.sync_error === null ? when : "sync failed — see settings"}</p>`;
151215 }
152216
153217 /** The left: making notes, finding them, and every one of them. */
@@ -157,30 +221,42 @@ function left(s: Shell): string {
157221 const row = (n: Note) =>
158222 `<li><a class="file${s.active?.id === n.id ? " is-active" : ""}" href="${hrefOf(n)}"${s.active?.id === n.id ? ' aria-current="page"' : ""} data-title="${escape(n.title.toLowerCase())}"><span class="file-title">${escape(n.title)}</span><time class="file-when" datetime="${escape(n.updated_at)}">${ago(n.updated_at)}</time></a></li>`;
159223 const tagList = tagCounts(s.vault);
160 return `<aside class="left" id="left" aria-label="Your notes">
224 const base = baseOf(s);
225 return `<aside class="left" id="left" aria-label="${s.space === undefined ? "Your notes" : escape(s.space.name)}">
161226 <div class="left-head">
162227 <a class="brand" href="/">${glyph}<span>${escape(title)}</span></a>
163228 <button type="button" class="icon-button" data-action="theme" aria-label="Switch theme" title="Theme">${icon.theme}</button>
164229 </div>
165230 <div class="actions">
166 <form method="post" action="/new"><button class="button button-wide" title="New note (Alt N)">${icon.plus}<span>New note</span></button></form>
167 <form method="post" action="/daily"><input type="hidden" name="tz" value="0"><button class="icon-button" aria-label="Today's note" title="Today's note (⌘⇧D)">${icon.today}</button></form>
168 <a class="icon-button" href="/graph" aria-label="Graph" title="Graph (⌘G)">${icon.graph}</a>
231 <form method="post" action="${base}/new">${s.space !== undefined && s.active?.path ? `<input type="hidden" name="folder" value="${escape(folderOf(s.space, s.active.path))}">` : ""}<button class="button button-wide" title="New note (Alt N)">${icon.plus}<span>New note</span></button></form>
232 <form method="post" action="${base}/daily" data-daily><input type="hidden" name="tz" value="0"><button class="icon-button" aria-label="Today's note" title="Today's note (⌘⇧D)">${icon.today}</button></form>
233 <a class="icon-button" href="${base}/graph" aria-label="Graph" title="Graph (⌘G)">${icon.graph}</a>
234 ${s.space === undefined ? "" : `<a class="icon-button" href="${base}/settings" aria-label="Sharing and sync" title="Sharing and sync">${icon.people}</a>`}
169235 </div>
170 <form class="find" method="get" action="/search" role="search">
236 <form class="find" method="get" action="${base}/search" role="search">
171237 ${icon.search}
172238 <input class="input input-find" type="search" name="q" value="${escape(s.query ?? "")}" placeholder="Search or filter…" aria-label="Search notes" autocomplete="off" data-filter>
173239 <kbd class="hint">⌘K</kbd>
174240 </form>
175241 <nav class="files" id="files" data-live="${CHANGES}">
242 ${vaults(s)}
176243 ${s.vault.length === 0 ? `<p class="muted small pad">No notes yet.</p>` : ""}
177244 ${pinned.length > 0 ? `<p class="section">Pinned</p><ul class="file-list">${pinned.map(row).join("")}</ul>` : ""}
178 ${rest.length > 0 ? `<p class="section">${pinned.length > 0 ? "Notes" : `Notes <span class="count">${rest.length}</span>`}</p><ul class="file-list">${rest.map(row).join("")}</ul>` : ""}
179 ${tagList.length > 0 ? `<p class="section">Tags</p><div class="tag-cloud">${tagList.map((t) => `<a class="tag" href="/search?q=${encodeURIComponent(`#${t.tag}`)}">#${escape(t.tag)}<span class="count">${t.count}</span></a>`).join("")}</div>` : ""}
245 ${
246 s.space !== undefined
247 ? s.vault.length > 0
248 ? `<p class="section">Files <span class="count">${s.vault.length}</span></p>${tree(s, row)}`
249 : ""
250 : rest.length > 0
251 ? `<p class="section">${pinned.length > 0 ? "Notes" : `Notes <span class="count">${rest.length}</span>`}</p><ul class="file-list">${rest.map(row).join("")}</ul>`
252 : ""
253 }
254 ${tagList.length > 0 ? `<p class="section">Tags</p><div class="tag-cloud">${tagList.map((t) => `<a class="tag" href="${base}/search?q=${encodeURIComponent(`#${t.tag}`)}">#${escape(t.tag)}<span class="count">${t.count}</span></a>`).join("")}</div>` : ""}
255 ${s.space === undefined ? "" : syncLine(s.space)}
180256 </nav>
181257 <div class="left-foot">
182258 <span class="you" title="${escape(where)}${commit ? ` · ${escape(commit)}` : ""}">${escape(s.who.name)}${env === "production" ? "" : ` · <span class="pill">${escape(where)}</span>`}</span>
183 <a class="icon-button" href="/export" aria-label="Download every note" title="Download everything">${icon.download}</a>
259 ${s.space === undefined ? `<a class="icon-button" href="/export" aria-label="Download every note" title="Download everything">${icon.download}</a>` : ""}
184260 ${
185261 isAccount(s.who)
186262 ? `<a class="icon-button" href="/account" aria-label="Your account" title="Account">${icon.user}</a><form method="post" action="/logout"><button class="button button-quiet">Log out</button></form>`
@@ -191,7 +267,9 @@ function left(s: Shell): string {
191267 }
192268
193269 /** The right: the note's shape and its neighbours. */
194 function right(note: Note, vault: readonly Note[]): string {
270 function right(s: Shell, note: Note): string {
271 const vault = s.vault;
272 const base = baseOf(s);
195273 const outline = headings(note.body);
196274 const back = backlinks(note, vault);
197275 const out = outgoing(note, vault);
@@ -214,10 +292,11 @@ function right(note: Note, vault: readonly Note[]): string {
214292 </section>
215293 <section class="panel">
216294 <h2 class="section">Links <span class="count">${out.found.length + out.missing.length}</span></h2>
217 ${out.found.length + out.missing.length === 0 ? `<p class="muted small">No links out.</p>` : `<ul class="link-list">${out.found.map((n) => `<li><a href="${hrefOf(n)}">${escape(n.title)}</a></li>`).join("")}${out.missing.map((t) => `<li><a class="unresolved" href="/open?title=${encodeURIComponent(t)}" data-title="${escape(t)}">${escape(t)}</a></li>`).join("")}</ul>`}
295 ${out.found.length + out.missing.length === 0 ? `<p class="muted small">No links out.</p>` : `<ul class="link-list">${out.found.map((n) => `<li><a href="${hrefOf(n)}">${escape(n.title)}</a></li>`).join("")}${out.missing.map((t) => `<li><a class="unresolved" href="${base}/open?title=${encodeURIComponent(t)}" data-title="${escape(t)}">${escape(t)}</a></li>`).join("")}</ul>`}
218296 </section>
219297 ${outline.length > 0 ? `<section class="panel"><h2 class="section">Outline</h2><ol class="outline">${outline.map((h) => `<li class="depth-${Math.min(h.level - 1, 5)}"><a href="#${escape(h.id)}">${escape(h.text)}</a></li>`).join("")}</ol></section>` : ""}
220 ${mine.length > 0 ? `<section class="panel"><h2 class="section">Tags</h2><div class="tag-cloud">${mine.map((t) => `<a class="tag" href="/search?q=${encodeURIComponent(`#${t}`)}">#${escape(t)}</a>`).join("")}</div></section>` : ""}
298 ${mine.length > 0 ? `<section class="panel"><h2 class="section">Tags</h2><div class="tag-cloud">${mine.map((t) => `<a class="tag" href="${base}/search?q=${encodeURIComponent(`#${t}`)}">#${escape(t)}</a>`).join("")}</div></section>` : ""}
299 ${s.space === undefined || note.path === null ? "" : filepanel(s.space, note as Note & { path: string })}
221300 <section class="panel meta muted small">
222301 <p>Created <time datetime="${escape(note.created_at)}">${long(note.created_at)}</time></p>
223302 <p>${words(note.body)} words · ${note.body.length.toLocaleString("en")} characters</p>
@@ -241,7 +320,7 @@ function shell(s: Shell, center: string, rightSide = ""): string {
241320 </div>
242321 </div>
243322 <ul class="suggest" id="suggest" role="listbox" hidden></ul>
244 ${data("vault", s.vault.map((n) => ({ id: n.id, title: n.title, updated_at: n.updated_at })), CHANGES)}
323 ${data("vault", s.vault.map((n) => ({ id: n.id, title: n.title, updated_at: n.updated_at })), CHANGES).replace(' id="vault"', ` id="vault" data-base="${baseOf(s)}"${s.space === undefined ? "" : ` data-space="${s.space.id}"`}`)}
245324 ${toast(s.notice)}`;
246325 }
247326
@@ -255,6 +334,7 @@ const toolbar = (s: Shell, crumbs: string, tools = ""): string => `<header class
255334 /** One note, open: the editor and the reading view side by side. */
256335 export function notePage(s: Shell & { active: Note }): string {
257336 const note = s.active;
337 const base = baseOf(s);
258338 const tools = `<div class="segmented" role="group" aria-label="View">
259339 <button type="button" data-mode="edit" aria-pressed="false" title="Edit">Edit</button>
260340 <button type="button" data-mode="split" aria-pressed="false" title="Edit and read side by side" class="only-wide">Split</button>
@@ -264,19 +344,21 @@ export function notePage(s: Shell & { active: Note }): string {
264344 <form method="post" action="/n/${note.id}/pin"><input type="hidden" name="pinned" value="${note.pinned === 1 ? "0" : "1"}"><button class="icon-button${note.pinned === 1 ? " is-on" : ""}" aria-label="${note.pinned === 1 ? "Unpin" : "Pin"}" title="${note.pinned === 1 ? "Unpin" : "Pin to the top"}" aria-pressed="${note.pinned === 1}">${icon.pin}</button></form>
265345 <a class="icon-button" href="/n/${note.id}/raw" aria-label="Download as markdown" title="Download .md">${icon.download}</a>
266346 ${canDelete(s.who, note) ? `<form method="post" action="/n/${note.id}/delete" data-confirm="Delete “${escape(note.title)}”? This cannot be undone."><button class="icon-button danger" aria-label="Delete note" title="Delete">${icon.trash}</button></form>` : ""}`;
267 const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">${escape(note.title)}</span>`, tools)}
347 const folder = s.space === undefined || note.path === null ? "" : folderOf(s.space, note.path);
348 const crumbs = [`<a href="${base || "/"}">${escape(vaultName(s))}</a>`, ...(folder === "" ? [] : folder.split("/").map((f) => `<span>${escape(f)}</span>`)), `<span class="crumb-here">${escape(note.title)}</span>`].join(`<span aria-hidden="true">/</span>`);
349 const center = `${toolbar(s, crumbs, tools)}
268350 <form class="editor" id="editor" method="post" action="/n/${note.id}" data-id="${note.id}" data-updated="${escape(note.updated_at)}">
269351 <div class="sheet">
270352 <input class="title-input" id="title" name="title" value="${escape(note.title)}" aria-label="Title" maxlength="200" autocomplete="off" spellcheck="false">
271353 <div class="panes" id="panes" data-mode="${note.body.trim() === "" ? "edit" : "split"}">
272354 <textarea class="source" id="source" name="body" aria-label="Note, in markdown" placeholder="Start writing. [[Link]] other notes, #tag this one…" spellcheck="true">${escape(note.body)}</textarea>
273 <article class="md preview" id="preview" aria-label="Reading view">${renderNote(note.body, s.vault) || `<p class="muted">Nothing here yet.</p>`}</article>
355 <article class="md preview" id="preview" aria-label="Reading view">${renderNote(note.body, s.vault, base) || `<p class="muted">Nothing here yet.</p>`}</article>
274356 </div>
275357 <noscript><p class="row"><button class="button">Save</button></p></noscript>
276358 </div>
277359 </form>
278360 ${graphData(s.vault)}`;
279 return layout(shell(s, center, right(note, s.vault)), hrefOf(note), {
361 return layout(shell(s, center, right(s, note)), hrefOf(note), {
280362 name: note.title,
281363 kind: "page app-page",
282364 scripts: ["app.js", "graph.js"],
@@ -285,6 +367,7 @@ export function notePage(s: Shell & { active: Note }): string {
285367
286368 /** An empty vault: two ways to begin. */
287369 export function emptyPage(s: Shell): string {
370 if (s.space !== undefined) return emptySpace(s, s.space);
288371 const center = `${toolbar(s, `<span class="crumb-here">Vault</span>`)}
289372 <div class="empty-state">
290373 <div class="mark" aria-hidden="true">${glyph}</div>
@@ -300,13 +383,14 @@ export function emptyPage(s: Shell): string {
300383
301384 /** A link to a note that isn't written yet. */
302385 export function missingPage(s: Shell & { title: string }): string {
303 const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">${escape(s.title)}</span>`)}
386 const base = baseOf(s);
387 const center = `${toolbar(s, `<a href="${base || "/"}">${escape(vaultName(s))}</a><span aria-hidden="true">/</span><span class="crumb-here">${escape(s.title)}</span>`)}
304388 <div class="empty-state">
305389 <h1>“${escape(s.title)}” doesn't exist yet</h1>
306390 <p class="muted">A link points here, but nothing's been written.</p>
307 <form method="post" action="/open"><input type="hidden" name="title" value="${escape(s.title)}"><button class="button button-big">Create it</button></form>
391 <form method="post" action="${base}/open"><input type="hidden" name="title" value="${escape(s.title)}"><button class="button button-big">Create it</button></form>
308392 </div>`;
309 return layout(shell(s, center), "/open", { name: s.title, kind: "page app-page", scripts: ["app.js"] });
393 return layout(shell(s, center), `${base}/open`, { name: s.title, kind: "page app-page", scripts: ["app.js"] });
310394 }
311395
312396 /** What a search found. */
@@ -324,25 +408,25 @@ export function searchPage(s: Shell & { query: string; hits: readonly Hit<Note>[
324408 }
325409 return html;
326410 };
327 const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">Search</span>`)}
411 const center = `${toolbar(s, `<a href="${baseOf(s) || "/"}">${escape(vaultName(s))}</a><span aria-hidden="true">/</span><span class="crumb-here">Search</span>`)}
328412 <div class="sheet results">
329413 <h1 class="results-head">${s.hits.length} ${s.hits.length === 1 ? "note" : "notes"} for <q>${escape(s.query)}</q></h1>
330414 ${s.hits.length === 0 ? `<p class="muted">Nothing matches. Try fewer words, or <code>#tag</code>.</p>` : `<ul class="hits">${s.hits.map((h) => `<li><a href="${hrefOf(h.note)}"><b>${mark(h.note.title)}</b><span class="muted">${mark(h.snippet)}</span></a></li>`).join("")}</ul>`}
331415 </div>`;
332 return layout(shell(s, center), "/search", { name: `Search: ${s.query}`, kind: "page app-page", scripts: ["app.js"] });
416 return layout(shell(s, center), `${baseOf(s)}/search`, { name: `Search: ${s.query}`, kind: "page app-page", scripts: ["app.js"] });
333417 }
334418
335419 /** The whole vault, as a graph. */
336420 export function graphPage(s: Shell): string {
337421 const g = graph(s.vault);
338422 if (g.nodes.length === 0) return emptyPage(s);
339 const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">Graph</span>`, `<span class="muted small">${g.nodes.length} notes · ${g.edges.length} links</span>`)}
423 const center = `${toolbar(s, `<a href="${baseOf(s) || "/"}">${escape(vaultName(s))}</a><span aria-hidden="true">/</span><span class="crumb-here">Graph</span>`, `<span class="muted small">${g.nodes.length} notes · ${g.edges.length} links</span>`)}
340424 <div class="graph-stage">
341425 <canvas class="big-graph" data-graph="all" aria-label="Every note, and the links between them"></canvas>
342426 <p class="graph-help muted small">Drag to move · scroll or pinch to zoom · click a note to open it</p>
343427 </div>
344428 ${graphData(s.vault)}`;
345 return layout(shell(s, center), "/graph", { name: "Graph", kind: "page app-page graph-page", scripts: ["app.js", "graph.js"] });
429 return layout(shell(s, center), `${baseOf(s)}/graph`, { name: "Graph", kind: "page app-page graph-page", scripts: ["app.js", "graph.js"] });
346430 }
347431
348432 /** An email account: who you are, a new password, or leaving. */
@@ -369,6 +453,114 @@ export function accountPage(s: Shell & { account: Account }): string {
369453 return layout(shell(s, center), "/account", { name: "Account", kind: "page app-page", scripts: ["app.js"] });
370454 }
371455
456 /** A shared note's file: where it is, who wrote last, whether the repository has it yet, and moving it. */
457 function filepanel(space: Space, note: Note & { path: string }): string {
458 return `<section class="panel">
459 <h2 class="section">File</h2>
460 <p class="small file-path"><code>${escape(note.path)}</code></p>
461 <p class="muted small">Last written by ${escape(note.author_name)} · ${unsynced(note) ? "waiting to commit" : "committed"}</p>
462 <form class="move" method="post" action="/n/${note.id}/move">
463 <label class="field"><span class="small">Folder</span><input class="input" name="folder" value="${escape(folderOf(space, note.path))}" placeholder="(top of ${escape(space.folder || "the repository")})" autocomplete="off"></label>
464 <button class="button button-quiet">Move</button>
465 </form>
466 </section>`;
467 }
468
469 /** A shared folder with nothing in it yet. */
470 function emptySpace(s: Shell, space: Space): string {
471 const base = spaceHref(space.id);
472 const center = `${toolbar(s, `<span class="crumb-here">${escape(space.name)}</span>`)}
473 <div class="empty-state">
474 <div class="mark" aria-hidden="true">${icon.folder}</div>
475 <h1>${escape(space.name)} is empty</h1>
476 <p class="muted">Notes written here are committed to <code>${escape(space.repo)}</code> in <code>${escape(space.folder || "/")}</code> — and markdown pushed there shows up here.</p>
477 ${space.sync_error === null ? "" : `<p class="auth-error" role="alert">${escape(space.sync_error)}</p>`}
478 <div class="row center">
479 <form method="post" action="${base}/new"><button class="button button-big">New note</button></form>
480 <a class="button button-big button-quiet" href="${base}/settings">Invite someone</a>
481 </div>
482 </div>`;
483 return layout(shell(s, center), base, { name: space.name, kind: "page app-page", scripts: ["app.js"] });
484 }
485
486 /** A shared folder's settings: who is in, the join link, and the repository. */
487 export function spacePage(s: Shell & { space: Space; members: readonly Member[] }): string {
488 const space = s.space;
489 const base = spaceHref(space.id);
490 const mine = space.owner_id === s.who.id;
491 const link = space.join_code === null ? null : `${publicOrigin}/join/${space.join_code}`;
492 const center = `${toolbar(s, `<a href="${base}">${escape(space.name)}</a><span aria-hidden="true">/</span><span class="crumb-here">Sharing</span>`)}
493 <div class="sheet results" id="space-settings" data-live="space.changed space.synced">
494 <h1 class="results-head">${escape(space.name)}</h1>
495 <p class="muted"><code>${escape(space.repo)}</code> · branch <code>${escape(space.branch)}</code> · folder <code>${escape(space.folder || "/")}</code></p>
496
497 <section class="settings">
498 <h2 class="section">Repository</h2>
499 ${space.sync_error === null ? `<p class="muted small">${space.synced_at === null ? "Not synced yet." : `Synced ${ago(space.synced_at)}${ago(space.synced_at) === "now" ? "" : " ago"}${space.head === null ? "" : ` at <code>${escape(space.head.slice(0, 7))}</code>`}.`} Edits commit a few seconds after you stop typing; pushes to the repository show up within a minute.</p>` : `<p class="auth-error" role="alert">Last sync failed: ${escape(space.sync_error)}</p>`}
500 <form method="post" action="${base}/sync"><button class="button">Sync now</button></form>
501 </section>
502
503 <section class="settings">
504 <h2 class="section">People <span class="count">${s.members.length}</span></h2>
505 <ul class="people">${s.members
506 .map(
507 (m) => `<li><span>${escape(m.who_name)}${m.who_id === space.owner_id ? ` <span class="pill">owner</span>` : ""}${m.who_id === s.who.id ? ` <span class="muted small">(you)</span>` : ""}</span>${
508 mine && m.who_id !== space.owner_id
509 ? `<form method="post" action="${base}/remove" data-confirm="Remove ${escape(m.who_name)} from ${escape(space.name)}?"><input type="hidden" name="member" value="${escape(m.who_id)}"><button class="button button-quiet">Remove</button></form>`
510 : ""
511 }</li>`,
512 )
513 .join("")}</ul>
514 </section>
515
516 ${
517 mine
518 ? `<section class="settings">
519 <h2 class="section">Join link</h2>
520 ${
521 link === null
522 ? `<p class="muted small">The link is off: nobody new can join.</p><form method="post" action="${base}/link"><input type="hidden" name="on" value="1"><button class="button">Make a join link</button></form>`
523 : `<p class="muted small">Anyone signed in to notes who opens this joins <b>${escape(space.name)}</b> — and sees only this folder, never your own notes. Send it to who you're working with, then turn it off.</p>
524 <input class="input join-link" value="${escape(link)}" readonly aria-label="Join link" data-select>
525 <div class="row">
526 <form method="post" action="${base}/link"><input type="hidden" name="on" value="0"><button class="button button-quiet">Turn the link off</button></form>
527 <form method="post" action="${base}/link" data-confirm="Make a new link? The old one stops working."><input type="hidden" name="on" value="1"><button class="button button-quiet">New link</button></form>
528 </div>`
529 }
530 </section>
531 <form class="settings" method="post" action="${base}/delete" data-confirm="Stop sharing ${escape(space.name)}? Everyone loses it here. The repository keeps every file.">
532 <h2 class="section">Stop sharing</h2>
533 <p class="muted small">Removes the folder from notes for everyone. Nothing in the repository is touched.</p>
534 <button class="button button-danger">Stop sharing</button>
535 </form>`
536 : `<form class="settings" method="post" action="${base}/leave" data-confirm="Leave ${escape(space.name)}?">
537 <h2 class="section">Leave</h2>
538 <p class="muted small">You won't see this folder any more, until someone sends you a join link again.</p>
539 <button class="button button-danger">Leave</button>
540 </form>`
541 }
542 </div>`;
543 return layout(shell(s, center), `${base}/settings`, { name: `${space.name} · Sharing`, kind: "page app-page", scripts: ["app.js"] });
544 }
545
546 /** Connecting a repository folder: an admin's form. */
547 export function newSpacePage(s: Shell & { form?: { name: string; repo: string; branch: string; folder: string } }): string {
548 const f = s.form ?? { name: "", repo: "", branch: "main", folder: "docs" };
549 const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">Share a repo folder</span>`)}
550 <div class="sheet results">
551 <h1 class="results-head">Share a repo folder</h1>
552 <p class="muted">A folder of a repository on the forge becomes a vault you write in with others. Every note is a markdown file there; edits here are commits, and pushes there show up here. Use a plain repository, not an app's — every save is a commit to the branch.</p>
553 <form class="settings" method="post" action="/spaces">
554 <label class="field"><span>Name</span><input class="input" name="name" value="${escape(f.name)}" required maxlength="80" placeholder="Brainstorm" autofocus></label>
555 <label class="field"><span>Repository</span><input class="input" name="repo" value="${escape(f.repo)}" required placeholder="owner/name" autocomplete="off"></label>
556 <label class="field"><span>Branch</span><input class="input" name="branch" value="${escape(f.branch)}" required autocomplete="off"></label>
557 <label class="field"><span>Folder <span class="muted">(empty for the whole repository)</span></span><input class="input" name="folder" value="${escape(f.folder)}" autocomplete="off"></label>
558 <button class="button">Connect and read it</button>
559 </form>
560 </div>`;
561 return layout(shell(s, center), "/spaces/new", { name: "Share a repo folder", kind: "page app-page", scripts: ["app.js"] });
562 }
563
372564 /** The graph's data, for any page that draws one. */
373565 export const graphData = (vault: readonly Note[]): string => data("graph", graph(vault), CHANGES);
374566
src/routes.ts +338 −88
@@ -66,9 +66,25 @@ import {
6666 graphPage,
6767 landing,
6868 missingPage,
69 newSpacePage,
6970 notePage,
7071 searchPage,
72 spacePage,
7173 } from "./page.ts";
74 import {
75 createSpace,
76 deleteSpace,
77 getSpace,
78 joinSpace,
79 members,
80 mySpaces,
81 removeMember,
82 setLink,
83 spaceHref,
84 spaceOf,
85 type Space,
86 } from "./spaces.ts";
87 import { syncSpace, type Forge } from "./sync.ts";
7288 import { tokensCss } from "./tokens.ts";
7389 import { allow } from "./security.ts";
7490 import { search } from "./vault.ts";
@@ -90,6 +106,8 @@ export interface Context {
90106 readonly db: Database;
91107 readonly who: Who;
92108 readonly params: Record<string, string>;
109 /** Where shared folders commit; absent, there are none to make. */
110 readonly forge?: Forge | undefined;
93111 }
94112
95113 /** A JSON Schema, as plain data. */
@@ -126,6 +144,10 @@ export const NOTE: Schema = {
126144 author_name: { type: "string" },
127145 created_at: { type: "string", format: "date-time" },
128146 updated_at: { type: "string", format: "date-time" },
147 path: {
148 type: ["string", "null"],
149 description: "A shared folder's note: its file in the repository. Null for a note of your own.",
150 },
129151 },
130152 required: [
131153 "id",
@@ -147,6 +169,8 @@ const NOTE_NEW: Schema = {
147169 description: "Unique in your vault; “Untitled” when left out.",
148170 },
149171 body: { type: "string", maxLength: BODY_MAX, description: "Markdown." },
172 space: { type: "integer", description: "A shared folder you are in, to add the note there instead of your vault." },
173 folder: { type: "string", description: "In a shared folder: the folder to put it in (`ideas/q4`)." },
150174 },
151175 };
152176 const NOTE_CHANGE: Schema = {
@@ -160,6 +184,12 @@ const NOTE_CHANGE: Schema = {
160184 },
161185 body: { type: "string", maxLength: BODY_MAX },
162186 pinned: { type: "boolean" },
187 folder: { type: "string", description: "A shared note's folder: moves its file." },
188 base: {
189 type: "string",
190 format: "date-time",
191 description: "The updated_at you edited from; when someone has saved since, new words are refused with 409.",
192 },
163193 },
164194 };
165195 export const AUDIT_ROW: Schema = {
@@ -196,6 +226,7 @@ const STATUS: Record<Failure, number> = {
196226 forbidden: 403,
197227 not_found: 404,
198228 invalid: 422,
229 conflict: 409,
199230 };
200231
201232 /** A result as JSON: the value, or `{ error, message }` with the matching status. */
@@ -219,23 +250,169 @@ const change = (id: number, body: Record<string, unknown>): Change => ({
219250 ...(typeof body.title === "string" ? { title: body.title } : {}),
220251 ...(typeof body.body === "string" ? { body: body.body } : {}),
221252 ...(typeof body.pinned === "boolean" ? { pinned: body.pinned } : {}),
253 ...(typeof body.folder === "string" ? { folder: body.folder } : {}),
254 ...(typeof body.base === "string" ? { base: body.base } : {}),
222255 });
223256
224 /** The vault around a page: who is signed in, and every note of theirs. */
225 function vaultOf(c: Context):
226 | { who: Who & { role: "user" | "admin" }; vault: Note[]; notice?: string; clear: Record<string, string> }
227 | Response {
257 interface Around {
258 who: Who & { role: "user" | "admin" };
259 vault: Note[];
260 notice?: string;
261 clear: Record<string, string>;
262 space?: Space;
263 spaces: Space[];
264 }
265
266 /**
267 * The vault around a page: who is signed in, every note of theirs — or of
268 * the shared folder `space`, when the page is in one — and the folders
269 * they are in, for the switcher.
270 */
271 function vaultOf(c: Context, space?: number): Around | Response {
228272 const { notice, clear } = readNotice(c.request);
229273 if (c.who.role === "anon") return html(landing(notice), 200, clear);
230 const vault = listNotes(c.db, c.who);
274 let found: Space | undefined;
275 if (space !== undefined) {
276 const got = getSpace(c.db, c.who, space);
277 if (!got.ok) return go("/", got.message);
278 found = got.value;
279 }
280 const vault = listNotes(c.db, c.who, space === undefined ? {} : { space });
231281 return {
232282 who: c.who,
233283 vault: vault.ok ? vault.value : [],
234284 ...(notice === undefined ? {} : { notice }),
235285 clear,
286 ...(found === undefined ? {} : { space: found }),
287 spaces: mySpaces(c.db, c.who),
236288 };
237289 }
238290
291 /** The shared folder a page's path names (`/s/:sid/…`); none for your own vault. */
292 const sidOf = (c: Context): number | undefined =>
293 c.params.sid === undefined ? undefined : Number(c.params.sid);
294 /** A vault's own address: `""` for yours, `/s/<id>` for a shared folder. */
295 const baseFor = (space: number | undefined): string => (space === undefined ? "" : spaceHref(space));
296 /** Where sign-in may send someone afterwards: back to the join link that brought them, or home. */
297 export const safeTo = (to: string | null | undefined): string =>
298 typeof to === "string" && /^\/join\/[\w-]{8,64}$/.test(to) ? to : "/";
299
300 /**
301 * The pages every vault has — its home, search, graph, following a link,
302 * a new note, today's note — once for your own (`""`) and once for a
303 * shared folder (`/s/:sid`). The same handlers, a different vault.
304 */
305 function vaultRoutes(prefix: string): Route[] {
306 return [
307 {
308 method: "GET",
309 path: prefix || "/",
310 handle: (c) => {
311 const v = vaultOf(c, sidOf(c));
312 if (v instanceof Response) return v;
313 const first = v.vault.reduce<Note | undefined>(
314 (latest, n) => (latest === undefined || n.updated_at > latest.updated_at ? n : latest),
315 undefined,
316 );
317 // The vault opens on the note you were last writing.
318 if (first !== undefined && v.notice === undefined) return go(toNote(first));
319 if (first !== undefined) return html(notePage({ ...v, active: first }), 200, v.clear);
320 return html(emptyPage(v), 200, v.clear);
321 },
322 },
323 {
324 method: "POST",
325 path: `${prefix}/new`,
326 handle: async (c) => {
327 const form = await readForm(c.request);
328 if (form === undefined) return text("too large", 413);
329 const title = String(form.get("title") ?? "");
330 const folder = String(form.get("folder") ?? "");
331 const space = sidOf(c);
332 return done(
333 addNote(c.db, c.who, {
334 ...(title === "" ? {} : { title }),
335 ...(space === undefined ? {} : { space, folder }),
336 }),
337 toNote,
338 baseFor(space) || "/",
339 );
340 },
341 },
342 {
343 method: "POST",
344 path: `${prefix}/daily`,
345 handle: async (c) => {
346 const form = await readForm(c.request);
347 if (form === undefined) return text("too large", 413);
348 const tz = Number(form.get("tz") ?? 0);
349 const day = today(Number.isFinite(tz) && Math.abs(tz) <= 840 ? tz : 0);
350 const space = sidOf(c);
351 return done(
352 openOrCreate(c.db, c.who, { title: day, body: `# ${day}\n\n`, ...(space === undefined ? {} : { space }) }),
353 toNote,
354 baseFor(space) || "/",
355 );
356 },
357 },
358 {
359 method: "GET",
360 path: `${prefix}/open`,
361 handle: (c) => {
362 const v = vaultOf(c, sidOf(c));
363 if (v instanceof Response) return v;
364 const title = (c.url.searchParams.get("title") ?? "").trim();
365 const found = v.vault.find((n) => n.title.toLowerCase() === title.toLowerCase());
366 if (found !== undefined) return go(toNote(found));
367 if (title === "") return go(baseFor(sidOf(c)) || "/");
368 return html(missingPage({ ...v, title }), 200, v.clear);
369 },
370 },
371 {
372 method: "POST",
373 path: `${prefix}/open`,
374 handle: async (c) => {
375 const form = await readForm(c.request);
376 if (form === undefined) return text("too large", 413);
377 const space = sidOf(c);
378 return done(
379 openOrCreate(c.db, c.who, { title: String(form.get("title") ?? ""), ...(space === undefined ? {} : { space }) }),
380 toNote,
381 baseFor(space) || "/",
382 );
383 },
384 },
385 {
386 method: "GET",
387 path: `${prefix}/search`,
388 handle: (c) => {
389 const v = vaultOf(c, sidOf(c));
390 if (v instanceof Response) return v;
391 const query = (c.url.searchParams.get("q") ?? "").slice(0, 500).trim();
392 if (query === "") return go(baseFor(sidOf(c)) || "/");
393 return html(searchPage({ ...v, query, hits: search(v.vault, query) }), 200, v.clear);
394 },
395 },
396 {
397 method: "GET",
398 path: `${prefix}/graph`,
399 handle: (c) => {
400 const v = vaultOf(c, sidOf(c));
401 if (v instanceof Response) return v;
402 return html(graphPage(v), 200, v.clear);
403 },
404 },
405 ];
406 }
407
408 /** A shared folder's own page, for one of its members — or the way home. */
409 function spaceAround(c: Context): (Around & { space: Space }) | Response {
410 const v = vaultOf(c, sidOf(c));
411 if (v instanceof Response) return v;
412 if (v.space === undefined) return go("/");
413 return v as Around & { space: Space };
414 }
415
239416 /** A whole number from the query, or undefined. */
240417 const whole = (value: string | null): number | undefined =>
241418 value !== null && /^\d+$/.test(value) ? Number(value) : undefined;
@@ -247,30 +424,18 @@ const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined;
247424 export const routes: readonly Route[] = [
248425 // The pages. Signed out, the way in; signed in, the vault. Forms post and
249426 // land where they lead, so every page works without JavaScript.
250 {
251 method: "GET",
252 path: "/",
253 handle: (c) => {
254 const v = vaultOf(c);
255 if (v instanceof Response) return v;
256 const first = v.vault.reduce<Note | undefined>(
257 (latest, n) => (latest === undefined || n.updated_at > latest.updated_at ? n : latest),
258 undefined,
259 );
260 // The vault opens on the note you were last writing.
261 if (first !== undefined && v.notice === undefined) return go(toNote(first));
262 if (first !== undefined) return html(notePage({ ...v, active: first }), 200, v.clear);
263 return html(emptyPage(v), 200, v.clear);
264 },
265 },
427 ...vaultRoutes(""),
428 ...vaultRoutes("/s/:sid"),
266429 {
267430 method: "GET",
268431 path: "/n/:id",
269432 handle: (c) => {
270 const v = vaultOf(c);
271 if (v instanceof Response) return v;
433 if (c.who.role === "anon") return vaultOf(c) as Response;
272434 const note = getNote(c.db, c.who, { id: Number(c.params.id) });
273435 if (!note.ok) return go("/", note.message);
436 // A shared note is shown in its folder.
437 const v = vaultOf(c, spaceOf(note.value.author_id));
438 if (v instanceof Response) return v;
274439 return html(notePage({ ...v, active: note.value }), 200, v.clear);
275440 },
276441 },
@@ -307,6 +472,16 @@ export const routes: readonly Route[] = [
307472 );
308473 },
309474 },
475 {
476 method: "POST",
477 path: "/n/:id/move",
478 handle: async (c) => {
479 const form = await readForm(c.request);
480 if (form === undefined) return text("too large", 413);
481 const id = Number(c.params.id);
482 return done(updateNote(c.db, c.who, { id, folder: String(form.get("folder") ?? "") }), toNote, toNote({ id }));
483 },
484 },
310485 {
311486 method: "POST",
312487 path: "/n/:id/pin",
@@ -324,28 +499,136 @@ export const routes: readonly Route[] = [
324499 {
325500 method: "POST",
326501 path: "/n/:id/delete",
327 handle: (c) =>
328 done(deleteNote(c.db, c.who, { id: Number(c.params.id) }), () => "/"),
502 handle: (c) => {
503 const note = getNote(c.db, c.who, { id: Number(c.params.id) });
504 const back = note.ok ? baseFor(spaceOf(note.value.author_id)) || "/" : "/";
505 return done(deleteNote(c.db, c.who, { id: Number(c.params.id) }), () => back);
506 },
329507 },
508
509 // Shared folders: connecting one, joining one, and running one.
330510 {
331 method: "POST",
332 path: "/new",
333 handle: async (c) => {
334 const form = await readForm(c.request);
335 if (form === undefined) return text("too large", 413);
336 const title = String(form.get("title") ?? "");
337 return done(addNote(c.db, c.who, title === "" ? {} : { title }), toNote);
511 method: "GET",
512 path: "/spaces/new",
513 handle: (c) => {
514 const v = vaultOf(c);
515 if (v instanceof Response) return v;
516 if (c.who.role !== "admin") return go("/", "Only an app admin can connect a repository.");
517 if (c.forge === undefined) return go("/", "Shared folders need NOTES_REPO_TOKEN set in the app's environment.");
518 return html(newSpacePage(v), 200, v.clear);
338519 },
339520 },
340521 {
341522 method: "POST",
342 path: "/daily",
523 path: "/spaces",
343524 handle: async (c) => {
344525 const form = await readForm(c.request);
345526 if (form === undefined) return text("too large", 413);
346 const tz = Number(form.get("tz") ?? 0);
347 const day = today(Number.isFinite(tz) && Math.abs(tz) <= 840 ? tz : 0);
348 return done(openOrCreate(c.db, c.who, { title: day, body: `# ${day}\n\n` }), toNote);
527 const input = {
528 name: String(form.get("name") ?? ""),
529 repo: String(form.get("repo") ?? ""),
530 branch: String(form.get("branch") ?? ""),
531 folder: String(form.get("folder") ?? ""),
532 };
533 const again = (notice: string) => {
534 const v = vaultOf(c);
535 return v instanceof Response ? v : html(newSpacePage({ ...v, notice, form: input }), 422);
536 };
537 if (c.forge === undefined) return again("Shared folders need NOTES_REPO_TOKEN set in the app's environment.");
538 const made = createSpace(c.db, c.who, input);
539 if (!made.ok) return made.error === "invalid" ? again(made.message) : go("/", made.message);
540 // Read it now, so it opens with its notes — and a wrong name is said at once.
541 try {
542 const read = await syncSpace(c.db, c.forge, made.value.id);
543 return go(spaceHref(made.value.id), `Connected — ${read.pulled} ${read.pulled === 1 ? "note" : "notes"} read from ${made.value.repo}.`);
544 } catch (e) {
545 deleteSpace(c.db, c.who, { id: made.value.id });
546 return again(`Couldn't read ${made.value.repo}: ${e instanceof Error ? e.message : String(e)}`);
547 }
548 },
549 },
550 {
551 method: "GET",
552 path: "/join/:code",
553 handle: (c) => {
554 const to = `/join/${c.params.code ?? ""}`;
555 if (c.who.role === "anon")
556 return html(landing("Sign in (or make an account) to join the shared folder.", safeTo(to)), 200);
557 const joined = joinSpace(c.db, c.who, { code: c.params.code ?? "" });
558 if (!joined.ok) return go("/", joined.message);
559 return go(spaceHref(joined.value.id), `You're in ${joined.value.name}.`);
560 },
561 },
562 {
563 method: "GET",
564 path: "/s/:sid/settings",
565 handle: (c) => {
566 const v = spaceAround(c);
567 if (v instanceof Response) return v;
568 return html(spacePage({ ...v, members: members(c.db, v.space.id) }), 200, v.clear);
569 },
570 },
571 {
572 method: "POST",
573 path: "/s/:sid/sync",
574 handle: async (c) => {
575 const v = spaceAround(c);
576 if (v instanceof Response) return v;
577 const back = `${spaceHref(v.space.id)}/settings`;
578 if (c.forge === undefined) return go(back, "Shared folders need NOTES_REPO_TOKEN set in the app's environment.");
579 try {
580 const r = await syncSpace(c.db, c.forge, v.space.id);
581 const said = [
582 r.pulled > 0 ? `${r.pulled} from the repository` : "",
583 r.pushed > 0 ? `${r.pushed} ${r.pushed === 1 ? "file" : "files"} committed` : "",
584 r.copies > 0 ? `${r.copies} kept as repo copies` : "",
585 ].filter((x) => x !== "");
586 return go(back, said.length === 0 ? "Already in step." : `Synced: ${said.join(", ")}.`);
587 } catch (e) {
588 return go(back, `Sync failed: ${e instanceof Error ? e.message : String(e)}`);
589 }
590 },
591 },
592 {
593 method: "POST",
594 path: "/s/:sid/link",
595 handle: async (c) => {
596 const form = await readForm(c.request);
597 if (form === undefined) return text("too large", 413);
598 const id = Number(c.params.sid);
599 const on = form.get("on") === "1";
600 return done(setLink(c.db, c.who, { id, on }), () => `${spaceHref(id)}/settings`, `${spaceHref(id)}/settings`);
601 },
602 },
603 {
604 method: "POST",
605 path: "/s/:sid/remove",
606 handle: async (c) => {
607 const form = await readForm(c.request);
608 if (form === undefined) return text("too large", 413);
609 const id = Number(c.params.sid);
610 return done(
611 removeMember(c.db, c.who, { id, member: String(form.get("member") ?? "") }),
612 () => `${spaceHref(id)}/settings`,
613 `${spaceHref(id)}/settings`,
614 );
615 },
616 },
617 {
618 method: "POST",
619 path: "/s/:sid/leave",
620 handle: (c) => {
621 const id = Number(c.params.sid);
622 if (c.who.role === "anon") return go("/");
623 return done(removeMember(c.db, c.who, { id, member: c.who.id }), () => "/", `${spaceHref(id)}/settings`);
624 },
625 },
626 {
627 method: "POST",
628 path: "/s/:sid/delete",
629 handle: (c) => {
630 const id = Number(c.params.sid);
631 return done(deleteSpace(c.db, c.who, { id }), () => "/", `${spaceHref(id)}/settings`);
349632 },
350633 },
351634 {
@@ -354,48 +637,6 @@ export const routes: readonly Route[] = [
354637 handle: (c) =>
355638 done(openOrCreate(c.db, c.who, { title: "Welcome", body: WELCOME }), toNote),
356639 },
357 {
358 method: "GET",
359 path: "/open",
360 handle: (c) => {
361 const v = vaultOf(c);
362 if (v instanceof Response) return v;
363 const title = (c.url.searchParams.get("title") ?? "").trim();
364 const found = v.vault.find((n) => n.title.toLowerCase() === title.toLowerCase());
365 if (found !== undefined) return go(toNote(found));
366 if (title === "") return go("/");
367 return html(missingPage({ ...v, title }), 200, v.clear);
368 },
369 },
370 {
371 method: "POST",
372 path: "/open",
373 handle: async (c) => {
374 const form = await readForm(c.request);
375 if (form === undefined) return text("too large", 413);
376 return done(openOrCreate(c.db, c.who, { title: String(form.get("title") ?? "") }), toNote);
377 },
378 },
379 {
380 method: "GET",
381 path: "/search",
382 handle: (c) => {
383 const v = vaultOf(c);
384 if (v instanceof Response) return v;
385 const query = (c.url.searchParams.get("q") ?? "").slice(0, 500).trim();
386 if (query === "") return go("/");
387 return html(searchPage({ ...v, query, hits: search(v.vault, query) }), 200, v.clear);
388 },
389 },
390 {
391 method: "GET",
392 path: "/graph",
393 handle: (c) => {
394 const v = vaultOf(c);
395 if (v instanceof Response) return v;
396 return html(graphPage(v), 200, v.clear);
397 },
398 },
399640 {
400641 method: "GET",
401642 path: "/export",
@@ -437,7 +678,7 @@ export const routes: readonly Route[] = [
437678 handle: (c) => {
438679 if (c.who.role !== "anon") return go("/");
439680 const { notice, clear } = readNotice(c.request);
440 return html(authPage({ mode: "signup", notice, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "" }), 200, clear);
681 return html(authPage({ mode: "signup", notice, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "", to: safeTo(c.url.searchParams.get("to")) }), 200, clear);
441682 },
442683 },
443684 {
@@ -448,8 +689,9 @@ export const routes: readonly Route[] = [
448689 if (form === undefined) return text("too large", 413);
449690 const email = String(form.get("email") ?? "");
450691 const name = String(form.get("name") ?? "");
692 const to = safeTo(String(form.get("to") ?? ""));
451693 const again = (notice: string, status = 422) =>
452 html(authPage({ mode: "signup", notice, email, name, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "" }), status);
694 html(authPage({ mode: "signup", notice, email, name, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "", to }), status);
453695 if (!allow("signup", SIGNUP_ALL)) return again("Lots of people are signing up right now — try again in a few minutes.", 429);
454696 const made = await signUp(c.db, {
455697 email,
@@ -458,7 +700,7 @@ export const routes: readonly Route[] = [
458700 code: String(form.get("code") ?? ""),
459701 });
460702 if (!made.ok) return again(made.message, made.error === "forbidden" ? 403 : 422);
461 return go("/", undefined, [session(made.value.token)]);
703 return go(to, undefined, [session(made.value.token)]);
462704 },
463705 },
464706 {
@@ -467,7 +709,7 @@ export const routes: readonly Route[] = [
467709 handle: (c) => {
468710 if (c.who.role !== "anon") return go("/");
469711 const { notice, clear } = readNotice(c.request);
470 return html(authPage({ mode: "login", notice, needsCode: false }), 200, clear);
712 return html(authPage({ mode: "login", notice, needsCode: false, to: safeTo(c.url.searchParams.get("to")) }), 200, clear);
471713 },
472714 },
473715 {
@@ -477,13 +719,14 @@ export const routes: readonly Route[] = [
477719 const form = await readForm(c.request);
478720 if (form === undefined) return text("too large", 413);
479721 const email = String(form.get("email") ?? "");
722 const to = safeTo(String(form.get("to") ?? ""));
480723 const again = (notice: string, status: number) =>
481 html(authPage({ mode: "login", notice, email, needsCode: false }), status);
724 html(authPage({ mode: "login", notice, email, needsCode: false, to }), status);
482725 if (!allow(`login:${email.trim().toLowerCase()}`, LOGIN_PER_EMAIL) || !allow("login", LOGIN_ALL))
483726 return again("Too many tries. Wait a minute and try again.", 429);
484727 const inside = await logIn(c.db, { email, password: String(form.get("password") ?? "") });
485728 if (!inside.ok) return again(inside.message, 401);
486 return go("/", undefined, [session(inside.value.token)]);
729 return go(to, undefined, [session(inside.value.token)]);
487730 },
488731 },
489732 {
@@ -542,10 +785,14 @@ export const routes: readonly Route[] = [
542785 handle: (c) => {
543786 const q = c.url.searchParams.get("q");
544787 const limit = whole(c.url.searchParams.get("limit"));
545 if (q === null || q.trim() === "")
546 return answer(listNotes(c.db, c.who, limit === undefined ? {} : { limit }));
788 const space = whole(c.url.searchParams.get("space"));
789 const scope = {
790 ...(limit === undefined ? {} : { limit }),
791 ...(space === undefined ? {} : { space }),
792 };
793 if (q === null || q.trim() === "") return answer(listNotes(c.db, c.who, scope));
547794 // A search answers the same notes, best match first.
548 const hits = searchNotes(c.db, c.who, { q, ...(limit === undefined ? {} : { limit }) });
795 const hits = searchNotes(c.db, c.who, { q, ...scope });
549796 if (!hits.ok) return answer(hits);
550797 const notes = hits.value
551798 .map((h) => getNote(c.db, c.who, { id: h.id }))
@@ -554,9 +801,10 @@ export const routes: readonly Route[] = [
554801 },
555802 api: {
556803 summary:
557 "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first.",
804 "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first. With `space`, a shared folder's instead.",
558805 query: {
559806 limit: { type: "integer", minimum: 1, maximum: 5000 },
807 space: { type: "integer" },
560808 q: { type: "string", maxLength: 500 },
561809 },
562810 answer: list(NOTE),
@@ -573,6 +821,8 @@ export const routes: readonly Route[] = [
573821 addNote(c.db, c.who, {
574822 ...(typeof f.title === "string" ? { title: f.title } : {}),
575823 ...(typeof f.body === "string" ? { body: f.body } : {}),
824 ...(typeof f.space === "number" ? { space: f.space } : {}),
825 ...(typeof f.folder === "string" ? { folder: f.folder } : {}),
576826 }),
577827 201,
578828 );
src/server.ts +14 −2
@@ -8,12 +8,13 @@
88 */
99
1010 import { openDb } from "./db.ts";
11 import { dataDir, identityKey, title, where } from "./env.ts";
11 import { dataDir, forgeApi, identityKey, repoToken, title, where } from "./env.ts";
1212 import { sessionWho } from "./accounts.ts";
1313 import { html, readCookie, text } from "./http.ts";
1414 import { SESSION_COOKIE } from "./routes.ts";
1515 import { match } from "./routes.ts";
1616 import { door } from "./security.ts";
17 import { httpForge, startSync, type Forge } from "./sync.ts";
1718 import { who } from "./who.ts";
1819 import { traceServe } from "./otel.ts";
1920
@@ -22,11 +23,14 @@ export interface ServeOptions {
2223 readonly hostname?: string | undefined;
2324 readonly dataDir: string;
2425 readonly identityKey: string;
26 /** Where shared folders commit; without one there are none. */
27 readonly forge?: Forge | undefined;
2528 }
2629
2730 export function serve(options: ServeOptions) {
2831 const db = openDb(options.dataDir);
29 return Bun.serve(traceServe({
32 const stopSync = startSync(db, options.forge);
33 const server = Bun.serve(traceServe({
3034 port: options.port,
3135 hostname: options.hostname ?? "127.0.0.1",
3236 // A connection that says nothing for a minute is dropped. The event
@@ -57,6 +61,7 @@ export function serve(options: ServeOptions) {
5761 db,
5862 who: me,
5963 params: found.params,
64 forge: options.forge,
6065 });
6166 return head(request, response);
6267 },
@@ -68,6 +73,12 @@ export function serve(options: ServeOptions) {
6873 return html("<h1>Something went wrong</h1>", 500);
6974 },
7075 }));
76 const stop = server.stop.bind(server);
77 server.stop = (force?: boolean) => {
78 stopSync();
79 return stop(force);
80 };
81 return server;
7182 }
7283
7384 // HEAD is GET without the body: a health check sees the app up.
@@ -82,6 +93,7 @@ if (import.meta.main) {
8293 hostname: process.env.HOST,
8394 dataDir,
8495 identityKey,
96 forge: repoToken === undefined || forgeApi === undefined ? undefined : httpForge(forgeApi, repoToken),
8597 });
8698 console.log(
8799 `${title} listening on http://${server.hostname}:${server.port} (${where})`,
src/spaces.server.test.ts added +114
@@ -0,0 +1,114 @@
1 import { afterAll, expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { serve } from "./server.ts";
6 import { blobOid, type Forge } from "./sync.ts";
7 import { sign } from "./who.ts";
8
9 // A shared folder through the doors a person uses: connect, invite, join,
10 // write, see the file — with a repository in memory behind the forge's API.
11 const files = new Map<string, string>([["docs/Welcome.md", "Hello from git. [[Plan]]"]]);
12 let head = "c0";
13 const forge: Forge = {
14 head: async () => head,
15 list: async (_r, _ref, path) => {
16 const prefix = path === "" ? "" : `${path}/`;
17 const seen = new Map<string, { name: string; type: string; oid: string; size: number }>();
18 for (const [p, c] of files) {
19 if (!p.startsWith(prefix)) continue;
20 const rest = p.slice(prefix.length);
21 const name = rest.split("/")[0]!;
22 seen.set(name, rest.includes("/") ? { name, type: "tree", oid: "t", size: -1 } : { name, type: "blob", oid: blobOid(c), size: c.length });
23 }
24 return [...seen.values()];
25 },
26 read: async (_r, _ref, path) => files.get(path)!,
27 commit: async (_r, _b, _m, changes) => {
28 for (const f of changes) f.content === null ? files.delete(f.path) : files.set(f.path, f.content);
29 head = `c${Number(head.slice(1)) + 1}`;
30 return head;
31 },
32 };
33
34 const KEY = "k";
35 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-shared-")), identityKey: KEY, forge });
36 const base = `http://${server.hostname}:${server.port}`;
37 afterAll(() => server.stop(true));
38
39 const as = (id: string, name: string, role: "user" | "admin" = "user"): Record<string, string> => {
40 const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`;
41 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
42 };
43 const ben = as("1", "ben", "admin");
44 const sam = as("2", "sam");
45 const same = { origin: base, "sec-fetch-site": "same-origin" };
46 const get = (path: string, who: Record<string, string> = {}) =>
47 fetch(base + path, { headers: { ...who, accept: "text/html" }, redirect: "manual" });
48 const post = (path: string, form: Record<string, string>, who: Record<string, string>) =>
49 fetch(base + path, {
50 method: "POST",
51 headers: { ...who, ...same, "content-type": "application/x-www-form-urlencoded" },
52 body: new URLSearchParams(form).toString(),
53 redirect: "manual",
54 });
55
56 test("connect a folder, invite someone by link, and write in it together", async () => {
57 // Only an admin may connect a repository.
58 expect((await post("/spaces", { name: "X", repo: "ben/x" }, sam)).headers.get("location")).toBe("/");
59 const made = await post("/spaces", { name: "Brainstorm", repo: "ben/brainstorm", branch: "main", folder: "docs" }, ben);
60 expect(made.status).toBe(303);
61 const at = made.headers.get("location")!;
62 expect(at).toMatch(/^\/s\/\d+$/);
63 const sid = Number(at.slice(3));
64
65 // It opens on what the repository had.
66 const home = await get(at, ben);
67 expect(home.status).toBe(303);
68 const page = await (await get(home.headers.get("location")!, ben)).text();
69 expect(page).toContain("Hello from git.");
70 expect(page).toContain(`href="/s/${sid}/open?title=Plan"`);
71 expect(page).toContain("docs/Welcome.md");
72
73 // Sam can't see it — until the join link.
74 expect((await get(at, sam)).headers.get("location")).toBe("/");
75 const settings = await (await get(`${at}/settings`, ben)).text();
76 const link = /value="https?:\/\/[^"]*(\/join\/[\w-]+)"/.exec(settings)![1]!;
77 // Signed out, the link asks you to sign in and remembers where you were going.
78 const guest = await (await get(link)).text();
79 expect(guest).toContain(`/login?to=${encodeURIComponent(link)}`);
80 const joined = await get(link, sam);
81 expect(joined.headers.get("location")).toBe(at);
82
83 // Sam writes a note into a folder; it's a file in the repository after a sync.
84 const note = await fetch(`${base}/api/notes`, {
85 method: "POST",
86 headers: { ...sam, ...same, "content-type": "application/json" },
87 body: JSON.stringify({ title: "ideas/Plan", body: "Ship it.", space: sid }),
88 });
89 expect(note.status).toBe(201);
90 const made2 = (await note.json()) as { id: number; path: string; updated_at: string };
91 expect(made2.path).toBe("docs/ideas/Plan.md");
92 const synced = await post(`${at}/sync`, {}, sam);
93 expect(synced.headers.get("location")).toBe(`${at}/settings`);
94 expect(files.get("docs/ideas/Plan.md")).toBe("Ship it.");
95
96 // Two people, one note: the second save written against an old version is refused.
97 const patch = (who: Record<string, string>, body: string, b: string) =>
98 fetch(`${base}/api/notes/${made2.id}`, {
99 method: "PATCH",
100 headers: { ...who, ...same, "content-type": "application/json" },
101 body: JSON.stringify({ body, base: b }),
102 });
103 await Bun.sleep(3);
104 expect((await patch(ben, "Ben's take", made2.updated_at)).status).toBe(200);
105 const late = await patch(sam, "Sam's take", made2.updated_at);
106 expect(late.status).toBe(409);
107 expect(((await late.json()) as { message: string }).message).toContain("ben");
108
109 // Ben's own vault is still Ben's alone, and doesn't hold the shared notes.
110 const mine = (await (await fetch(`${base}/api/notes`, { headers: ben })).json()) as unknown[];
111 expect(mine).toEqual([]);
112 const theirs = (await (await fetch(`${base}/api/notes?space=${sid}`, { headers: sam })).json()) as { title: string }[];
113 expect(theirs.map((n) => n.title).sort()).toEqual(["Plan", "Welcome"]);
114 });
src/spaces.test.ts added +252
@@ -0,0 +1,252 @@
1 import { expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { addNote, deleteNote, getNote, listNotes, searchNotes, updateNote } from "./actions.ts";
6 import { openDb } from "./db.ts";
7 import {
8 cleanFolder,
9 createSpace,
10 deleteSpace,
11 fileOf,
12 folderOf,
13 joinSpace,
14 mySpaces,
15 removeMember,
16 setLink,
17 } from "./spaces.ts";
18 import { blobOid, syncSpace, type Entry, type Forge } from "./sync.ts";
19 // @ts-ignore — plain JS, shared with the browser.
20 import * as md from "../static/md.js";
21 import type { Who } from "./who.ts";
22
23 const db = openDb(mkdtempSync(join(tmpdir(), "app-spaces-")));
24 const ben: Who = { role: "admin", id: "1", name: "ben" };
25 const sam: Who = { role: "user", id: "acct:7", name: "sam" };
26 const eve: Who = { role: "user", id: "9", name: "eve" };
27
28 const must = <T>(r: { ok: true; value: T } | { ok: false; message: string }): T => {
29 if (!r.ok) throw new Error(r.message);
30 return r.value;
31 };
32
33 /** A repository in memory, behind the same four calls the forge answers. */
34 function fakeForge(files: Record<string, string> = {}) {
35 const tree = new Map(Object.entries(files));
36 let head = tree.size === 0 ? null : "c0";
37 let n = 0;
38 const commits: { message: string; files: { path: string; content: string | null }[] }[] = [];
39 const forge: Forge = {
40 async head() {
41 return head;
42 },
43 async list(_repo, _ref, path) {
44 const prefix = path === "" ? "" : `${path}/`;
45 const seen = new Map<string, Entry>();
46 for (const [p, content] of tree) {
47 if (!p.startsWith(prefix)) continue;
48 const rest = p.slice(prefix.length);
49 const name = rest.split("/")[0]!;
50 if (rest.includes("/")) seen.set(name, { name, type: "tree", oid: `t-${name}`, size: -1 });
51 else seen.set(name, { name, type: "blob", oid: blobOid(content), size: content.length });
52 }
53 return seen.size === 0 && path !== "" ? null : [...seen.values()];
54 },
55 async read(_repo, _ref, path) {
56 const content = tree.get(path);
57 if (content === undefined) throw new Error(`no ${path}`);
58 return content;
59 },
60 async commit(_repo, _branch, message, changes) {
61 for (const f of changes) f.content === null ? tree.delete(f.path) : tree.set(f.path, f.content);
62 commits.push({ message, files: changes });
63 head = `c${++n}`;
64 return head;
65 },
66 };
67 return {
68 forge,
69 tree,
70 commits,
71 /** Someone pushes to the repository, outside the app. */
72 push(changes: Record<string, string | null>) {
73 for (const [p, c] of Object.entries(changes)) c === null ? tree.delete(p) : tree.set(p, c);
74 head = `x${++n}`;
75 },
76 };
77 }
78
79 test("only an app admin connects a repository; a join link lets someone in, and only into that folder", () => {
80 const refused = createSpace(db, sam, { name: "Nope", repo: "sam/x", folder: "docs" });
81 expect(refused.ok).toBe(false);
82 if (!refused.ok) expect(refused.error).toBe("forbidden");
83 expect(createSpace(db, ben, { name: "Bad", repo: "not a repo" }).ok).toBe(false);
84 expect(createSpace(db, ben, { name: "Bad", repo: "ben/x", folder: "../etc" }).ok).toBe(false);
85
86 const space = must(createSpace(db, ben, { name: "Brainstorm", repo: "ben/brainstorm", folder: "/docs/" }));
87 expect(space.folder).toBe("docs");
88 expect(space.branch).toBe("main");
89 expect(mySpaces(db, ben).map((s) => s.id)).toEqual([space.id]);
90 expect(mySpaces(db, sam)).toEqual([]);
91
92 const mine = must(addNote(db, ben, { title: "My own thoughts" }));
93 const shared = must(addNote(db, ben, { title: "Pricing", space: space.id }));
94 expect(shared.path).toBe("docs/Pricing.md");
95 // A stranger can't see the folder or its notes, by id or by list.
96 expect(getNote(db, sam, { id: shared.id }).ok).toBe(false);
97 expect(listNotes(db, sam, { space: space.id }).ok).toBe(false);
98 expect(addNote(db, sam, { title: "sneaky", space: space.id }).ok).toBe(false);
99
100 must(joinSpace(db, sam, { code: space.join_code! }));
101 expect(must(listNotes(db, sam, { space: space.id })).map((n) => n.title)).toEqual(["Pricing"]);
102 expect(getNote(db, sam, { id: shared.id }).ok).toBe(true);
103 // …and still nothing of Ben's own.
104 expect(getNote(db, sam, { id: mine.id }).ok).toBe(false);
105 expect(must(listNotes(db, sam)).map((n) => n.id)).not.toContain(mine.id);
106 // Shared notes never show in anyone's own vault, Ben's included.
107 expect(must(listNotes(db, ben)).map((n) => n.id)).not.toContain(shared.id);
108 expect(must(searchNotes(db, ben, { q: "Pricing" }))).toEqual([]);
109 expect(must(searchNotes(db, sam, { q: "Pricing", space: space.id })).length).toBe(1);
110
111 // Sam writes; the note says so.
112 const edited = must(updateNote(db, sam, { id: shared.id, body: "per seat?" }));
113 expect(edited.author_name).toBe("sam");
114
115 // The link off, nobody new gets in; the owner removes a member; the owner can't be removed.
116 const off = must(setLink(db, ben, { id: space.id, on: false }));
117 expect(joinSpace(db, eve, { code: space.join_code! }).ok).toBe(false);
118 expect(off.join_code).toBeNull();
119 expect(setLink(db, sam, { id: space.id, on: true }).ok).toBe(false);
120 expect(removeMember(db, sam, { id: space.id, member: ben.id }).ok).toBe(false);
121 must(removeMember(db, ben, { id: space.id, member: sam.id }));
122 expect(getNote(db, sam, { id: shared.id }).ok).toBe(false);
123
124 must(deleteSpace(db, ben, { id: space.id }));
125 expect(getNote(db, ben, { id: shared.id }).ok).toBe(false);
126 expect(getNote(db, ben, { id: mine.id }).ok).toBe(true);
127 });
128
129 test("a shared note's title is a file name, its folder a path; renames and moves carry the file", () => {
130 const space = must(createSpace(db, ben, { name: "Paths", repo: "ben/paths", folder: "docs" }));
131 expect(addNote(db, ben, { title: "a:b", space: space.id }).ok).toBe(false);
132 const deep = must(addNote(db, ben, { title: "ideas/q4/Launch", space: space.id }));
133 expect(deep.title).toBe("Launch");
134 expect(deep.path).toBe("docs/ideas/q4/Launch.md");
135 expect(folderOf(space, deep.path!)).toBe("ideas/q4");
136 const renamed = must(updateNote(db, ben, { id: deep.id, title: "Launch plan" }));
137 expect(renamed.path).toBe("docs/ideas/q4/Launch plan.md");
138 const moved = must(updateNote(db, ben, { id: deep.id, folder: "archive" }));
139 expect(moved.path).toBe("docs/archive/Launch plan.md");
140 expect(must(updateNote(db, ben, { id: deep.id, folder: "" })).path).toBe("docs/Launch plan.md");
141 expect(cleanFolder(" a / b ").ok && must(cleanFolder(" a / b "))).toBe("a/b");
142 expect(fileOf({ folder: "" }, "", "Top")).toBe("Top.md");
143 });
144
145 test("someone else saved first: new words are a conflict, not an overwrite", async () => {
146 const space = must(createSpace(db, ben, { name: "Race", repo: "ben/race" }));
147 must(joinSpace(db, sam, { code: space.join_code! }));
148 const note = must(addNote(db, ben, { title: "Plan", space: space.id, body: "v1" }));
149 await Bun.sleep(3); // updated_at is to the millisecond
150 const theirs = must(updateNote(db, sam, { id: note.id, body: "sam's v2", base: note.updated_at }));
151 const mine = updateNote(db, ben, { id: note.id, body: "ben's v2", base: note.updated_at });
152 expect(mine.ok).toBe(false);
153 if (!mine.ok) expect(mine.error).toBe("conflict");
154 expect(must(getNote(db, ben, { id: note.id })).body).toBe("sam's v2");
155 // Written against what is there now, it goes through.
156 expect(must(updateNote(db, ben, { id: note.id, body: "ben's v3", base: theirs.updated_at })).body).toBe("ben's v3");
157 });
158
159 test("sync reads the folder, commits what is written here, and takes what is pushed there", async () => {
160 const repo = fakeForge({
161 "README.md": "# not in the folder",
162 "docs/Ideas.md": "---\ntags: [brainstorm, q4]\n---\nSee [[Pricing]].",
163 "docs/research/Pricing.md": "per seat? #money",
164 "docs/research/chart.png": "binary",
165 "docs/.obsidian/workspace.md": "editor settings",
166 });
167 const space = must(createSpace(db, ben, { name: "Synced", repo: "ben/synced", folder: "docs" }));
168 const first = await syncSpace(db, repo.forge, space.id);
169 expect(first.pulled).toBe(2);
170 const notes = () => must(listNotes(db, ben, { space: space.id }));
171 const byTitle = (t: string) => notes().find((n) => n.title === t)!;
172 expect(notes().map((n) => n.path).sort()).toEqual(["docs/Ideas.md", "docs/research/Pricing.md"]);
173 expect(md.tags(byTitle("Ideas").body).sort()).toEqual(["brainstorm", "q4"]);
174 // Nothing changed here, so nothing is committed.
175 expect((await syncSpace(db, repo.forge, space.id)).pushed).toBe(0);
176 expect(repo.commits).toEqual([]);
177
178 // Written, made, renamed and removed here → one commit.
179 must(updateNote(db, ben, { id: byTitle("Ideas").id, body: "See [[Pricing]] and [[Naming]]." }));
180 must(addNote(db, ben, { title: "Naming", space: space.id, folder: "research", body: "names" }));
181 must(updateNote(db, ben, { id: byTitle("Pricing").id, title: "Pricing model" }));
182 const wrote = await syncSpace(db, repo.forge, space.id);
183 expect(repo.commits.length).toBe(1);
184 expect(repo.commits[0]!.message).toContain("Written in notes by ben");
185 expect(wrote.pushed).toBe(4);
186 expect([...repo.tree.keys()].sort()).toEqual([
187 "README.md",
188 "docs/.obsidian/workspace.md",
189 "docs/Ideas.md",
190 "docs/research/Naming.md",
191 "docs/research/Pricing model.md",
192 "docs/research/chart.png",
193 ]);
194 expect(repo.tree.get("docs/Ideas.md")).toBe("See [[Pricing model]] and [[Naming]].");
195 // The next read finds our own commit and changes nothing.
196 expect((await syncSpace(db, repo.forge, space.id)).pulled).toBe(0);
197
198 must(deleteNote(db, ben, { id: byTitle("Naming").id }));
199 await syncSpace(db, repo.forge, space.id);
200 expect(repo.tree.has("docs/research/Naming.md")).toBe(false);
201
202 // Someone pushes: a change, a new file, a removal.
203 repo.push({
204 "docs/Ideas.md": "Rewritten on a laptop.",
205 "docs/meetings/2026-10-13.md": "# Kickoff",
206 "docs/research/Pricing model.md": null,
207 });
208 const read = await syncSpace(db, repo.forge, space.id);
209 expect(read.pulled).toBe(3);
210 expect(byTitle("Ideas").body).toBe("Rewritten on a laptop.");
211 expect(byTitle("Ideas").author_name).toBe("the repository");
212 expect(byTitle("2026-10-13").path).toBe("docs/meetings/2026-10-13.md");
213 expect(notes().some((n) => n.title === "Pricing model")).toBe(false);
214 expect(repo.commits.length).toBe(2);
215 });
216
217 test("edited in both places: ours is committed, theirs is kept beside it — nothing is lost", async () => {
218 const repo = fakeForge({ "Plan.md": "v1" });
219 const space = must(createSpace(db, ben, { name: "Both", repo: "ben/both" }));
220 await syncSpace(db, repo.forge, space.id);
221 const plan = must(listNotes(db, ben, { space: space.id }))[0]!;
222 must(updateNote(db, ben, { id: plan.id, body: "ours" }));
223 repo.push({ "Plan.md": "theirs" });
224 const r = await syncSpace(db, repo.forge, space.id);
225 expect(r.copies).toBe(1);
226 expect(repo.tree.get("Plan.md")).toBe("ours");
227 expect(repo.tree.get("Plan (repo copy).md")).toBe("theirs");
228 const titles = must(listNotes(db, ben, { space: space.id })).map((n) => n.title).sort();
229 expect(titles).toEqual(["Plan", "Plan (repo copy)"]);
230 });
231
232 test("a repository that can't be read is said, and the space remembers why", async () => {
233 const space = must(createSpace(db, ben, { name: "Broken", repo: "ben/gone" }));
234 const broken: Forge = {
235 ...fakeForge().forge,
236 head: async () => {
237 throw new Error("No repository ben/gone — or the token can't see it.");
238 },
239 };
240 await expect(syncSpace(db, broken, space.id)).rejects.toThrow("No repository");
241 const row = mySpaces(db, ben).find((s) => s.id === space.id)!;
242 expect(row.sync_error).toContain("No repository");
243 });
244
245 test("properties at the top of a note read as properties, not as a rule and a paragraph", () => {
246 const html = md.render("---\ntitle: Plan\ntags:\n - a\n - b\n---\n# Body", {});
247 expect(html).toContain('<dl class="properties">');
248 expect(html).toContain("<dd>a, b</dd>");
249 expect(html).not.toContain("<hr>");
250 expect(md.tags("---\ntags: x, y\n---\n#z")).toEqual(["x", "y", "z"]);
251 expect(md.render("[[Missing]] #t", { base: "/s/3" })).toContain('href="/s/3/open?title=Missing"');
252 });
src/spaces.ts added +261
@@ -0,0 +1,261 @@
1 /**
2 * Shared folders. A space is a folder of a git repository on the forge —
3 * `docs/` of `ben/brainstorm`, say — that its members write in together.
4 * Its notes are ordinary notes whose vault is the space (author_id
5 * `space:<id>`), so links, backlinks, tags, search and the graph work in it
6 * exactly as they do in a personal vault, and never reach across: a
7 * personal vault and a space share nothing. Each note keeps its file's
8 * path, and `src/sync.ts` keeps the folder and the repository in step.
9 *
10 * Who is in: the person who made it (an app admin — the repository token
11 * is theirs, so nobody else may point it at a repository), and whoever
12 * opened its join link while it was on. The owner can turn the link off,
13 * make a new one, and remove members; a member can leave.
14 */
15
16 import type { Database } from "bun:sqlite";
17 import { randomBytes } from "node:crypto";
18 import { emit } from "./live.ts";
19 import type { Failure, Result } from "./actions.ts";
20 import type { Who } from "./who.ts";
21
22 export interface Space {
23 readonly id: number;
24 readonly name: string;
25 /** `owner/name` on the forge. */
26 readonly repo: string;
27 readonly branch: string;
28 /** The folder in the repository the notes live in, `""` for its root. */
29 readonly folder: string;
30 readonly owner_id: string;
31 readonly owner_name: string;
32 readonly join_code: string | null;
33 /** The commit the notes were last read from. */
34 readonly head: string | null;
35 readonly synced_at: string | null;
36 readonly sync_error: string | null;
37 readonly created_at: string;
38 }
39
40 export interface Member {
41 readonly who_id: string;
42 readonly who_name: string;
43 readonly joined_at: string;
44 }
45
46 const ok = <T>(value: T): Result<T> => ({ ok: true, value });
47 const fail = <T>(error: Failure, message: string): Result<T> => ({ ok: false, error, message });
48
49 /** The vault a space's notes are kept in: what their author_id says. */
50 export const spaceVault = (id: number): string => `space:${id}`;
51 /** The space a vault is, when it is one. */
52 export const spaceOf = (vault: string): number | undefined => {
53 const m = /^space:(\d+)$/.exec(vault);
54 return m === null ? undefined : Number(m[1]);
55 };
56 /** A space's address in the app. */
57 export const spaceHref = (id: number): string => `/s/${id}`;
58
59 export const findSpace = (db: Database, id: number): Space | undefined =>
60 Number.isInteger(id)
61 ? (db.query<Space, [number]>("SELECT * FROM space WHERE id = ?1").get(id) ?? undefined)
62 : undefined;
63
64 export const isMember = (db: Database, id: number, who: Who): boolean =>
65 who.role !== "anon" &&
66 db
67 .query<{ n: number }, [number, string]>(
68 "SELECT 1 AS n FROM space_member WHERE space_id = ?1 AND who_id = ?2",
69 )
70 .get(id, who.id) !== null;
71
72 export const members = (db: Database, id: number): Member[] =>
73 db
74 .query<Member, [number]>(
75 "SELECT who_id, who_name, joined_at FROM space_member WHERE space_id = ?1 ORDER BY joined_at, who_name",
76 )
77 .all(id);
78
79 /** Tell every member of a space what changed in it. */
80 export function tellSpace(db: Database, id: number, type: string, data: unknown): void {
81 for (const m of members(db, id)) emit(type, data, m.who_id);
82 }
83
84 /** The spaces `who` is in, by name. */
85 export const mySpaces = (db: Database, who: Who): Space[] =>
86 who.role === "anon"
87 ? []
88 : db
89 .query<Space, [string]>(
90 "SELECT s.* FROM space s JOIN space_member m ON m.space_id = s.id WHERE m.who_id = ?1 ORDER BY s.name COLLATE NOCASE",
91 )
92 .all(who.id);
93
94 /** A space, for one of its members. */
95 export function getSpace(db: Database, who: Who, id: number): Result<Space> {
96 if (who.role === "anon") return fail("signin_required", "Sign in to open this folder.");
97 const space = findSpace(db, id);
98 // Whoever is not in it learns nothing about whether it exists.
99 if (space === undefined || !isMember(db, id, who)) return fail("not_found", "No such shared folder.");
100 return ok(space);
101 }
102
103 /** A space, for its owner. */
104 function owned(db: Database, who: Who, id: number): Result<Space> {
105 const space = getSpace(db, who, id);
106 if (!space.ok) return space;
107 if (who.role === "anon" || space.value.owner_id !== who.id)
108 return fail("forbidden", "Only the folder's owner can do that.");
109 return space;
110 }
111
112 const code = (): string => randomBytes(18).toString("base64url");
113
114 // What a repository, branch and folder may be called: the forge's own rules, roughly.
115 const REPO = /^[\w.-]+\/[\w.-]+$/;
116 const BRANCH = /^(?!-)(?!.*\.\.)[\w./-]{1,100}$/;
117
118 /** A file or folder name a note can have: one segment, nothing a filesystem or a link would trip on. */
119 export function cleanSegment(raw: string): Result<string> {
120 const s = raw.replace(/\s+/g, " ").trim();
121 if (s === "" || s.length > 120) return fail("invalid", "A name is 1 to 120 characters.");
122 if (s.startsWith(".")) return fail("invalid", "A name cannot start with a dot.");
123 if (/[\/\\:*?"<>|\[\]#^\u0000-\u001f]/.test(s))
124 return fail("invalid", "A name cannot hold / \\ : * ? \" < > | [ ] # or ^.");
125 return ok(s);
126 }
127
128 /** A folder path, `a/b`: each segment clean, no empty ones. `""` is the top. */
129 export function cleanFolder(raw: string): Result<string> {
130 const parts = raw.split("/").map((p) => p.trim()).filter((p) => p !== "");
131 const out: string[] = [];
132 for (const p of parts) {
133 const clean = cleanSegment(p);
134 if (!clean.ok) return clean;
135 out.push(clean.value);
136 }
137 return ok(out.join("/"));
138 }
139
140 const join = (...parts: string[]): string => parts.filter((p) => p !== "").join("/");
141
142 /** The file a note titled `title` in `folder` (relative to the space's own) is kept in. */
143 export const fileOf = (space: Pick<Space, "folder">, folder: string, title: string): string =>
144 join(space.folder, folder, `${title}.md`);
145
146 /** The folder a file is in, relative to the space's own. */
147 export function folderOf(space: Pick<Space, "folder">, path: string): string {
148 const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
149 if (space.folder === "") return dir;
150 return dir === space.folder ? "" : dir.slice(space.folder.length + 1);
151 }
152
153 /** A file's note title: its name, without `.md`. */
154 export const titleOfPath = (path: string): string =>
155 path.slice(path.lastIndexOf("/") + 1).replace(/\.md$/i, "");
156
157 /** Make a space: an app admin points it at a repository folder, and is its first member. */
158 export function createSpace(
159 db: Database,
160 who: Who,
161 input: { name: string; repo: string; branch?: string; folder?: string },
162 ): Result<Space> {
163 if (who.role === "anon") return fail("signin_required", "Sign in to make a shared folder.");
164 if (who.role !== "admin")
165 return fail("forbidden", "Only an app admin can connect a repository — it is written to with their token.");
166 const name = input.name.replace(/\s+/g, " ").trim();
167 if (name === "" || name.length > 80) return fail("invalid", "A name is 1 to 80 characters.");
168 const repo = input.repo.trim().replace(/^\/+|\/+$/g, "");
169 if (!REPO.test(repo)) return fail("invalid", "The repository is owner/name, as on the forge.");
170 const branch = (input.branch ?? "").trim() || "main";
171 if (!BRANCH.test(branch)) return fail("invalid", "That is not a branch name.");
172 const folder = cleanFolder(input.folder ?? "");
173 if (!folder.ok) return folder;
174 const taken = db
175 .query<{ id: number }, [string, string, string]>(
176 "SELECT id FROM space WHERE repo = ?1 AND branch = ?2 AND folder = ?3",
177 )
178 .get(repo, branch, folder.value);
179 if (taken !== null) return fail("invalid", "That folder is already shared here.");
180 const space = db.transaction(() => {
181 const made = db
182 .query<Space, [string, string, string, string, string, string, string]>(
183 "INSERT INTO space (name, repo, branch, folder, owner_id, owner_name, join_code) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING *",
184 )
185 .get(name, repo, branch, folder.value, who.id, who.name, code())!;
186 db.query("INSERT INTO space_member (space_id, who_id, who_name) VALUES (?1, ?2, ?3)").run(
187 made.id,
188 who.id,
189 who.name,
190 );
191 return made;
192 })();
193 return ok(space);
194 }
195
196 /** Open a join link: whoever is signed in becomes a member. */
197 export function joinSpace(db: Database, who: Who, input: { code: string }): Result<Space> {
198 if (who.role === "anon") return fail("signin_required", "Sign in, then open the link again.");
199 const space =
200 input.code === ""
201 ? null
202 : db.query<Space, [string]>("SELECT * FROM space WHERE join_code = ?1").get(input.code);
203 if (space === null) return fail("not_found", "That link has been turned off, or was never one.");
204 db.query(
205 "INSERT INTO space_member (space_id, who_id, who_name) VALUES (?1, ?2, ?3) ON CONFLICT (space_id, who_id) DO UPDATE SET who_name = excluded.who_name",
206 ).run(space.id, who.id, who.name);
207 tellSpace(db, space.id, "space.changed", { id: space.id });
208 return ok(space);
209 }
210
211 /** Turn the join link off, or on with a new address (the old one stops working). */
212 export function setLink(db: Database, who: Who, input: { id: number; on: boolean }): Result<Space> {
213 const space = owned(db, who, input.id);
214 if (!space.ok) return space;
215 return ok(
216 db
217 .query<Space, [string | null, number]>("UPDATE space SET join_code = ?1 WHERE id = ?2 RETURNING *")
218 .get(input.on ? code() : null, input.id)!,
219 );
220 }
221
222 /** The owner removes a member, or a member leaves. The owner stays. */
223 export function removeMember(
224 db: Database,
225 who: Who,
226 input: { id: number; member: string },
227 ): Result<{ id: number }> {
228 const space = getSpace(db, who, input.id);
229 if (!space.ok) return space;
230 if (who.role === "anon") return fail("signin_required", "Sign in first.");
231 const self = input.member === who.id;
232 if (!self && space.value.owner_id !== who.id)
233 return fail("forbidden", "Only the folder's owner can remove someone.");
234 if (input.member === space.value.owner_id)
235 return fail("invalid", "The owner can't leave their own folder — delete it instead.");
236 db.query("DELETE FROM space_member WHERE space_id = ?1 AND who_id = ?2").run(input.id, input.member);
237 emit("space.changed", { id: input.id }, input.member);
238 tellSpace(db, input.id, "space.changed", { id: input.id });
239 return ok({ id: input.id });
240 }
241
242 /** Stop sharing: the space and its copy of the notes go; the repository keeps every file. */
243 export function deleteSpace(db: Database, who: Who, input: { id: number }): Result<{ id: number }> {
244 const space = owned(db, who, input.id);
245 if (!space.ok) return space;
246 const everyone = members(db, input.id);
247 db.transaction(() => {
248 db.query("DELETE FROM note WHERE author_id = ?1").run(spaceVault(input.id));
249 db.query("DELETE FROM space WHERE id = ?1").run(input.id);
250 })();
251 for (const m of everyone) emit("space.changed", { id: input.id }, m.who_id);
252 return ok({ id: input.id });
253 }
254
255 // A change to a space's notes is a commit owed: `src/sync.ts` listens here
256 // (and actions call `edited`) so the two never import each other.
257 let onEdit: (id: number) => void = () => {};
258 export const whenEdited = (listen: (id: number) => void): void => {
259 onEdit = listen;
260 };
261 export const edited = (id: number): void => onEdit(id);
src/sync.ts added +359
@@ -0,0 +1,359 @@
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 }
src/vault.ts +3 −1
@@ -44,9 +44,11 @@ export const hrefOf = (note: { readonly id: number }): string => `/n/${note.id}`
4444 export function renderNote(
4545 body: string,
4646 notes: readonly VaultNote[],
47 base = "",
4748 ): string {
4849 const titles = byTitle(notes);
4950 return md.render(body, {
51 base,
5052 resolve: (title: string) => {
5153 const found = titles.get(key(title));
5254 return found === undefined ? null : hrefOf(found);
@@ -191,7 +193,7 @@ export function relink(body: string, from: string, to: string): string {
191193
192194 /** A plain-text preview of a note's first words, for lists. */
193195 export function excerpt(body: string, max = 120): string {
194 const text = body
196 const text = (md.frontmatter(body)?.body ?? body)
195197 .replace(/^(```|~~~)[\s\S]*?^\1/gm, "")
196198 .replace(/^#{1,6}\s+/gm, "")
197199 .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2")
static/app.css +96
@@ -1189,3 +1189,99 @@ form {
11891189 animation: none;
11901190 }
11911191 }
1192
1193 /* ── Shared folders ─────────────────────────────────────────────────── */
1194 .file-title svg,
1195 .folder summary svg {
1196 width: 1em;
1197 height: 1em;
1198 margin-right: var(--space-1);
1199 vertical-align: -0.125em;
1200 fill: none;
1201 stroke: currentColor;
1202 stroke-width: 1.8;
1203 stroke-linejoin: round;
1204 }
1205 .folder > summary {
1206 display: flex;
1207 align-items: center;
1208 min-height: 2rem;
1209 padding: var(--space-1) var(--space-2);
1210 border-radius: var(--radius-sm);
1211 color: var(--color-muted);
1212 font-weight: 600;
1213 cursor: pointer;
1214 list-style: none;
1215 }
1216 .folder > summary::-webkit-details-marker {
1217 display: none;
1218 }
1219 .folder > summary:hover {
1220 background: var(--color-hover);
1221 }
1222 .folder > .file-list {
1223 margin-left: var(--space-3);
1224 padding-left: var(--space-1);
1225 border-left: 1px solid var(--color-line);
1226 }
1227 .sync-line {
1228 margin: var(--space-4) var(--space-2) 0;
1229 overflow-wrap: anywhere;
1230 }
1231 .sync-line.is-error {
1232 color: var(--color-danger);
1233 }
1234 .file-path code {
1235 overflow-wrap: anywhere;
1236 }
1237 .move {
1238 display: flex;
1239 align-items: end;
1240 gap: var(--space-2);
1241 }
1242 .move .field {
1243 flex: 1;
1244 min-width: 0;
1245 }
1246 .people {
1247 list-style: none;
1248 margin: 0 0 var(--space-3);
1249 padding: 0;
1250 }
1251 .people li {
1252 display: flex;
1253 align-items: center;
1254 justify-content: space-between;
1255 gap: var(--space-2);
1256 min-height: 2.5rem;
1257 border-bottom: 1px solid var(--color-line);
1258 }
1259 .join-link {
1260 width: 100%;
1261 margin-bottom: var(--space-2);
1262 font-family: var(--font-mono);
1263 font-size: var(--text-sm);
1264 }
1265 .join-link[data-copied="true"] {
1266 border-color: var(--color-accent);
1267 }
1268 #collide {
1269 display: inline-flex;
1270 gap: var(--space-1);
1271 }
1272 .md .properties {
1273 display: grid;
1274 grid-template-columns: max-content 1fr;
1275 gap: var(--space-1) var(--space-3);
1276 margin: 0 0 var(--space-4);
1277 padding: var(--space-2) var(--space-3);
1278 border: 1px solid var(--color-line);
1279 border-radius: var(--radius-sm);
1280 font-size: var(--text-sm);
1281 }
1282 .md .properties dt {
1283 color: var(--color-muted);
1284 }
1285 .md .properties dd {
1286 margin: 0;
1287 }
static/app.js +81 −8
@@ -25,6 +25,12 @@ const store = {
2525 },
2626 };
2727
28 /** Where this vault's pages are: "" for yours, /s/<id> for a shared folder. */
29 const base = $("#vault")?.dataset.base ?? "";
30 const space = $("#vault")?.dataset.space;
31 /** In a shared folder, new notes go in the folder of the note you are in. */
32 const folder = () => $('form[action$="/new"] input[name="folder"]')?.value ?? "";
33
2834 /** The vault's titles, as the page last heard them. */
2935 function vault() {
3036 try {
@@ -45,12 +51,15 @@ async function createNote(title) {
4551 const r = await fetch("/api/notes", {
4652 method: "POST",
4753 headers: { "content-type": "application/json" },
48 body: JSON.stringify(title ? { title } : {}),
54 body: JSON.stringify({
55 ...(title ? { title } : {}),
56 ...(space ? { space: Number(space), folder: folder() } : {}),
57 }),
4958 });
5059 if (r.ok) {
5160 const note = await r.json();
5261 await go(`/n/${note.id}`);
53 } else if (title) location.href = `/open?title=${encodeURIComponent(title)}`;
62 } else if (title) location.href = `${base}/open?title=${encodeURIComponent(title)}`;
5463 }
5564
5665 // ── Theme ─────────────────────────────────────────────────────────────
@@ -81,6 +90,15 @@ document.addEventListener("click", (e) => {
8190 });
8291
8392 // ── Small things every page does ─────────────────────────────────────
93 // A join link selects itself, and is copied, when you click it.
94 document.addEventListener("click", (e) => {
95 const box = e.target.closest?.("[data-select]");
96 if (!box) return;
97 box.select();
98 navigator.clipboard?.writeText(box.value).then(() => {
99 box.dataset.copied = "true";
100 }, () => {});
101 });
84102 for (const tz of $$('input[name="tz"]')) tz.value = String(new Date().getTimezoneOffset());
85103 document.addEventListener("submit", (e) => {
86104 const message = e.target.dataset?.confirm;
@@ -228,10 +246,10 @@ document.addEventListener("keydown", (e) => {
228246 palette?.hidden === false ? closePalette() : openPalette();
229247 } else if (mod(e) && e.shiftKey && e.key.toLowerCase() === "d") {
230248 e.preventDefault();
231 $('form[action="/daily"]')?.requestSubmit();
249 $("form[data-daily]")?.requestSubmit();
232250 } else if (mod(e) && !e.shiftKey && e.key === "g") {
233251 e.preventDefault();
234 go("/graph");
252 go(`${base}/graph`);
235253 } else if (e.altKey && e.code === "KeyN") {
236254 e.preventDefault();
237255 createNote();
@@ -270,6 +288,8 @@ function editor(form) {
270288 let timer;
271289 let saving = null;
272290 let frame = 0;
291 // Someone else saved while you were writing: nothing saves until you choose.
292 let held = false;
273293
274294 // Views: edit, split (side by side, on a wide screen), read. ⌘E flips
275295 // between reading and the last way you were writing.
@@ -310,7 +330,7 @@ function editor(form) {
310330 function draw() {
311331 cancelAnimationFrame(frame);
312332 frame = requestAnimationFrame(() => {
313 preview.innerHTML = render(source.value, { resolve: resolver() }) || '<p class="muted">Nothing here yet.</p>';
333 preview.innerHTML = render(source.value, { resolve: resolver(), base }) || '<p class="muted">Nothing here yet.</p>';
314334 });
315335 }
316336
@@ -322,12 +342,14 @@ function editor(form) {
322342 }
323343 function changed() {
324344 dirty = titleEl.value !== saved.title || source.value !== saved.body;
345 if (held) return;
325346 say(dirty ? "Editing…" : "Saved");
326347 clearTimeout(timer);
327348 if (dirty) timer = setTimeout(save, 600);
328349 }
329350 async function save() {
330351 clearTimeout(timer);
352 if (held) return;
331353 if (saving) await saving;
332354 const body = {};
333355 const t = titleEl.value.trim();
@@ -338,6 +360,7 @@ function editor(form) {
338360 return;
339361 }
340362 say("Saving…");
363 body.base = saved.updated;
341364 saving = (async () => {
342365 try {
343366 const r = await fetch(`/api/notes/${id}`, {
@@ -352,6 +375,8 @@ function editor(form) {
352375 if (body.title !== undefined) renamed(answer);
353376 say(dirty ? "Editing…" : answer.relinked ? `Saved · updated links in ${answer.relinked} ${answer.relinked === 1 ? "note" : "notes"}` : "Saved");
354377 if (dirty) timer = setTimeout(save, 600);
378 } else if (r.status === 409) {
379 collide(answer.message ?? "Someone else saved this note while you were writing.");
355380 } else if (r.status === 422 && body.title !== undefined) {
356381 say(answer.message ?? "That title can't be used.", "error");
357382 // Keep the words: save them under the old title.
@@ -374,6 +399,51 @@ function editor(form) {
374399 return saving;
375400 }
376401 flush = save;
402
403 // Two people, one note: theirs is saved, yours is on screen. Say so, and
404 // let the writer choose — never write over either without asking.
405 function collide(message) {
406 held = true;
407 say(message, "error");
408 if ($("#collide")) return;
409 const bar = document.createElement("span");
410 bar.id = "collide";
411 bar.className = "row";
412 const button = (label, act) => {
413 const b = document.createElement("button");
414 b.type = "button";
415 b.className = "button button-quiet";
416 b.textContent = label;
417 b.addEventListener("click", act);
418 bar.append(b);
419 };
420 const latest = async () => {
421 const r = await fetch(`/api/notes/${id}`);
422 return r.ok ? r.json() : null;
423 };
424 button("Load theirs", async () => {
425 const note = await latest();
426 if (!note) return;
427 bar.remove();
428 held = false;
429 saved = { title: note.title, body: note.body, updated: note.updated_at };
430 source.value = note.body;
431 titleEl.value = note.title;
432 renamed(note);
433 dirty = false;
434 draw();
435 say("Loaded their version");
436 });
437 button("Keep mine", async () => {
438 const note = await latest();
439 if (!note) return;
440 bar.remove();
441 held = false;
442 saved = { ...saved, updated: note.updated_at };
443 save();
444 });
445 status?.after(bar);
446 }
377447 function renamed(note) {
378448 const here = $(".crumb-here");
379449 if (here) here.textContent = note.title;
@@ -396,8 +466,9 @@ function editor(form) {
396466 });
397467 addEventListener("beforeunload", (e) => {
398468 if (!dirty) return;
469 if (held) return e.preventDefault();
399470 // Best effort on the way out; the prompt covers what keepalive cannot carry.
400 const body = JSON.stringify({ body: source.value, ...(titleEl.value.trim() !== saved.title && titleEl.value.trim() ? { title: titleEl.value.trim() } : {}) });
471 const body = JSON.stringify({ body: source.value, base: saved.updated, ...(titleEl.value.trim() !== saved.title && titleEl.value.trim() ? { title: titleEl.value.trim() } : {}) });
401472 if (body.length < 60_000)
402473 fetch(`/api/notes/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body, keepalive: true });
403474 else e.preventDefault();
@@ -406,7 +477,9 @@ function editor(form) {
406477 // Someone (another tab, a script, Claude over MCP) changed this note:
407478 // take their words if you have none of your own in flight.
408479 document.addEventListener("live:note.changed", async (e) => {
409 if (String(e.detail.id) !== id || e.detail.updated_at === saved.updated || dirty || saving) return;
480 if (String(e.detail.id) !== id || e.detail.updated_at === saved.updated || saving) return;
481 // Mid-sentence when theirs arrives: don't wait for the save to find out.
482 if (dirty) return collide("Someone else just saved this note.");
410483 const r = await fetch(`/api/notes/${id}`);
411484 if (!r.ok) return;
412485 const note = await r.json();
@@ -423,7 +496,7 @@ function editor(form) {
423496 document.addEventListener("live:note.removed", (e) => {
424497 if (String(e.detail.id) === id) {
425498 dirty = false;
426 location.href = "/";
499 location.href = base || "/";
427500 }
428501 });
429502 document.addEventListener("live:swapped", () => draw());
static/md.js +53 −8
@@ -49,10 +49,47 @@ export function links(md) {
4949 return out;
5050 }
5151
52 /** The tags a note carries, lower-cased, once each, without the #. @param {string} md */
52 /**
53 * A note's properties: the YAML-ish block between `---` lines at the very
54 * top, the way Obsidian (and every static-site generator) writes it. Only
55 * what a person reads is understood — `key: value`, `key: [a, b]` and a
56 * key followed by `- item` lines — and the rest of the note is its body.
57 * @param {string} md
58 * @returns {{ props: [string, string[]][], body: string } | null}
59 */
60 export function frontmatter(md) {
61 const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(md);
62 if (!m) return null;
63 /** @type {[string, string[]][]} */
64 const props = [];
65 for (const line of m[1].split(/\r?\n/)) {
66 const item = /^\s+-\s+(.*)$/.exec(line);
67 if (item && props.length > 0) {
68 props[props.length - 1][1].push(unquote(item[1]));
69 continue;
70 }
71 const kv = /^([\w][\w .-]*?)\s*:\s*(.*)$/.exec(line);
72 if (!kv) continue;
73 const value = kv[2].trim();
74 const listed = /^\[(.*)\]$/.exec(value);
75 props.push([kv[1], listed ? listed[1].split(",").map(unquote).filter((v) => v !== "") : value === "" ? [] : [unquote(value)]]);
76 }
77 return { props, body: md.slice(m[0].length) };
78 }
79 /** @param {string} s */
80 const unquote = (s) => s.trim().replace(/^(["'])(.*)\1$/, "$2");
81
82 /** The tags a note carries, lower-cased, once each, without the #: in its words and in its `tags` property. @param {string} md */
5383 export function tags(md) {
5484 const out = new Set();
55 for (const m of prose(md).matchAll(TAG)) out.add(m[2].toLowerCase());
85 const fm = frontmatter(md);
86 for (const [key, values] of fm?.props ?? [])
87 if (/^tags?$/i.test(key))
88 for (const v of values.flatMap((v) => v.split(/[\s,]+/))) {
89 const tag = v.replace(/^#/, "").toLowerCase();
90 if (/^[\p{L}\p{N}_/-]*[\p{L}_/-][\p{L}\p{N}_/-]*$/u.test(tag)) out.add(tag);
91 }
92 for (const m of prose(fm?.body ?? md).matchAll(TAG)) out.add(m[2].toLowerCase());
5693 return [...out];
5794 }
5895
@@ -99,7 +136,7 @@ function marks(s, o) {
99136 const anchor = heading ? `#${slug(heading)}` : "";
100137 return hold(
101138 href === null
102 ? `<a class="wikilink unresolved" href="/open?title=${encodeURIComponent(name)}" data-title="${escapeHtml(name)}">${shown}</a>`
139 ? `<a class="wikilink unresolved" href="${o.base}/open?title=${encodeURIComponent(name)}" data-title="${escapeHtml(name)}">${shown}</a>`
103140 : `<a class="wikilink${embed ? " embed" : ""}" href="${escapeHtml(href)}${anchor}" data-title="${escapeHtml(name)}">${shown}</a>`,
104141 );
105142 })
@@ -112,7 +149,7 @@ function marks(s, o) {
112149 lead + hold(`<a href="${url}" rel="noopener noreferrer" target="_blank">${url}</a>`),
113150 )
114151 .replace(TAG, (_, lead, tag) =>
115 lead + hold(`<a class="tag" href="/search?q=${encodeURIComponent(`#${unescapeHtml(tag)}`)}">#${tag}</a>`),
152 lead + hold(`<a class="tag" href="${o.base}/search?q=${encodeURIComponent(`#${unescapeHtml(tag)}`)}">#${tag}</a>`),
116153 );
117154 s = s
118155 .replace(/\*\*(?=\S)([^*]*?\S)\*\*/g, "<strong>$1</strong>")
@@ -125,9 +162,11 @@ function marks(s, o) {
125162 }
126163
127164 /**
128 * @typedef {{ resolve: (title: string) => string | null }} Options
165 * @typedef {{ resolve: (title: string) => string | null, base: string }} Options
129166 * `resolve` turns a linked note's title into its address, or null when no
130 * such note exists yet (the link is drawn as one to create).
167 * such note exists yet (the link is drawn as one to create). `base` is the
168 * vault's own address (`""` for yours, `/s/<id>` for a shared one), where a
169 * link to a note not written yet, or a tag, goes looking.
131170 */
132171
133172 const CALLOUTS = new Set(["note", "info", "tip", "success", "question", "warning", "danger", "bug", "example", "quote", "abstract", "todo", "failure"]);
@@ -138,8 +177,14 @@ const CALLOUTS = new Set(["note", "info", "tip", "success", "question", "warning
138177 * @param {Partial<Options>} [options]
139178 */
140179 export function render(md, options = {}) {
141 const o = { resolve: options.resolve ?? (() => null) };
142 return blocks(md.replace(/\r\n?/g, "\n").split("\n"), o);
180 const o = { resolve: options.resolve ?? (() => null), base: options.base ?? "" };
181 const text = md.replace(/\r\n?/g, "\n");
182 const fm = frontmatter(text);
183 if (fm === null) return blocks(text.split("\n"), o);
184 const props = fm.props
185 .map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${v.map((x) => inline(escapeHtml(x), o)).join(", ") || "—"}</dd>`)
186 .join("");
187 return `${props === "" ? "" : `<dl class="properties">${props}</dl>\n`}${blocks(fm.body.split("\n"), o)}`;
143188 }
144189
145190 /** @param {string[]} lines @param {Options} o */
↑↓ navigate ↵ open esc close

Keyboard shortcuts