@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.
@@ -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
  };