// `umtool notes` -- the agent's side of the notes the operator writes in the // app. It reads and writes through the same store (./store.mjs) and targets // (./targets.mjs) the app does, and stamps every write `author: agent`. An // agent never hand-edits notes.json: this validates, locks, and keeps the // operator's page from losing a reply (docs/notes.md). // // umtool notes [--all] every notes file, with open counts // umtool notes / | the digest (open notes) // [--open | --resolved | --all-status] [--json] // umtool notes reply "" [--resolve] [--in ] // umtool notes resolve | wontfix | reopen [--in ] // umtool notes source [--draft P] [--generator P] [--how T] import { REPORTS_ROOT, SITES_DIR } from "../paths.mjs"; import { digest } from "./digest.mjs"; import { readNotes } from "./store.mjs"; import { articleTarget, listNotesFiles, projectTarget, resolveTarget, writeNote } from "./targets.mjs"; const USAGE = [ "usage: umtool notes [--all] every notes file and its open count", " umtool notes / | the notes, as markdown (open ones)", " [--open | --resolved | --all-status] [--json]", " umtool notes reply \"\" [--resolve] answer a note (and close it)", " umtool notes resolve | wontfix | reopen ", " umtool notes source [--draft P] [--generator P] [--how T]", "", "Notes are the operator's, written in umtool (/sites, a video project). Act on one", "by editing its SOURCE file and regenerating; never edit notes.json by hand.", ].join("\n"); class CliError extends Error {} /** * @param {string[]} args everything after `notes` * @param {{ sitesDir?: string, reportsRoot?: string, log?: (s: string) => void }} [opts] * @returns {Promise} exit code */ export async function notesCommand(args, { sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT, log = console.log } = {}) { const flags = new Set(args.filter((a) => a.startsWith("--"))); const val = (n) => { const i = args.indexOf(n); return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined; }; const VALUED = new Set(["--in", "--draft", "--generator", "--how"]); const pos = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.has(args[i - 1]))); const json = flags.has("--json"); const opts = { sitesDir, reportsRoot }; const print = (v) => log(json ? JSON.stringify(v, null, 2) : v); try { const verb = pos[0]; if (flags.has("--help") || verb === "help") { log(USAGE); return 0; } if (verb === "reply" || verb === "resolve" || verb === "wontfix" || verb === "reopen") { const id = pos[1]; if (!id) throw new CliError(`which note? \`umtool notes ${verb} \``); const where = await findNote(id, val("--in"), opts); let op; if (verb === "reply") { const text = pos[2]; if (!text) throw new CliError('what reply? `umtool notes reply "" [--resolve]`'); op = { op: "reply", id, text, resolve: flags.has("--resolve") }; } else { op = { op: "status", id, status: verb === "reopen" ? "open" : verb === "wontfix" ? "wontfix" : "resolved" }; } const r = await writeNote(where.target, op, { by: "agent" }); if (json) print({ ok: true, target: where.target.id, file: where.target.file, note: r.note }); else log(`${id} in ${where.target.id}: ${r.note?.status}${verb === "reply" ? `, ${r.note?.replies.length} repl${r.note?.replies.length === 1 ? "y" : "ies"}` : ""}`); return 0; } if (verb === "source") { const spec = pos[1]; if (!spec) throw new CliError("which target? `umtool notes source / --draft P`"); const target = await resolveTarget(spec, opts); const cur = await readNotes(target.file); if (!cur.doc) throw new CliError(`${target.id} has no notes; the source is recorded with the first note`); const source = { ...(cur.doc.source ?? {}) }; for (const k of ["draft", "generator", "how"]) if (val(`--${k}`) !== undefined) source[k] = val(`--${k}`); const r = await writeNote(target, { op: "source", source }, { by: "agent" }); print(json ? { ok: true, source: r.doc?.source ?? null } : `${target.id}: source ${JSON.stringify(r.doc?.source ?? {})}`); return 0; } const status = flags.has("--all-status") ? "all" : flags.has("--resolved") ? "resolved" : "open"; if (!verb || flags.has("--all")) { const files = await listNotesFiles(opts); if (json) { print(files.map((f) => ({ kind: f.kind, id: f.id, file: f.file, open: f.doc ? f.doc.notes.filter((n) => n.status === "open").length : null, total: f.doc?.notes.length ?? null, error: f.error }))); return 0; } if (!files.length) { log(`no notes under ${sitesDir} or ${reportsRoot}`); return 0; } const w = Math.max(...files.map((f) => f.id.length)); let open = 0; for (const f of files) { const o = f.doc ? f.doc.notes.filter((n) => n.status === "open").length : 0; open += o; if (status === "open" && !o && !f.error) continue; log(`${f.id.padEnd(w)} ${f.kind === "article" ? "article" : "video "} ${f.error ? `UNREADABLE: ${f.error}` : `${o} open / ${f.doc.notes.length}`}`); } log(`\n${open} open note(s) in ${files.length} file(s). \`umtool notes \` for one.`); return 0; } const target = await resolveTarget(verb, opts); const read = await readNotes(target.file); const entry = { kind: target.kind, id: target.id, file: target.file, doc: read.doc, ...(read.error ? { error: read.error } : {}) }; if (json) { print({ ...entry, source: read.doc?.source ?? (await target.source()) ?? null }); return 0; } if (!read.doc && !read.error) { log(`no notes on ${target.id} (${target.file})`); return 0; } log(await digest(entry, { status, sitesDir, reportsRoot, projectDir: target.dir })); return 0; } catch (err) { console.error(err instanceof Error ? err.message : String(err)); return err instanceof CliError || err?.name === "NoteError" || err?.name === "TargetError" ? 2 : 1; } } /** The notes file holding note `id`: the one named by --in, else a search of every file. */ async function findNote(id, inSpec, opts) { if (inSpec) { const target = await resolveTarget(inSpec, opts); const r = await readNotes(target.file); if (!r.doc?.notes.some((n) => n.id === id)) throw new CliError(`no note ${id} in ${target.id}`); return { target }; } const hits = (await listNotesFiles(opts)).filter((f) => f.doc?.notes.some((n) => n.id === id)); if (!hits.length) throw new CliError(`no note ${id} (see \`umtool notes --all\`)`); if (hits.length > 1) throw new CliError(`${id} is in ${hits.length} files; say which with --in: ${hits.map((h) => h.id).join(", ")}`); const hit = hits[0]; return { target: hit.kind === "article" ? await articleTarget(hit.id, opts) : await projectTarget(hit.id, opts) }; }