@bongos/core 1.19.1060 → 1.19.1062
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 +62 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +294 -4
- package/docs/api-reference.md +7 -3
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/docx.js +818 -0
- package/modules/copy-desk/pages.js +216 -0
- package/modules/copy-desk/routes/copy-desk.js +467 -0
- package/modules/copy-desk/tests/copy_docx.mjs +347 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +66 -2
- package/modules/copy-desk/tests/copy_page_draft.mjs +202 -0
- package/modules/copy-desk/tests/fixtures/word-docx.mjs +131 -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/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_docx.mjs +279 -0
- package/tests/copy_desk_page_submit.mjs +289 -0
- package/tests/fitness.mjs +7 -2
- package/tests/fixtures/page-ledger.mjs +33 -2
|
@@ -215,6 +215,211 @@ 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
|
+
// saveDraftInto(description, draft) -> { ok, description, draft } | refusal
|
|
315
|
+
//
|
|
316
|
+
// The one way a resolved draft is written into a round's description, shared by
|
|
317
|
+
// the autosave (PUT .../draft) and the Word upload (POST .../draft.docx, task
|
|
318
|
+
// 1004320), so both save through the same rules: the same draft again keeps the
|
|
319
|
+
// stored block (saved_at included) and so writes nothing; no draft yet and
|
|
320
|
+
// nothing changed stores nothing; otherwise the block is replaced in place.
|
|
321
|
+
function saveDraftInto(description, draft) {
|
|
322
|
+
const prev = parseDraftBlock(description);
|
|
323
|
+
if (prev.ok && sameDraft(prev.draft, draft)) return { ok: true, description, draft: prev.draft };
|
|
324
|
+
if (prev.code === 'no_block' && !draft.lines.length) return { ok: true, description, draft };
|
|
325
|
+
const r = replaceBlock(description, FENCES.draft, composeDraftBlock(draft));
|
|
326
|
+
return r.ok ? { ok: true, description: r.description, draft } : r;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Find a draft line on the CURRENT reading: the same key showing the same text,
|
|
330
|
+
// else the one line showing that text in the same section, else the one line
|
|
331
|
+
// showing it anywhere on the page. Anything else is gone, or too ambiguous to
|
|
332
|
+
// pick for the artist.
|
|
333
|
+
function locateLine(lines, draftLine) {
|
|
334
|
+
const same = (l) => l.text === draftLine.before;
|
|
335
|
+
const byKey = lines.find((l) => l.key === draftLine.key);
|
|
336
|
+
if (byKey && same(byKey)) return { line: byKey };
|
|
337
|
+
const inSection = lines.filter((l) => same(l) && l.section === draftLine.section);
|
|
338
|
+
if (inSection.length === 1) return { line: inSection[0] };
|
|
339
|
+
const anywhere = lines.filter(same);
|
|
340
|
+
if (anywhere.length === 1) return { line: anywhere[0] };
|
|
341
|
+
return { code: anywhere.length > 1 ? 'ambiguous_target' : 'target_gone' };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// A line the applier cannot place goes to an engineer (spec decision 7). That
|
|
345
|
+
// is every `unplaced` line, and a `shared` line the registry has no row for:
|
|
346
|
+
// shell text with no file behind it cannot be applied mechanically either.
|
|
347
|
+
function goesToEngineer(line) {
|
|
348
|
+
return line.placement === 'unplaced' || (line.placement === 'shared' && !line.file);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// freezeBatch({ readingPage, draft, submittedAt }) -> { ok, batch, unplaced } | refusal
|
|
352
|
+
//
|
|
353
|
+
// The submit's pure half. Every draft line is re-resolved on the current
|
|
354
|
+
// reading; a refused line (`target_gone`, `ambiguous_target`,
|
|
355
|
+
// `placeholder_mismatch`) refuses the submit, naming each line, and nothing is
|
|
356
|
+
// frozen: the artist fixes those lines and submits again. `batch` is the
|
|
357
|
+
// `page-tweak` block's content without its `unplaced_tasks` (the lifecycle
|
|
358
|
+
// transaction fills those in once the engineer tasks exist). `unplaced` is the
|
|
359
|
+
// lines taken out of it.
|
|
360
|
+
function freezeBatch({ readingPage, draft, submittedAt }) {
|
|
361
|
+
if (!readingPage || !Array.isArray(readingPage.lines)) return fail('reading_missing');
|
|
362
|
+
if (!draft || !Array.isArray(draft.lines) || !draft.lines.length) return fail('nothing_to_submit');
|
|
363
|
+
const refused = [];
|
|
364
|
+
const placed = [];
|
|
365
|
+
const unplaced = [];
|
|
366
|
+
const keys = new Set();
|
|
367
|
+
for (const d of draft.lines) {
|
|
368
|
+
const found = locateLine(readingPage.lines, d);
|
|
369
|
+
if (!found.line) { refused.push({ key: d.key, section: d.section == null ? null : d.section, before: d.before, code: found.code }); continue; }
|
|
370
|
+
const at = found.line;
|
|
371
|
+
if (keys.has(at.key)) { refused.push({ key: d.key, section: d.section == null ? null : d.section, before: d.before, code: 'ambiguous_target' }); continue; }
|
|
372
|
+
keys.add(at.key);
|
|
373
|
+
const line = { key: at.key, section: at.section == null ? null : at.section, before: at.text, after: d.after, placement: at.placement };
|
|
374
|
+
if (goesToEngineer(at)) { unplaced.push(line); continue; }
|
|
375
|
+
const want = placeholderCount(at.text);
|
|
376
|
+
const got = placeholderCount(d.after);
|
|
377
|
+
if (want !== got) { refused.push({ key: at.key, section: line.section, before: at.text, code: 'placeholder_mismatch', expected: want, got }); continue; }
|
|
378
|
+
placed.push({ ...line, file: at.file, line: at.line == null ? null : at.line, string_id: at.string_id == null ? null : at.string_id });
|
|
379
|
+
}
|
|
380
|
+
if (refused.length) return fail('lines_refused', { refused });
|
|
381
|
+
const order = new Map(readingPage.lines.map((l, i) => [l.key, i]));
|
|
382
|
+
const byOrder = (x, y) => order.get(x.key) - order.get(y.key);
|
|
383
|
+
return {
|
|
384
|
+
ok: true,
|
|
385
|
+
batch: { page_id: readingPage.id, reading_hash: readingPage.reading_hash, submitted_at: submittedAt, lines: placed.sort(byOrder) },
|
|
386
|
+
unplaced: unplaced.sort(byOrder),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The engineer task an unplaced rewrite files (ADR 0341 D4): one per line,
|
|
391
|
+
// keyed so a repeated submit finds the task it already filed.
|
|
392
|
+
function unplacedSourceRef(roundTaskId, key) {
|
|
393
|
+
return `${UNPLACED_SOURCE}/${roundTaskId}/${key}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function clip(text, n) {
|
|
397
|
+
const s = String(text || '');
|
|
398
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function unplacedTaskTitle(page, line) {
|
|
402
|
+
return `Place and reword "${clip(line.before, 60)}" (${page.id})`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function unplacedTaskBody(page, line, roundTaskId) {
|
|
406
|
+
const where = line.placement === 'shared'
|
|
407
|
+
? 'It is shared shell text (it shows on every page of the surface), and the copy registry has no row for it.'
|
|
408
|
+
: '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).';
|
|
409
|
+
return [
|
|
410
|
+
`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).`,
|
|
411
|
+
'',
|
|
412
|
+
where,
|
|
413
|
+
'',
|
|
414
|
+
`- Section: ${line.section == null ? '(none)' : line.section}`,
|
|
415
|
+
`- Line: ${line.key} in the page reading`,
|
|
416
|
+
`- It says now: ${JSON.stringify(line.before)}`,
|
|
417
|
+
`- The artist's wording: ${JSON.stringify(line.after)}`,
|
|
418
|
+
'',
|
|
419
|
+
`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.`,
|
|
420
|
+
].join('\n');
|
|
421
|
+
}
|
|
422
|
+
|
|
218
423
|
module.exports = {
|
|
219
424
|
SCHEMA,
|
|
220
425
|
SOURCE,
|
|
@@ -235,4 +440,15 @@ module.exports = {
|
|
|
235
440
|
parseSentBackBlocks,
|
|
236
441
|
blockPresence,
|
|
237
442
|
roundOpenForWriting,
|
|
443
|
+
MAX_LINE_CHARS,
|
|
444
|
+
MAX_DRAFT_LINES,
|
|
445
|
+
UNPLACED_SOURCE,
|
|
446
|
+
replaceBlock,
|
|
447
|
+
resolveDraft,
|
|
448
|
+
sameDraft,
|
|
449
|
+
saveDraftInto,
|
|
450
|
+
freezeBatch,
|
|
451
|
+
unplacedSourceRef,
|
|
452
|
+
unplacedTaskTitle,
|
|
453
|
+
unplacedTaskBody,
|
|
238
454
|
};
|