// Sage, 2026-10-11: read-only, Q61a. With the sync key, saves project #19's state before the agents // install the five buckets: the project, its views, each kanban view's buckets, the tasks in each // bucket, and every task's done flag. GETs only; refuses to overwrite OUT; prints no secret. // Usage: node before-19.mjs https://tasks.woltje.com SYNC_TOKEN_FILE OUT_JSON import { readFileSync, writeFileSync, existsSync } from "node:fs"; const [ORIGIN, KEY, OUT] = process.argv.slice(2); if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only"); if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing"); const BASE = ORIGIN + "/api/v2", P = 19; const key = readFileSync(KEY, "utf8").trim(); const get = async (path) => { const r = await fetch(BASE + path, { headers: { Authorization: `Bearer ${key}` }, redirect: "error" }); const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {} if (r.status !== 200) throw new Error(`GET ${path}: ${r.status} ${json?.code ?? ""} ${json?.message ?? ""}`); return json; }; const list = (j) => j?.items ?? (Array.isArray(j) ? j : []); async function all(path) { const out = []; for (let page = 1; page < 100; page++) { const items = list(await get(`${path}${path.includes("?") ? "&" : "?"}per_page=50&page=${page}`)); out.push(...items); if (items.length < 50) return out; } throw new Error(`${path}: more than 99 pages`); } const p = await get(`/projects/${P}`); const rec = { at: new Date().toISOString(), project: { id: p.id, title: p.title, parent: p.parent_project_id, archived: p.is_archived, done_bucket_id: p.done_bucket_id ?? null, default_bucket_id: p.default_bucket_id ?? null }, views: [], tasks: [], }; for (const v of await all(`/projects/${P}/views`)) { const view = { id: v.id, title: v.title, kind: v.view_kind, position: v.position, mode: v.bucket_configuration_mode, done_bucket_id: v.done_bucket_id, default_bucket_id: v.default_bucket_id, filter: v.filter ?? null }; if (v.view_kind === "kanban") { view.buckets = (await all(`/projects/${P}/views/${v.id}/buckets`)).map((b) => ({ id: b.id, title: b.title, position: b.position, limit: b.limit, count: b.count, tasks: [] })); // The whole board, done tasks included, as the adapter reads it (sync.mjs board()). const byId = new Map(view.buckets.map((b) => [b.id, b])); for (let page = 1; page < 100; page++) { let more = false; for (const b of list(await get(`/projects/${P}/views/${v.id}/buckets/tasks?per_page=50&page=${page}`))) { const tasks = b.tasks ?? []; byId.get(b.id)?.tasks.push(...tasks.map((t) => t.id)); if (Number.isSafeInteger(b.count) ? b.count > page * 50 : tasks.length >= 50) more = true; } if (!more) break; } } rec.views.push(view); } rec.tasks = (await all(`/projects/${P}/tasks`)).map((t) => ({ id: t.id, title: t.title, done: t.done, bucket_id: t.bucket_id ?? null, project_id: t.project_id })); const out = JSON.stringify(rec, null, 1) + "\n"; if (out.includes(key)) throw new Error("secret in output; not written"); writeFileSync(OUT, out, { flag: "wx" }); process.stdout.write(out);