// What a note is ON, and therefore which notes.json it lives in -- decided // here, once, for the app's /api/notes and for `umtool notes`. // // article `SITES_DIR//reports//notes.json`, beside // report.json. The generators that write a report dir // overwrite only report.json, video.mp4 and poster.jpg, so it // survives a regenerate; the compose stage and the report // history never read it (common/publish tests hold that), so // it is never published. The ONE corpus file umtool writes, // and only through isCorpusNotesFile. // video-project `/notes.json`, beside video.manifest.json. A // report-video project under REPORTS_ROOT, found by the same // walk `umtool ls` uses. import { readdir, readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { NOTES_FILENAME, REPORTS_ROOT, SEGMENT_RE, SITES_DIR, corpusNotesFile, inside, isCorpusNotesFile } from "../paths.mjs"; import { projectRefs, resolveProject } from "../projects/core.mjs"; import { kindTakesNotes } from "../projects/kinds.mjs"; import { sourceFor, tildify } from "../articles/sources.mjs"; import { readNotes, writeOp } from "./store.mjs"; const exists = (p) => stat(/* turbopackIgnore: true */ p).then(() => true, () => false); /** A refused target: the caller's fault (400/404). */ export class TargetError extends Error { constructor(message, status = 400) { super(message); this.name = "TargetError"; this.status = status; } } /** * An article target, checked: the site and report ids, the report directory * on disk, and the notes path through isCorpusNotesFile. * * @param {string} spec `/` * @param {{ sitesDir?: string, reportsRoot?: string }} [opts] */ export async function articleTarget(spec, { sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT } = {}) { const [site, report, ...rest] = String(spec ?? "").split("/"); if (rest.length || !SEGMENT_RE.test(site ?? "") || !SEGMENT_RE.test(report ?? "")) { throw new TargetError(`not an article: ${JSON.stringify(spec)} (want /)`); } const file = corpusNotesFile(site, report, { sitesDir }); if (!file || !(await exists(path.dirname(/* turbopackIgnore: true */ file)))) throw new TargetError(`no report ${site}/${report}`, 404); if (!(await isCorpusNotesFile(file, { sitesDir }))) throw new TargetError(`refusing to write ${file}`, 403); return { kind: "article", id: `${site}/${report}`, file, subject: { kind: "article", site, report }, // Lazy: the scan reads every workspace's generators, and a read of an // existing file never needs it. source: async () => sourceFor(site, report, { reportsRoot }), }; } /** * A video-project target: a report-video project under REPORTS_ROOT, by id, * unique name or directory. * * @param {string} spec * @param {{ reportsRoot?: string }} [opts] */ export async function projectTarget(spec, { reportsRoot = REPORTS_ROOT } = {}) { const r = await resolveProject(String(spec ?? ""), reportsRoot); if (r.ambiguous) throw new TargetError(`${spec} names ${r.ambiguous.length} projects: ${r.ambiguous.map((p) => p.id).join(", ")}`); if (!r.project) throw new TargetError(`no project ${spec}`, 404); return projectTargetFor(r.project, { reportsRoot }); } /** * The target for a project ref already in hand (the app's memoised walk). * * @param {{ id: string, dir: string, kind: string }} p * @param {{ reportsRoot?: string }} [opts] */ export async function projectTargetFor(p, { reportsRoot = REPORTS_ROOT } = {}) { if (!kindTakesNotes(p.kind)) throw new TargetError(`${p.id} is a ${p.kind} project; notes are for report videos`); const [realRoot, realDir] = await Promise.all([ realpath(/* turbopackIgnore: true */ reportsRoot).catch(() => null), realpath(/* turbopackIgnore: true */ p.dir).catch(() => null), ]); if (!realRoot || !realDir || !inside(realRoot, realDir) || realRoot === realDir) { throw new TargetError(`${p.id} is not under the reports root`, 403); } const file = path.join(/* turbopackIgnore: true */ p.dir, NOTES_FILENAME); return { kind: "video-project", id: p.id, dir: p.dir, file, subject: { kind: "video-project", project: p.id }, source: async () => projectSource(p.dir, reportsRoot), }; } /** * A report-video project's source: its manifest, and -- when the manifest is * generated -- the generator, resolved against the project's workspace (the * first directory under REPORTS_ROOT) when that file exists. */ export async function projectSource(dir, reportsRoot = REPORTS_ROOT) { const manifest = path.join(/* turbopackIgnore: true */ dir, "video.manifest.json"); const out = { manifest: tildify(manifest) }; let generatedBy = null; try { const m = JSON.parse(await readFile(/* turbopackIgnore: true */ manifest, "utf8")); if (typeof m.generatedBy === "string" && m.generatedBy.trim()) generatedBy = m.generatedBy.trim(); } catch { // no manifest, or not JSON: the manifest path is still the place to look } if (!generatedBy) { out.how = "hand-edited manifest"; return out; } const rel = path.relative(/* turbopackIgnore: true */ reportsRoot, dir); const ws = rel && !rel.startsWith("..") ? path.join(/* turbopackIgnore: true */ reportsRoot, rel.split(path.sep)[0]) : null; const candidates = [ws && path.join(/* turbopackIgnore: true */ ws, generatedBy), path.join(/* turbopackIgnore: true */ dir, generatedBy)].filter(Boolean); let gen = null; for (const c of candidates) { if (await exists(c)) { gen = c; break; } } out.generator = gen ? tildify(gen) : generatedBy; out.how = `manifest is generated by ${generatedBy}; edit its inputs, then regenerate`; return out; } /** * Resolve what the CLI was handed: `/` when that report exists, * else a project. */ export async function resolveTarget(spec, opts = {}) { const parts = String(spec ?? "").split("/"); if (parts.length === 2 && parts.every((s) => SEGMENT_RE.test(s))) { const dir = path.join(/* turbopackIgnore: true */ opts.sitesDir ?? SITES_DIR, parts[0], "reports", parts[1]); if (await exists(dir)) return articleTarget(spec, opts); } return projectTarget(spec, opts); } /** * Every notes.json there is: articles under SITES_DIR, projects under * REPORTS_ROOT. `{ target, file, doc, error? }` each; sorted by id. * * @param {{ sitesDir?: string, reportsRoot?: string }} [opts] */ export async function listNotesFiles({ sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT } = {}) { const out = []; for (const site of await readdir(/* turbopackIgnore: true */ sitesDir).catch(() => [])) { if (!SEGMENT_RE.test(site)) continue; for (const report of await readdir(/* turbopackIgnore: true */ path.join(/* turbopackIgnore: true */ sitesDir, site, "reports")).catch(() => [])) { if (!SEGMENT_RE.test(report)) continue; const file = path.join(/* turbopackIgnore: true */ sitesDir, site, "reports", report, NOTES_FILENAME); if (!(await exists(file))) continue; out.push({ kind: "article", id: `${site}/${report}`, file, ...(await readNotes(file)) }); } } for (const p of await projectRefs(reportsRoot)) { if (!kindTakesNotes(p.kind)) continue; const file = path.join(/* turbopackIgnore: true */ p.dir, NOTES_FILENAME); if (!(await exists(file))) continue; out.push({ kind: "video-project", id: p.id, projectKind: p.kind, file, ...(await readNotes(file)) }); } return out.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id)); } /** * One write to a target's notes. A file that does not exist yet starts with * the target's subject and its discovered source (which the agent may correct * later with a `source` op); an existing file keeps both. * * @param {{ file: string, subject: Record, source: () => Promise | null> }} target * @param {Record} op * @param {{ by: "operator" | "agent", token?: string | null }} opts */ export async function writeNote(target, op, opts) { const now = await readNotes(target.file); const source = now.doc || now.error ? undefined : ((await target.source()) ?? undefined); return writeOp(target.file, { subject: target.subject, source }, op, opts); }