@bongos/core 1.19.1057 → 1.19.1059
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.
- package/.bongos-core.json +88 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/api/openapi.json +235 -2
- package/docs/api-reference.md +7 -2
- package/docs/architecture.md +2 -1
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1178 -0
- package/docs/page-readings.json +4108 -0
- package/modules/copy-desk/flags.js +84 -9
- package/modules/copy-desk/migrations/copy_desk_002_page_asks.sql +62 -0
- package/modules/copy-desk/page-data.js +79 -0
- package/modules/copy-desk/page-status.js +357 -0
- package/modules/copy-desk/pages.js +238 -0
- package/modules/copy-desk/routes/copy-desk.js +357 -4
- package/modules/copy-desk/tests/copy_flags.mjs +52 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +63 -17
- package/modules/copy-desk/tests/copy_page_status.mjs +240 -0
- package/modules/copy-desk/tests/fixtures/page-tweak.cjs +154 -0
- package/modules/lifecycle/lifecycle.js +14 -0
- package/modules/lifecycle/page-tweak-claim.js +184 -0
- package/modules/lifecycle/page-tweak-reads.js +87 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/publish-manifest.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_flags_db.mjs +44 -0
- package/tests/copy_desk_page_claim.mjs +334 -0
- package/tests/copy_desk_page_reads.mjs +194 -0
|
@@ -28,7 +28,12 @@ const { pool } = require('../../src/module-api');
|
|
|
28
28
|
// Mirrors of the DB CHECK constraints — enforced here first so a bad payload
|
|
29
29
|
// gets a clean, named 400 instead of a raw constraint violation surfacing as a
|
|
30
30
|
// 500. The DB stays the backstop; this is the error message.
|
|
31
|
-
|
|
31
|
+
// SCOPES mirrors the DB CHECK (widened by copy_desk_002_page_asks.sql). A
|
|
32
|
+
// 'page' row is a PAGE ASK (ADR 0341 D6) and is filed only through
|
|
33
|
+
// POST /copy-desk/pages/:pageId/asks, so the flag route takes FLAG_SCOPES.
|
|
34
|
+
const SCOPES = Object.freeze(['string', 'surface', 'page']);
|
|
35
|
+
const FLAG_SCOPES = Object.freeze(['string', 'surface']);
|
|
36
|
+
const PAGE_SCOPE = 'page';
|
|
32
37
|
const STATUSES = Object.freeze(['open', 'resolved', 'dismissed']);
|
|
33
38
|
const KINDS = Object.freeze(['slop', 'off-voice', 'stale', 'unclear', 'duplicate', 'typo', 'other']);
|
|
34
39
|
// The closing verdicts a person can apply to an open flag. 'open' is not among
|
|
@@ -65,7 +70,11 @@ function normalizeFlagPayload(body, registryIndex, registryHelpers) {
|
|
|
65
70
|
const b = body || {};
|
|
66
71
|
|
|
67
72
|
const scope = String(b.scope || 'string');
|
|
68
|
-
if (!
|
|
73
|
+
if (!FLAG_SCOPES.includes(scope)) {
|
|
74
|
+
return fail('bad_scope', scope === PAGE_SCOPE
|
|
75
|
+
? { allowed: FLAG_SCOPES, why: 'A page is asked for at POST /copy-desk/pages/:pageId/asks.' }
|
|
76
|
+
: { allowed: FLAG_SCOPES });
|
|
77
|
+
}
|
|
69
78
|
|
|
70
79
|
const surface = String(b.surface || '');
|
|
71
80
|
if (!surface) return fail('surface_required');
|
|
@@ -155,6 +164,31 @@ function normalizeResolution(body) {
|
|
|
155
164
|
return { ok: true, value: { status, resolution_note: note, resolved_task_id: taskId } };
|
|
156
165
|
}
|
|
157
166
|
|
|
167
|
+
// Validate a PAGE ASK (task 1004317 / TW06, ADR 0341 D6). PURE. The page id is
|
|
168
|
+
// the route's, already checked against the inventory; the body carries only the
|
|
169
|
+
// reason, held to the flag's own floor. `surface` carries the page id too: that
|
|
170
|
+
// is what makes 001's one-open-flag-per-target index mean one open ask per
|
|
171
|
+
// (builder, page) — see copy_desk_002_page_asks.sql.
|
|
172
|
+
function normalizePageAsk(body, pageId) {
|
|
173
|
+
const b = body || {};
|
|
174
|
+
const reason = tidy(b.reason);
|
|
175
|
+
if (reason.length < MIN_REASON_CHARS) {
|
|
176
|
+
return fail('reason_too_thin', {
|
|
177
|
+
min_chars: MIN_REASON_CHARS,
|
|
178
|
+
got: reason.length,
|
|
179
|
+
why: 'An ask tells an artist why this page, before the others. One line is enough; a word is not.',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (reason.length > MAX_REASON_CHARS) return fail('reason_too_long', { max_chars: MAX_REASON_CHARS });
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
value: {
|
|
186
|
+
scope: PAGE_SCOPE, surface: pageId, page_id: pageId, string_id: null, reason, kind: 'other',
|
|
187
|
+
text_at_flag: null, file_at_flag: null, line_at_flag: null, registry_schema: null,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
158
192
|
// ---------------------------------------------------------------------------
|
|
159
193
|
// SQL. Thin by design — the rules live above and in the migration's constraints.
|
|
160
194
|
// ---------------------------------------------------------------------------
|
|
@@ -165,7 +199,7 @@ function normalizeResolution(body) {
|
|
|
165
199
|
// shape, which is what lets the hall render an insert response and a queue row
|
|
166
200
|
// with one function.
|
|
167
201
|
const FLAG_COL_NAMES = Object.freeze([
|
|
168
|
-
'id', 'scope', 'surface', 'string_id', 'reason', 'kind',
|
|
202
|
+
'id', 'scope', 'surface', 'string_id', 'page_id', 'reason', 'kind',
|
|
169
203
|
'text_at_flag', 'file_at_flag', 'line_at_flag', 'registry_schema',
|
|
170
204
|
'flagged_by', 'flagged_at', 'status', 'resolution_note',
|
|
171
205
|
'resolved_task_id', 'resolved_by', 'resolved_at',
|
|
@@ -176,11 +210,11 @@ const FLAG_COLS_BARE = FLAG_COL_NAMES.join(', ');
|
|
|
176
210
|
async function insertFlag(value, builderId) {
|
|
177
211
|
const { rows } = await pool.query(
|
|
178
212
|
`INSERT INTO copy_desk_flags
|
|
179
|
-
(scope, surface, string_id, reason, kind, text_at_flag, file_at_flag, line_at_flag, registry_schema, flagged_by)
|
|
180
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
213
|
+
(scope, surface, string_id, reason, kind, text_at_flag, file_at_flag, line_at_flag, registry_schema, flagged_by, page_id)
|
|
214
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
181
215
|
RETURNING ${FLAG_COLS_BARE}`,
|
|
182
216
|
[value.scope, value.surface, value.string_id, value.reason, value.kind,
|
|
183
|
-
value.text_at_flag, value.file_at_flag, value.line_at_flag, value.registry_schema, builderId]
|
|
217
|
+
value.text_at_flag, value.file_at_flag, value.line_at_flag, value.registry_schema, builderId, value.page_id ?? null]
|
|
184
218
|
);
|
|
185
219
|
return rows[0];
|
|
186
220
|
}
|
|
@@ -191,9 +225,12 @@ async function insertFlag(value, builderId) {
|
|
|
191
225
|
//
|
|
192
226
|
// Joined to builders for the display name: a flag is a judgement by a PERSON,
|
|
193
227
|
// and an artist deciding how much weight to give it needs to know whose it is.
|
|
194
|
-
|
|
228
|
+
// `scopes` narrows by scope. The copy queue passes FLAG_SCOPES: page asks are
|
|
229
|
+
// the recommendation's signal (TW07), not rows in the string queue.
|
|
230
|
+
async function listFlags({ status = 'open', surface = null, stringId = null, scopes = null, limit = 200 } = {}) {
|
|
195
231
|
const where = [];
|
|
196
232
|
const args = [];
|
|
233
|
+
if (Array.isArray(scopes) && scopes.length) { args.push([...scopes]); where.push(`f.scope = ANY($${args.length}::text[])`); }
|
|
197
234
|
if (status && status !== 'all') { args.push(status); where.push(`f.status = $${args.length}`); }
|
|
198
235
|
if (surface) { args.push(surface); where.push(`f.surface = $${args.length}`); }
|
|
199
236
|
if (stringId) { args.push(stringId); where.push(`f.string_id = $${args.length}`); }
|
|
@@ -238,8 +275,46 @@ async function closeFlag(id, value, builderId) {
|
|
|
238
275
|
return rows[0] || null;
|
|
239
276
|
}
|
|
240
277
|
|
|
278
|
+
// The caller's OPEN ask on a page, if any: a second ask returns the first
|
|
279
|
+
// (ADR 0341 D6), rather than a 409 the person has to read.
|
|
280
|
+
async function findOpenPageAsk(pageId, builderId) {
|
|
281
|
+
const { rows } = await pool.query(
|
|
282
|
+
`SELECT ${FLAG_COLS} FROM copy_desk_flags f
|
|
283
|
+
WHERE f.scope = 'page' AND f.page_id = $1 AND f.flagged_by = $2 AND f.status = 'open'
|
|
284
|
+
ORDER BY f.id ASC LIMIT 1`,
|
|
285
|
+
[pageId, builderId]
|
|
286
|
+
);
|
|
287
|
+
return rows[0] || null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// "N people asked for it": distinct askers among a page's OPEN asks.
|
|
291
|
+
async function countPageAskers(pageId) {
|
|
292
|
+
const { rows } = await pool.query(
|
|
293
|
+
`SELECT COUNT(DISTINCT flagged_by)::int AS askers FROM copy_desk_flags
|
|
294
|
+
WHERE scope = 'page' AND page_id = $1 AND status = 'open'`,
|
|
295
|
+
[pageId]
|
|
296
|
+
);
|
|
297
|
+
return rows[0] ? Number(rows[0].askers) : 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// An open ask is RESOLVED when the page's next tweak ships (ADR 0341 D6): every
|
|
301
|
+
// ask on the page filed before that ship, stamped with the tweak task. Guarded on
|
|
302
|
+
// status = 'open', so a re-run changes nothing and a dismissed ask stays dismissed.
|
|
303
|
+
async function resolvePageAsks({ pageId, taskId, resolvedBy, shippedAt }) {
|
|
304
|
+
const { rows } = await pool.query(
|
|
305
|
+
`UPDATE copy_desk_flags
|
|
306
|
+
SET status = 'resolved', resolved_task_id = $2, resolved_by = $3, resolved_at = now(),
|
|
307
|
+
resolution_note = $5
|
|
308
|
+
WHERE scope = 'page' AND page_id = $1 AND status = 'open' AND flagged_at <= $4::timestamptz
|
|
309
|
+
RETURNING id`,
|
|
310
|
+
[pageId, taskId, resolvedBy, shippedAt, `Resolved by the page's tweak shipping (task ${taskId}).`]
|
|
311
|
+
);
|
|
312
|
+
return rows.map((r) => r.id);
|
|
313
|
+
}
|
|
314
|
+
|
|
241
315
|
module.exports = {
|
|
242
|
-
SCOPES, STATUSES, KINDS, CLOSING_STATUSES, MIN_REASON_CHARS, MAX_REASON_CHARS, MAX_NOTE_CHARS,
|
|
243
|
-
tidy, normalizeFlagPayload, normalizeResolution,
|
|
316
|
+
SCOPES, FLAG_SCOPES, PAGE_SCOPE, STATUSES, KINDS, CLOSING_STATUSES, MIN_REASON_CHARS, MAX_REASON_CHARS, MAX_NOTE_CHARS,
|
|
317
|
+
tidy, normalizeFlagPayload, normalizeResolution, normalizePageAsk,
|
|
244
318
|
insertFlag, listFlags, getFlag, closeFlag,
|
|
319
|
+
findOpenPageAsk, countPageAskers, resolvePageAsks,
|
|
245
320
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- copy_desk_002_page_asks.sql — the PAGE ASK: anyone asks for a whole page to be
|
|
2
|
+
-- tweaked, with a one-line reason (task 1004317 / BV2.TW06, ADR 0341 D6).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT CHANGES. copy_desk_flags gains a third scope, 'page', and a nullable
|
|
5
|
+
-- `page_id` naming a page of the page inventory (docs/page-inventory.json,
|
|
6
|
+
-- `<surface>:<page>`). A page ask IS a flag: it keeps the flag's reason floor
|
|
7
|
+
-- (copy_desk_flags_reason_chk, untouched), its open / resolved / dismissed
|
|
8
|
+
-- lifecycle and its closed-stamp rule. It is resolved when the page's next tweak
|
|
9
|
+
-- ships (resolved_task_id = that tweak task), never auto-dismissed.
|
|
10
|
+
--
|
|
11
|
+
-- THE TARGET RULE. `string_id` is present exactly for scope 'string' and
|
|
12
|
+
-- `page_id` exactly for scope 'page'. Both halves of each, as in 001: a page ask
|
|
13
|
+
-- with no page points nowhere, and a string flag carrying a page would be a
|
|
14
|
+
-- second target nobody reads.
|
|
15
|
+
--
|
|
16
|
+
-- WHY `surface` HOLDS THE PAGE ID ON A PAGE ASK. The one-open-flag-per-target
|
|
17
|
+
-- index from 001 keys on (flagged_by, surface, COALESCE(string_id, '')). A page
|
|
18
|
+
-- ask has no string_id, so if its surface were the page's surface ('builders')
|
|
19
|
+
-- one builder could ask for only ONE page per surface: the second ask would
|
|
20
|
+
-- collide with the first. Storing the page id there too makes the existing index
|
|
21
|
+
-- mean "one open ask per (builder, page)", which is the rule ADR 0341 D6 wants,
|
|
22
|
+
-- with no index dropped (a module migration stays additive). A page id always
|
|
23
|
+
-- contains a ':' and a registry surface id never does, so a page ask can never
|
|
24
|
+
-- collide with a surface flag. copy_desk_flags_page_surface_chk pins the pair.
|
|
25
|
+
--
|
|
26
|
+
-- NO LIVE CMS still holds (ADR 0178 §3): the one new column is a POINTER, a page
|
|
27
|
+
-- id. Nothing here can hold replacement text; copy_no_cms.mjs lists page_id as
|
|
28
|
+
-- the only text column this migration adds.
|
|
29
|
+
--
|
|
30
|
+
-- ADDITIVE, per ADR 0083 §Decision #5: the two CHECKs are dropped and re-added
|
|
31
|
+
-- under the SAME names in this file (the widening shape the fitness gate
|
|
32
|
+
-- allows), and existing rows all satisfy the new forms (they are 'string' or
|
|
33
|
+
-- 'surface' rows with page_id NULL). Idempotent: safe to re-run.
|
|
34
|
+
|
|
35
|
+
BEGIN;
|
|
36
|
+
|
|
37
|
+
ALTER TABLE copy_desk_flags ADD COLUMN IF NOT EXISTS page_id text;
|
|
38
|
+
|
|
39
|
+
ALTER TABLE copy_desk_flags DROP CONSTRAINT IF EXISTS copy_desk_flags_scope_chk;
|
|
40
|
+
ALTER TABLE copy_desk_flags
|
|
41
|
+
ADD CONSTRAINT copy_desk_flags_scope_chk CHECK (scope IN ('string', 'surface', 'page'));
|
|
42
|
+
|
|
43
|
+
ALTER TABLE copy_desk_flags DROP CONSTRAINT IF EXISTS copy_desk_flags_target_chk;
|
|
44
|
+
ALTER TABLE copy_desk_flags
|
|
45
|
+
ADD CONSTRAINT copy_desk_flags_target_chk CHECK (
|
|
46
|
+
((scope = 'string') = (string_id IS NOT NULL))
|
|
47
|
+
AND ((scope = 'page') = (page_id IS NOT NULL))
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
ALTER TABLE copy_desk_flags DROP CONSTRAINT IF EXISTS copy_desk_flags_page_surface_chk;
|
|
51
|
+
ALTER TABLE copy_desk_flags
|
|
52
|
+
ADD CONSTRAINT copy_desk_flags_page_surface_chk CHECK (scope <> 'page' OR surface = page_id);
|
|
53
|
+
|
|
54
|
+
-- The recommendation's read (TW07): open asks per page, counted by distinct
|
|
55
|
+
-- asker, oldest first.
|
|
56
|
+
CREATE INDEX IF NOT EXISTS copy_desk_flags_page_open_idx
|
|
57
|
+
ON copy_desk_flags (page_id, flagged_at) WHERE scope = 'page' AND status = 'open';
|
|
58
|
+
|
|
59
|
+
INSERT INTO schema_migrations (version) VALUES ('copy_desk_002_page_asks')
|
|
60
|
+
ON CONFLICT (version) DO NOTHING;
|
|
61
|
+
|
|
62
|
+
COMMIT;
|
|
@@ -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
|
+
};
|