| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
import type { Database } from "bun:sqlite"; |
| 10 |
import { record, type AuditRow } from "./audit.ts"; |
| 11 |
import { permit } from "./contract.ts"; |
| 12 |
import { emit } from "./live.ts"; |
| 13 |
import { relink, search } from "./vault.ts"; |
| 14 |
import type { Who } from "./who.ts"; |
| 15 |
|
| 16 |
export interface Note { |
| 17 |
readonly id: number; |
| 18 |
readonly title: string; |
| 19 |
readonly body: string; |
| 20 |
readonly pinned: number; |
| 21 |
readonly author_id: string; |
| 22 |
readonly author_name: string; |
| 23 |
readonly created_at: string; |
| 24 |
readonly updated_at: string; |
| 25 |
} |
| 26 |
|
| 27 |
export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; |
| 28 |
export type Result<T> = |
| 29 |
| { ok: true; value: T } |
| 30 |
| { ok: false; error: Failure; message: string }; |
| 31 |
const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 32 |
const fail = <T>(error: Failure, message: string): Result<T> => ({ |
| 33 |
ok: false, |
| 34 |
error, |
| 35 |
message, |
| 36 |
}); |
| 37 |
|
| 38 |
export const TITLE_MAX = 200; |
| 39 |
export const BODY_MAX = 200_000; |
| 40 |
|
| 41 |
export const VAULT_MAX = 5000; |
| 42 |
|
| 43 |
|
| 44 |
const refuse = <T>(who: Who, signIn: string, no: string): Result<T> => |
| 45 |
who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no); |
| 46 |
|
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
|
| 51 |
|
| 52 |
const audited = <T extends { readonly id: number }>( |
| 53 |
db: Database, |
| 54 |
who: Who, |
| 55 |
resource: string, |
| 56 |
verb: "create" | "update" | "delete", |
| 57 |
target: number | undefined, |
| 58 |
result: Result<T>, |
| 59 |
): Result<T> => { |
| 60 |
record(db, { |
| 61 |
who, |
| 62 |
resource, |
| 63 |
verb, |
| 64 |
target: result.ok ? result.value.id : target, |
| 65 |
outcome: result.ok ? "ok" : result.error, |
| 66 |
}); |
| 67 |
return result; |
| 68 |
}; |
| 69 |
|
| 70 |
|
| 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); |
| 73 |
}; |
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
|
| 79 |
export function cleanTitle(raw: string): Result<string> { |
| 80 |
const title = raw.replace(/\s+/g, " ").trim(); |
| 81 |
if (title.length === 0 || title.length > TITLE_MAX) |
| 82 |
return fail("invalid", `A title is 1 to ${TITLE_MAX} characters.`); |
| 83 |
if (/[\[\]|#^\\]/.test(title)) |
| 84 |
return fail("invalid", "A title cannot hold [ ] | # ^ or \\ — links use them."); |
| 85 |
return ok(title); |
| 86 |
} |
| 87 |
|
| 88 |
const checkBody = (body: string): Result<string> => |
| 89 |
body.length > BODY_MAX |
| 90 |
? fail("invalid", `A note is at most ${BODY_MAX.toLocaleString("en")} characters.`) |
| 91 |
: ok(body); |
| 92 |
|
| 93 |
|
| 94 |
|
| 95 |
|
| 96 |
|
| 97 |
|
| 98 |
|
| 99 |
export function listNotes( |
| 100 |
db: Database, |
| 101 |
who: Who, |
| 102 |
input: { limit?: number } = {}, |
| 103 |
): Result<Note[]> { |
| 104 |
const limit = Math.min( |
| 105 |
Math.max(Math.trunc(input.limit ?? VAULT_MAX) || VAULT_MAX, 1), |
| 106 |
VAULT_MAX, |
| 107 |
); |
| 108 |
const may = permit(who, "note", "list"); |
| 109 |
if (may === "no" || who.role === "anon") |
| 110 |
return refuse(who, "Sign in to open your notes.", "You may not see these notes."); |
| 111 |
return ok( |
| 112 |
db |
| 113 |
.query<Note, [string, number]>( |
| 114 |
"SELECT * FROM note WHERE author_id = ?1 ORDER BY pinned DESC, updated_at DESC LIMIT ?2", |
| 115 |
) |
| 116 |
.all(who.id, limit), |
| 117 |
); |
| 118 |
} |
| 119 |
|
| 120 |
|
| 121 |
function freeTitle(db: Database, authorId: string, base: string): string { |
| 122 |
const taken = (t: string) => |
| 123 |
db |
| 124 |
.query<{ id: number }, [string, string]>( |
| 125 |
"SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE", |
| 126 |
) |
| 127 |
.get(authorId, t) !== null; |
| 128 |
if (!taken(base)) return base; |
| 129 |
for (let n = 2; ; n++) if (!taken(`${base} ${n}`)) return `${base} ${n}`; |
| 130 |
} |
| 131 |
|
| 132 |
const titleTaken = (db: Database, authorId: string, title: string, except = -1): boolean => |
| 133 |
db |
| 134 |
.query<{ id: number }, [string, string, number]>( |
| 135 |
"SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE AND id != ?3", |
| 136 |
) |
| 137 |
.get(authorId, title, except) !== null; |
| 138 |
|
| 139 |
export const addNote = ( |
| 140 |
db: Database, |
| 141 |
who: Who, |
| 142 |
input: { title?: string; body?: string }, |
| 143 |
): Result<Note> => |
| 144 |
audited(db, who, "note", "create", undefined, add(db, who, input)); |
| 145 |
|
| 146 |
function add( |
| 147 |
db: Database, |
| 148 |
who: Who, |
| 149 |
input: { title?: string; body?: string }, |
| 150 |
): Result<Note> { |
| 151 |
|
| 152 |
|
| 153 |
if (who.role === "anon" || permit(who, "note", "create") !== "yes") |
| 154 |
return refuse(who, "Sign in to add a note.", "You may not add a note."); |
| 155 |
const body = checkBody(input.body ?? ""); |
| 156 |
if (!body.ok) return body; |
| 157 |
let title: string; |
| 158 |
if (input.title === undefined || input.title.trim() === "") |
| 159 |
title = freeTitle(db, who.id, "Untitled"); |
| 160 |
else { |
| 161 |
const clean = cleanTitle(input.title); |
| 162 |
if (!clean.ok) return clean; |
| 163 |
if (titleTaken(db, who.id, clean.value)) |
| 164 |
return fail("invalid", `A note called “${clean.value}” already exists.`); |
| 165 |
title = clean.value; |
| 166 |
} |
| 167 |
const note = db |
| 168 |
.query<Note, [string, string, string, string]>( |
| 169 |
"INSERT INTO note (title, body, author_id, author_name) VALUES (?1, ?2, ?3, ?4) RETURNING *", |
| 170 |
) |
| 171 |
.get(title, body.value, who.id, who.name)!; |
| 172 |
tell("note.added", note); |
| 173 |
return ok(note); |
| 174 |
} |
| 175 |
|
| 176 |
|
| 177 |
|
| 178 |
|
| 179 |
|
| 180 |
|
| 181 |
function noteFor( |
| 182 |
db: Database, |
| 183 |
who: Who, |
| 184 |
verb: "read" | "update" | "delete", |
| 185 |
id: number, |
| 186 |
said: { signIn: string; no: string }, |
| 187 |
): Result<Note> { |
| 188 |
const note = Number.isInteger(id) |
| 189 |
? db.query<Note, [number]>("SELECT * FROM note WHERE id = ?1").get(id) |
| 190 |
: null; |
| 191 |
|
| 192 |
|
| 193 |
|
| 194 |
const row: Readonly<Record<string, unknown>> = |
| 195 |
note === null ? { author_id: who.role === "anon" ? null : who.id } : { ...note }; |
| 196 |
if (permit(who, "note", verb, row) !== "yes") |
| 197 |
return refuse(who, said.signIn, said.no); |
| 198 |
if (note !== null) return ok(note); |
| 199 |
if (!Number.isInteger(id)) |
| 200 |
return fail("invalid", "A note id is a whole number."); |
| 201 |
return fail("not_found", "No such note."); |
| 202 |
} |
| 203 |
|
| 204 |
export function getNote( |
| 205 |
db: Database, |
| 206 |
who: Who, |
| 207 |
input: { id: number }, |
| 208 |
): Result<Note> { |
| 209 |
return noteFor(db, who, "read", input.id, { |
| 210 |
signIn: "Sign in to read this note.", |
| 211 |
no: "You may not read this note.", |
| 212 |
}); |
| 213 |
} |
| 214 |
|
| 215 |
|
| 216 |
export function findNote( |
| 217 |
db: Database, |
| 218 |
who: Who, |
| 219 |
input: { title: string }, |
| 220 |
): Result<Note> { |
| 221 |
if (who.role === "anon") |
| 222 |
return fail("signin_required", "Sign in to open your notes."); |
| 223 |
const title = input.title.replace(/\s+/g, " ").trim(); |
| 224 |
const note = db |
| 225 |
.query<Note, [string, string]>( |
| 226 |
"SELECT * FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE", |
| 227 |
) |
| 228 |
.get(who.id, title); |
| 229 |
if (note === null) return fail("not_found", `No note called “${title}”.`); |
| 230 |
return getNote(db, who, { id: note.id }); |
| 231 |
} |
| 232 |
|
| 233 |
|
| 234 |
export function openOrCreate( |
| 235 |
db: Database, |
| 236 |
who: Who, |
| 237 |
input: { title: string; body?: string }, |
| 238 |
): Result<Note & { created: boolean }> { |
| 239 |
const found = findNote(db, who, input); |
| 240 |
if (found.ok) return ok({ ...found.value, created: false }); |
| 241 |
if (found.error !== "not_found") return found; |
| 242 |
const made = addNote(db, who, { title: input.title, body: input.body ?? "" }); |
| 243 |
return made.ok ? ok({ ...made.value, created: true }) : made; |
| 244 |
} |
| 245 |
|
| 246 |
export interface Change { |
| 247 |
readonly id: number; |
| 248 |
readonly title?: string; |
| 249 |
readonly body?: string; |
| 250 |
readonly pinned?: boolean; |
| 251 |
} |
| 252 |
|
| 253 |
export const updateNote = ( |
| 254 |
db: Database, |
| 255 |
who: Who, |
| 256 |
input: Change, |
| 257 |
): Result<Note & { relinked: number }> => |
| 258 |
audited(db, who, "note", "update", input.id, update(db, who, input)); |
| 259 |
|
| 260 |
|
| 261 |
|
| 262 |
|
| 263 |
|
| 264 |
|
| 265 |
function update( |
| 266 |
db: Database, |
| 267 |
who: Who, |
| 268 |
input: Change, |
| 269 |
): Result<Note & { relinked: number }> { |
| 270 |
const found = noteFor(db, who, "update", input.id, { |
| 271 |
signIn: "Sign in to edit a note.", |
| 272 |
no: "Only the author can edit a note.", |
| 273 |
}); |
| 274 |
if (!found.ok) return found; |
| 275 |
const before = found.value; |
| 276 |
let title = before.title; |
| 277 |
if (input.title !== undefined) { |
| 278 |
const clean = cleanTitle(input.title); |
| 279 |
if (!clean.ok) return clean; |
| 280 |
if (titleTaken(db, before.author_id, clean.value, before.id)) |
| 281 |
return fail("invalid", `A note called “${clean.value}” already exists.`); |
| 282 |
title = clean.value; |
| 283 |
} |
| 284 |
let body = before.body; |
| 285 |
if (input.body !== undefined) { |
| 286 |
const checked = checkBody(input.body); |
| 287 |
if (!checked.ok) return checked; |
| 288 |
body = checked.value; |
| 289 |
} |
| 290 |
const pinned = input.pinned === undefined ? before.pinned : input.pinned ? 1 : 0; |
| 291 |
if (title === before.title && body === before.body && pinned === before.pinned) |
| 292 |
return ok({ ...before, relinked: 0 }); |
| 293 |
const renamed = title.toLowerCase() !== before.title.toLowerCase(); |
| 294 |
const touched: Note[] = []; |
| 295 |
const note = db.transaction(() => { |
| 296 |
const saved = db |
| 297 |
.query<Note, [string, string, number, number]>( |
| 298 |
"UPDATE note SET title = ?1, body = ?2, pinned = ?3, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?4 RETURNING *", |
| 299 |
) |
| 300 |
.get(title, body, pinned, before.id)!; |
| 301 |
if (renamed || title !== before.title) |
| 302 |
for (const other of db |
| 303 |
.query<Note, [string, number]>("SELECT * FROM note WHERE author_id = ?1 AND id != ?2") |
| 304 |
.all(before.author_id, before.id)) { |
| 305 |
const next = relink(other.body, before.title, title); |
| 306 |
if (next === other.body) continue; |
| 307 |
touched.push( |
| 308 |
db |
| 309 |
.query<Note, [string, number]>( |
| 310 |
"UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *", |
| 311 |
) |
| 312 |
.get(next, other.id)!, |
| 313 |
); |
| 314 |
} |
| 315 |
return saved; |
| 316 |
})(); |
| 317 |
tell("note.changed", note); |
| 318 |
for (const other of touched) tell("note.changed", other); |
| 319 |
return ok({ ...note, relinked: touched.length }); |
| 320 |
} |
| 321 |
|
| 322 |
export const deleteNote = ( |
| 323 |
db: Database, |
| 324 |
who: Who, |
| 325 |
input: { id: number }, |
| 326 |
): Result<{ id: number }> => |
| 327 |
audited(db, who, "note", "delete", input.id, remove(db, who, input)); |
| 328 |
|
| 329 |
function remove( |
| 330 |
db: Database, |
| 331 |
who: Who, |
| 332 |
input: { id: number }, |
| 333 |
): Result<{ id: number }> { |
| 334 |
const found = noteFor(db, who, "delete", input.id, { |
| 335 |
signIn: "Sign in to remove a note.", |
| 336 |
no: "Only the author can remove a note.", |
| 337 |
}); |
| 338 |
if (!found.ok) return found; |
| 339 |
db.query("DELETE FROM note WHERE id = ?1").run(found.value.id); |
| 340 |
emit("note.removed", { id: found.value.id }, found.value.author_id); |
| 341 |
return ok({ id: found.value.id }); |
| 342 |
} |
| 343 |
|
| 344 |
|
| 345 |
export function searchNotes( |
| 346 |
db: Database, |
| 347 |
who: Who, |
| 348 |
input: { q: string; limit?: number }, |
| 349 |
): Result<{ id: number; title: string; snippet: string; updated_at: string }[]> { |
| 350 |
const vault = listNotes(db, who); |
| 351 |
if (!vault.ok) return vault; |
| 352 |
const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 500); |
| 353 |
return ok( |
| 354 |
search(vault.value, input.q.slice(0, 500)) |
| 355 |
.slice(0, limit) |
| 356 |
.map(({ note, snippet }) => ({ |
| 357 |
id: note.id, |
| 358 |
title: note.title, |
| 359 |
snippet, |
| 360 |
updated_at: note.updated_at, |
| 361 |
})), |
| 362 |
); |
| 363 |
} |
| 364 |
|
| 365 |
|
| 366 |
export const today = (offsetMinutes = 0, now = new Date()): string => |
| 367 |
new Date(now.getTime() - offsetMinutes * 60_000).toISOString().slice(0, 10); |
| 368 |
|
| 369 |
|
| 370 |
export const canDelete = (who: Who, note: Note): boolean => |
| 371 |
permit(who, "note", "delete", { ...note }) === "yes"; |
| 372 |
|
| 373 |
|
| 374 |
export const WELCOME = `# Welcome to your vault |
| 375 |
|
| 376 |
This is a note. Every note is **markdown**, saved as you type, and private to you. |
| 377 |
|
| 378 |
## Linking thoughts |
| 379 |
Write [[double brackets]] around a title to link notes — that one doesn't exist yet, so following it creates it. Links work both ways: open a note and its **backlinks** show everything pointing at it. Rename a note and every link to it follows. |
| 380 |
|
| 381 |
Try: [[Ideas]] · [[Reading list]] · [[Ideas|an alias]] |
| 382 |
|
| 383 |
## Tags |
| 384 |
Add #tags anywhere. Nest them like #projects/notes. Click one to see every note carrying it. |
| 385 |
|
| 386 |
## The rest of markdown |
| 387 |
- ==highlights==, ~~strikes~~, \`code\`, *emphasis* |
| 388 |
- [x] tasks you've done |
| 389 |
- [ ] tasks you haven't |
| 390 |
|
| 391 |
> [!tip] Keys |
| 392 |
> **⌘K** / **Ctrl K** — jump to any note, or create one |
| 393 |
> **⌘E** / **Ctrl E** — switch between editing and reading |
| 394 |
> **⌘⇧D** / **Ctrl ⇧ D** — today's daily note |
| 395 |
> **⌘G** / **Ctrl G** — the graph |
| 396 |
|
| 397 |
| Where | What | |
| 398 |
| --- | --- | |
| 399 |
| Left | your notes, pinned first, and your tags | |
| 400 |
| Middle | the note | |
| 401 |
| Right | outline, links and backlinks | |
| 402 |
|
| 403 |
Open the **graph** to see how everything connects. |
| 404 |
`; |
| 405 |
|
| 406 |
|
| 407 |
|
| 408 |
|
| 409 |
|
| 410 |
|
| 411 |
|
| 412 |
export function listAudit( |
| 413 |
db: Database, |
| 414 |
who: Who, |
| 415 |
input: { limit?: number; before?: number } = {}, |
| 416 |
): Result<AuditRow[]> { |
| 417 |
if (permit(who, "audit", "list") !== "yes") |
| 418 |
return refuse( |
| 419 |
who, |
| 420 |
"Sign in to read the audit trail.", |
| 421 |
"Only an admin can read the audit trail.", |
| 422 |
); |
| 423 |
const limit = Math.min( |
| 424 |
Math.max(Math.trunc(input.limit ?? 100) || 100, 1), |
| 425 |
500, |
| 426 |
); |
| 427 |
const before = Number.isInteger(input.before) |
| 428 |
? input.before! |
| 429 |
: Number.MAX_SAFE_INTEGER; |
| 430 |
return ok( |
| 431 |
db |
| 432 |
.query< |
| 433 |
AuditRow, |
| 434 |
[number, number] |
| 435 |
>("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2") |
| 436 |
.all(before, limit), |
| 437 |
); |
| 438 |
} |
| 439 |
|
| 440 |
export function getAudit( |
| 441 |
db: Database, |
| 442 |
who: Who, |
| 443 |
input: { id: number }, |
| 444 |
): Result<AuditRow> { |
| 445 |
const row = Number.isInteger(input.id) |
| 446 |
? db |
| 447 |
.query<AuditRow, [number]>("SELECT * FROM audit WHERE id = ?1") |
| 448 |
.get(input.id) |
| 449 |
: null; |
| 450 |
if (permit(who, "audit", "read", { ...row }) !== "yes") |
| 451 |
return refuse( |
| 452 |
who, |
| 453 |
"Sign in to read the audit trail.", |
| 454 |
"Only an admin can read the audit trail.", |
| 455 |
); |
| 456 |
if (row !== null) return ok(row); |
| 457 |
if (!Number.isInteger(input.id)) |
| 458 |
return fail("invalid", "An audit id is a whole number."); |
| 459 |
return fail("not_found", "No such audit row."); |
| 460 |
} |