@bongos/core 1.19.1061 → 1.19.1063
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 +94 -34
- 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 +178 -4
- package/docs/api-reference.md +6 -3
- package/docs/copy-inventory.md +44 -7
- package/docs/copy-registry.json +393 -26
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +36 -4
- package/docs/page-readings.json +34 -1
- package/modules/copy-desk/docx.js +818 -0
- package/modules/copy-desk/page-status.js +63 -0
- package/modules/copy-desk/pages.js +17 -0
- package/modules/copy-desk/routes/copy-desk.js +198 -9
- package/modules/copy-desk/tests/copy_docx.mjs +347 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +32 -3
- package/modules/copy-desk/tests/fixtures/word-docx.mjs +131 -0
- package/modules/hall-ui/public/tweak-editor-lib.js +137 -0
- package/modules/hall-ui/public/tweak-editor.css +437 -0
- package/modules/hall-ui/public/tweak-editor.html +111 -0
- package/modules/hall-ui/public/tweak-editor.js +447 -0
- package/modules/hall-ui/public/tweak-editor.states.json +99 -0
- package/modules/hall-ui/records/tweak-editor.md +32 -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/scripts/hall-preview/server.js +21 -0
- package/src/bongos/serve-internal.js +5 -1
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_docx.mjs +279 -0
- package/tests/copy_desk_page_editor.mjs +181 -0
- package/tests/fitness.mjs +7 -2
- package/tests/hall_audit.mjs +5 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_tweak_editor.mjs +432 -0
|
@@ -511,7 +511,70 @@ function composeNext({ inventory, readings, tasks, asks, builderId, alternatives
|
|
|
511
511
|
};
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
// The margin note a line carries in the editor (ADR 0341 D4, TW10): shared
|
|
515
|
+
// shell text changes on every page, and a line the applier cannot place goes
|
|
516
|
+
// to an engineer at submit (pages.goesToEngineer, the submit's own rule).
|
|
517
|
+
const EDITOR_NOTES = Object.freeze({ shared: 'changes this on every page', engineer: 'goes to an engineer' });
|
|
518
|
+
|
|
519
|
+
function editorNote(line) {
|
|
520
|
+
if (pages.goesToEngineer(line)) return 'engineer';
|
|
521
|
+
return line.placement === 'shared' ? 'shared' : null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// GET /copy-desk/pages/:pageId/draft — what the editor (task 1004321 / BV2.TW10)
|
|
525
|
+
// opens on: every line of the page reading in page order, the open round and
|
|
526
|
+
// whether the caller holds it, and the saved draft. A draft saved on another
|
|
527
|
+
// reading is not applied to these lines (its keys may name other lines); it is
|
|
528
|
+
// reported as `draft_on_older_reading` instead.
|
|
529
|
+
//
|
|
530
|
+
// `readingPage` is the page's entry in docs/page-readings.json (null when the
|
|
531
|
+
// page has not been read); `round` is the page's open round as the lifecycle
|
|
532
|
+
// port lists it, or null.
|
|
533
|
+
function composeEditor({ page, readingPage, round, builderId }) {
|
|
534
|
+
const lines = ((readingPage && readingPage.lines) || []).map((l) => ({
|
|
535
|
+
key: l.key, section: l.section, text: l.text, placement: l.placement, note: editorNote(l),
|
|
536
|
+
}));
|
|
537
|
+
const readingHash = readingPage ? readingPage.reading_hash || null : null;
|
|
538
|
+
let draft = null;
|
|
539
|
+
let olderReading = false;
|
|
540
|
+
if (round) {
|
|
541
|
+
const d = pages.parseDraftBlock(round.description);
|
|
542
|
+
if (d.ok) {
|
|
543
|
+
if (d.draft.reading_hash === readingHash) {
|
|
544
|
+
const keys = new Set(lines.map((l) => l.key));
|
|
545
|
+
draft = {
|
|
546
|
+
reading_hash: d.draft.reading_hash,
|
|
547
|
+
saved_at: d.draft.saved_at || null,
|
|
548
|
+
lines: d.draft.lines.filter((l) => keys.has(l.key)).map((l) => ({ key: l.key, after: l.after })),
|
|
549
|
+
};
|
|
550
|
+
} else {
|
|
551
|
+
olderReading = true;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const heldByYou = !!round && round.holder_id != null && String(round.holder_id) === String(builderId);
|
|
556
|
+
return {
|
|
557
|
+
ok: true,
|
|
558
|
+
page_id: page.id,
|
|
559
|
+
page: { id: page.id, title: page.title || page.id, surface: page.surface, surface_word: surfaceWord(page.surface), path: page.path || null, url: page.url || null },
|
|
560
|
+
reading: { reading_hash: readingHash, read: !!readingPage, lines },
|
|
561
|
+
notes: EDITOR_NOTES,
|
|
562
|
+
round: round ? {
|
|
563
|
+
task_id: String(round.id),
|
|
564
|
+
state: roundState(round),
|
|
565
|
+
status: round.status,
|
|
566
|
+
held_by_you: heldByYou,
|
|
567
|
+
holder: round.holder_id != null ? { id: String(round.holder_id), login: round.holder_login || null } : null,
|
|
568
|
+
claim: { method: 'POST', path: `/copy-desk/pages/${page.id}/claim` },
|
|
569
|
+
} : null,
|
|
570
|
+
draft,
|
|
571
|
+
draft_on_older_reading: olderReading,
|
|
572
|
+
counter: { changed: draft ? draft.lines.length : 0, total: lines.length },
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
514
576
|
module.exports = {
|
|
577
|
+
composeEditor,
|
|
515
578
|
TALLY_REASON_PREFIX,
|
|
516
579
|
NEXT_ALTERNATIVES,
|
|
517
580
|
NEXT_TIERS,
|
|
@@ -311,6 +311,21 @@ function sameDraft(saved, next) {
|
|
|
311
311
|
return pick(saved) === pick(next);
|
|
312
312
|
}
|
|
313
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
|
+
|
|
314
329
|
// Find a draft line on the CURRENT reading: the same key showing the same text,
|
|
315
330
|
// else the one line showing that text in the same section, else the one line
|
|
316
331
|
// showing it anywhere on the page. Anything else is gone, or too ambiguous to
|
|
@@ -431,7 +446,9 @@ module.exports = {
|
|
|
431
446
|
replaceBlock,
|
|
432
447
|
resolveDraft,
|
|
433
448
|
sameDraft,
|
|
449
|
+
saveDraftInto,
|
|
434
450
|
freezeBatch,
|
|
451
|
+
goesToEngineer,
|
|
435
452
|
unplacedSourceRef,
|
|
436
453
|
unplacedTaskTitle,
|
|
437
454
|
unplacedTaskBody,
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// task 1004317): POST /copy-desk/pages/:pageId/claim opens or takes a page's
|
|
15
15
|
// tweak round as a TASK with a web claim, and POST /copy-desk/pages/:pageId/asks
|
|
16
16
|
// files a page ask, a flag row whose target is a page id. Neither stores copy.
|
|
17
|
+
// TW08 (task 1004319) added the draft autosave and the submit, and TW09 (task
|
|
18
|
+
// 1004320) the Word round trip, GET and POST /copy-desk/pages/:pageId/draft.docx:
|
|
19
|
+
// the upload lands in the round's DRAFT, through the autosave's own port call.
|
|
20
|
+
// TW10 (task 1004321) added the editor's read, GET /copy-desk/pages/:pageId/draft.
|
|
17
21
|
//
|
|
18
22
|
// THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
|
|
19
23
|
//
|
|
@@ -53,6 +57,7 @@ const proposals = require('../proposals');
|
|
|
53
57
|
const pageData = require('../page-data');
|
|
54
58
|
const pageStatus = require('../page-status');
|
|
55
59
|
const pages = require('../pages');
|
|
60
|
+
const docx = require('../docx');
|
|
56
61
|
|
|
57
62
|
const { validateOrRespond } = api;
|
|
58
63
|
|
|
@@ -125,6 +130,10 @@ const PAGE_REFUSAL_STATUS = Object.freeze({
|
|
|
125
130
|
block_schema_unknown: 409,
|
|
126
131
|
block_field_missing: 409,
|
|
127
132
|
wrong_page: 409,
|
|
133
|
+
// The Word upload (task 1004320, ADR 0341 D12).
|
|
134
|
+
structure_changed: 409,
|
|
135
|
+
not_a_docx: 415,
|
|
136
|
+
upload_too_large: 413,
|
|
128
137
|
});
|
|
129
138
|
const PAGE_REFUSAL_MEANS = Object.freeze({
|
|
130
139
|
reading_moved: 'The page changed since the editor loaded it, so a line key may now mean a different line. Reload the page to get the current reading; nothing was saved.',
|
|
@@ -133,7 +142,27 @@ const PAGE_REFUSAL_MEANS = Object.freeze({
|
|
|
133
142
|
line_too_long: `A line holds at most ${pages.MAX_LINE_CHARS} characters.`,
|
|
134
143
|
lines_refused: 'Some lines cannot be frozen as written: the page no longer shows them (target_gone), shows the same words in more than one place (ambiguous_target), or the rewrite dropped or added a {…} hole (placeholder_mismatch). Fix or remove each named line and submit again; nothing was submitted.',
|
|
135
144
|
nothing_to_submit: 'The draft has no rewritten lines yet.',
|
|
145
|
+
structure_changed: 'The file\'s lines no longer match the page: a paragraph was added, removed or moved, or a heading changed. Each one is listed. Put them back (one paragraph per line, in the page\'s order) or download the page again, and upload once more; nothing was saved.',
|
|
146
|
+
not_a_docx: 'Upload the .docx file this page downloaded, as the request body. Nothing was saved.',
|
|
147
|
+
upload_too_large: `A page file is at most ${Math.round(docx.MAX_UPLOAD_BYTES / (1024 * 1024))} MB. Nothing was saved.`,
|
|
136
148
|
});
|
|
149
|
+
// The upload's answers when the file is readable but is not this page's, or not
|
|
150
|
+
// this reading's (task 1004320, ADR 0341 D12).
|
|
151
|
+
const UPLOAD_WRONG_PAGE_MEANS = 'This file is not the download of this page, or the program that saved it dropped the page details Word keeps. Download this page again and make your changes in that file. Nothing was saved.';
|
|
152
|
+
const UPLOAD_READING_MOVED_MEANS = 'The page has changed since this file was downloaded, so its lines may no longer line up with the page. Download the page again and copy your changes across. Nothing was saved.';
|
|
153
|
+
|
|
154
|
+
// The upload's body is the .docx itself, never JSON (the global parser only
|
|
155
|
+
// reads application/json, so it passes this through). Capped at the parser, and
|
|
156
|
+
// never inflated from a Content-Encoding, so the cap is the bytes on the wire.
|
|
157
|
+
const DOCX_BODY_TYPES = [docx.DOCX_MIME, 'application/zip', 'application/x-zip-compressed', 'application/octet-stream'];
|
|
158
|
+
const rawDocxBody = express.raw({ type: DOCX_BODY_TYPES, limit: docx.MAX_UPLOAD_BYTES, inflate: false });
|
|
159
|
+
function docxBody(req, res, next) {
|
|
160
|
+
rawDocxBody(req, res, (err) => {
|
|
161
|
+
if (!err) return next();
|
|
162
|
+
if (err.type === 'entity.too.large') return refusePage(res, { code: 'upload_too_large', detail: { max_bytes: docx.MAX_UPLOAD_BYTES } });
|
|
163
|
+
return refusePage(res, { code: 'not_a_docx', detail: { reason: 'the upload could not be read' } });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
137
166
|
|
|
138
167
|
function refusePage(res, r) {
|
|
139
168
|
const status = PAGE_REFUSAL_STATUS[r.code] || 400;
|
|
@@ -944,6 +973,35 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
944
973
|
}
|
|
945
974
|
});
|
|
946
975
|
|
|
976
|
+
// -------------------------------------------------------------------------
|
|
977
|
+
// GET /copy-desk/pages/:pageId/draft — what the tweak editor opens on (task
|
|
978
|
+
// 1004321 / BV2.TW10, ADR 0341 D4): every line of the page reading in page
|
|
979
|
+
// order with its margin note, the open round and whether the caller holds
|
|
980
|
+
// it, and the saved draft (only on the current reading). The read the PUT
|
|
981
|
+
// below saves over, and the .docx download's own data as JSON.
|
|
982
|
+
//
|
|
983
|
+
// rank: any signed-in builder, as the download: the words are the page's
|
|
984
|
+
// own and the draft sits on a task every builder can read.
|
|
985
|
+
// -------------------------------------------------------------------------
|
|
986
|
+
router.get('/copy-desk/pages/:pageId/draft', api.requireBuilder, async (req, res) => {
|
|
987
|
+
try {
|
|
988
|
+
const found = pageOrFail(req, res);
|
|
989
|
+
if (!found) return;
|
|
990
|
+
const { page, pageId } = found;
|
|
991
|
+
const lifecycle = pageReadsOrFail(res);
|
|
992
|
+
if (!lifecycle) return;
|
|
993
|
+
const readingPage = readingPageOrFail(res, pageId);
|
|
994
|
+
if (!readingPage) return;
|
|
995
|
+
const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
|
|
996
|
+
const out = pageStatus.composeEditor({ page, readingPage, round, builderId: req.builder.id });
|
|
997
|
+
res.set('Cache-Control', 'no-store');
|
|
998
|
+
res.json(out);
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
log.error({ err }, 'GET /copy-desk/pages/:pageId/draft failed');
|
|
1001
|
+
if (!res.headersSent) res.fail('copy_page_editor_failed', 500);
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
|
|
947
1005
|
// -------------------------------------------------------------------------
|
|
948
1006
|
// PUT /copy-desk/pages/:pageId/draft — autosave the artist's draft (task
|
|
949
1007
|
// 1004319 / BV2.TW08, ADR 0341 D4).
|
|
@@ -999,16 +1057,12 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
999
1057
|
taskId: round.id,
|
|
1000
1058
|
builderId: req.builder.id,
|
|
1001
1059
|
source: pages.SOURCE,
|
|
1060
|
+
// The same draft again keeps the stored block (saved_at included) and
|
|
1061
|
+
// writes nothing; see pages.saveDraftInto, which the upload shares.
|
|
1002
1062
|
compose: (task) => {
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
draft = prev.draft;
|
|
1007
|
-
return { ok: true, description: task.description };
|
|
1008
|
-
}
|
|
1009
|
-
// No draft yet and nothing changed: there is nothing to store.
|
|
1010
|
-
if (prev.code === 'no_block' && !resolved.draft.lines.length) return { ok: true, description: task.description };
|
|
1011
|
-
return pages.replaceBlock(task.description, pages.FENCES.draft, pages.composeDraftBlock(resolved.draft));
|
|
1063
|
+
const s = pages.saveDraftInto(task.description, resolved.draft);
|
|
1064
|
+
if (s.ok) draft = s.draft;
|
|
1065
|
+
return s;
|
|
1012
1066
|
},
|
|
1013
1067
|
});
|
|
1014
1068
|
switch (r.outcome) {
|
|
@@ -1036,6 +1090,141 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
1036
1090
|
}
|
|
1037
1091
|
});
|
|
1038
1092
|
|
|
1093
|
+
// -------------------------------------------------------------------------
|
|
1094
|
+
// GET /copy-desk/pages/:pageId/draft.docx — download the page's words as a
|
|
1095
|
+
// Word document, to work on offline (task 1004320 / BV2.TW09, ADR 0341 D12).
|
|
1096
|
+
//
|
|
1097
|
+
// The file is built from the page reading plus the open round's draft (a line
|
|
1098
|
+
// the artist has rewritten shows its rewrite). A draft saved on an older
|
|
1099
|
+
// reading is not shown, since its keys may now name other lines.
|
|
1100
|
+
// rank: any signed-in builder. The words are the page's own, and the draft is
|
|
1101
|
+
// on a task description every builder can already read; only the
|
|
1102
|
+
// upload is the holder's.
|
|
1103
|
+
// -------------------------------------------------------------------------
|
|
1104
|
+
router.get('/copy-desk/pages/:pageId/draft.docx', api.requireBuilder, async (req, res) => {
|
|
1105
|
+
try {
|
|
1106
|
+
const found = pageOrFail(req, res);
|
|
1107
|
+
if (!found) return;
|
|
1108
|
+
const { page, pageId } = found;
|
|
1109
|
+
const lifecycle = pageReadsOrFail(res);
|
|
1110
|
+
if (!lifecycle) return;
|
|
1111
|
+
const readingPage = readingPageOrFail(res, pageId);
|
|
1112
|
+
if (!readingPage) return;
|
|
1113
|
+
const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
|
|
1114
|
+
const d = round ? pages.parseDraftBlock(round.description) : null;
|
|
1115
|
+
const file = docx.composeDownload({ readingPage, title: page.title, draft: d && d.ok ? d.draft : null });
|
|
1116
|
+
res.set('Content-Type', docx.DOCX_MIME);
|
|
1117
|
+
res.set('Content-Disposition', `attachment; filename="${docx.downloadName(pageId)}"`);
|
|
1118
|
+
res.set('Cache-Control', 'no-store');
|
|
1119
|
+
res.send(file);
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
log.error({ err }, 'GET /copy-desk/pages/:pageId/draft.docx failed');
|
|
1122
|
+
if (!res.headersSent) res.fail('copy_page_docx_failed', 500);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
// -------------------------------------------------------------------------
|
|
1127
|
+
// POST /copy-desk/pages/:pageId/draft.docx — upload the Word file back into
|
|
1128
|
+
// the DRAFT (task 1004320 / BV2.TW09, ADR 0341 D12). Never to the site: the
|
|
1129
|
+
// words land in the round's draft block, exactly where the autosave puts
|
|
1130
|
+
// them, through the same port call (holder only) and the same resolver
|
|
1131
|
+
// (every target from the page reading by key, never from the file).
|
|
1132
|
+
//
|
|
1133
|
+
// Body: the .docx bytes (Content-Type: the Word type, application/zip or
|
|
1134
|
+
// application/octet-stream), at most docx.MAX_UPLOAD_BYTES.
|
|
1135
|
+
// rank: any signed-in builder; only the HOLDER of the page's writing claim.
|
|
1136
|
+
//
|
|
1137
|
+
// A MERGE, not a replace: a line is taken from the file only when the artist
|
|
1138
|
+
// changed it in Word (against the text the download showed), so a line they
|
|
1139
|
+
// rewrote in the studio after downloading keeps that rewrite.
|
|
1140
|
+
//
|
|
1141
|
+
// 200 saved | unchanged the draft, the counter, and `merge` (which lines
|
|
1142
|
+
// came from the file, and how they were matched)
|
|
1143
|
+
// 409 structure_changed a line added, dropped or reordered, or a heading
|
|
1144
|
+
// changed; each is named (section, first words)
|
|
1145
|
+
// 409 wrong_page · reading_moved · page_not_held · no_open_round
|
|
1146
|
+
// 413 upload_too_large · 415 not_a_docx
|
|
1147
|
+
// -------------------------------------------------------------------------
|
|
1148
|
+
router.post('/copy-desk/pages/:pageId/draft.docx', api.requireBuilder, docxBody, async (req, res) => {
|
|
1149
|
+
try {
|
|
1150
|
+
const found = pageOrFail(req, res);
|
|
1151
|
+
if (!found) return;
|
|
1152
|
+
const { pageId } = found;
|
|
1153
|
+
const lifecycle = pageReadsOrFail(res);
|
|
1154
|
+
if (!lifecycle) return;
|
|
1155
|
+
if (typeof lifecycle.updateHeldTaskDescription !== 'function') {
|
|
1156
|
+
return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot save a page draft. Nothing was saved.' });
|
|
1157
|
+
}
|
|
1158
|
+
const readingPage = readingPageOrFail(res, pageId);
|
|
1159
|
+
if (!readingPage) return;
|
|
1160
|
+
|
|
1161
|
+
const upload = docx.readUpload(Buffer.isBuffer(req.body) ? req.body : null);
|
|
1162
|
+
if (!upload.ok) return refusePage(res, upload);
|
|
1163
|
+
if (upload.page_id !== pageId) {
|
|
1164
|
+
return res.fail('wrong_page', 409, { page_id: pageId, file_page_id: upload.page_id, what_this_means: UPLOAD_WRONG_PAGE_MEANS });
|
|
1165
|
+
}
|
|
1166
|
+
if (upload.reading_hash !== readingPage.reading_hash) {
|
|
1167
|
+
return res.fail('reading_moved', 409, { reading_hash: readingPage.reading_hash || null, file_reading_hash: upload.reading_hash, what_this_means: UPLOAD_READING_MOVED_MEANS });
|
|
1168
|
+
}
|
|
1169
|
+
const mapped = docx.mapUpload({ readingPage, upload });
|
|
1170
|
+
if (!mapped.ok) return refusePage(res, mapped);
|
|
1171
|
+
|
|
1172
|
+
const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
|
|
1173
|
+
if (!round) {
|
|
1174
|
+
return res.fail('no_open_round', 409, {
|
|
1175
|
+
page_id: pageId,
|
|
1176
|
+
what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim), then upload the file.',
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const savedAt = new Date().toISOString();
|
|
1181
|
+
let draft = null;
|
|
1182
|
+
let merge = null;
|
|
1183
|
+
const r = await lifecycle.updateHeldTaskDescription({
|
|
1184
|
+
taskId: round.id,
|
|
1185
|
+
builderId: req.builder.id,
|
|
1186
|
+
source: pages.SOURCE,
|
|
1187
|
+
// Merged on the LOCKED task, so the draft the file merges into is the
|
|
1188
|
+
// one stored now, not one read before the lock.
|
|
1189
|
+
compose: (task) => {
|
|
1190
|
+
const prev = pages.parseDraftBlock(task.description);
|
|
1191
|
+
const m = docx.mergeUpload({ readingPage, texts: mapped.texts, baseline: upload.baseline, draft: prev.ok ? prev.draft : null });
|
|
1192
|
+
const resolved = pages.resolveDraft({ readingPage, body: { reading_hash: readingPage.reading_hash, lines: m.lines }, savedAt });
|
|
1193
|
+
if (!resolved.ok) return resolved;
|
|
1194
|
+
const s = pages.saveDraftInto(task.description, resolved.draft);
|
|
1195
|
+
if (s.ok) { draft = s.draft; merge = m; }
|
|
1196
|
+
return s;
|
|
1197
|
+
},
|
|
1198
|
+
});
|
|
1199
|
+
switch (r.outcome) {
|
|
1200
|
+
case 'saved':
|
|
1201
|
+
case 'unchanged':
|
|
1202
|
+
return res.json({
|
|
1203
|
+
ok: true,
|
|
1204
|
+
outcome: r.outcome,
|
|
1205
|
+
page_id: pageId,
|
|
1206
|
+
round: { task_id: String(round.id), state: 'writing' },
|
|
1207
|
+
draft: { reading_hash: draft.reading_hash, saved_at: draft.saved_at, lines: draft.lines },
|
|
1208
|
+
counter: { changed: draft.lines.length, total: readingPage.lines.length },
|
|
1209
|
+
// What the editor shows after an upload ("uploaded · 3 lines from
|
|
1210
|
+
// your doc", Editor.dc.html): the lines the file changed, the draft
|
|
1211
|
+
// lines it left alone, and whether lines were matched by tag or by
|
|
1212
|
+
// position, with or without the file's record of what it showed.
|
|
1213
|
+
merge: { mode: mapped.mode, baseline: !!upload.baseline, from_file: merge.from_file, kept_from_draft: merge.kept_from_draft },
|
|
1214
|
+
});
|
|
1215
|
+
case 'not_held':
|
|
1216
|
+
return refuseNotHeld(res, pageId, r, round);
|
|
1217
|
+
case 'refused':
|
|
1218
|
+
return refusePage(res, r.refusal);
|
|
1219
|
+
default:
|
|
1220
|
+
throw new Error(`updateHeldTaskDescription: unexpected outcome ${r.outcome}`);
|
|
1221
|
+
}
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
log.error({ err }, 'POST /copy-desk/pages/:pageId/draft.docx failed');
|
|
1224
|
+
if (!res.headersSent) res.fail('copy_page_docx_upload_failed', 500);
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1039
1228
|
// -------------------------------------------------------------------------
|
|
1040
1229
|
// POST /copy-desk/pages/:pageId/submit — freeze the draft and queue the page
|
|
1041
1230
|
// for /tweak (task 1004319 / BV2.TW08, ADR 0341 D4, D7).
|