@bongos/core 1.19.1057 → 1.19.1058

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ // modules/copy-desk/page-data.js — the READ side of the two committed page
2
+ // artifacts Tweak Mode keys on (task 1004316 / BV2.TW05, ADR 0341 D1 and D3).
3
+ //
4
+ // docs/page-inventory.json every page, its surface, title and files
5
+ // (scripts/gds/page-inventory.js, TW02)
6
+ // docs/page-readings.json every page's visible lines, in page order
7
+ // (scripts/gds/page-reader.js, TW04)
8
+ //
9
+ // This is the first SERVER path that reads either, so both now ship in the
10
+ // publish manifest (scripts/gds/publish-manifest.js, the copy-registry
11
+ // precedent of ADR 0178). Same rules as registry.js, for the same reasons:
12
+ //
13
+ // * READ, never written or regenerated here. The reader opens a browser; a GET
14
+ // that did that would make two replicas disagree about what a page says.
15
+ // * (mtime, size) cache: the files change at deploy, not per request.
16
+ // * A MISSING OR CORRUPT FILE IS A NAMED STATE, NEVER A CRASH. An instance on
17
+ // an older core, or a checkout that has not run the generator, gets
18
+ // { ok: false, code, detail } with the command that fixes it. The routes
19
+ // answer a missing inventory with a named 503 (there is no page to speak
20
+ // of), and a missing reading by marking drift unknown, because status,
21
+ // count and changelog come from the ledger and still read true without it.
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+
28
+ const ROOT = path.resolve(__dirname, '..', '..');
29
+
30
+ const ARTIFACTS = Object.freeze({
31
+ inventory: Object.freeze({
32
+ rel: 'docs/page-inventory.json',
33
+ run: 'node scripts/gds/page-inventory.js',
34
+ missing: 'page_inventory_missing',
35
+ unreadable: 'page_inventory_unreadable',
36
+ }),
37
+ readings: Object.freeze({
38
+ rel: 'docs/page-readings.json',
39
+ run: 'node scripts/gds/page-reader.js --stale',
40
+ missing: 'page_readings_missing',
41
+ unreadable: 'page_readings_unreadable',
42
+ }),
43
+ });
44
+
45
+ const _cache = new Map();
46
+
47
+ // Test seam: point a loader at another root (a temp dir holding fixture files,
48
+ // or an empty one to prove the degrade). Never called by the routes.
49
+ let _root = ROOT;
50
+ function _setRoot(dir) { _root = dir || ROOT; _cache.clear(); }
51
+
52
+ function load(which) {
53
+ const a = ARTIFACTS[which];
54
+ const abs = path.join(_root, a.rel);
55
+ let st;
56
+ try { st = fs.statSync(abs); } catch {
57
+ return { ok: false, code: a.missing, detail: { expected_at: a.rel, run: a.run } };
58
+ }
59
+ const key = `${abs}:${st.mtimeMs}:${st.size}`;
60
+ const hit = _cache.get(which);
61
+ if (hit && hit.key === key) return hit.value;
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
65
+ } catch (err) {
66
+ return { ok: false, code: a.unreadable, detail: { expected_at: a.rel, reason: err.message, run: a.run } };
67
+ }
68
+ if (!parsed || !Array.isArray(parsed.pages)) {
69
+ return { ok: false, code: a.unreadable, detail: { expected_at: a.rel, reason: 'no pages[] array', run: a.run } };
70
+ }
71
+ const value = { ok: true, data: parsed, at: a.rel };
72
+ _cache.set(which, { key, value });
73
+ return value;
74
+ }
75
+
76
+ function loadInventory() { return load('inventory'); }
77
+ function loadReadings() { return load('readings'); }
78
+
79
+ module.exports = { ARTIFACTS, loadInventory, loadReadings, _setRoot };
@@ -0,0 +1,357 @@
1
+ // modules/copy-desk/page-status.js — the three Tweak Mode READS, derived
2
+ // (task 1004316 / BV2.TW05, ADR 0341 D7 and D8).
3
+ //
4
+ // There is NO status table, by the planning session's ruling: a table would be a
5
+ // second record of what the task ledger already says. Everything here is a pure
6
+ // function over
7
+ //
8
+ // * the page-tweak tasks (source 'page-tweak'), as the lifecycle port's
9
+ // listPageTweakTasks() returns them: status, description (the fenced blocks
10
+ // of pages.js), shipped_at, the latest grade, the active claim and the
11
+ // round's artist;
12
+ // * docs/page-inventory.json (which pages exist, their surface and files);
13
+ // * docs/page-readings.json (what each page says now), for drift only;
14
+ // * the caller's `tweak.page_approved:` credit rows, for the tally.
15
+ //
16
+ // The three reads:
17
+ //
18
+ // PER PAGE status (Untweaked / Text tweaked / UI tweaked / Both) with its
19
+ // count ("Text tweaked 2x"), the changelog, and the lines changed
20
+ // since the last tweak (drift).
21
+ // PER SURFACE Artist Review Status: "N of M tweaked". N counts pages with a
22
+ // shipped round, and drift does not lower it.
23
+ // PER ARTIST the studio corner's tally: credits from approved pages, pages
24
+ // tweaked, and this week's gain.
25
+ //
26
+ // Pure: no fs, no DB, no clock (the caller passes `now`). The route owns the I/O.
27
+
28
+ 'use strict';
29
+
30
+ const pages = require('./pages');
31
+
32
+ const TERMINAL = new Set(['shipped', 'abandoned']);
33
+
34
+ // The credit reason ADR 0341 D10 books an approved page under. TW13 writes it;
35
+ // until then no row carries it and every tally reads 0, which is the truth.
36
+ const TALLY_REASON_PREFIX = 'tweak.page_approved:';
37
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
38
+
39
+ // Surface order and names, the mock's (docs/design/mocks/tweak-mode/Main.dc.html:
40
+ // Landing, Builders hall, Status). A surface the inventory adds later sorts after
41
+ // these, in inventory order, under its own id.
42
+ const SURFACE_ORDER = Object.freeze(['landing', 'builders', 'status']);
43
+ const SURFACE_LABELS = Object.freeze({ landing: 'Landing', builders: 'Builders hall', status: 'Status' });
44
+
45
+ const STATUS = Object.freeze({
46
+ untweaked: 'Untweaked',
47
+ text: 'Text tweaked',
48
+ ui: 'UI tweaked',
49
+ both: 'Both',
50
+ });
51
+
52
+ function iso(v) {
53
+ if (v == null) return null;
54
+ const d = v instanceof Date ? v : new Date(v);
55
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
56
+ }
57
+
58
+ function person(id, login) {
59
+ if (id == null) return null;
60
+ return { id: String(id), login: login || null };
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Grouping the ledger by page.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ // rounds by page id, each list in round order. A task whose source_ref does not
68
+ // parse (a hand-made row) belongs to no page and is skipped, not guessed at.
69
+ function indexRounds(tasks) {
70
+ const byPage = new Map();
71
+ for (const t of tasks || []) {
72
+ const ref = pages.parseRoundRef(t && t.source_ref);
73
+ if (!ref) continue;
74
+ const round = { ...t, page_id: ref.page_id, round: ref.round };
75
+ const list = byPage.get(ref.page_id);
76
+ if (list) list.push(round); else byPage.set(ref.page_id, [round]);
77
+ }
78
+ for (const list of byPage.values()) list.sort((a, b) => a.round - b.round || Number(a.id) - Number(b.id));
79
+ return byPage;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Count and status (D8).
84
+ // ---------------------------------------------------------------------------
85
+
86
+ // A shipped round counts as TEXT unless its batch says kind 'ui'. While Tweak
87
+ // Mode is text only no batch carries a kind, so the UI count reads 0 — the field
88
+ // is reserved, not invented. A shipped round whose batch cannot be read is still
89
+ // a shipped round, and still text.
90
+ function roundKind(round) {
91
+ const b = pages.parseBatchBlock(round.description);
92
+ return b.ok && b.batch.kind === 'ui' ? 'ui' : 'text';
93
+ }
94
+
95
+ function countsFor(rounds) {
96
+ const shipped = (rounds || []).filter((r) => r.status === 'shipped');
97
+ let ui = 0;
98
+ for (const r of shipped) if (roundKind(r) === 'ui') ui++;
99
+ const count = shipped.length;
100
+ const text = count - ui;
101
+ let status = STATUS.untweaked;
102
+ if (text > 0 && ui > 0) status = STATUS.both;
103
+ else if (text > 0) status = STATUS.text;
104
+ else if (ui > 0) status = STATUS.ui;
105
+ return {
106
+ count,
107
+ text_count: text,
108
+ ui_count: ui,
109
+ status,
110
+ status_label: count === 0 ? STATUS.untweaked : `${status} ${count}x`,
111
+ };
112
+ }
113
+
114
+ function latestShipped(rounds) {
115
+ const shipped = (rounds || []).filter((r) => r.status === 'shipped');
116
+ return shipped.length ? shipped[shipped.length - 1] : null;
117
+ }
118
+
119
+ // When the page's latest shipped round landed (ISO), or null for an untweaked
120
+ // page: the `since` the detail route reads the later ships from.
121
+ function lastRoundShippedAt(tasks, pageId) {
122
+ const last = latestShipped(indexRounds(tasks).get(pageId));
123
+ return last ? iso(last.shipped_at) : null;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Drift, "changed since last tweak" (D8, spec decision 8).
128
+ //
129
+ // The page's current NON-SHARED reading lines, compared as a MULTISET of texts
130
+ // with lines_after of its latest shipped round. Shared shell lines are left out
131
+ // on both sides (builder pick 7): a shell change must not mark all 30 hall pages
132
+ // changed. A multiset, not a set, because a page may say "Save" twice and lose
133
+ // one of them.
134
+ // ---------------------------------------------------------------------------
135
+
136
+ function driftFor(readingPage, rounds, readingsOk) {
137
+ const last = latestShipped(rounds);
138
+ if (!last) return { known: true, since_task_id: null, changed: false, changed_count: 0, removed_count: 0, changed_lines: [], removed_lines: [] };
139
+ const base = { since_task_id: String(last.id), since: iso(last.shipped_at) };
140
+ if (!readingsOk) return { known: false, reason: 'readings_missing', ...base };
141
+ if (!readingPage) return { known: false, reason: 'page_not_read', ...base };
142
+ const applied = pages.parseAppliedBlock(last.description);
143
+ if (!applied.ok) return { known: false, reason: 'no_applied_block', ...base };
144
+
145
+ const left = new Map();
146
+ for (const t of applied.applied.lines_after) left.set(t, (left.get(t) || 0) + 1);
147
+ const changed = [];
148
+ for (const l of readingPage.lines || []) {
149
+ if (!l || l.placement === 'shared') continue;
150
+ const n = left.get(l.text) || 0;
151
+ if (n > 0) left.set(l.text, n - 1);
152
+ else changed.push({ key: l.key, section: l.section, text: l.text });
153
+ }
154
+ const removed = [];
155
+ for (const [text, n] of left) for (let i = 0; i < n; i++) removed.push(text);
156
+ return {
157
+ known: true,
158
+ ...base,
159
+ changed: changed.length > 0 || removed.length > 0,
160
+ changed_count: changed.length,
161
+ removed_count: removed.length,
162
+ changed_lines: changed,
163
+ removed_lines: removed,
164
+ };
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // The open round and its state (D7). Derived, never stored.
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function roundState(round) {
172
+ const p = pages.blockPresence(round.description);
173
+ switch (round.status) {
174
+ case 'active':
175
+ if (round.holder_id == null) return 'unknown';
176
+ return round.holder_worktree ? 'applying' : 'writing';
177
+ case 'ready':
178
+ if (p.batch) {
179
+ // submitted = a batch, and no applied block newer than the latest
180
+ // send-back. A sent-back round reads submitted again (the mock's
181
+ // "it comes back to the queue once it is re-applied").
182
+ if (!p.applied) return 'submitted';
183
+ if (p.last_sent_back_at && (!p.applied_at || p.applied_at <= p.last_sent_back_at)) return 'submitted';
184
+ return 'unknown';
185
+ }
186
+ return p.draft ? 'draft' : 'unknown';
187
+ case 'completed':
188
+ // A held pass waits for the artist; a failed grade stays with the applier.
189
+ return round.grade_passed === true ? 'waiting_for_artist' : 'applying';
190
+ case 'confirmed':
191
+ return 'landing';
192
+ default:
193
+ return 'unknown';
194
+ }
195
+ }
196
+
197
+ function openRound(rounds) {
198
+ const open = (rounds || []).filter((r) => !TERMINAL.has(r.status));
199
+ if (!open.length) return null;
200
+ const r = open[open.length - 1];
201
+ return {
202
+ task_id: String(r.id),
203
+ round: r.round,
204
+ status: r.status,
205
+ state: roundState(r),
206
+ holder: person(r.holder_id, r.holder_login),
207
+ };
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // The changelog (D8): one entry per shipped round, newest first.
212
+ // ---------------------------------------------------------------------------
213
+
214
+ function changelogFor(rounds) {
215
+ const out = [];
216
+ for (const r of (rounds || []).filter((x) => x.status === 'shipped')) {
217
+ const b = pages.parseBatchBlock(r.description);
218
+ const lines = b.ok ? b.batch.lines.map((l) => ({ key: l.key, section: l.section || null, before: l.before, after: l.after })) : [];
219
+ out.push({
220
+ task_id: String(r.id),
221
+ round: r.round,
222
+ artist: person(r.artist_id, r.artist_login),
223
+ shipped_at: iso(r.shipped_at),
224
+ kind: roundKind(r),
225
+ line_count: lines.length,
226
+ lines,
227
+ ...(b.ok ? {} : { batch_unreadable: b.code }),
228
+ });
229
+ }
230
+ return out.reverse();
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Composition.
235
+ // ---------------------------------------------------------------------------
236
+
237
+ function readingIndex(readings) {
238
+ const m = new Map();
239
+ for (const p of (readings && readings.pages) || []) if (p && p.id) m.set(p.id, p);
240
+ return m;
241
+ }
242
+
243
+ function summaryFor(page, rounds, readingPage, readingsOk) {
244
+ // The list carries drift's counts; the lines themselves are the detail read's.
245
+ const drift = { ...driftFor(readingPage, rounds, readingsOk) };
246
+ delete drift.changed_lines;
247
+ delete drift.removed_lines;
248
+ return {
249
+ id: page.id,
250
+ surface: page.surface,
251
+ title: page.title || page.id,
252
+ path: page.path || null,
253
+ ...countsFor(rounds),
254
+ drift,
255
+ open_round: openRound(rounds),
256
+ };
257
+ }
258
+
259
+ function orderedSurfaces(inventory) {
260
+ const ids = [];
261
+ for (const s of (inventory && inventory.surfaces) || []) if (s && s.id && !ids.includes(s.id)) ids.push(s.id);
262
+ for (const p of (inventory && inventory.pages) || []) if (p && p.surface && !ids.includes(p.surface)) ids.push(p.surface);
263
+ const rank = (id) => { const i = SURFACE_ORDER.indexOf(id); return i === -1 ? SURFACE_ORDER.length : i; };
264
+ return ids.map((id, i) => ({ id, i })).sort((a, b) => rank(a.id) - rank(b.id) || a.i - b.i).map((x) => x.id);
265
+ }
266
+
267
+ // GET /copy-desk/pages — every page's status, count and drift, plus the
268
+ // per-surface rollup (Artist Review Status).
269
+ function composePages({ inventory, readings, tasks }) {
270
+ const byPage = indexRounds(tasks);
271
+ const readingsOk = !!readings;
272
+ const rIdx = readingIndex(readings);
273
+ const list = ((inventory && inventory.pages) || []).map((p) => summaryFor(p, byPage.get(p.id) || [], rIdx.get(p.id), readingsOk));
274
+
275
+ const surfaces = orderedSurfaces(inventory).map((id) => {
276
+ const on = list.filter((p) => p.surface === id);
277
+ const tweaked = on.filter((p) => p.count >= 1).length;
278
+ return { id, label: SURFACE_LABELS[id] || id, tweaked, total: on.length, line: `${tweaked} of ${on.length} tweaked` };
279
+ });
280
+ const tweaked = list.filter((p) => p.count >= 1).length;
281
+ return {
282
+ pages: list,
283
+ surfaces,
284
+ totals: { tweaked, total: list.length, line: `${tweaked} of ${list.length} pages tweaked` },
285
+ };
286
+ }
287
+
288
+ // The ships that changed a page since its last round, as the payload carries them.
289
+ function shipList(ships) {
290
+ return (ships || []).map((s) => ({ task_id: String(s.id), title: s.title, shipped_at: iso(s.shipped_at) }));
291
+ }
292
+
293
+ // GET /copy-desk/pages/:pageId — one page, in full. `ships` is the list the
294
+ // route read for drift attribution: shipped tasks since the last round whose
295
+ // touches meet the page's files.
296
+ function composePage({ page, tasks, readingPage, readingsOk, ships }) {
297
+ const rounds = indexRounds(tasks).get(page.id) || [];
298
+ const drift = driftFor(readingPage, rounds, readingsOk);
299
+ return {
300
+ id: page.id,
301
+ surface: page.surface,
302
+ title: page.title || page.id,
303
+ path: page.path || null,
304
+ files: page.files || [],
305
+ ...countsFor(rounds),
306
+ changelog: changelogFor(rounds),
307
+ drift: {
308
+ ...drift,
309
+ ships: drift.since_task_id ? shipList(ships) : [],
310
+ },
311
+ open_round: openRound(rounds),
312
+ reading_hash: readingPage ? readingPage.reading_hash || null : null,
313
+ };
314
+ }
315
+
316
+ // GET /copy-desk/tally — the caller's corner tally.
317
+ function composeTally({ builderId, tasks, creditRows, now }) {
318
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
319
+ const since = nowMs - WEEK_MS;
320
+ let credits = 0;
321
+ let week = 0;
322
+ for (const row of creditRows || []) {
323
+ if (!row || !String(row.reason || '').startsWith(TALLY_REASON_PREFIX)) continue;
324
+ const d = Number(row.delta) || 0;
325
+ credits += d;
326
+ const at = new Date(row.recorded_at).getTime();
327
+ if (!Number.isNaN(at) && at >= since && at <= nowMs) week += d;
328
+ }
329
+ let pagesTweaked = 0;
330
+ for (const t of tasks || []) {
331
+ if (t && t.status === 'shipped' && pages.parseRoundRef(t.source_ref) && String(t.artist_id) === String(builderId)) pagesTweaked++;
332
+ }
333
+ return {
334
+ builder_id: String(builderId),
335
+ credits,
336
+ pages_tweaked: pagesTweaked,
337
+ this_week: week,
338
+ week_since: new Date(since).toISOString(),
339
+ reason_prefix: TALLY_REASON_PREFIX,
340
+ };
341
+ }
342
+
343
+ module.exports = {
344
+ TALLY_REASON_PREFIX,
345
+ SURFACE_ORDER,
346
+ SURFACE_LABELS,
347
+ STATUS,
348
+ indexRounds,
349
+ countsFor,
350
+ driftFor,
351
+ roundState,
352
+ changelogFor,
353
+ composePages,
354
+ composePage,
355
+ lastRoundShippedAt,
356
+ composeTally,
357
+ };
@@ -0,0 +1,204 @@
1
+ // modules/copy-desk/pages.js — the PAGE TWEAK task format (ADR 0341 D2, D4).
2
+ //
3
+ // A page tweak is an ordinary GDS task (source 'page-tweak', ref
4
+ // 'page-tweak/<page_id>/r<n>'), and the words rest in its DESCRIPTION, never in a
5
+ // table (ADR 0233 §2, amended by ADR 0341 D4). The description carries up to four
6
+ // fenced blocks, and this file is each block's ONE writer and ONE reader, tested
7
+ // against each other:
8
+ //
9
+ // ```page-tweak-draft the draft, while the artist writes (TW08 writes it)
10
+ // ```page-tweak the batch, frozen at submit (TW08)
11
+ // ```page-tweak-applied what the applying session landed (TW11); its
12
+ // lines_after is the baseline drift is measured from
13
+ // ```page-tweak-sent-back one per send-back, appended, never replaced (TW12)
14
+ //
15
+ // TW05 (task 1004316) is the first reader, so it lands the format whole: the
16
+ // status, changelog and drift reads parse all four, and the later links write
17
+ // through the composers here rather than hand-building a fence.
18
+ //
19
+ // PURE, like proposals.js and for the same reason: this file handles an artist's
20
+ // replacement wording, so it is the file most able to become a copy source by
21
+ // accident. No require, no fs, no DB, no network. copy_no_cms.mjs pins that.
22
+
23
+ 'use strict';
24
+
25
+ const SCHEMA = 1;
26
+ const SOURCE = 'page-tweak';
27
+
28
+ const FENCES = Object.freeze({
29
+ draft: 'page-tweak-draft',
30
+ batch: 'page-tweak',
31
+ applied: 'page-tweak-applied',
32
+ sentBack: 'page-tweak-sent-back',
33
+ });
34
+
35
+ // The page id shape the inventory's generator enforces: <surface>:<page>, both
36
+ // halves lowercase [a-z0-9-] (docs/page-inventory.json id_rule).
37
+ const PAGE_ID_RE = /^[a-z0-9-]+:[a-z0-9-]+$/;
38
+
39
+ function fail(code, detail) { return { ok: false, code, detail: detail || {} }; }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // The round's identity (ADR 0341 D2).
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function roundSourceRef(pageId, n) {
46
+ return `${SOURCE}/${pageId}/r${n}`;
47
+ }
48
+
49
+ // 'page-tweak/landing:index/r2' -> { page_id: 'landing:index', round: 2 }, or null.
50
+ function parseRoundRef(sourceRef) {
51
+ const m = /^page-tweak\/([a-z0-9-]+:[a-z0-9-]+)\/r([1-9][0-9]*)$/.exec(String(sourceRef || ''));
52
+ return m ? { page_id: m[1], round: Number(m[2]) } : null;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Fences. A block is ```<fence>\n<json>\n```, and the fence name must be followed
57
+ // by the end of its line: 'page-tweak' is a prefix of 'page-tweak-draft', so a
58
+ // looser match would read a draft as a batch.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function fenceBlock(fence, value) {
62
+ return ['```' + fence, JSON.stringify(value, null, 2), '```'].join('\n');
63
+ }
64
+
65
+ function findBlocks(markdown, fence) {
66
+ const re = new RegExp('```' + fence.replace(/-/g, '\\-') + '[ \\t]*\\r?\\n([\\s\\S]*?)\\r?\\n```', 'g');
67
+ return [...String(markdown || '').matchAll(re)].map((m) => m[1]);
68
+ }
69
+
70
+ // Parse the ONE block a description may carry for a single-writer fence. A second
71
+ // block is a named refusal, never a coin flip (the proposals.js rule).
72
+ function parseSingle(markdown, fence) {
73
+ const found = findBlocks(markdown, fence);
74
+ if (!found.length) return fail('no_block', { fence });
75
+ if (found.length > 1) return fail('multiple_blocks', { fence, count: found.length });
76
+ let value;
77
+ try { value = JSON.parse(found[0]); } catch (err) { return fail('block_unreadable', { fence, reason: err.message }); }
78
+ if (!value || Number(value.schema) !== SCHEMA) return fail('block_schema_unknown', { fence, expected: SCHEMA, got: value && value.schema });
79
+ return { ok: true, value };
80
+ }
81
+
82
+ function linesOk(lines) {
83
+ return Array.isArray(lines) && lines.every((l) => l && typeof l.key === 'string'
84
+ && typeof l.before === 'string' && typeof l.after === 'string');
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // The four blocks.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function composeDraftBlock(d) {
92
+ return fenceBlock(FENCES.draft, {
93
+ schema: SCHEMA,
94
+ page_id: d.page_id,
95
+ reading_hash: d.reading_hash,
96
+ saved_at: d.saved_at,
97
+ lines: (d.lines || []).map((l) => ({ key: l.key, section: l.section, before: l.before, after: l.after, placement: l.placement })),
98
+ });
99
+ }
100
+
101
+ function parseDraftBlock(markdown) {
102
+ const r = parseSingle(markdown, FENCES.draft);
103
+ if (!r.ok) return r;
104
+ if (!PAGE_ID_RE.test(String(r.value.page_id || ''))) return fail('block_field_missing', { fence: FENCES.draft, field: 'page_id' });
105
+ if (!linesOk(r.value.lines)) return fail('block_field_missing', { fence: FENCES.draft, field: 'lines' });
106
+ return { ok: true, draft: r.value };
107
+ }
108
+
109
+ // The batch. `kind` is reserved for visual tweaks (ADR 0341 D8: the UI count
110
+ // reads batches whose kind is 'ui'); it is written only when given, so a text
111
+ // batch today carries no such field and reads as text.
112
+ function composeBatchBlock(b) {
113
+ const out = {
114
+ schema: SCHEMA,
115
+ page_id: b.page_id,
116
+ reading_hash: b.reading_hash,
117
+ submitted_at: b.submitted_at,
118
+ };
119
+ if (b.kind) out.kind = b.kind;
120
+ out.lines = (b.lines || []).map((l) => ({
121
+ key: l.key, section: l.section, before: l.before, after: l.after, placement: l.placement,
122
+ file: l.file == null ? null : l.file, line: l.line == null ? null : l.line, string_id: l.string_id == null ? null : l.string_id,
123
+ }));
124
+ out.unplaced_tasks = Array.isArray(b.unplaced_tasks) ? b.unplaced_tasks : [];
125
+ return fenceBlock(FENCES.batch, out);
126
+ }
127
+
128
+ function parseBatchBlock(markdown) {
129
+ const r = parseSingle(markdown, FENCES.batch);
130
+ if (!r.ok) return r;
131
+ if (!PAGE_ID_RE.test(String(r.value.page_id || ''))) return fail('block_field_missing', { fence: FENCES.batch, field: 'page_id' });
132
+ if (!linesOk(r.value.lines)) return fail('block_field_missing', { fence: FENCES.batch, field: 'lines' });
133
+ return { ok: true, batch: r.value };
134
+ }
135
+
136
+ function composeAppliedBlock(a) {
137
+ return fenceBlock(FENCES.applied, {
138
+ schema: SCHEMA,
139
+ page_id: a.page_id,
140
+ applied_at: a.applied_at,
141
+ reading_hash_after: a.reading_hash_after,
142
+ lines_after: (a.lines_after || []).map(String),
143
+ refused: (a.refused || []).map((x) => ({ key: x.key, code: x.code })),
144
+ });
145
+ }
146
+
147
+ function parseAppliedBlock(markdown) {
148
+ const r = parseSingle(markdown, FENCES.applied);
149
+ if (!r.ok) return r;
150
+ const lines = r.value.lines_after;
151
+ if (!Array.isArray(lines) || !lines.every((t) => typeof t === 'string')) {
152
+ return fail('block_field_missing', { fence: FENCES.applied, field: 'lines_after' });
153
+ }
154
+ return { ok: true, applied: r.value };
155
+ }
156
+
157
+ // Send-backs APPEND, so this block has no schema of its own and many may exist.
158
+ function composeSentBackBlock(s) {
159
+ return fenceBlock(FENCES.sentBack, { at: s.at, by: s.by, note: s.note });
160
+ }
161
+
162
+ function parseSentBackBlocks(markdown) {
163
+ const out = [];
164
+ for (const raw of findBlocks(markdown, FENCES.sentBack)) {
165
+ try {
166
+ const v = JSON.parse(raw);
167
+ if (v && typeof v.at === 'string') out.push({ at: v.at, by: v.by == null ? null : v.by, note: String(v.note || '') });
168
+ } catch { /* an unreadable send-back is skipped; the rest still count */ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ // Which blocks a description carries, without failing on any one of them. The
174
+ // round-state derivation (ADR 0341 D7) reads presence and times, not contents.
175
+ function blockPresence(markdown) {
176
+ const sent = parseSentBackBlocks(markdown);
177
+ const applied = parseAppliedBlock(markdown);
178
+ return {
179
+ draft: findBlocks(markdown, FENCES.draft).length > 0,
180
+ batch: findBlocks(markdown, FENCES.batch).length > 0,
181
+ applied: findBlocks(markdown, FENCES.applied).length > 0,
182
+ applied_at: applied.ok ? (applied.applied.applied_at || null) : null,
183
+ sent_back_count: sent.length,
184
+ last_sent_back_at: sent.length ? sent.map((s) => s.at).sort().pop() : null,
185
+ };
186
+ }
187
+
188
+ module.exports = {
189
+ SCHEMA,
190
+ SOURCE,
191
+ FENCES,
192
+ PAGE_ID_RE,
193
+ roundSourceRef,
194
+ parseRoundRef,
195
+ composeDraftBlock,
196
+ parseDraftBlock,
197
+ composeBatchBlock,
198
+ parseBatchBlock,
199
+ composeAppliedBlock,
200
+ parseAppliedBlock,
201
+ composeSentBackBlock,
202
+ parseSentBackBlocks,
203
+ blockPresence,
204
+ };