@bongos/core 1.19.1058 → 1.19.1060
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 +58 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +159 -2
- package/docs/api-reference.md +5 -2
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/flags.js +97 -9
- package/modules/copy-desk/migrations/copy_desk_002_page_asks.sql +62 -0
- package/modules/copy-desk/page-status.js +177 -3
- package/modules/copy-desk/pages.js +34 -0
- package/modules/copy-desk/routes/copy-desk.js +253 -14
- package/modules/copy-desk/tests/copy_flags.mjs +52 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +47 -11
- package/modules/copy-desk/tests/copy_page_next.mjs +184 -0
- package/modules/lifecycle/lifecycle.js +7 -0
- package/modules/lifecycle/page-tweak-claim.js +184 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_flags_db.mjs +56 -0
- package/tests/copy_desk_page_claim.mjs +219 -0
- package/tests/copy_desk_page_next.mjs +172 -0
- package/tests/fixtures/page-ledger.mjs +175 -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,59 @@ 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
|
+
// The recommendation's signal (TW07, ADR 0341 D8): every page with an open
|
|
301
|
+
// ask, its distinct askers and its OLDEST open ask (the tie-break). One grouped
|
|
302
|
+
// read over copy_desk_flags_page_open_idx, never a count per page.
|
|
303
|
+
async function pageAskSummary() {
|
|
304
|
+
const { rows } = await pool.query(
|
|
305
|
+
`SELECT page_id, COUNT(DISTINCT flagged_by)::int AS askers, MIN(flagged_at) AS oldest_ask_at
|
|
306
|
+
FROM copy_desk_flags
|
|
307
|
+
WHERE scope = 'page' AND status = 'open'
|
|
308
|
+
GROUP BY page_id`
|
|
309
|
+
);
|
|
310
|
+
return rows.map((r) => ({ page_id: r.page_id, askers: Number(r.askers) || 0, oldest_ask_at: r.oldest_ask_at }));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// An open ask is RESOLVED when the page's next tweak ships (ADR 0341 D6): every
|
|
314
|
+
// ask on the page filed before that ship, stamped with the tweak task. Guarded on
|
|
315
|
+
// status = 'open', so a re-run changes nothing and a dismissed ask stays dismissed.
|
|
316
|
+
async function resolvePageAsks({ pageId, taskId, resolvedBy, shippedAt }) {
|
|
317
|
+
const { rows } = await pool.query(
|
|
318
|
+
`UPDATE copy_desk_flags
|
|
319
|
+
SET status = 'resolved', resolved_task_id = $2, resolved_by = $3, resolved_at = now(),
|
|
320
|
+
resolution_note = $5
|
|
321
|
+
WHERE scope = 'page' AND page_id = $1 AND status = 'open' AND flagged_at <= $4::timestamptz
|
|
322
|
+
RETURNING id`,
|
|
323
|
+
[pageId, taskId, resolvedBy, shippedAt, `Resolved by the page's tweak shipping (task ${taskId}).`]
|
|
324
|
+
);
|
|
325
|
+
return rows.map((r) => r.id);
|
|
326
|
+
}
|
|
327
|
+
|
|
241
328
|
module.exports = {
|
|
242
|
-
SCOPES, STATUSES, KINDS, CLOSING_STATUSES, MIN_REASON_CHARS, MAX_REASON_CHARS, MAX_NOTE_CHARS,
|
|
243
|
-
tidy, normalizeFlagPayload, normalizeResolution,
|
|
329
|
+
SCOPES, FLAG_SCOPES, PAGE_SCOPE, STATUSES, KINDS, CLOSING_STATUSES, MIN_REASON_CHARS, MAX_REASON_CHARS, MAX_NOTE_CHARS,
|
|
330
|
+
tidy, normalizeFlagPayload, normalizeResolution, normalizePageAsk,
|
|
244
331
|
insertFlag, listFlags, getFlag, closeFlag,
|
|
332
|
+
findOpenPageAsk, countPageAskers, pageAskSummary, resolvePageAsks,
|
|
245
333
|
};
|
|
@@ -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;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// modules/copy-desk/page-status.js — the
|
|
2
|
-
// (task 1004316 / BV2.TW05,
|
|
1
|
+
// modules/copy-desk/page-status.js — the Tweak Mode READS, derived
|
|
2
|
+
// (task 1004316 / BV2.TW05, and the recommendation, task 1004318 / BV2.TW07;
|
|
3
|
+
// ADR 0341 D7 and D8).
|
|
3
4
|
//
|
|
4
5
|
// There is NO status table, by the planning session's ruling: a table would be a
|
|
5
6
|
// second record of what the task ledger already says. Everything here is a pure
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
// * docs/page-readings.json (what each page says now), for drift only;
|
|
14
15
|
// * the caller's `tweak.page_approved:` credit rows, for the tally.
|
|
15
16
|
//
|
|
16
|
-
// The
|
|
17
|
+
// The four reads:
|
|
17
18
|
//
|
|
18
19
|
// PER PAGE status (Untweaked / Text tweaked / UI tweaked / Both) with its
|
|
19
20
|
// count ("Text tweaked 2x"), the changelog, and the lines changed
|
|
@@ -22,6 +23,8 @@
|
|
|
22
23
|
// shipped round, and drift does not lower it.
|
|
23
24
|
// PER ARTIST the studio corner's tally: credits from approved pages, pages
|
|
24
25
|
// tweaked, and this week's gain.
|
|
26
|
+
// NEXT the recommendation (TW07): the page to tweak next, its why,
|
|
27
|
+
// and the next few for "pick another" (composeNext, below).
|
|
25
28
|
//
|
|
26
29
|
// Pure: no fs, no DB, no clock (the caller passes `now`). The route owns the I/O.
|
|
27
30
|
|
|
@@ -340,8 +343,179 @@ function composeTally({ builderId, tasks, creditRows, now }) {
|
|
|
340
343
|
};
|
|
341
344
|
}
|
|
342
345
|
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// The recommendation, "what should I tweak next" (task 1004318 / BV2.TW07,
|
|
348
|
+
// ADR 0341 D8). The fourth read, over the same ledger rows as the other three
|
|
349
|
+
// plus one more signal: the open page asks (flags.pageAskSummary()).
|
|
350
|
+
//
|
|
351
|
+
// WHO MAY BE RECOMMENDED A PAGE. Exactly the pages the claim route would hand
|
|
352
|
+
// the caller, decided by the claim's own rule (modules/lifecycle/
|
|
353
|
+
// page-tweak-claim.js) over the same fields, so "take next" on the answer can
|
|
354
|
+
// never be refused for a reason this read already knew:
|
|
355
|
+
//
|
|
356
|
+
// no open round free: the claim creates round n+1
|
|
357
|
+
// open, held by someone else SKIPPED (page_held)
|
|
358
|
+
// open, the caller's own web claim RESUME, reported apart, never a
|
|
359
|
+
// recommendation (the caller is already
|
|
360
|
+
// writing it)
|
|
361
|
+
// open, the caller's own CLI claim SKIPPED: they are applying it (in flight)
|
|
362
|
+
// open, unheld, roundOpenForWriting free: an unheld draft, taken over
|
|
363
|
+
// open, unheld, anything else SKIPPED (page_in_flight)
|
|
364
|
+
//
|
|
365
|
+
// THE ORDER (D8). Four tiers, and within each its own ties, then the base
|
|
366
|
+
// order (surface landing, builders, status, then inventory order):
|
|
367
|
+
//
|
|
368
|
+
// asked open asks: most distinct askers first, then the OLDEST ask
|
|
369
|
+
// changed tweaked and drifted: most changed lines first
|
|
370
|
+
// untweaked count 0
|
|
371
|
+
// tweaked tweaked with no drift, or drift unknown: last
|
|
372
|
+
//
|
|
373
|
+
// The mock's sample list puts an untweaked page before a changed one; the
|
|
374
|
+
// spec's order wins and the mock's list is sample data (ADR 0341 pick 8).
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
const NEXT_ALTERNATIVES = 4; // the mock's "pick another" row holds four
|
|
378
|
+
const NEXT_TIERS = Object.freeze(['asked', 'changed', 'untweaked', 'tweaked']);
|
|
379
|
+
|
|
380
|
+
function plural(n, one, many) { return `${n} ${n === 1 ? one : many}`; }
|
|
381
|
+
|
|
382
|
+
// Where the caller stands with one page, by the claim route's own rule.
|
|
383
|
+
function availability(rounds, builderId) {
|
|
384
|
+
const open = [...(rounds || [])].reverse().find((r) => !TERMINAL.has(r.status)) || null;
|
|
385
|
+
if (!open) return { kind: 'free', open: null };
|
|
386
|
+
if (open.holder_id != null) {
|
|
387
|
+
if (String(open.holder_id) !== String(builderId)) {
|
|
388
|
+
return { kind: 'held', open, holder: person(open.holder_id, open.holder_login) };
|
|
389
|
+
}
|
|
390
|
+
return open.holder_worktree ? { kind: 'in_flight', open } : { kind: 'resume', open };
|
|
391
|
+
}
|
|
392
|
+
return pages.roundOpenForWriting(open) ? { kind: 'free', open } : { kind: 'in_flight', open };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function surfaceWord(id) {
|
|
396
|
+
return (SURFACE_LABELS[id] || id).toLowerCase();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function candidateFor({ page, rounds, readingPage, readingsOk, ask, builderId, avail }) {
|
|
400
|
+
const counts = countsFor(rounds);
|
|
401
|
+
const drift = driftFor(readingPage, rounds, readingsOk);
|
|
402
|
+
const askers = ask ? ask.askers : 0;
|
|
403
|
+
// "Lines changed": a reworded line is one line that differs on each side of
|
|
404
|
+
// the multiset, so it counts once; a line only added, or only removed, still
|
|
405
|
+
// counts. The larger side is that number.
|
|
406
|
+
const changedLines = drift.known && drift.changed ? Math.max(drift.changed_count, drift.removed_count) : 0;
|
|
407
|
+
const last = latestShipped(rounds);
|
|
408
|
+
const yours = !!last && last.artist_id != null && String(last.artist_id) === String(builderId);
|
|
409
|
+
|
|
410
|
+
const parts = [];
|
|
411
|
+
if (askers > 0) parts.push(`${plural(askers, 'person', 'people')} asked for it`);
|
|
412
|
+
if (counts.count === 0) parts.push('untweaked');
|
|
413
|
+
else if (changedLines > 0) parts.push(`${plural(changedLines, 'line', 'lines')} changed since ${yours ? 'your' : 'the'} last pass`);
|
|
414
|
+
else parts.push(drift.known ? `${counts.status_label.toLowerCase()}, nothing changed since` : counts.status_label.toLowerCase());
|
|
415
|
+
|
|
416
|
+
let tier = 'tweaked';
|
|
417
|
+
if (askers > 0) tier = 'asked';
|
|
418
|
+
else if (changedLines > 0) tier = 'changed';
|
|
419
|
+
else if (counts.count === 0) tier = 'untweaked';
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
page_id: page.id,
|
|
423
|
+
surface: page.surface,
|
|
424
|
+
title: page.title || page.id,
|
|
425
|
+
path: page.path || null,
|
|
426
|
+
tier,
|
|
427
|
+
why: parts[0],
|
|
428
|
+
why_line: [surfaceWord(page.surface), ...parts].join(' · '),
|
|
429
|
+
askers,
|
|
430
|
+
oldest_ask_at: ask ? iso(ask.oldest_ask_at) : null,
|
|
431
|
+
changed_lines: changedLines,
|
|
432
|
+
drift_known: drift.known,
|
|
433
|
+
count: counts.count,
|
|
434
|
+
status_label: counts.status_label,
|
|
435
|
+
// An unheld draft the claim takes over, so the take is honest about it.
|
|
436
|
+
draft_round: avail.open ? { task_id: String(avail.open.id), round: avail.open.round } : null,
|
|
437
|
+
take: { method: 'POST', path: `/copy-desk/pages/${page.id}/claim` },
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function compareCandidates(a, b) {
|
|
442
|
+
const t = NEXT_TIERS.indexOf(a.tier) - NEXT_TIERS.indexOf(b.tier);
|
|
443
|
+
if (t) return t;
|
|
444
|
+
if (a.tier === 'asked') {
|
|
445
|
+
if (a.askers !== b.askers) return b.askers - a.askers;
|
|
446
|
+
const at = a.oldest_ask_at ? Date.parse(a.oldest_ask_at) : Infinity;
|
|
447
|
+
const bt = b.oldest_ask_at ? Date.parse(b.oldest_ask_at) : Infinity;
|
|
448
|
+
if (at !== bt) return at - bt;
|
|
449
|
+
}
|
|
450
|
+
if (a.tier === 'changed' && a.changed_lines !== b.changed_lines) return b.changed_lines - a.changed_lines;
|
|
451
|
+
return a._base - b._base;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// GET /copy-desk/next — the recommended page for `builderId`, its why, and
|
|
455
|
+
// the next few for "pick another". `asks` is flags.pageAskSummary()'s rows.
|
|
456
|
+
function composeNext({ inventory, readings, tasks, asks, builderId, alternatives = NEXT_ALTERNATIVES }) {
|
|
457
|
+
const byPage = indexRounds(tasks);
|
|
458
|
+
const readingsOk = !!readings;
|
|
459
|
+
const rIdx = readingIndex(readings);
|
|
460
|
+
const askIdx = new Map();
|
|
461
|
+
for (const a of asks || []) if (a && a.page_id && Number(a.askers) > 0) askIdx.set(a.page_id, { askers: Number(a.askers), oldest_ask_at: a.oldest_ask_at });
|
|
462
|
+
|
|
463
|
+
const surfaceRank = new Map(orderedSurfaces(inventory).map((id, i) => [id, i]));
|
|
464
|
+
const invPages = ((inventory && inventory.pages) || []).filter((p) => p && p.id);
|
|
465
|
+
const base = new Map(invPages
|
|
466
|
+
.map((p, i) => ({ id: p.id, s: surfaceRank.has(p.surface) ? surfaceRank.get(p.surface) : surfaceRank.size, i }))
|
|
467
|
+
.sort((a, b) => a.s - b.s || a.i - b.i)
|
|
468
|
+
.map((x, k) => [x.id, k]));
|
|
469
|
+
|
|
470
|
+
let resume = null;
|
|
471
|
+
const skipped = [];
|
|
472
|
+
const candidates = [];
|
|
473
|
+
for (const page of invPages) {
|
|
474
|
+
const rounds = byPage.get(page.id) || [];
|
|
475
|
+
const avail = availability(rounds, builderId);
|
|
476
|
+
if (avail.kind === 'resume') {
|
|
477
|
+
resume = {
|
|
478
|
+
page_id: page.id, surface: page.surface, title: page.title || page.id, path: page.path || null,
|
|
479
|
+
task_id: String(avail.open.id), round: avail.open.round,
|
|
480
|
+
take: { method: 'POST', path: `/copy-desk/pages/${page.id}/claim` },
|
|
481
|
+
};
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (avail.kind === 'held' || avail.kind === 'in_flight') {
|
|
485
|
+
skipped.push({
|
|
486
|
+
page_id: page.id,
|
|
487
|
+
reason: avail.kind === 'held' ? 'page_held' : 'page_in_flight',
|
|
488
|
+
state: roundState(avail.open),
|
|
489
|
+
...(avail.holder ? { holder: avail.holder } : {}),
|
|
490
|
+
});
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const c = candidateFor({ page, rounds, readingPage: rIdx.get(page.id), readingsOk, ask: askIdx.get(page.id), builderId, avail });
|
|
494
|
+
c._base = base.get(page.id);
|
|
495
|
+
candidates.push(c);
|
|
496
|
+
}
|
|
497
|
+
candidates.sort(compareCandidates);
|
|
498
|
+
for (const c of candidates) delete c._base;
|
|
499
|
+
|
|
500
|
+
const n = Math.max(0, Math.floor(Number(alternatives) || 0));
|
|
501
|
+
return {
|
|
502
|
+
builder_id: String(builderId),
|
|
503
|
+
resume,
|
|
504
|
+
recommended: candidates[0] || null,
|
|
505
|
+
alternatives: candidates.slice(1, 1 + n),
|
|
506
|
+
candidates: candidates.length,
|
|
507
|
+
// "4 pages have been asked for" (the mock's Artist Review Status line):
|
|
508
|
+
// every inventory page with an open ask, held or not.
|
|
509
|
+
asked_pages: invPages.filter((p) => askIdx.has(p.id)).length,
|
|
510
|
+
skipped,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
343
514
|
module.exports = {
|
|
344
515
|
TALLY_REASON_PREFIX,
|
|
516
|
+
NEXT_ALTERNATIVES,
|
|
517
|
+
NEXT_TIERS,
|
|
518
|
+
composeNext,
|
|
345
519
|
SURFACE_ORDER,
|
|
346
520
|
SURFACE_LABELS,
|
|
347
521
|
STATUS,
|
|
@@ -52,6 +52,27 @@ function parseRoundRef(sourceRef) {
|
|
|
52
52
|
return m ? { page_id: m[1], round: Number(m[2]) } : null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// The round's task, as the claim route creates it (ADR 0341 D2; task 1004317 /
|
|
56
|
+
// TW06). The title is `Tweak: <inventory title> (<page_id>)`. The body says what
|
|
57
|
+
// the round is and where its words will rest; it carries NO wording and no block
|
|
58
|
+
// yet, since the draft block is written by the autosave (TW08), never here.
|
|
59
|
+
const ROUND_CREDITS = 30;
|
|
60
|
+
|
|
61
|
+
function roundTaskTitle(page) {
|
|
62
|
+
const title = String((page && page.title) || (page && page.id) || '').trim();
|
|
63
|
+
return `Tweak: ${title} (${page.id})`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function roundTaskBody(page, round) {
|
|
67
|
+
return [
|
|
68
|
+
`Page tweak round ${round} for **${String(page.title || page.id)}** (\`${page.id}\`${page.path ? `, ${page.path}` : ''}).`,
|
|
69
|
+
'',
|
|
70
|
+
'An artist rewrites every line of this page as one document in the studio. The draft rests in this description as a fenced `page-tweak-draft` block while they write, and is frozen into a `page-tweak` block when they submit. A session running `/tweak` then applies the batch, and the artist approves the rendered page before it ships (ADR 0341).',
|
|
71
|
+
'',
|
|
72
|
+
'This task was opened by the page claim (`POST /copy-desk/pages/:pageId/claim`). It is web-claimed by the artist while they write: there is no worktree until an applier claims it.',
|
|
73
|
+
].join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
// ---------------------------------------------------------------------------
|
|
56
77
|
// Fences. A block is ```<fence>\n<json>\n```, and the fence name must be followed
|
|
57
78
|
// by the end of its line: 'page-tweak' is a prefix of 'page-tweak-draft', so a
|
|
@@ -185,11 +206,23 @@ function blockPresence(markdown) {
|
|
|
185
206
|
};
|
|
186
207
|
}
|
|
187
208
|
|
|
209
|
+
// May an artist's claim take over an UNHELD open round (ADR 0341 D5)? Only one
|
|
210
|
+
// still being written: `ready`, with no frozen batch. That is the `draft` state
|
|
211
|
+
// (released without submitting; the draft stays) and a round released before
|
|
212
|
+
// any draft was saved. A round with a batch is `submitted`: it belongs to the
|
|
213
|
+
// /tweak queue, and a writing claim would pull it back out of it.
|
|
214
|
+
function roundOpenForWriting(task) {
|
|
215
|
+
return !!task && task.status === 'ready' && !blockPresence(task.description).batch;
|
|
216
|
+
}
|
|
217
|
+
|
|
188
218
|
module.exports = {
|
|
189
219
|
SCHEMA,
|
|
190
220
|
SOURCE,
|
|
191
221
|
FENCES,
|
|
192
222
|
PAGE_ID_RE,
|
|
223
|
+
ROUND_CREDITS,
|
|
224
|
+
roundTaskTitle,
|
|
225
|
+
roundTaskBody,
|
|
193
226
|
roundSourceRef,
|
|
194
227
|
parseRoundRef,
|
|
195
228
|
composeDraftBlock,
|
|
@@ -201,4 +234,5 @@ module.exports = {
|
|
|
201
234
|
composeSentBackBlock,
|
|
202
235
|
parseSentBackBlocks,
|
|
203
236
|
blockPresence,
|
|
237
|
+
roundOpenForWriting,
|
|
204
238
|
};
|