Shared folders: a repo folder you write in together, kept in git #3
shared → main 1 commit
-
Merged.
Review
Sign in to review this change.
18 files
changed +2116
−196
README.md +33
| @@ -45,6 +45,39 @@ the MCP endpoint and the terminal are for forge accounts. | ||
| 45 | 45 | Everything is real: notes live in SQLite under `FORGE_APP_DATA`. A preview |
| 46 | 46 | of a pull gets a copy of production data; production is the default branch. |
| 47 | 47 | |
| 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 | ||
| 48 | 81 | ## Claude can use it too |
| 49 | 82 | |
| 50 | 83 | `POST /mcp` is an MCP server with `list_notes`, `search_notes`, |
contract.json +6 −5
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | "roles": { |
| 3 | 3 | "anon": { "above": [], "held": { "by": "everyone" } }, |
| 4 | 4 | "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" } }, | |
| 6 | 7 | "admin": { "above": ["user"], "held": { "by": "identity", "role": "admin" } } |
| 7 | 8 | }, |
| 8 | 9 | "resources": { |
| @@ -10,10 +11,10 @@ | ||
| 10 | 11 | "api": "/api/notes", |
| 11 | 12 | "can": { |
| 12 | 13 | "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" | |
| 17 | 18 | } |
| 18 | 19 | }, |
| 19 | 20 | "audit": { |
contract/openapi.json +44 −6
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | "paths": { |
| 9 | 9 | "/api/notes": { |
| 10 | 10 | "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.", | |
| 12 | 12 | "operationId": "getApiNotes", |
| 13 | 13 | "parameters": [ |
| 14 | 14 | { |
| @@ -20,6 +20,13 @@ | ||
| 20 | 20 | "maximum": 5000 |
| 21 | 21 | } |
| 22 | 22 | }, |
| 23 | { | |
| 24 | "name": "space", | |
| 25 | "in": "query", | |
| 26 | "schema": { | |
| 27 | "type": "integer" | |
| 28 | } | |
| 29 | }, | |
| 23 | 30 | { |
| 24 | 31 | "name": "q", |
| 25 | 32 | "in": "query", |
| @@ -31,7 +38,7 @@ | ||
| 31 | 38 | ], |
| 32 | 39 | "responses": { |
| 33 | 40 | "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.", | |
| 35 | 42 | "content": { |
| 36 | 43 | "application/json": { |
| 37 | 44 | "schema": { |
| @@ -57,7 +64,7 @@ | ||
| 57 | 64 | "x-contract": { |
| 58 | 65 | "resource": "note", |
| 59 | 66 | "verb": "list", |
| 60 | "least": "author" | |
| 67 | "least": "member" | |
| 61 | 68 | } |
| 62 | 69 | }, |
| 63 | 70 | "post": { |
| @@ -79,6 +86,14 @@ | ||
| 79 | 86 | "type": "string", |
| 80 | 87 | "maxLength": 200000, |
| 81 | 88 | "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`)." | |
| 82 | 97 | } |
| 83 | 98 | } |
| 84 | 99 | } |
| @@ -153,7 +168,7 @@ | ||
| 153 | 168 | "x-contract": { |
| 154 | 169 | "resource": "note", |
| 155 | 170 | "verb": "read", |
| 156 | "least": "author" | |
| 171 | "least": "member" | |
| 157 | 172 | } |
| 158 | 173 | }, |
| 159 | 174 | "patch": { |
| @@ -188,6 +203,15 @@ | ||
| 188 | 203 | }, |
| 189 | 204 | "pinned": { |
| 190 | 205 | "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." | |
| 191 | 215 | } |
| 192 | 216 | } |
| 193 | 217 | } |
| @@ -235,6 +259,13 @@ | ||
| 235 | 259 | "updated_at": { |
| 236 | 260 | "type": "string", |
| 237 | 261 | "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." | |
| 238 | 269 | } |
| 239 | 270 | }, |
| 240 | 271 | "required": [ |
| @@ -278,7 +309,7 @@ | ||
| 278 | 309 | "x-contract": { |
| 279 | 310 | "resource": "note", |
| 280 | 311 | "verb": "update", |
| 281 | "least": "author" | |
| 312 | "least": "member" | |
| 282 | 313 | } |
| 283 | 314 | }, |
| 284 | 315 | "delete": { |
| @@ -327,7 +358,7 @@ | ||
| 327 | 358 | "x-contract": { |
| 328 | 359 | "resource": "note", |
| 329 | 360 | "verb": "delete", |
| 330 | "least": "author" | |
| 361 | "least": "member" | |
| 331 | 362 | } |
| 332 | 363 | } |
| 333 | 364 | }, |
| @@ -570,6 +601,13 @@ | ||
| 570 | 601 | "updated_at": { |
| 571 | 602 | "type": "string", |
| 572 | 603 | "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." | |
| 573 | 611 | } |
| 574 | 612 | }, |
| 575 | 613 | "required": [ |
src/accounts.ts +1
| @@ -185,6 +185,7 @@ export async function deleteAccount( | ||
| 185 | 185 | const notes = db.transaction(() => { |
| 186 | 186 | const gone = db.query("DELETE FROM note WHERE author_id = ?1").run(`acct:${id}`).changes; |
| 187 | 187 | 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}`); | |
| 188 | 189 | db.query("DELETE FROM account WHERE id = ?1").run(id); |
| 189 | 190 | return gone; |
| 190 | 191 | })(); |
src/actions.ts +192 −48
| @@ -10,6 +10,19 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { record, type AuditRow } from "./audit.ts"; |
| 11 | 11 | import { permit } from "./contract.ts"; |
| 12 | 12 | 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"; | |
| 13 | 26 | import { relink, search } from "./vault.ts"; |
| 14 | 27 | import type { Who } from "./who.ts"; |
| 15 | 28 | |
| @@ -22,9 +35,14 @@ export interface Note { | ||
| 22 | 35 | readonly author_name: string; |
| 23 | 36 | readonly created_at: string; |
| 24 | 37 | 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; | |
| 25 | 43 | } |
| 26 | 44 | |
| 27 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; | |
| 45 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid" | "conflict"; | |
| 28 | 46 | export type Result<T> = |
| 29 | 47 | | { ok: true; value: T } |
| 30 | 48 | | { ok: false; error: Failure; message: string }; |
| @@ -67,11 +85,31 @@ const audited = <T extends { readonly id: number }>( | ||
| 67 | 85 | return result; |
| 68 | 86 | }; |
| 69 | 87 | |
| 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); | |
| 73 | 94 | }; |
| 74 | 95 | |
| 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 | ||
| 75 | 113 | /** |
| 76 | 114 | * A title, as the vault keeps it: trimmed, one line, and free of the |
| 77 | 115 | * characters a [[link]] uses to say something else (`[ ] | # ^`). |
| @@ -99,7 +137,7 @@ const checkBody = (body: string): Result<string> => | ||
| 99 | 137 | export function listNotes( |
| 100 | 138 | db: Database, |
| 101 | 139 | who: Who, |
| 102 | input: { limit?: number } = {}, | |
| 140 | input: { limit?: number; space?: number } = {}, | |
| 103 | 141 | ): Result<Note[]> { |
| 104 | 142 | const limit = Math.min( |
| 105 | 143 | Math.max(Math.trunc(input.limit ?? VAULT_MAX) || VAULT_MAX, 1), |
| @@ -108,12 +146,14 @@ export function listNotes( | ||
| 108 | 146 | const may = permit(who, "note", "list"); |
| 109 | 147 | if (may === "no" || who.role === "anon") |
| 110 | 148 | 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; | |
| 111 | 151 | return ok( |
| 112 | 152 | db |
| 113 | 153 | .query<Note, [string, number]>( |
| 114 | 154 | "SELECT * FROM note WHERE author_id = ?1 ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT ?2", |
| 115 | 155 | ) |
| 116 | .all(who.id, limit), | |
| 156 | .all(vault.value.vault, limit), | |
| 117 | 157 | ); |
| 118 | 158 | } |
| 119 | 159 | |
| @@ -136,48 +176,85 @@ const titleTaken = (db: Database, authorId: string, title: string, except = -1): | ||
| 136 | 176 | ) |
| 137 | 177 | .get(authorId, title, except) !== null; |
| 138 | 178 | |
| 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> => | |
| 144 | 188 | audited(db, who, "note", "create", undefined, add(db, who, input)); |
| 145 | 189 | |
| 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> { | |
| 151 | 203 | // The table decides; the type only learns it here: whoever may create is |
| 152 | 204 | // signed in, and carries a name to write beside the note. |
| 153 | 205 | if (who.role === "anon" || permit(who, "note", "create") !== "yes") |
| 154 | 206 | 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; | |
| 155 | 210 | const body = checkBody(input.body ?? ""); |
| 156 | 211 | if (!body.ok) return body; |
| 157 | 212 | const count = db |
| 158 | 213 | .query<{ n: number }, [string]>("SELECT count(*) AS n FROM note WHERE author_id = ?1") |
| 159 | .get(who.id)!.n; | |
| 214 | .get(vault)!.n; | |
| 160 | 215 | if (count >= VAULT_MAX) |
| 161 | 216 | return fail("invalid", `A vault holds at most ${VAULT_MAX.toLocaleString("en")} notes.`); |
| 162 | 217 | 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 { | |
| 166 | 232 | const clean = cleanTitle(input.title); |
| 167 | 233 | if (!clean.ok) return clean; |
| 168 | if (titleTaken(db, who.id, clean.value)) | |
| 169 | return fail("invalid", `A note called “${clean.value}” already exists.`); | |
| 170 | 234 | title = clean.value; |
| 171 | 235 | } |
| 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}.`); | |
| 172 | 241 | 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 *", | |
| 175 | 244 | ) |
| 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); | |
| 178 | 248 | return ok(note); |
| 179 | 249 | } |
| 180 | 250 | |
| 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 | ||
| 181 | 258 | /** |
| 182 | 259 | * One note, for `who` to do `verb` to. The door is asked before the row is |
| 183 | 260 | * looked for, so whoever may not is told that and never learns whether the |
| @@ -196,8 +273,17 @@ function noteFor( | ||
| 196 | 273 | // With no row to show, the door is asked about a row of their own: whoever |
| 197 | 274 | // could not pass even then is refused, and learns nothing about what |
| 198 | 275 | // 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); | |
| 199 | 279 | 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 | }; | |
| 201 | 287 | if (permit(who, "note", verb, row) !== "yes") |
| 202 | 288 | return refuse(who, said.signIn, said.no); |
| 203 | 289 | if (note !== null) return ok(note); |
| @@ -217,20 +303,22 @@ export function getNote( | ||
| 217 | 303 | }); |
| 218 | 304 | } |
| 219 | 305 | |
| 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. */ | |
| 221 | 307 | export function findNote( |
| 222 | 308 | db: Database, |
| 223 | 309 | who: Who, |
| 224 | input: { title: string }, | |
| 310 | input: { title: string; space?: number }, | |
| 225 | 311 | ): 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(); | |
| 229 | 317 | const note = db |
| 230 | 318 | .query<Note, [string, string]>( |
| 231 | 319 | "SELECT * FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE", |
| 232 | 320 | ) |
| 233 | .get(who.id, title); | |
| 321 | .get(where.value.vault, title); | |
| 234 | 322 | if (note === null) return fail("not_found", `No note called “${title}”.`); |
| 235 | 323 | return getNote(db, who, { id: note.id }); |
| 236 | 324 | } |
| @@ -239,12 +327,12 @@ export function findNote( | ||
| 239 | 327 | export function openOrCreate( |
| 240 | 328 | db: Database, |
| 241 | 329 | who: Who, |
| 242 | input: { title: string; body?: string }, | |
| 330 | input: { title: string; body?: string; space?: number; folder?: string }, | |
| 243 | 331 | ): Result<Note & { created: boolean }> { |
| 244 | 332 | const found = findNote(db, who, input); |
| 245 | 333 | if (found.ok) return ok({ ...found.value, created: false }); |
| 246 | 334 | 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 ?? "" }); | |
| 248 | 336 | return made.ok ? ok({ ...made.value, created: true }) : made; |
| 249 | 337 | } |
| 250 | 338 | |
| @@ -253,6 +341,14 @@ export interface Change { | ||
| 253 | 341 | readonly title?: string; |
| 254 | 342 | readonly body?: string; |
| 255 | 343 | 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; | |
| 256 | 352 | } |
| 257 | 353 | |
| 258 | 354 | export const updateNote = ( |
| @@ -278,9 +374,19 @@ function update( | ||
| 278 | 374 | }); |
| 279 | 375 | if (!found.ok) return found; |
| 280 | 376 | 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.`); | |
| 281 | 387 | let title = before.title; |
| 282 | 388 | if (input.title !== undefined) { |
| 283 | const clean = cleanTitle(input.title); | |
| 389 | const clean = space === undefined ? cleanTitle(input.title) : cleanSegment(input.title); | |
| 284 | 390 | if (!clean.ok) return clean; |
| 285 | 391 | if (titleTaken(db, before.author_id, clean.value, before.id)) |
| 286 | 392 | return fail("invalid", `A note called “${clean.value}” already exists.`); |
| @@ -293,17 +399,43 @@ function update( | ||
| 293 | 399 | body = checked.value; |
| 294 | 400 | } |
| 295 | 401 | 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) | |
| 297 | 419 | return ok({ ...before, relinked: 0 }); |
| 298 | 420 | const renamed = title.toLowerCase() !== before.title.toLowerCase(); |
| 299 | 421 | 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; | |
| 300 | 424 | const note = db.transaction(() => { |
| 301 | 425 | 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 *", | |
| 305 | 429 | ) |
| 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 | )!; | |
| 307 | 439 | if (renamed || title !== before.title) |
| 308 | 440 | for (const other of db |
| 309 | 441 | .query<Note, [string, number]>("SELECT * FROM note WHERE author_id = ?1 AND id != ?2") |
| @@ -320,8 +452,9 @@ function update( | ||
| 320 | 452 | } |
| 321 | 453 | return saved; |
| 322 | 454 | })(); |
| 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); | |
| 325 | 458 | return ok({ ...note, relinked: touched.length }); |
| 326 | 459 | } |
| 327 | 460 | |
| @@ -342,18 +475,29 @@ function remove( | ||
| 342 | 475 | no: "Only the author can remove a note.", |
| 343 | 476 | }); |
| 344 | 477 | 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 }); | |
| 348 | 492 | } |
| 349 | 493 | |
| 350 | 494 | /** Search the asker's vault (see `vault.search` for the language). */ |
| 351 | 495 | export function searchNotes( |
| 352 | 496 | db: Database, |
| 353 | 497 | who: Who, |
| 354 | input: { q: string; limit?: number }, | |
| 498 | input: { q: string; limit?: number; space?: number }, | |
| 355 | 499 | ): 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 }); | |
| 357 | 501 | if (!vault.ok) return vault; |
| 358 | 502 | const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 500); |
| 359 | 503 | return ok( |
src/db.ts +39
| @@ -73,6 +73,45 @@ export const migrations: string[] = [ | ||
| 73 | 73 | expires_at TEXT NOT NULL |
| 74 | 74 | )`, |
| 75 | 75 | `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 | )`, | |
| 76 | 115 | ]; |
| 77 | 116 | |
| 78 | 117 | export function openDb(dir = dataDir): Database { |
src/env.ts +8
| @@ -30,3 +30,11 @@ export const publicOrigin: string = new URL( | ||
| 30 | 30 | export const forgeUrl: string | undefined = process.env.FORGE_URL; |
| 31 | 31 | /** The key the forge signs identities with (`who.ts`). Unset, everyone is anonymous. */ |
| 32 | 32 | 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 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { isAccount, type Account } from "./accounts.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { asset, escape } from "./http.ts"; |
| 13 | import { folderOf, spaceHref, type Member, type Space } from "./spaces.ts"; | |
| 14 | import { unsynced } from "./sync.ts"; | |
| 13 | 15 | import { token, tokensHref } from "./tokens.ts"; |
| 14 | 16 | import { |
| 15 | 17 | backlinks, |
| @@ -29,7 +31,7 @@ import type { Who } from "./who.ts"; | ||
| 29 | 31 | const data = (id: string, value: unknown, live = ""): string => |
| 30 | 32 | `<script type="application/json" id="${id}"${live ? ` data-live="${live}"` : ""}>${JSON.stringify(value).replace(/</g, "\\u003c")}</script>`; |
| 31 | 33 | |
| 32 | const CHANGES = "note.added note.removed note.changed"; | |
| 34 | const CHANGES = "note.added note.removed note.changed space.changed space.synced"; | |
| 33 | 35 | |
| 34 | 36 | /** |
| 35 | 37 | * The frame every page shares: the tokens and the look, and what a link |
| @@ -73,7 +75,8 @@ ${body} | ||
| 73 | 75 | } |
| 74 | 76 | |
| 75 | 77 | /** 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)}`; | |
| 77 | 80 | return layout( |
| 78 | 81 | `<div class="landing"> |
| 79 | 82 | <div class="landing-card"> |
| @@ -81,10 +84,10 @@ export function landing(notice?: string): string { | ||
| 81 | 84 | <h1>${escape(title)}</h1> |
| 82 | 85 | <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> |
| 83 | 86 | <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> | |
| 86 | 89 | </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> | |
| 88 | 91 | <p class="muted small">Your notes are yours alone — nobody else who uses this app can read them.</p> |
| 89 | 92 | </div> |
| 90 | 93 | ${toast(notice)} |
| @@ -101,11 +104,16 @@ export function authPage(input: { | ||
| 101 | 104 | email?: string; |
| 102 | 105 | name?: string; |
| 103 | 106 | needsCode: boolean; |
| 107 | /** Where to land afterwards: a join link, when that is what brought them. */ | |
| 108 | to?: string; | |
| 104 | 109 | }): string { |
| 105 | 110 | const signup = input.mode === "signup"; |
| 111 | const to = input.to ?? "/"; | |
| 112 | const next = to === "/" ? "" : `?to=${encodeURIComponent(to)}`; | |
| 106 | 113 | return layout( |
| 107 | 114 | `<div class="landing"> |
| 108 | 115 | <form class="landing-card auth" method="post" action="/${input.mode}"> |
| 116 | ${to === "/" ? "" : `<input type="hidden" name="to" value="${escape(to)}">`} | |
| 109 | 117 | <a class="mark" href="/" aria-label="Back">${glyph}</a> |
| 110 | 118 | <h1>${signup ? "Create your vault" : "Welcome back"}</h1> |
| 111 | 119 | ${input.notice === undefined ? "" : `<p class="auth-error" role="alert">${escape(input.notice)}</p>`} |
| @@ -115,7 +123,7 @@ export function authPage(input: { | ||
| 115 | 123 | ${signup && input.needsCode ? `<label class="field"><span>Sign-up code</span><input class="input" name="code" required autocomplete="off"></label>` : ""} |
| 116 | 124 | ${signup ? `<p class="muted small">At least 8 characters. There's no email reset, so keep it somewhere safe.</p>` : ""} |
| 117 | 125 | <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> | |
| 119 | 127 | </form> |
| 120 | 128 | </div>`, |
| 121 | 129 | `/${input.mode}`, |
| @@ -140,14 +148,70 @@ const icon = { | ||
| 140 | 148 | 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>`, |
| 141 | 149 | 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>`, |
| 142 | 150 | 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>`, | |
| 143 | 154 | }; |
| 144 | 155 | |
| 145 | interface Shell { | |
| 156 | export interface Shell { | |
| 146 | 157 | readonly who: Who & { role: "user" | "admin" }; |
| 147 | 158 | readonly vault: readonly Note[]; |
| 148 | 159 | readonly active?: Note | undefined; |
| 149 | 160 | readonly notice?: string | undefined; |
| 150 | 161 | 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>`; | |
| 151 | 215 | } |
| 152 | 216 | |
| 153 | 217 | /** The left: making notes, finding them, and every one of them. */ |
| @@ -157,30 +221,42 @@ function left(s: Shell): string { | ||
| 157 | 221 | const row = (n: Note) => |
| 158 | 222 | `<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>`; |
| 159 | 223 | 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)}"> | |
| 161 | 226 | <div class="left-head"> |
| 162 | 227 | <a class="brand" href="/">${glyph}<span>${escape(title)}</span></a> |
| 163 | 228 | <button type="button" class="icon-button" data-action="theme" aria-label="Switch theme" title="Theme">${icon.theme}</button> |
| 164 | 229 | </div> |
| 165 | 230 | <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>`} | |
| 169 | 235 | </div> |
| 170 | <form class="find" method="get" action="/search" role="search"> | |
| 236 | <form class="find" method="get" action="${base}/search" role="search"> | |
| 171 | 237 | ${icon.search} |
| 172 | 238 | <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> |
| 173 | 239 | <kbd class="hint">⌘K</kbd> |
| 174 | 240 | </form> |
| 175 | 241 | <nav class="files" id="files" data-live="${CHANGES}"> |
| 242 | ${vaults(s)} | |
| 176 | 243 | ${s.vault.length === 0 ? `<p class="muted small pad">No notes yet.</p>` : ""} |
| 177 | 244 | ${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)} | |
| 180 | 256 | </nav> |
| 181 | 257 | <div class="left-foot"> |
| 182 | 258 | <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>` : ""} | |
| 184 | 260 | ${ |
| 185 | 261 | isAccount(s.who) |
| 186 | 262 | ? `<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 { | ||
| 191 | 267 | } |
| 192 | 268 | |
| 193 | 269 | /** 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); | |
| 195 | 273 | const outline = headings(note.body); |
| 196 | 274 | const back = backlinks(note, vault); |
| 197 | 275 | const out = outgoing(note, vault); |
| @@ -214,10 +292,11 @@ function right(note: Note, vault: readonly Note[]): string { | ||
| 214 | 292 | </section> |
| 215 | 293 | <section class="panel"> |
| 216 | 294 | <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>`} | |
| 218 | 296 | </section> |
| 219 | 297 | ${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 })} | |
| 221 | 300 | <section class="panel meta muted small"> |
| 222 | 301 | <p>Created <time datetime="${escape(note.created_at)}">${long(note.created_at)}</time></p> |
| 223 | 302 | <p>${words(note.body)} words · ${note.body.length.toLocaleString("en")} characters</p> |
| @@ -241,7 +320,7 @@ function shell(s: Shell, center: string, rightSide = ""): string { | ||
| 241 | 320 | </div> |
| 242 | 321 | </div> |
| 243 | 322 | <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}"`}`)} | |
| 245 | 324 | ${toast(s.notice)}`; |
| 246 | 325 | } |
| 247 | 326 | |
| @@ -255,6 +334,7 @@ const toolbar = (s: Shell, crumbs: string, tools = ""): string => `<header class | ||
| 255 | 334 | /** One note, open: the editor and the reading view side by side. */ |
| 256 | 335 | export function notePage(s: Shell & { active: Note }): string { |
| 257 | 336 | const note = s.active; |
| 337 | const base = baseOf(s); | |
| 258 | 338 | const tools = `<div class="segmented" role="group" aria-label="View"> |
| 259 | 339 | <button type="button" data-mode="edit" aria-pressed="false" title="Edit">Edit</button> |
| 260 | 340 | <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 { | ||
| 264 | 344 | <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> |
| 265 | 345 | <a class="icon-button" href="/n/${note.id}/raw" aria-label="Download as markdown" title="Download .md">${icon.download}</a> |
| 266 | 346 | ${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)} | |
| 268 | 350 | <form class="editor" id="editor" method="post" action="/n/${note.id}" data-id="${note.id}" data-updated="${escape(note.updated_at)}"> |
| 269 | 351 | <div class="sheet"> |
| 270 | 352 | <input class="title-input" id="title" name="title" value="${escape(note.title)}" aria-label="Title" maxlength="200" autocomplete="off" spellcheck="false"> |
| 271 | 353 | <div class="panes" id="panes" data-mode="${note.body.trim() === "" ? "edit" : "split"}"> |
| 272 | 354 | <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> | |
| 274 | 356 | </div> |
| 275 | 357 | <noscript><p class="row"><button class="button">Save</button></p></noscript> |
| 276 | 358 | </div> |
| 277 | 359 | </form> |
| 278 | 360 | ${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), { | |
| 280 | 362 | name: note.title, |
| 281 | 363 | kind: "page app-page", |
| 282 | 364 | scripts: ["app.js", "graph.js"], |
| @@ -285,6 +367,7 @@ export function notePage(s: Shell & { active: Note }): string { | ||
| 285 | 367 | |
| 286 | 368 | /** An empty vault: two ways to begin. */ |
| 287 | 369 | export function emptyPage(s: Shell): string { |
| 370 | if (s.space !== undefined) return emptySpace(s, s.space); | |
| 288 | 371 | const center = `${toolbar(s, `<span class="crumb-here">Vault</span>`)} |
| 289 | 372 | <div class="empty-state"> |
| 290 | 373 | <div class="mark" aria-hidden="true">${glyph}</div> |
| @@ -300,13 +383,14 @@ export function emptyPage(s: Shell): string { | ||
| 300 | 383 | |
| 301 | 384 | /** A link to a note that isn't written yet. */ |
| 302 | 385 | 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>`)} | |
| 304 | 388 | <div class="empty-state"> |
| 305 | 389 | <h1>“${escape(s.title)}” doesn't exist yet</h1> |
| 306 | 390 | <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> | |
| 308 | 392 | </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"] }); | |
| 310 | 394 | } |
| 311 | 395 | |
| 312 | 396 | /** What a search found. */ |
| @@ -324,25 +408,25 @@ export function searchPage(s: Shell & { query: string; hits: readonly Hit<Note>[ | ||
| 324 | 408 | } |
| 325 | 409 | return html; |
| 326 | 410 | }; |
| 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>`)} | |
| 328 | 412 | <div class="sheet results"> |
| 329 | 413 | <h1 class="results-head">${s.hits.length} ${s.hits.length === 1 ? "note" : "notes"} for <q>${escape(s.query)}</q></h1> |
| 330 | 414 | ${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>`} |
| 331 | 415 | </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"] }); | |
| 333 | 417 | } |
| 334 | 418 | |
| 335 | 419 | /** The whole vault, as a graph. */ |
| 336 | 420 | export function graphPage(s: Shell): string { |
| 337 | 421 | const g = graph(s.vault); |
| 338 | 422 | 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>`)} | |
| 340 | 424 | <div class="graph-stage"> |
| 341 | 425 | <canvas class="big-graph" data-graph="all" aria-label="Every note, and the links between them"></canvas> |
| 342 | 426 | <p class="graph-help muted small">Drag to move · scroll or pinch to zoom · click a note to open it</p> |
| 343 | 427 | </div> |
| 344 | 428 | ${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"] }); | |
| 346 | 430 | } |
| 347 | 431 | |
| 348 | 432 | /** An email account: who you are, a new password, or leaving. */ |
| @@ -369,6 +453,114 @@ export function accountPage(s: Shell & { account: Account }): string { | ||
| 369 | 453 | return layout(shell(s, center), "/account", { name: "Account", kind: "page app-page", scripts: ["app.js"] }); |
| 370 | 454 | } |
| 371 | 455 | |
| 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 | ||
| 372 | 564 | /** The graph's data, for any page that draws one. */ |
| 373 | 565 | export const graphData = (vault: readonly Note[]): string => data("graph", graph(vault), CHANGES); |
| 374 | 566 |
src/routes.ts +338 −88
| @@ -66,9 +66,25 @@ import { | ||
| 66 | 66 | graphPage, |
| 67 | 67 | landing, |
| 68 | 68 | missingPage, |
| 69 | newSpacePage, | |
| 69 | 70 | notePage, |
| 70 | 71 | searchPage, |
| 72 | spacePage, | |
| 71 | 73 | } 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"; | |
| 72 | 88 | import { tokensCss } from "./tokens.ts"; |
| 73 | 89 | import { allow } from "./security.ts"; |
| 74 | 90 | import { search } from "./vault.ts"; |
| @@ -90,6 +106,8 @@ export interface Context { | ||
| 90 | 106 | readonly db: Database; |
| 91 | 107 | readonly who: Who; |
| 92 | 108 | readonly params: Record<string, string>; |
| 109 | /** Where shared folders commit; absent, there are none to make. */ | |
| 110 | readonly forge?: Forge | undefined; | |
| 93 | 111 | } |
| 94 | 112 | |
| 95 | 113 | /** A JSON Schema, as plain data. */ |
| @@ -126,6 +144,10 @@ export const NOTE: Schema = { | ||
| 126 | 144 | author_name: { type: "string" }, |
| 127 | 145 | created_at: { type: "string", format: "date-time" }, |
| 128 | 146 | 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 | }, | |
| 129 | 151 | }, |
| 130 | 152 | required: [ |
| 131 | 153 | "id", |
| @@ -147,6 +169,8 @@ const NOTE_NEW: Schema = { | ||
| 147 | 169 | description: "Unique in your vault; “Untitled” when left out.", |
| 148 | 170 | }, |
| 149 | 171 | 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`)." }, | |
| 150 | 174 | }, |
| 151 | 175 | }; |
| 152 | 176 | const NOTE_CHANGE: Schema = { |
| @@ -160,6 +184,12 @@ const NOTE_CHANGE: Schema = { | ||
| 160 | 184 | }, |
| 161 | 185 | body: { type: "string", maxLength: BODY_MAX }, |
| 162 | 186 | 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 | }, | |
| 163 | 193 | }, |
| 164 | 194 | }; |
| 165 | 195 | export const AUDIT_ROW: Schema = { |
| @@ -196,6 +226,7 @@ const STATUS: Record<Failure, number> = { | ||
| 196 | 226 | forbidden: 403, |
| 197 | 227 | not_found: 404, |
| 198 | 228 | invalid: 422, |
| 229 | conflict: 409, | |
| 199 | 230 | }; |
| 200 | 231 | |
| 201 | 232 | /** 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 => ({ | ||
| 219 | 250 | ...(typeof body.title === "string" ? { title: body.title } : {}), |
| 220 | 251 | ...(typeof body.body === "string" ? { body: body.body } : {}), |
| 221 | 252 | ...(typeof body.pinned === "boolean" ? { pinned: body.pinned } : {}), |
| 253 | ...(typeof body.folder === "string" ? { folder: body.folder } : {}), | |
| 254 | ...(typeof body.base === "string" ? { base: body.base } : {}), | |
| 222 | 255 | }); |
| 223 | 256 | |
| 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 { | |
| 228 | 272 | const { notice, clear } = readNotice(c.request); |
| 229 | 273 | 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 }); | |
| 231 | 281 | return { |
| 232 | 282 | who: c.who, |
| 233 | 283 | vault: vault.ok ? vault.value : [], |
| 234 | 284 | ...(notice === undefined ? {} : { notice }), |
| 235 | 285 | clear, |
| 286 | ...(found === undefined ? {} : { space: found }), | |
| 287 | spaces: mySpaces(c.db, c.who), | |
| 236 | 288 | }; |
| 237 | 289 | } |
| 238 | 290 | |
| 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 | ||
| 239 | 416 | /** A whole number from the query, or undefined. */ |
| 240 | 417 | const whole = (value: string | null): number | undefined => |
| 241 | 418 | value !== null && /^\d+$/.test(value) ? Number(value) : undefined; |
| @@ -247,30 +424,18 @@ const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined; | ||
| 247 | 424 | export const routes: readonly Route[] = [ |
| 248 | 425 | // The pages. Signed out, the way in; signed in, the vault. Forms post and |
| 249 | 426 | // 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"), | |
| 266 | 429 | { |
| 267 | 430 | method: "GET", |
| 268 | 431 | path: "/n/:id", |
| 269 | 432 | 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; | |
| 272 | 434 | const note = getNote(c.db, c.who, { id: Number(c.params.id) }); |
| 273 | 435 | 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; | |
| 274 | 439 | return html(notePage({ ...v, active: note.value }), 200, v.clear); |
| 275 | 440 | }, |
| 276 | 441 | }, |
| @@ -307,6 +472,16 @@ export const routes: readonly Route[] = [ | ||
| 307 | 472 | ); |
| 308 | 473 | }, |
| 309 | 474 | }, |
| 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 | }, | |
| 310 | 485 | { |
| 311 | 486 | method: "POST", |
| 312 | 487 | path: "/n/:id/pin", |
| @@ -324,28 +499,136 @@ export const routes: readonly Route[] = [ | ||
| 324 | 499 | { |
| 325 | 500 | method: "POST", |
| 326 | 501 | 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 | }, | |
| 329 | 507 | }, |
| 508 | ||
| 509 | // Shared folders: connecting one, joining one, and running one. | |
| 330 | 510 | { |
| 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); | |
| 338 | 519 | }, |
| 339 | 520 | }, |
| 340 | 521 | { |
| 341 | 522 | method: "POST", |
| 342 | path: "/daily", | |
| 523 | path: "/spaces", | |
| 343 | 524 | handle: async (c) => { |
| 344 | 525 | const form = await readForm(c.request); |
| 345 | 526 | 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`); | |
| 349 | 632 | }, |
| 350 | 633 | }, |
| 351 | 634 | { |
| @@ -354,48 +637,6 @@ export const routes: readonly Route[] = [ | ||
| 354 | 637 | handle: (c) => |
| 355 | 638 | done(openOrCreate(c.db, c.who, { title: "Welcome", body: WELCOME }), toNote), |
| 356 | 639 | }, |
| 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 | }, | |
| 399 | 640 | { |
| 400 | 641 | method: "GET", |
| 401 | 642 | path: "/export", |
| @@ -437,7 +678,7 @@ export const routes: readonly Route[] = [ | ||
| 437 | 678 | handle: (c) => { |
| 438 | 679 | if (c.who.role !== "anon") return go("/"); |
| 439 | 680 | 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); | |
| 441 | 682 | }, |
| 442 | 683 | }, |
| 443 | 684 | { |
| @@ -448,8 +689,9 @@ export const routes: readonly Route[] = [ | ||
| 448 | 689 | if (form === undefined) return text("too large", 413); |
| 449 | 690 | const email = String(form.get("email") ?? ""); |
| 450 | 691 | const name = String(form.get("name") ?? ""); |
| 692 | const to = safeTo(String(form.get("to") ?? "")); | |
| 451 | 693 | 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); | |
| 453 | 695 | if (!allow("signup", SIGNUP_ALL)) return again("Lots of people are signing up right now — try again in a few minutes.", 429); |
| 454 | 696 | const made = await signUp(c.db, { |
| 455 | 697 | email, |
| @@ -458,7 +700,7 @@ export const routes: readonly Route[] = [ | ||
| 458 | 700 | code: String(form.get("code") ?? ""), |
| 459 | 701 | }); |
| 460 | 702 | 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)]); | |
| 462 | 704 | }, |
| 463 | 705 | }, |
| 464 | 706 | { |
| @@ -467,7 +709,7 @@ export const routes: readonly Route[] = [ | ||
| 467 | 709 | handle: (c) => { |
| 468 | 710 | if (c.who.role !== "anon") return go("/"); |
| 469 | 711 | 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); | |
| 471 | 713 | }, |
| 472 | 714 | }, |
| 473 | 715 | { |
| @@ -477,13 +719,14 @@ export const routes: readonly Route[] = [ | ||
| 477 | 719 | const form = await readForm(c.request); |
| 478 | 720 | if (form === undefined) return text("too large", 413); |
| 479 | 721 | const email = String(form.get("email") ?? ""); |
| 722 | const to = safeTo(String(form.get("to") ?? "")); | |
| 480 | 723 | 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); | |
| 482 | 725 | if (!allow(`login:${email.trim().toLowerCase()}`, LOGIN_PER_EMAIL) || !allow("login", LOGIN_ALL)) |
| 483 | 726 | return again("Too many tries. Wait a minute and try again.", 429); |
| 484 | 727 | const inside = await logIn(c.db, { email, password: String(form.get("password") ?? "") }); |
| 485 | 728 | 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)]); | |
| 487 | 730 | }, |
| 488 | 731 | }, |
| 489 | 732 | { |
| @@ -542,10 +785,14 @@ export const routes: readonly Route[] = [ | ||
| 542 | 785 | handle: (c) => { |
| 543 | 786 | const q = c.url.searchParams.get("q"); |
| 544 | 787 | 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)); | |
| 547 | 794 | // 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 }); | |
| 549 | 796 | if (!hits.ok) return answer(hits); |
| 550 | 797 | const notes = hits.value |
| 551 | 798 | .map((h) => getNote(c.db, c.who, { id: h.id })) |
| @@ -554,9 +801,10 @@ export const routes: readonly Route[] = [ | ||
| 554 | 801 | }, |
| 555 | 802 | api: { |
| 556 | 803 | 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.", | |
| 558 | 805 | query: { |
| 559 | 806 | limit: { type: "integer", minimum: 1, maximum: 5000 }, |
| 807 | space: { type: "integer" }, | |
| 560 | 808 | q: { type: "string", maxLength: 500 }, |
| 561 | 809 | }, |
| 562 | 810 | answer: list(NOTE), |
| @@ -573,6 +821,8 @@ export const routes: readonly Route[] = [ | ||
| 573 | 821 | addNote(c.db, c.who, { |
| 574 | 822 | ...(typeof f.title === "string" ? { title: f.title } : {}), |
| 575 | 823 | ...(typeof f.body === "string" ? { body: f.body } : {}), |
| 824 | ...(typeof f.space === "number" ? { space: f.space } : {}), | |
| 825 | ...(typeof f.folder === "string" ? { folder: f.folder } : {}), | |
| 576 | 826 | }), |
| 577 | 827 | 201, |
| 578 | 828 | ); |
src/server.ts +14 −2
| @@ -8,12 +8,13 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | 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"; | |
| 12 | 12 | import { sessionWho } from "./accounts.ts"; |
| 13 | 13 | import { html, readCookie, text } from "./http.ts"; |
| 14 | 14 | import { SESSION_COOKIE } from "./routes.ts"; |
| 15 | 15 | import { match } from "./routes.ts"; |
| 16 | 16 | import { door } from "./security.ts"; |
| 17 | import { httpForge, startSync, type Forge } from "./sync.ts"; | |
| 17 | 18 | import { who } from "./who.ts"; |
| 18 | 19 | import { traceServe } from "./otel.ts"; |
| 19 | 20 | |
| @@ -22,11 +23,14 @@ export interface ServeOptions { | ||
| 22 | 23 | readonly hostname?: string | undefined; |
| 23 | 24 | readonly dataDir: string; |
| 24 | 25 | readonly identityKey: string; |
| 26 | /** Where shared folders commit; without one there are none. */ | |
| 27 | readonly forge?: Forge | undefined; | |
| 25 | 28 | } |
| 26 | 29 | |
| 27 | 30 | export function serve(options: ServeOptions) { |
| 28 | 31 | const db = openDb(options.dataDir); |
| 29 | return Bun.serve(traceServe({ | |
| 32 | const stopSync = startSync(db, options.forge); | |
| 33 | const server = Bun.serve(traceServe({ | |
| 30 | 34 | port: options.port, |
| 31 | 35 | hostname: options.hostname ?? "127.0.0.1", |
| 32 | 36 | // A connection that says nothing for a minute is dropped. The event |
| @@ -57,6 +61,7 @@ export function serve(options: ServeOptions) { | ||
| 57 | 61 | db, |
| 58 | 62 | who: me, |
| 59 | 63 | params: found.params, |
| 64 | forge: options.forge, | |
| 60 | 65 | }); |
| 61 | 66 | return head(request, response); |
| 62 | 67 | }, |
| @@ -68,6 +73,12 @@ export function serve(options: ServeOptions) { | ||
| 68 | 73 | return html("<h1>Something went wrong</h1>", 500); |
| 69 | 74 | }, |
| 70 | 75 | })); |
| 76 | const stop = server.stop.bind(server); | |
| 77 | server.stop = (force?: boolean) => { | |
| 78 | stopSync(); | |
| 79 | return stop(force); | |
| 80 | }; | |
| 81 | return server; | |
| 71 | 82 | } |
| 72 | 83 | |
| 73 | 84 | // HEAD is GET without the body: a health check sees the app up. |
| @@ -82,6 +93,7 @@ if (import.meta.main) { | ||
| 82 | 93 | hostname: process.env.HOST, |
| 83 | 94 | dataDir, |
| 84 | 95 | identityKey, |
| 96 | forge: repoToken === undefined || forgeApi === undefined ? undefined : httpForge(forgeApi, repoToken), | |
| 85 | 97 | }); |
| 86 | 98 | console.log( |
| 87 | 99 | `${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}` | ||
| 44 | 44 | export function renderNote( |
| 45 | 45 | body: string, |
| 46 | 46 | notes: readonly VaultNote[], |
| 47 | base = "", | |
| 47 | 48 | ): string { |
| 48 | 49 | const titles = byTitle(notes); |
| 49 | 50 | return md.render(body, { |
| 51 | base, | |
| 50 | 52 | resolve: (title: string) => { |
| 51 | 53 | const found = titles.get(key(title)); |
| 52 | 54 | return found === undefined ? null : hrefOf(found); |
| @@ -191,7 +193,7 @@ export function relink(body: string, from: string, to: string): string { | ||
| 191 | 193 | |
| 192 | 194 | /** A plain-text preview of a note's first words, for lists. */ |
| 193 | 195 | export function excerpt(body: string, max = 120): string { |
| 194 | const text = body | |
| 196 | const text = (md.frontmatter(body)?.body ?? body) | |
| 195 | 197 | .replace(/^(```|~~~)[\s\S]*?^\1/gm, "") |
| 196 | 198 | .replace(/^#{1,6}\s+/gm, "") |
| 197 | 199 | .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2") |
static/app.css +96
| @@ -1189,3 +1189,99 @@ form { | ||
| 1189 | 1189 | animation: none; |
| 1190 | 1190 | } |
| 1191 | 1191 | } |
| 1192 | ||
| 1193 | /* ── Shared folders ─────────────────────────────────────────────────── */ | |
| 1194 | .file-title svg, | |
| 1195 | .folder summary svg { | |
| 1196 | width: 1em; | |
| 1197 | height: 1em; | |
| 1198 | margin-right: var(); | |
| 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() var(); | |
| 1210 | border-radius: var(); | |
| 1211 | color: var(); | |
| 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(); | |
| 1221 | } | |
| 1222 | .folder > .file-list { | |
| 1223 | margin-left: var(); | |
| 1224 | padding-left: var(); | |
| 1225 | border-left: 1px solid var(); | |
| 1226 | } | |
| 1227 | .sync-line { | |
| 1228 | margin: var() var() 0; | |
| 1229 | overflow-wrap: anywhere; | |
| 1230 | } | |
| 1231 | .sync-line.is-error { | |
| 1232 | color: var(); | |
| 1233 | } | |
| 1234 | .file-path code { | |
| 1235 | overflow-wrap: anywhere; | |
| 1236 | } | |
| 1237 | .move { | |
| 1238 | display: flex; | |
| 1239 | align-items: end; | |
| 1240 | gap: var(); | |
| 1241 | } | |
| 1242 | .move .field { | |
| 1243 | flex: 1; | |
| 1244 | min-width: 0; | |
| 1245 | } | |
| 1246 | .people { | |
| 1247 | list-style: none; | |
| 1248 | margin: 0 0 var(); | |
| 1249 | padding: 0; | |
| 1250 | } | |
| 1251 | .people li { | |
| 1252 | display: flex; | |
| 1253 | align-items: center; | |
| 1254 | justify-content: space-between; | |
| 1255 | gap: var(); | |
| 1256 | min-height: 2.5rem; | |
| 1257 | border-bottom: 1px solid var(); | |
| 1258 | } | |
| 1259 | .join-link { | |
| 1260 | width: 100%; | |
| 1261 | margin-bottom: var(); | |
| 1262 | font-family: var(); | |
| 1263 | font-size: var(); | |
| 1264 | } | |
| 1265 | .join-link[data-copied="true"] { | |
| 1266 | border-color: var(); | |
| 1267 | } | |
| 1268 | #collide { | |
| 1269 | display: inline-flex; | |
| 1270 | gap: var(); | |
| 1271 | } | |
| 1272 | .md .properties { | |
| 1273 | display: grid; | |
| 1274 | grid-template-columns: max-content 1fr; | |
| 1275 | gap: var() var(); | |
| 1276 | margin: 0 0 var(); | |
| 1277 | padding: var() var(); | |
| 1278 | border: 1px solid var(); | |
| 1279 | border-radius: var(); | |
| 1280 | font-size: var(); | |
| 1281 | } | |
| 1282 | .md .properties dt { | |
| 1283 | color: var(); | |
| 1284 | } | |
| 1285 | .md .properties dd { | |
| 1286 | margin: 0; | |
| 1287 | } |
static/app.js +81 −8
| @@ -25,6 +25,12 @@ const store = { | ||
| 25 | 25 | }, |
| 26 | 26 | }; |
| 27 | 27 | |
| 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 | ||
| 28 | 34 | /** The vault's titles, as the page last heard them. */ |
| 29 | 35 | function vault() { |
| 30 | 36 | try { |
| @@ -45,12 +51,15 @@ async function createNote(title) { | ||
| 45 | 51 | const r = await fetch("/api/notes", { |
| 46 | 52 | method: "POST", |
| 47 | 53 | 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 | }), | |
| 49 | 58 | }); |
| 50 | 59 | if (r.ok) { |
| 51 | 60 | const note = await r.json(); |
| 52 | 61 | 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)}`; | |
| 54 | 63 | } |
| 55 | 64 | |
| 56 | 65 | // ── Theme ───────────────────────────────────────────────────────────── |
| @@ -81,6 +90,15 @@ document.addEventListener("click", (e) => { | ||
| 81 | 90 | }); |
| 82 | 91 | |
| 83 | 92 | // ── 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 | }); | |
| 84 | 102 | for (const tz of $$('input[name="tz"]')) tz.value = String(new Date().getTimezoneOffset()); |
| 85 | 103 | document.addEventListener("submit", (e) => { |
| 86 | 104 | const message = e.target.dataset?.confirm; |
| @@ -228,10 +246,10 @@ document.addEventListener("keydown", (e) => { | ||
| 228 | 246 | palette?.hidden === false ? closePalette() : openPalette(); |
| 229 | 247 | } else if (mod(e) && e.shiftKey && e.key.toLowerCase() === "d") { |
| 230 | 248 | e.preventDefault(); |
| 231 | $('form[action="/daily"]')?.requestSubmit(); | |
| 249 | $("form[data-daily]")?.requestSubmit(); | |
| 232 | 250 | } else if (mod(e) && !e.shiftKey && e.key === "g") { |
| 233 | 251 | e.preventDefault(); |
| 234 | go("/graph"); | |
| 252 | go(`${base}/graph`); | |
| 235 | 253 | } else if (e.altKey && e.code === "KeyN") { |
| 236 | 254 | e.preventDefault(); |
| 237 | 255 | createNote(); |
| @@ -270,6 +288,8 @@ function editor(form) { | ||
| 270 | 288 | let timer; |
| 271 | 289 | let saving = null; |
| 272 | 290 | let frame = 0; |
| 291 | // Someone else saved while you were writing: nothing saves until you choose. | |
| 292 | let held = false; | |
| 273 | 293 | |
| 274 | 294 | // Views: edit, split (side by side, on a wide screen), read. ⌘E flips |
| 275 | 295 | // between reading and the last way you were writing. |
| @@ -310,7 +330,7 @@ function editor(form) { | ||
| 310 | 330 | function draw() { |
| 311 | 331 | cancelAnimationFrame(frame); |
| 312 | 332 | 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>'; | |
| 314 | 334 | }); |
| 315 | 335 | } |
| 316 | 336 | |
| @@ -322,12 +342,14 @@ function editor(form) { | ||
| 322 | 342 | } |
| 323 | 343 | function changed() { |
| 324 | 344 | dirty = titleEl.value !== saved.title || source.value !== saved.body; |
| 345 | if (held) return; | |
| 325 | 346 | say(dirty ? "Editing…" : "Saved"); |
| 326 | 347 | clearTimeout(timer); |
| 327 | 348 | if (dirty) timer = setTimeout(save, 600); |
| 328 | 349 | } |
| 329 | 350 | async function save() { |
| 330 | 351 | clearTimeout(timer); |
| 352 | if (held) return; | |
| 331 | 353 | if (saving) await saving; |
| 332 | 354 | const body = {}; |
| 333 | 355 | const t = titleEl.value.trim(); |
| @@ -338,6 +360,7 @@ function editor(form) { | ||
| 338 | 360 | return; |
| 339 | 361 | } |
| 340 | 362 | say("Saving…"); |
| 363 | body.base = saved.updated; | |
| 341 | 364 | saving = (async () => { |
| 342 | 365 | try { |
| 343 | 366 | const r = await fetch(`/api/notes/${id}`, { |
| @@ -352,6 +375,8 @@ function editor(form) { | ||
| 352 | 375 | if (body.title !== undefined) renamed(answer); |
| 353 | 376 | say(dirty ? "Editing…" : answer.relinked ? `Saved · updated links in ${answer.relinked} ${answer.relinked === 1 ? "note" : "notes"}` : "Saved"); |
| 354 | 377 | 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."); | |
| 355 | 380 | } else if (r.status === 422 && body.title !== undefined) { |
| 356 | 381 | say(answer.message ?? "That title can't be used.", "error"); |
| 357 | 382 | // Keep the words: save them under the old title. |
| @@ -374,6 +399,51 @@ function editor(form) { | ||
| 374 | 399 | return saving; |
| 375 | 400 | } |
| 376 | 401 | 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 | } | |
| 377 | 447 | function renamed(note) { |
| 378 | 448 | const here = $(".crumb-here"); |
| 379 | 449 | if (here) here.textContent = note.title; |
| @@ -396,8 +466,9 @@ function editor(form) { | ||
| 396 | 466 | }); |
| 397 | 467 | addEventListener("beforeunload", (e) => { |
| 398 | 468 | if (!dirty) return; |
| 469 | if (held) return e.preventDefault(); | |
| 399 | 470 | // 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() } : {}) }); | |
| 401 | 472 | if (body.length < 60_000) |
| 402 | 473 | fetch(`/api/notes/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body, keepalive: true }); |
| 403 | 474 | else e.preventDefault(); |
| @@ -406,7 +477,9 @@ function editor(form) { | ||
| 406 | 477 | // Someone (another tab, a script, Claude over MCP) changed this note: |
| 407 | 478 | // take their words if you have none of your own in flight. |
| 408 | 479 | 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."); | |
| 410 | 483 | const r = await fetch(`/api/notes/${id}`); |
| 411 | 484 | if (!r.ok) return; |
| 412 | 485 | const note = await r.json(); |
| @@ -423,7 +496,7 @@ function editor(form) { | ||
| 423 | 496 | document.addEventListener("live:note.removed", (e) => { |
| 424 | 497 | if (String(e.detail.id) === id) { |
| 425 | 498 | dirty = false; |
| 426 | location.href = "/"; | |
| 499 | location.href = base || "/"; | |
| 427 | 500 | } |
| 428 | 501 | }); |
| 429 | 502 | document.addEventListener("live:swapped", () => draw()); |
static/md.js +53 −8
| @@ -49,10 +49,47 @@ export function links(md) { | ||
| 49 | 49 | return out; |
| 50 | 50 | } |
| 51 | 51 | |
| 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 */ | |
| 53 | 83 | export function tags(md) { |
| 54 | 84 | 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()); | |
| 56 | 93 | return [...out]; |
| 57 | 94 | } |
| 58 | 95 | |
| @@ -99,7 +136,7 @@ function marks(s, o) { | ||
| 99 | 136 | const anchor = heading ? `#${slug(heading)}` : ""; |
| 100 | 137 | return hold( |
| 101 | 138 | 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>` | |
| 103 | 140 | : `<a class="wikilink${embed ? " embed" : ""}" href="${escapeHtml(href)}${anchor}" data-title="${escapeHtml(name)}">${shown}</a>`, |
| 104 | 141 | ); |
| 105 | 142 | }) |
| @@ -112,7 +149,7 @@ function marks(s, o) { | ||
| 112 | 149 | lead + hold(`<a href="${url}" rel="noopener noreferrer" target="_blank">${url}</a>`), |
| 113 | 150 | ) |
| 114 | 151 | .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>`), | |
| 116 | 153 | ); |
| 117 | 154 | s = s |
| 118 | 155 | .replace(/\*\*(?=\S)([^*]*?\S)\*\*/g, "<strong>$1</strong>") |
| @@ -125,9 +162,11 @@ function marks(s, o) { | ||
| 125 | 162 | } |
| 126 | 163 | |
| 127 | 164 | /** |
| 128 | * @typedef {{ resolve: (title: string) => string | null }} Options | |
| 165 | * @typedef {{ resolve: (title: string) => string | null, base: string }} Options | |
| 129 | 166 | * `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. | |
| 131 | 170 | */ |
| 132 | 171 | |
| 133 | 172 | 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 | ||
| 138 | 177 | * @param {Partial<Options>} [options] |
| 139 | 178 | */ |
| 140 | 179 | 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)}`; | |
| 143 | 188 | } |
| 144 | 189 | |
| 145 | 190 | /** @param {string[]} lines @param {Options} o */ |