@bongos/core 1.19.1059 → 1.19.1061
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 +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +193 -3
- package/docs/api-reference.md +5 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/flags.js +14 -1
- package/modules/copy-desk/page-status.js +177 -3
- package/modules/copy-desk/pages.js +200 -0
- package/modules/copy-desk/routes/copy-desk.js +349 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +36 -1
- package/modules/copy-desk/tests/copy_page_draft.mjs +202 -0
- package/modules/copy-desk/tests/copy_page_next.mjs +184 -0
- package/modules/lifecycle/lifecycle.js +8 -0
- package/modules/lifecycle/page-tweak-reads.js +11 -2
- package/modules/lifecycle/page-tweak-write.js +192 -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 +12 -0
- package/tests/copy_desk_page_claim.mjs +9 -124
- package/tests/copy_desk_page_next.mjs +172 -0
- package/tests/copy_desk_page_submit.mjs +289 -0
- package/tests/fixtures/page-ledger.mjs +206 -0
|
@@ -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,
|
|
@@ -215,6 +215,196 @@ function roundOpenForWriting(task) {
|
|
|
215
215
|
return !!task && task.status === 'ready' && !blockPresence(task.description).batch;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// The draft and the submit (task 1004319 / BV2.TW08, ADR 0341 D4).
|
|
220
|
+
//
|
|
221
|
+
// Both resolve every TARGET from the page reading (docs/page-readings.json),
|
|
222
|
+
// never from the request body (ADR 0233 §7). A draft save sends only
|
|
223
|
+
// { reading_hash, lines: [{ key, after }] }; the line's section, its current
|
|
224
|
+
// text (`before`) and its placement are copied from the reading. The submit
|
|
225
|
+
// re-resolves each draft line against the CURRENT reading, so a line the page
|
|
226
|
+
// no longer shows is refused by name (`target_gone`) instead of being frozen
|
|
227
|
+
// against a sentence that is not there.
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
// The interpolation hole the inventory writes for `${...}` (proposals.js's
|
|
231
|
+
// PLACEHOLDER; restated here because this file requires nothing).
|
|
232
|
+
const PLACEHOLDER = '{…}';
|
|
233
|
+
// One rewritten line. The longest line any page shows today is 760 characters,
|
|
234
|
+
// so 1000 leaves room to rewrite it and still bounds a draft.
|
|
235
|
+
const MAX_LINE_CHARS = 1000;
|
|
236
|
+
// A draft holds changed lines only, and the longest page has under 500 lines.
|
|
237
|
+
const MAX_DRAFT_LINES = 1000;
|
|
238
|
+
const UNPLACED_SOURCE = 'page-tweak-unplaced';
|
|
239
|
+
const LINE_KEY_RE = /^L[0-9]{4,}$/;
|
|
240
|
+
|
|
241
|
+
// The inventory's own whitespace rule (proposals.js tidy): a line is measured
|
|
242
|
+
// and compared in the form the reading records it.
|
|
243
|
+
function tidy(raw) {
|
|
244
|
+
return String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function placeholderCount(text) {
|
|
248
|
+
return String(text || '').split(PLACEHOLDER).length - 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Replace the ONE block of a fence in a description, or append it when there is
|
|
252
|
+
// none. A description carrying two is refused rather than guessed at.
|
|
253
|
+
function replaceBlock(markdown, fence, block) {
|
|
254
|
+
const src = String(markdown || '');
|
|
255
|
+
const re = new RegExp('```' + fence.replace(/-/g, '\\-') + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g');
|
|
256
|
+
const hits = src.match(re) || [];
|
|
257
|
+
if (hits.length > 1) return fail('multiple_blocks', { fence, count: hits.length });
|
|
258
|
+
if (hits.length === 1) return { ok: true, description: src.replace(re, () => block) };
|
|
259
|
+
return { ok: true, description: src.trimEnd() + (src.trim() ? '\n\n' : '') + block };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// resolveDraft({ readingPage, body, savedAt }) -> { ok, draft } | refusal
|
|
263
|
+
//
|
|
264
|
+
// A WHOLE-DRAFT replace: the body is every changed line the artist has, and the
|
|
265
|
+
// draft becomes exactly that. A line whose `after` equals the page's current
|
|
266
|
+
// text is not a change and is dropped, so only changed lines are stored (D4).
|
|
267
|
+
// Placeholder arithmetic is NOT checked here: an autosave lands mid-sentence,
|
|
268
|
+
// and the check belongs to the submit (and again to the applier).
|
|
269
|
+
function resolveDraft({ readingPage, body, savedAt }) {
|
|
270
|
+
if (!readingPage || !Array.isArray(readingPage.lines)) return fail('reading_missing');
|
|
271
|
+
const b = body || {};
|
|
272
|
+
if (String(b.reading_hash || '') !== String(readingPage.reading_hash || '')) {
|
|
273
|
+
return fail('reading_moved', { reading_hash: readingPage.reading_hash || null, sent: b.reading_hash || null });
|
|
274
|
+
}
|
|
275
|
+
const list = Array.isArray(b.lines) ? b.lines : [];
|
|
276
|
+
if (list.length > MAX_DRAFT_LINES) return fail('too_many_lines', { max: MAX_DRAFT_LINES, was: list.length });
|
|
277
|
+
const byKey = new Map(readingPage.lines.map((l) => [l.key, l]));
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
const unknown = [];
|
|
280
|
+
const tooLong = [];
|
|
281
|
+
const empty = [];
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const raw of list) {
|
|
284
|
+
const key = raw && typeof raw.key === 'string' ? raw.key : '';
|
|
285
|
+
if (!LINE_KEY_RE.test(key) || typeof raw.after !== 'string') return fail('line_malformed', { line: raw == null ? null : raw });
|
|
286
|
+
if (seen.has(key)) return fail('duplicate_line', { key });
|
|
287
|
+
seen.add(key);
|
|
288
|
+
const at = byKey.get(key);
|
|
289
|
+
if (!at) { unknown.push(key); continue; }
|
|
290
|
+
const after = tidy(raw.after);
|
|
291
|
+
if (after === tidy(at.text)) continue;
|
|
292
|
+
if (!after) { empty.push(key); continue; }
|
|
293
|
+
if (after.length > MAX_LINE_CHARS) { tooLong.push(key); continue; }
|
|
294
|
+
out.push({ key, section: at.section == null ? null : at.section, before: at.text, after, placement: at.placement });
|
|
295
|
+
}
|
|
296
|
+
if (unknown.length) return fail('unknown_line', { keys: unknown });
|
|
297
|
+
if (empty.length) return fail('line_empty', { keys: empty });
|
|
298
|
+
if (tooLong.length) return fail('line_too_long', { keys: tooLong, max: MAX_LINE_CHARS });
|
|
299
|
+
// Page order, whatever order the editor sent them in.
|
|
300
|
+
const order = new Map(readingPage.lines.map((l, i) => [l.key, i]));
|
|
301
|
+
out.sort((x, y) => order.get(x.key) - order.get(y.key));
|
|
302
|
+
return { ok: true, draft: { page_id: readingPage.id, reading_hash: readingPage.reading_hash, saved_at: savedAt, lines: out } };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Is a newly resolved draft the one already saved? Then the save writes
|
|
306
|
+
// nothing: an autosave that changed nothing must not touch the task.
|
|
307
|
+
function sameDraft(saved, next) {
|
|
308
|
+
if (!saved || !next) return false;
|
|
309
|
+
const pick = (d) => JSON.stringify([d.page_id, d.reading_hash,
|
|
310
|
+
(d.lines || []).map((l) => [l.key, l.section == null ? null : l.section, l.before, l.after, l.placement])]);
|
|
311
|
+
return pick(saved) === pick(next);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Find a draft line on the CURRENT reading: the same key showing the same text,
|
|
315
|
+
// else the one line showing that text in the same section, else the one line
|
|
316
|
+
// showing it anywhere on the page. Anything else is gone, or too ambiguous to
|
|
317
|
+
// pick for the artist.
|
|
318
|
+
function locateLine(lines, draftLine) {
|
|
319
|
+
const same = (l) => l.text === draftLine.before;
|
|
320
|
+
const byKey = lines.find((l) => l.key === draftLine.key);
|
|
321
|
+
if (byKey && same(byKey)) return { line: byKey };
|
|
322
|
+
const inSection = lines.filter((l) => same(l) && l.section === draftLine.section);
|
|
323
|
+
if (inSection.length === 1) return { line: inSection[0] };
|
|
324
|
+
const anywhere = lines.filter(same);
|
|
325
|
+
if (anywhere.length === 1) return { line: anywhere[0] };
|
|
326
|
+
return { code: anywhere.length > 1 ? 'ambiguous_target' : 'target_gone' };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A line the applier cannot place goes to an engineer (spec decision 7). That
|
|
330
|
+
// is every `unplaced` line, and a `shared` line the registry has no row for:
|
|
331
|
+
// shell text with no file behind it cannot be applied mechanically either.
|
|
332
|
+
function goesToEngineer(line) {
|
|
333
|
+
return line.placement === 'unplaced' || (line.placement === 'shared' && !line.file);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// freezeBatch({ readingPage, draft, submittedAt }) -> { ok, batch, unplaced } | refusal
|
|
337
|
+
//
|
|
338
|
+
// The submit's pure half. Every draft line is re-resolved on the current
|
|
339
|
+
// reading; a refused line (`target_gone`, `ambiguous_target`,
|
|
340
|
+
// `placeholder_mismatch`) refuses the submit, naming each line, and nothing is
|
|
341
|
+
// frozen: the artist fixes those lines and submits again. `batch` is the
|
|
342
|
+
// `page-tweak` block's content without its `unplaced_tasks` (the lifecycle
|
|
343
|
+
// transaction fills those in once the engineer tasks exist). `unplaced` is the
|
|
344
|
+
// lines taken out of it.
|
|
345
|
+
function freezeBatch({ readingPage, draft, submittedAt }) {
|
|
346
|
+
if (!readingPage || !Array.isArray(readingPage.lines)) return fail('reading_missing');
|
|
347
|
+
if (!draft || !Array.isArray(draft.lines) || !draft.lines.length) return fail('nothing_to_submit');
|
|
348
|
+
const refused = [];
|
|
349
|
+
const placed = [];
|
|
350
|
+
const unplaced = [];
|
|
351
|
+
const keys = new Set();
|
|
352
|
+
for (const d of draft.lines) {
|
|
353
|
+
const found = locateLine(readingPage.lines, d);
|
|
354
|
+
if (!found.line) { refused.push({ key: d.key, section: d.section == null ? null : d.section, before: d.before, code: found.code }); continue; }
|
|
355
|
+
const at = found.line;
|
|
356
|
+
if (keys.has(at.key)) { refused.push({ key: d.key, section: d.section == null ? null : d.section, before: d.before, code: 'ambiguous_target' }); continue; }
|
|
357
|
+
keys.add(at.key);
|
|
358
|
+
const line = { key: at.key, section: at.section == null ? null : at.section, before: at.text, after: d.after, placement: at.placement };
|
|
359
|
+
if (goesToEngineer(at)) { unplaced.push(line); continue; }
|
|
360
|
+
const want = placeholderCount(at.text);
|
|
361
|
+
const got = placeholderCount(d.after);
|
|
362
|
+
if (want !== got) { refused.push({ key: at.key, section: line.section, before: at.text, code: 'placeholder_mismatch', expected: want, got }); continue; }
|
|
363
|
+
placed.push({ ...line, file: at.file, line: at.line == null ? null : at.line, string_id: at.string_id == null ? null : at.string_id });
|
|
364
|
+
}
|
|
365
|
+
if (refused.length) return fail('lines_refused', { refused });
|
|
366
|
+
const order = new Map(readingPage.lines.map((l, i) => [l.key, i]));
|
|
367
|
+
const byOrder = (x, y) => order.get(x.key) - order.get(y.key);
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
batch: { page_id: readingPage.id, reading_hash: readingPage.reading_hash, submitted_at: submittedAt, lines: placed.sort(byOrder) },
|
|
371
|
+
unplaced: unplaced.sort(byOrder),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// The engineer task an unplaced rewrite files (ADR 0341 D4): one per line,
|
|
376
|
+
// keyed so a repeated submit finds the task it already filed.
|
|
377
|
+
function unplacedSourceRef(roundTaskId, key) {
|
|
378
|
+
return `${UNPLACED_SOURCE}/${roundTaskId}/${key}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function clip(text, n) {
|
|
382
|
+
const s = String(text || '');
|
|
383
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function unplacedTaskTitle(page, line) {
|
|
387
|
+
return `Place and reword "${clip(line.before, 60)}" (${page.id})`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function unplacedTaskBody(page, line, roundTaskId) {
|
|
391
|
+
const where = line.placement === 'shared'
|
|
392
|
+
? 'It is shared shell text (it shows on every page of the surface), and the copy registry has no row for it.'
|
|
393
|
+
: 'The copy registry cannot place it: the string is built somewhere the inventory does not read (data from an API, a string assembled in code, or markup the extractors miss).';
|
|
394
|
+
return [
|
|
395
|
+
`An artist rewrote a line on **${String(page.title || page.id)}** (\`${page.id}\`${page.path ? `, ${page.path}` : ''}) that the applier cannot change mechanically, so it comes to an engineer (ADR 0341 D4, spec decision 7).`,
|
|
396
|
+
'',
|
|
397
|
+
where,
|
|
398
|
+
'',
|
|
399
|
+
`- Section: ${line.section == null ? '(none)' : line.section}`,
|
|
400
|
+
`- Line: ${line.key} in the page reading`,
|
|
401
|
+
`- It says now: ${JSON.stringify(line.before)}`,
|
|
402
|
+
`- The artist's wording: ${JSON.stringify(line.after)}`,
|
|
403
|
+
'',
|
|
404
|
+
`Find where this text is rendered and change it to the artist's wording, or make it a string the copy registry can place so the next tweak can change it directly. The page's tweak round (task ${roundTaskId}) does not wait for this.`,
|
|
405
|
+
].join('\n');
|
|
406
|
+
}
|
|
407
|
+
|
|
218
408
|
module.exports = {
|
|
219
409
|
SCHEMA,
|
|
220
410
|
SOURCE,
|
|
@@ -235,4 +425,14 @@ module.exports = {
|
|
|
235
425
|
parseSentBackBlocks,
|
|
236
426
|
blockPresence,
|
|
237
427
|
roundOpenForWriting,
|
|
428
|
+
MAX_LINE_CHARS,
|
|
429
|
+
MAX_DRAFT_LINES,
|
|
430
|
+
UNPLACED_SOURCE,
|
|
431
|
+
replaceBlock,
|
|
432
|
+
resolveDraft,
|
|
433
|
+
sameDraft,
|
|
434
|
+
freezeBatch,
|
|
435
|
+
unplacedSourceRef,
|
|
436
|
+
unplacedTaskTitle,
|
|
437
|
+
unplacedTaskBody,
|
|
238
438
|
};
|