// notes.json: its constants, its validation and the edits made to it -- PURE // (no node imports), so the store (./store.mjs, node), the CLI and a client // component all hold the same rules. ./types.ts gives them types. // // { "format": "umtool-notes", "version": 1, // "subject": { kind: "article", site, report } | { kind: "video-project", project }, // "source": { draft?, generator?, manifest?, how? }, which file to EDIT // "notes": [ { id, status, author, text, at, updatedAt, anchor, replies, // resolvedAt?, resolvedBy? } ] } // // docs/notes.md is the prose. export const NOTES_FORMAT = "umtool-notes"; export const NOTES_VERSION = 1; export const NOTE_TEXT_LIMIT = 8000; export const NOTE_STATUSES = ["open", "resolved", "wontfix"]; export const NOTE_AUTHORS = ["operator", "agent"]; export const ANCHOR_KINDS = ["text", "cite", "section", "whole", "moment", "entry", "take", "edit"]; export const REPORT_BLOCKS = ["title", "subtitle", "summary", "method"]; const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const TAKE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; const NOTE_ID = /^n_[a-z0-9]{4,32}$/; const isStr = (v) => typeof v === "string"; const short = (v, max) => isStr(v) && v.length <= max; export const isNoteId = (v) => isStr(v) && NOTE_ID.test(v); /** A fresh note id: time then randomness, base36. */ export function newNoteId(now = Date.now()) { return `n_${now.toString(36)}${Math.random().toString(36).slice(2, 6).padEnd(4, "0")}`; } /** A moment's rel path: relative, no `..`, no empty segment, no backslash. */ function relFile(v) { return short(v, 512) && v.length > 0 && !v.startsWith("/") && !/[\\\0]/.test(v) && !v.split("/").some((s) => s === ".." || s === "" || s === "."); } /** A JSON value small enough to keep: an edit's from/to. */ function smallJson(v) { if (v === undefined) return true; try { return JSON.stringify(v).length <= 8000; } catch { return false; } } const RESOLVED_STR = ["entry", "title", "quote", "channel", "video", "url"]; /** * An anchor as stored, or `{ error }`. Extra keys are dropped; a text anchor's * prefix/suffix default to "". * * @param {unknown} raw * @returns {{ anchor: Record } | { error: string }} */ export function validateAnchor(raw) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: "anchor is not an object" }; const a = /** @type {Record} */ (raw); switch (a.kind) { case "whole": return { anchor: { kind: "whole" } }; case "section": if (!short(a.section, 128) || !ID.test(a.section)) return { error: "section anchor needs a section id" }; return { anchor: { kind: "section", section: a.section } }; case "text": { if (!short(a.section, 128) || !ID.test(a.section)) return { error: "text anchor needs a section id" }; if (!short(a.quote, 4000) || !a.quote.trim()) return { error: "text anchor needs a quote" }; if (a.prefix !== undefined && !short(a.prefix, 256)) return { error: "prefix is not a short string" }; if (a.suffix !== undefined && !short(a.suffix, 256)) return { error: "suffix is not a short string" }; return { anchor: { kind: "text", section: a.section, quote: a.quote, prefix: a.prefix ?? "", suffix: a.suffix ?? "" } }; } case "cite": if (!short(a.cite, 128) || !ID.test(a.cite)) return { error: "cite anchor needs a citation id" }; return { anchor: { kind: "cite", cite: a.cite } }; case "entry": if (!short(a.entry, 128) || !ID.test(a.entry)) return { error: "entry anchor needs an entry id" }; return { anchor: { kind: "entry", entry: a.entry } }; case "take": if (!isStr(a.take) || !TAKE_ID.test(a.take)) return { error: "take anchor needs a take id" }; return { anchor: { kind: "take", take: a.take } }; case "moment": { if (!relFile(a.file)) return { error: "moment anchor needs a relative file" }; const t = Number(a.t); if (!Number.isFinite(t) || t < 0) return { error: "moment anchor needs t ≥ 0" }; const out = { kind: "moment", file: a.file, t: Number(t.toFixed(2)) }; if (a.take !== undefined) { if (!isStr(a.take) || !TAKE_ID.test(a.take)) return { error: "moment take is not a take id" }; out.take = a.take; } if (a.entry !== undefined) { if (!short(a.entry, 128) || !ID.test(a.entry)) return { error: "moment entry is not an entry id" }; out.entry = a.entry; } if (a.resolved !== undefined) { if (!a.resolved || typeof a.resolved !== "object" || Array.isArray(a.resolved)) return { error: "resolved is not an object" }; const r = /** @type {Record} */ (a.resolved); const res = {}; for (const k of RESOLVED_STR) if (short(r[k], 2000)) res[k] = r[k]; if (typeof r.sourceT === "number" && Number.isFinite(r.sourceT)) res.sourceT = Number(r.sourceT.toFixed(2)); if (r.approx === true) res.approx = true; out.resolved = res; } return { anchor: out }; } case "edit": { if (!short(a.field, 128) || !a.field) return { error: "edit anchor needs a field" }; if (a.entry !== undefined && (!short(a.entry, 128) || !ID.test(a.entry))) return { error: "edit entry is not an entry id" }; if (!smallJson(a.from) || !smallJson(a.to)) return { error: "edit from/to too large" }; const out = { kind: "edit", field: a.field, from: a.from ?? null, to: a.to ?? null }; if (a.entry !== undefined) out.entry = a.entry; return { anchor: out }; } default: return { error: `anchor kind must be one of ${ANCHOR_KINDS.join(", ")}` }; } } /** @returns {{ subject: Record } | { error: string }} */ export function validateSubject(raw) { if (!raw || typeof raw !== "object") return { error: "subject is not an object" }; const s = /** @type {Record} */ (raw); if (s.kind === "article" && isStr(s.site) && TAKE_ID.test(s.site) && isStr(s.report) && TAKE_ID.test(s.report)) { return { subject: { kind: "article", site: s.site, report: s.report } }; } if (s.kind === "video-project" && short(s.project, 512) && s.project.length > 0) { return { subject: { kind: "video-project", project: s.project } }; } return { error: "subject must be an article {site, report} or a video-project {project}" }; } function cleanSource(raw) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; const out = {}; for (const k of ["draft", "generator", "manifest", "how"]) if (short(raw[k], 1000) && raw[k]) out[k] = raw[k]; return Object.keys(out).length ? out : undefined; } function cleanText(v) { if (!isStr(v)) throw new NoteError("text must be a string"); const t = v.replace(/\s+$/, ""); if (!t.trim()) throw new NoteError("text is empty"); if (t.length > NOTE_TEXT_LIMIT) throw new NoteError(`text is over ${NOTE_TEXT_LIMIT} characters`); return t; } /** A refused edit: the caller's fault, a 400. */ export class NoteError extends Error { constructor(message) { super(message); this.name = "NoteError"; } } /** * A parsed notes.json, checked. `{ doc }` or `{ error }`. A note that is not * the contract's shape is an error for the whole file -- the file is never * "repaired" by dropping it, because the next write would erase it. */ export function parseNotesDoc(raw) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: "not an object" }; if (raw.format !== NOTES_FORMAT) return { error: `format is not ${NOTES_FORMAT}` }; if (raw.version !== NOTES_VERSION) return { error: `version ${raw.version} is not ${NOTES_VERSION}` }; const subject = validateSubject(raw.subject); if ("error" in subject) return subject; if (!Array.isArray(raw.notes)) return { error: "notes is not a list" }; const notes = []; for (const [i, n] of raw.notes.entries()) { const where = `notes[${i}]`; if (!n || typeof n !== "object") return { error: `${where} is not an object` }; if (!isNoteId(n.id)) return { error: `${where}.id is not a note id` }; if (!NOTE_STATUSES.includes(n.status)) return { error: `${where}.status` }; if (!NOTE_AUTHORS.includes(n.author)) return { error: `${where}.author` }; if (!isStr(n.text) || !isStr(n.at) || !isStr(n.updatedAt)) return { error: `${where} text/at/updatedAt` }; const a = validateAnchor(n.anchor); if ("error" in a) return { error: `${where}.anchor: ${a.error}` }; if (!Array.isArray(n.replies)) return { error: `${where}.replies is not a list` }; const replies = []; for (const r of n.replies) { if (!r || !NOTE_AUTHORS.includes(r.author) || !isStr(r.text) || !isStr(r.at)) return { error: `${where}.replies` }; replies.push({ author: r.author, text: r.text, at: r.at }); } const note = { id: n.id, status: n.status, author: n.author, text: n.text, at: n.at, updatedAt: n.updatedAt, anchor: a.anchor, replies }; if (isStr(n.resolvedAt)) note.resolvedAt = n.resolvedAt; if (NOTE_AUTHORS.includes(n.resolvedBy)) note.resolvedBy = n.resolvedBy; notes.push(note); } const doc = { format: NOTES_FORMAT, version: NOTES_VERSION, subject: subject.subject }; const source = cleanSource(raw.source); if (source) doc.source = source; doc.notes = notes; return { doc }; } export function emptyDoc(subject, source) { const doc = { format: NOTES_FORMAT, version: NOTES_VERSION, subject }; const s = cleanSource(source); if (s) doc.source = s; doc.notes = []; return doc; } function find(doc, id) { const note = doc.notes.find((n) => n.id === id); if (!note) throw new NoteError(`no note ${id}`); return note; } function setStatus(note, status, by, now) { if (!NOTE_STATUSES.includes(status)) throw new NoteError(`status must be one of ${NOTE_STATUSES.join(", ")}`); note.status = status; note.updatedAt = now; if (status === "open") { delete note.resolvedAt; delete note.resolvedBy; } else { note.resolvedAt = now; note.resolvedBy = by; } } /** * Apply one op to a doc, in place. Returns the note touched (null on delete). * `by` is who is writing: the app stamps "operator", the CLI "agent". * * { op: "add", text, anchor } a new open note * { op: "edit", id, text?, anchor? } rewrite it (its author only) * { op: "status", id, status } open | resolved | wontfix * { op: "reply", id, text, resolve? } a threaded reply, optionally resolving * { op: "delete", id } remove the note * { op: "delete-reply", id, index } remove one reply * { op: "source", source } correct which file to edit * * @param {Record} doc * @param {Record} op * @param {"operator" | "agent"} by * @param {string} [now] */ export function applyOp(doc, op, by, now = new Date().toISOString()) { if (!NOTE_AUTHORS.includes(by)) throw new NoteError("author must be operator or agent"); switch (op?.op) { case "add": { const a = validateAnchor(op.anchor); if ("error" in a) throw new NoteError(a.error); let id = newNoteId(); while (doc.notes.some((n) => n.id === id)) id = newNoteId(); const note = { id, status: "open", author: by, text: cleanText(op.text), at: now, updatedAt: now, anchor: a.anchor, replies: [] }; doc.notes.push(note); return note; } case "edit": { const note = find(doc, op.id); if (note.author !== by) throw new NoteError(`only the ${note.author} edits this note; reply instead`); if (op.text !== undefined) note.text = cleanText(op.text); if (op.anchor !== undefined) { const a = validateAnchor(op.anchor); if ("error" in a) throw new NoteError(a.error); note.anchor = a.anchor; } note.updatedAt = now; return note; } case "status": { const note = find(doc, op.id); setStatus(note, op.status, by, now); return note; } case "reply": { const note = find(doc, op.id); note.replies.push({ author: by, text: cleanText(op.text), at: now }); note.updatedAt = now; if (op.resolve) setStatus(note, "resolved", by, now); return note; } case "delete": { const i = doc.notes.findIndex((n) => n.id === op.id); if (i === -1) throw new NoteError(`no note ${op.id}`); doc.notes.splice(i, 1); return null; } case "delete-reply": { const note = find(doc, op.id); const i = Number(op.index); if (!Number.isInteger(i) || i < 0 || i >= note.replies.length) throw new NoteError("no such reply"); if (note.replies[i].author !== by) throw new NoteError(`only the ${note.replies[i].author} deletes that reply`); note.replies.splice(i, 1); note.updatedAt = now; return note; } case "source": { const s = cleanSource(op.source); if (s) doc.source = s; else delete doc.source; return null; } default: throw new NoteError("op must be add, edit, status, reply, delete, delete-reply or source"); } } export const openNotes = (doc) => (doc ? doc.notes.filter((n) => n.status === "open") : []);