| 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 { |
| 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"; |
| 26 |
import { relink, search } from "./vault.ts"; |
| 27 |
import type { Who } from "./who.ts"; |
| 28 |
|
| 29 |
export interface Note { |
| 30 |
readonly id: number; |
| 31 |
readonly title: string; |
| 32 |
readonly body: string; |
| 33 |
readonly pinned: number; |
| 34 |
readonly author_id: string; |
| 35 |
readonly author_name: string; |
| 36 |
readonly created_at: string; |
| 37 |
readonly updated_at: string; |
| 38 |
|
| 39 |
readonly path: string | null; |
| 40 |
|
| 41 |
readonly synced_path: string | null; |
| 42 |
readonly synced_oid: string | null; |
| 43 |
} |
| 44 |
|
| 45 |
export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid" | "conflict"; |
| 46 |
export type Result<T> = |
| 47 |
| { ok: true; value: T } |
| 48 |
| { ok: false; error: Failure; message: string }; |
| 49 |
const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 50 |
const fail = <T>(error: Failure, message: string): Result<T> => ({ |
| 51 |
ok: false, |
| 52 |
error, |
| 53 |
message, |
| 54 |
}); |
| 55 |
|
| 56 |
export const TITLE_MAX = 200; |
| 57 |
export const BODY_MAX = 200_000; |
| 58 |
|
| 59 |
export const VAULT_MAX = 5000; |
| 60 |
|
| 61 |
|
| 62 |
const refuse = <T>(who: Who, signIn: string, no: string): Result<T> => |
| 63 |
who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no); |
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
|
| 70 |
const audited = <T extends { readonly id: number }>( |
| 71 |
db: Database, |
| 72 |
who: Who, |
| 73 |
resource: string, |
| 74 |
verb: "create" | "update" | "delete", |
| 75 |
target: number | undefined, |
| 76 |
result: Result<T>, |
| 77 |
): Result<T> => { |
| 78 |
record(db, { |
| 79 |
who, |
| 80 |
resource, |
| 81 |
verb, |
| 82 |
target: result.ok ? result.value.id : target, |
| 83 |
outcome: result.ok ? "ok" : result.error, |
| 84 |
}); |
| 85 |
return result; |
| 86 |
}; |
| 87 |
|
| 88 |
|
| 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); |
| 94 |
}; |
| 95 |
|
| 96 |
|
| 97 |
|
| 98 |
|
| 99 |
|
| 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 |
|
| 113 |
|
| 114 |
|
| 115 |
|
| 116 |
|
| 117 |
export function cleanTitle(raw: string): Result<string> { |
| 118 |
const title = raw.replace(/\s+/g, " ").trim(); |
| 119 |
if (title.length === 0 || title.length > TITLE_MAX) |
| 120 |
return fail("invalid", `A title is 1 to ${TITLE_MAX} characters.`); |
| 121 |
if (/[\[\]|#^\\]/.test(title)) |
| 122 |
return fail("invalid", "A title cannot hold [ ] | # ^ or \\ — links use them."); |
| 123 |
return ok(title); |
| 124 |
} |
| 125 |
|
| 126 |
const checkBody = (body: string): Result<string> => |
| 127 |
body.length > BODY_MAX |
| 128 |
? fail("invalid", `A note is at most ${BODY_MAX.toLocaleString("en")} characters.`) |
| 129 |
: ok(body); |
| 130 |
|
| 131 |
|
| 132 |
|
| 133 |
|
| 134 |
|
| 135 |
|
| 136 |
|
| 137 |
export function listNotes( |
| 138 |
db: Database, |
| 139 |
who: Who, |
| 140 |
input: { limit?: number; space?: number } = {}, |
| 141 |
): Result<Note[]> { |
| 142 |
const limit = Math.min( |
| 143 |
Math.max(Math.trunc(input.limit ?? VAULT_MAX) || VAULT_MAX, 1), |
| 144 |
VAULT_MAX, |
| 145 |
); |
| 146 |
const may = permit(who, "note", "list"); |
| 147 |
if (may === "no" || who.role === "anon") |
| 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; |
| 151 |
return ok( |
| 152 |
db |
| 153 |
.query<Note, [string, number]>( |
| 154 |
"SELECT * FROM note WHERE author_id = ?1 ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT ?2", |
| 155 |
) |
| 156 |
.all(vault.value.vault, limit), |
| 157 |
); |
| 158 |
} |
| 159 |
|
| 160 |
|
| 161 |
function freeTitle(db: Database, authorId: string, base: string): string { |
| 162 |
const taken = (t: string) => |
| 163 |
db |
| 164 |
.query<{ id: number }, [string, string]>( |
| 165 |
"SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE", |
| 166 |
) |
| 167 |
.get(authorId, t) !== null; |
| 168 |
if (!taken(base)) return base; |
| 169 |
for (let n = 2; ; n++) if (!taken(`${base} ${n}`)) return `${base} ${n}`; |
| 170 |
} |
| 171 |
|
| 172 |
const titleTaken = (db: Database, authorId: string, title: string, except = -1): boolean => |
| 173 |
db |
| 174 |
.query<{ id: number }, [string, string, number]>( |
| 175 |
"SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE AND id != ?3", |
| 176 |
) |
| 177 |
.get(authorId, title, except) !== null; |
| 178 |
|
| 179 |
|
| 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> => |
| 188 |
audited(db, who, "note", "create", undefined, add(db, who, input)); |
| 189 |
|
| 190 |
|
| 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> { |
| 203 |
|
| 204 |
|
| 205 |
if (who.role === "anon" || permit(who, "note", "create") !== "yes") |
| 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; |
| 210 |
const body = checkBody(input.body ?? ""); |
| 211 |
if (!body.ok) return body; |
| 212 |
const count = db |
| 213 |
.query<{ n: number }, [string]>("SELECT count(*) AS n FROM note WHERE author_id = ?1") |
| 214 |
.get(vault)!.n; |
| 215 |
if (count >= VAULT_MAX) |
| 216 |
return fail("invalid", `A vault holds at most ${VAULT_MAX.toLocaleString("en")} notes.`); |
| 217 |
let title: string; |
| 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 { |
| 232 |
const clean = cleanTitle(input.title); |
| 233 |
if (!clean.ok) return clean; |
| 234 |
title = clean.value; |
| 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}.`); |
| 241 |
const note = db |
| 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 *", |
| 244 |
) |
| 245 |
.get(title, body.value, vault, who.name, path)!; |
| 246 |
tell(db, "note.added", note); |
| 247 |
if (space !== undefined) edited(space.id); |
| 248 |
return ok(note); |
| 249 |
} |
| 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 |
|
| 258 |
|
| 259 |
|
| 260 |
|
| 261 |
|
| 262 |
|
| 263 |
function noteFor( |
| 264 |
db: Database, |
| 265 |
who: Who, |
| 266 |
verb: "read" | "update" | "delete", |
| 267 |
id: number, |
| 268 |
said: { signIn: string; no: string }, |
| 269 |
): Result<Note> { |
| 270 |
const note = Number.isInteger(id) |
| 271 |
? db.query<Note, [number]>("SELECT * FROM note WHERE id = ?1").get(id) |
| 272 |
: null; |
| 273 |
|
| 274 |
|
| 275 |
|
| 276 |
|
| 277 |
|
| 278 |
const space = note === null ? undefined : spaceOf(note.author_id); |
| 279 |
const row: Readonly<Record<string, unknown>> = |
| 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 |
}; |
| 287 |
if (permit(who, "note", verb, row) !== "yes") |
| 288 |
return refuse(who, said.signIn, said.no); |
| 289 |
if (note !== null) return ok(note); |
| 290 |
if (!Number.isInteger(id)) |
| 291 |
return fail("invalid", "A note id is a whole number."); |
| 292 |
return fail("not_found", "No such note."); |
| 293 |
} |
| 294 |
|
| 295 |
export function getNote( |
| 296 |
db: Database, |
| 297 |
who: Who, |
| 298 |
input: { id: number }, |
| 299 |
): Result<Note> { |
| 300 |
return noteFor(db, who, "read", input.id, { |
| 301 |
signIn: "Sign in to read this note.", |
| 302 |
no: "You may not read this note.", |
| 303 |
}); |
| 304 |
} |
| 305 |
|
| 306 |
|
| 307 |
export function findNote( |
| 308 |
db: Database, |
| 309 |
who: Who, |
| 310 |
input: { title: string; space?: number }, |
| 311 |
): Result<Note> { |
| 312 |
const where = vaultFor(db, who, input.space); |
| 313 |
if (!where.ok) return where; |
| 314 |
|
| 315 |
const raw = input.space === undefined ? input.title : input.title.slice(input.title.lastIndexOf("/") + 1); |
| 316 |
const title = raw.replace(/\s+/g, " ").trim(); |
| 317 |
const note = db |
| 318 |
.query<Note, [string, string]>( |
| 319 |
"SELECT * FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE", |
| 320 |
) |
| 321 |
.get(where.value.vault, title); |
| 322 |
if (note === null) return fail("not_found", `No note called “${title}”.`); |
| 323 |
return getNote(db, who, { id: note.id }); |
| 324 |
} |
| 325 |
|
| 326 |
|
| 327 |
export function openOrCreate( |
| 328 |
db: Database, |
| 329 |
who: Who, |
| 330 |
input: { title: string; body?: string; space?: number; folder?: string }, |
| 331 |
): Result<Note & { created: boolean }> { |
| 332 |
const found = findNote(db, who, input); |
| 333 |
if (found.ok) return ok({ ...found.value, created: false }); |
| 334 |
if (found.error !== "not_found") return found; |
| 335 |
const made = addNote(db, who, { ...input, body: input.body ?? "" }); |
| 336 |
return made.ok ? ok({ ...made.value, created: true }) : made; |
| 337 |
} |
| 338 |
|
| 339 |
export interface Change { |
| 340 |
readonly id: number; |
| 341 |
readonly title?: string; |
| 342 |
readonly body?: string; |
| 343 |
readonly pinned?: boolean; |
| 344 |
|
| 345 |
readonly folder?: string; |
| 346 |
|
| 347 |
|
| 348 |
|
| 349 |
|
| 350 |
|
| 351 |
readonly base?: string; |
| 352 |
} |
| 353 |
|
| 354 |
export const updateNote = ( |
| 355 |
db: Database, |
| 356 |
who: Who, |
| 357 |
input: Change, |
| 358 |
): Result<Note & { relinked: number }> => |
| 359 |
audited(db, who, "note", "update", input.id, update(db, who, input)); |
| 360 |
|
| 361 |
|
| 362 |
|
| 363 |
|
| 364 |
|
| 365 |
|
| 366 |
function update( |
| 367 |
db: Database, |
| 368 |
who: Who, |
| 369 |
input: Change, |
| 370 |
): Result<Note & { relinked: number }> { |
| 371 |
const found = noteFor(db, who, "update", input.id, { |
| 372 |
signIn: "Sign in to edit a note.", |
| 373 |
no: "Only the author can edit a note.", |
| 374 |
}); |
| 375 |
if (!found.ok) return found; |
| 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.`); |
| 387 |
let title = before.title; |
| 388 |
if (input.title !== undefined) { |
| 389 |
const clean = space === undefined ? cleanTitle(input.title) : cleanSegment(input.title); |
| 390 |
if (!clean.ok) return clean; |
| 391 |
if (titleTaken(db, before.author_id, clean.value, before.id)) |
| 392 |
return fail("invalid", `A note called “${clean.value}” already exists.`); |
| 393 |
title = clean.value; |
| 394 |
} |
| 395 |
let body = before.body; |
| 396 |
if (input.body !== undefined) { |
| 397 |
const checked = checkBody(input.body); |
| 398 |
if (!checked.ok) return checked; |
| 399 |
body = checked.value; |
| 400 |
} |
| 401 |
const pinned = input.pinned === undefined ? before.pinned : input.pinned ? 1 : 0; |
| 402 |
|
| 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 |
|
| 412 |
|
| 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) |
| 419 |
return ok({ ...before, relinked: 0 }); |
| 420 |
const renamed = title.toLowerCase() !== before.title.toLowerCase(); |
| 421 |
const touched: Note[] = []; |
| 422 |
|
| 423 |
const by = space !== undefined && who.role !== "anon" ? who.name : before.author_name; |
| 424 |
const note = db.transaction(() => { |
| 425 |
const saved = db |
| 426 |
.query<Note, [string, string, number, number, number, string | null, string]>( |
| 427 |
|
| 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 *", |
| 429 |
) |
| 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 |
)!; |
| 439 |
if (renamed || title !== before.title) |
| 440 |
for (const other of db |
| 441 |
.query<Note, [string, number]>("SELECT * FROM note WHERE author_id = ?1 AND id != ?2") |
| 442 |
.all(before.author_id, before.id)) { |
| 443 |
const next = relink(other.body, before.title, title); |
| 444 |
if (next === other.body) continue; |
| 445 |
touched.push( |
| 446 |
db |
| 447 |
.query<Note, [string, number]>( |
| 448 |
"UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *", |
| 449 |
) |
| 450 |
.get(next, other.id)!, |
| 451 |
); |
| 452 |
} |
| 453 |
return saved; |
| 454 |
})(); |
| 455 |
tell(db, "note.changed", note); |
| 456 |
for (const other of touched) tell(db, "note.changed", other); |
| 457 |
if (spaceId !== undefined) edited(spaceId); |
| 458 |
return ok({ ...note, relinked: touched.length }); |
| 459 |
} |
| 460 |
|
| 461 |
export const deleteNote = ( |
| 462 |
db: Database, |
| 463 |
who: Who, |
| 464 |
input: { id: number }, |
| 465 |
): Result<{ id: number }> => |
| 466 |
audited(db, who, "note", "delete", input.id, remove(db, who, input)); |
| 467 |
|
| 468 |
function remove( |
| 469 |
db: Database, |
| 470 |
who: Who, |
| 471 |
input: { id: number }, |
| 472 |
): Result<{ id: number }> { |
| 473 |
const found = noteFor(db, who, "delete", input.id, { |
| 474 |
signIn: "Sign in to remove a note.", |
| 475 |
no: "Only the author can remove a note.", |
| 476 |
}); |
| 477 |
if (!found.ok) return found; |
| 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 |
|
| 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 }); |
| 492 |
} |
| 493 |
|
| 494 |
|
| 495 |
export function searchNotes( |
| 496 |
db: Database, |
| 497 |
who: Who, |
| 498 |
input: { q: string; limit?: number; space?: number }, |
| 499 |
): Result<{ id: number; title: string; snippet: string; updated_at: string }[]> { |
| 500 |
const vault = listNotes(db, who, input.space === undefined ? {} : { space: input.space }); |
| 501 |
if (!vault.ok) return vault; |
| 502 |
const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 500); |
| 503 |
return ok( |
| 504 |
search(vault.value, input.q.slice(0, 500)) |
| 505 |
.slice(0, limit) |
| 506 |
.map(({ note, snippet }) => ({ |
| 507 |
id: note.id, |
| 508 |
title: note.title, |
| 509 |
snippet, |
| 510 |
updated_at: note.updated_at, |
| 511 |
})), |
| 512 |
); |
| 513 |
} |
| 514 |
|
| 515 |
|
| 516 |
export const today = (offsetMinutes = 0, now = new Date()): string => |
| 517 |
new Date(now.getTime() - offsetMinutes * 60_000).toISOString().slice(0, 10); |
| 518 |
|
| 519 |
|
| 520 |
export const canDelete = (who: Who, note: Note): boolean => |
| 521 |
permit(who, "note", "delete", { ...note }) === "yes"; |
| 522 |
|
| 523 |
|
| 524 |
export const WELCOME = `# Welcome to your vault |
| 525 |
|
| 526 |
This is a note. Every note is **markdown**, saved as you type, and private to you. |
| 527 |
|
| 528 |
## Linking thoughts |
| 529 |
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. |
| 530 |
|
| 531 |
Try: [[Ideas]] · [[Reading list]] · [[Ideas|an alias]] |
| 532 |
|
| 533 |
## Tags |
| 534 |
Add #tags anywhere. Nest them like #projects/notes. Click one to see every note carrying it. |
| 535 |
|
| 536 |
## The rest of markdown |
| 537 |
- ==highlights==, ~~strikes~~, \`code\`, *emphasis* |
| 538 |
- [x] tasks you've done |
| 539 |
- [ ] tasks you haven't |
| 540 |
|
| 541 |
> [!tip] Keys |
| 542 |
> **⌘K** / **Ctrl K** — jump to any note, or create one |
| 543 |
> **⌘E** / **Ctrl E** — switch between editing and reading |
| 544 |
> **⌘⇧D** / **Ctrl ⇧ D** — today's daily note |
| 545 |
> **⌘G** / **Ctrl G** — the graph |
| 546 |
|
| 547 |
| Where | What | |
| 548 |
| --- | --- | |
| 549 |
| Left | your notes, pinned first, and your tags | |
| 550 |
| Middle | the note | |
| 551 |
| Right | outline, links and backlinks | |
| 552 |
|
| 553 |
Open the **graph** to see how everything connects. |
| 554 |
`; |
| 555 |
|
| 556 |
|
| 557 |
|
| 558 |
|
| 559 |
|
| 560 |
|
| 561 |
|
| 562 |
export function listAudit( |
| 563 |
db: Database, |
| 564 |
who: Who, |
| 565 |
input: { limit?: number; before?: number } = {}, |
| 566 |
): Result<AuditRow[]> { |
| 567 |
if (permit(who, "audit", "list") !== "yes") |
| 568 |
return refuse( |
| 569 |
who, |
| 570 |
"Sign in to read the audit trail.", |
| 571 |
"Only an admin can read the audit trail.", |
| 572 |
); |
| 573 |
const limit = Math.min( |
| 574 |
Math.max(Math.trunc(input.limit ?? 100) || 100, 1), |
| 575 |
500, |
| 576 |
); |
| 577 |
const before = Number.isInteger(input.before) |
| 578 |
? input.before! |
| 579 |
: Number.MAX_SAFE_INTEGER; |
| 580 |
return ok( |
| 581 |
db |
| 582 |
.query< |
| 583 |
AuditRow, |
| 584 |
[number, number] |
| 585 |
>("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2") |
| 586 |
.all(before, limit), |
| 587 |
); |
| 588 |
} |
| 589 |
|
| 590 |
export function getAudit( |
| 591 |
db: Database, |
| 592 |
who: Who, |
| 593 |
input: { id: number }, |
| 594 |
): Result<AuditRow> { |
| 595 |
const row = Number.isInteger(input.id) |
| 596 |
? db |
| 597 |
.query<AuditRow, [number]>("SELECT * FROM audit WHERE id = ?1") |
| 598 |
.get(input.id) |
| 599 |
: null; |
| 600 |
if (permit(who, "audit", "read", { ...row }) !== "yes") |
| 601 |
return refuse( |
| 602 |
who, |
| 603 |
"Sign in to read the audit trail.", |
| 604 |
"Only an admin can read the audit trail.", |
| 605 |
); |
| 606 |
if (row !== null) return ok(row); |
| 607 |
if (!Number.isInteger(input.id)) |
| 608 |
return fail("invalid", "An audit id is a whole number."); |
| 609 |
return fail("not_found", "No such audit row."); |
| 610 |
} |