@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
|
@@ -14,6 +14,9 @@
|
|
|
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.
|
|
17
20
|
//
|
|
18
21
|
// THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
|
|
19
22
|
//
|
|
@@ -53,6 +56,7 @@ const proposals = require('../proposals');
|
|
|
53
56
|
const pageData = require('../page-data');
|
|
54
57
|
const pageStatus = require('../page-status');
|
|
55
58
|
const pages = require('../pages');
|
|
59
|
+
const docx = require('../docx');
|
|
56
60
|
|
|
57
61
|
const { validateOrRespond } = api;
|
|
58
62
|
|
|
@@ -103,6 +107,133 @@ const CLAIM_BODY_SCHEMA = Object.freeze({});
|
|
|
103
107
|
const ASK_BODY_SCHEMA = Object.freeze({
|
|
104
108
|
reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
|
|
105
109
|
});
|
|
110
|
+
// The draft autosave (task 1004319 / TW08): the reading the editor was built
|
|
111
|
+
// on, and every changed line as { key, after }. Nothing else, and in
|
|
112
|
+
// particular no `before`, `section`, `placement` or file: every fact about a
|
|
113
|
+
// target is resolved from the page reading (pages.resolveDraft).
|
|
114
|
+
const DRAFT_BODY_SCHEMA = Object.freeze({
|
|
115
|
+
reading_hash: { required: true, type: 'string', maxLength: 64, minLength: 1 },
|
|
116
|
+
lines: { required: true, type: 'array', maxItems: pages.MAX_DRAFT_LINES },
|
|
117
|
+
});
|
|
118
|
+
// The submit takes nothing: it freezes the draft already on the task.
|
|
119
|
+
const SUBMIT_BODY_SCHEMA = Object.freeze({});
|
|
120
|
+
|
|
121
|
+
// The refusals of pages.resolveDraft / freezeBatch, as HTTP statuses.
|
|
122
|
+
const PAGE_REFUSAL_STATUS = Object.freeze({
|
|
123
|
+
reading_missing: 503,
|
|
124
|
+
reading_moved: 409,
|
|
125
|
+
lines_refused: 409,
|
|
126
|
+
nothing_to_submit: 409,
|
|
127
|
+
multiple_blocks: 409,
|
|
128
|
+
block_unreadable: 409,
|
|
129
|
+
block_schema_unknown: 409,
|
|
130
|
+
block_field_missing: 409,
|
|
131
|
+
wrong_page: 409,
|
|
132
|
+
// The Word upload (task 1004320, ADR 0341 D12).
|
|
133
|
+
structure_changed: 409,
|
|
134
|
+
not_a_docx: 415,
|
|
135
|
+
upload_too_large: 413,
|
|
136
|
+
});
|
|
137
|
+
const PAGE_REFUSAL_MEANS = Object.freeze({
|
|
138
|
+
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.',
|
|
139
|
+
unknown_line: 'The page reading has no line with that key. Reload the editor.',
|
|
140
|
+
line_empty: 'A line cannot be rewritten to nothing. Removing text from a page is a code change; leave the line as it is or give it new words.',
|
|
141
|
+
line_too_long: `A line holds at most ${pages.MAX_LINE_CHARS} characters.`,
|
|
142
|
+
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.',
|
|
143
|
+
nothing_to_submit: 'The draft has no rewritten lines yet.',
|
|
144
|
+
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.',
|
|
145
|
+
not_a_docx: 'Upload the .docx file this page downloaded, as the request body. Nothing was saved.',
|
|
146
|
+
upload_too_large: `A page file is at most ${Math.round(docx.MAX_UPLOAD_BYTES / (1024 * 1024))} MB. Nothing was saved.`,
|
|
147
|
+
});
|
|
148
|
+
// The upload's answers when the file is readable but is not this page's, or not
|
|
149
|
+
// this reading's (task 1004320, ADR 0341 D12).
|
|
150
|
+
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.';
|
|
151
|
+
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.';
|
|
152
|
+
|
|
153
|
+
// The upload's body is the .docx itself, never JSON (the global parser only
|
|
154
|
+
// reads application/json, so it passes this through). Capped at the parser, and
|
|
155
|
+
// never inflated from a Content-Encoding, so the cap is the bytes on the wire.
|
|
156
|
+
const DOCX_BODY_TYPES = [docx.DOCX_MIME, 'application/zip', 'application/x-zip-compressed', 'application/octet-stream'];
|
|
157
|
+
const rawDocxBody = express.raw({ type: DOCX_BODY_TYPES, limit: docx.MAX_UPLOAD_BYTES, inflate: false });
|
|
158
|
+
function docxBody(req, res, next) {
|
|
159
|
+
rawDocxBody(req, res, (err) => {
|
|
160
|
+
if (!err) return next();
|
|
161
|
+
if (err.type === 'entity.too.large') return refusePage(res, { code: 'upload_too_large', detail: { max_bytes: docx.MAX_UPLOAD_BYTES } });
|
|
162
|
+
return refusePage(res, { code: 'not_a_docx', detail: { reason: 'the upload could not be read' } });
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function refusePage(res, r) {
|
|
167
|
+
const status = PAGE_REFUSAL_STATUS[r.code] || 400;
|
|
168
|
+
const means = PAGE_REFUSAL_MEANS[r.code];
|
|
169
|
+
return res.fail(r.code, status, { ...(r.detail || {}), ...(means ? { what_this_means: means } : {}) });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The page's reading, which every draft and batch target comes from. Returns
|
|
173
|
+
// null when it has already responded.
|
|
174
|
+
function readingPageOrFail(res, pageId) {
|
|
175
|
+
const readings = readingsState();
|
|
176
|
+
if (!readings.data) {
|
|
177
|
+
res.fail('readings_missing', 503, {
|
|
178
|
+
run: readings.info.run || 'node scripts/gds/page-reader.js',
|
|
179
|
+
what_this_means: 'A draft is resolved against the committed page readings, and this instance has none. Nothing was saved.',
|
|
180
|
+
});
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const readingPage = (readings.data.pages || []).find((p) => p && p.id === pageId);
|
|
184
|
+
if (!readingPage) {
|
|
185
|
+
res.fail('page_unread', 503, {
|
|
186
|
+
page_id: pageId,
|
|
187
|
+
what_this_means: 'The page readings do not include this page yet, so there are no lines to resolve a draft against.',
|
|
188
|
+
});
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return readingPage;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The page's OPEN round, from the same read the page routes use.
|
|
195
|
+
function openRoundOf(tasks, pageId) {
|
|
196
|
+
const open = (tasks || []).filter((t) => {
|
|
197
|
+
const ref = pages.parseRoundRef(t.source_ref);
|
|
198
|
+
return ref && ref.page_id === pageId && t.status !== 'shipped' && t.status !== 'abandoned';
|
|
199
|
+
});
|
|
200
|
+
return open.length ? open[open.length - 1] : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The answer to a repeated submit: the round is in the queue (ready, a frozen
|
|
204
|
+
// batch, nobody holding it) and the caller is its artist, the builder of its
|
|
205
|
+
// latest web claim. Null when that is not the case.
|
|
206
|
+
function alreadySubmitted(round, builderId, pageId) {
|
|
207
|
+
if (round.holder_id != null || round.status !== 'ready' || String(round.artist_id) !== String(builderId)) return null;
|
|
208
|
+
const b = pages.parseBatchBlock(round.description);
|
|
209
|
+
if (!b.ok) return null;
|
|
210
|
+
return {
|
|
211
|
+
ok: true, outcome: 'already_submitted', page_id: pageId,
|
|
212
|
+
round: { task_id: String(round.id), state: 'submitted', status: 'ready' },
|
|
213
|
+
batch: { line_count: b.batch.lines.length, submitted_at: b.batch.submitted_at, reading_hash: b.batch.reading_hash },
|
|
214
|
+
unplaced_tasks: (b.batch.unplaced_tasks || []).map((id) => ({ task_id: String(id) })),
|
|
215
|
+
next: { path: '/copy-desk/next' },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The port's not_held outcome, as the answer the editor shows.
|
|
220
|
+
function refuseNotHeld(res, pageId, r, round) {
|
|
221
|
+
// Nobody holds it and its batch is frozen: it is in the /tweak queue.
|
|
222
|
+
if (!r.holder && round && pages.blockPresence(round.description).batch) {
|
|
223
|
+
return res.fail('page_in_flight', 409, {
|
|
224
|
+
page_id: pageId,
|
|
225
|
+
what_this_means: 'This page has been submitted and is on its way to the site. Its draft is frozen until it ships or is sent back.',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const who = r.holder ? (r.holder.name || r.holder.login || 'Another builder') : null;
|
|
229
|
+
return res.fail('page_not_held', 409, {
|
|
230
|
+
page_id: pageId,
|
|
231
|
+
holder: r.holder ? { id: r.holder.id, login: r.holder.login, name: r.holder.name } : null,
|
|
232
|
+
what_this_means: who
|
|
233
|
+
? `${who} holds this page now, so only they can change its draft.`
|
|
234
|
+
: 'You do not hold this page. Claim it again (POST /copy-desk/pages/:pageId/claim) and the draft is still there.',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
106
237
|
|
|
107
238
|
// Load the registry or answer the named "no registry" state. Returns null when
|
|
108
239
|
// it has already responded, so every handler is `if (!reg) return;`.
|
|
@@ -841,6 +972,342 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
841
972
|
}
|
|
842
973
|
});
|
|
843
974
|
|
|
975
|
+
// -------------------------------------------------------------------------
|
|
976
|
+
// PUT /copy-desk/pages/:pageId/draft — autosave the artist's draft (task
|
|
977
|
+
// 1004319 / BV2.TW08, ADR 0341 D4).
|
|
978
|
+
//
|
|
979
|
+
// Body: { reading_hash, lines: [{ key, after }] }, the WHOLE draft: every line
|
|
980
|
+
// the artist has changed. The saved draft becomes exactly that (a line sent
|
|
981
|
+
// back at its current text is dropped, so only changed lines are stored).
|
|
982
|
+
// rank: any signed-in builder; only the HOLDER of the page's writing claim
|
|
983
|
+
// (the web claim the page claim took) may save, checked in the
|
|
984
|
+
// lifecycle transaction.
|
|
985
|
+
//
|
|
986
|
+
// Every target is resolved from the page reading by key: section, current
|
|
987
|
+
// text (`before`) and placement are the reading's, never the body's. A body
|
|
988
|
+
// built on an older reading is refused `reading_moved`, whole, since its keys
|
|
989
|
+
// may now mean other lines.
|
|
990
|
+
//
|
|
991
|
+
// Cheap and idempotent: a save that resolves to the draft already stored
|
|
992
|
+
// writes nothing (200 `unchanged`; `updated_at` does not move). The editor
|
|
993
|
+
// debounces; the server only has to make a repeated save free.
|
|
994
|
+
//
|
|
995
|
+
// 200 saved | unchanged with the draft and "N of M rewritten"
|
|
996
|
+
// 409 page_not_held the caller does not hold the page (holder named)
|
|
997
|
+
// 409 no_open_round the page has no round: claim it first
|
|
998
|
+
// 409 reading_moved · 400 unknown_line / line_empty / line_too_long
|
|
999
|
+
// -------------------------------------------------------------------------
|
|
1000
|
+
router.put('/copy-desk/pages/:pageId/draft', api.requireBuilder, async (req, res) => {
|
|
1001
|
+
try {
|
|
1002
|
+
if (validateOrRespond(req, res, DRAFT_BODY_SCHEMA)) return;
|
|
1003
|
+
const found = pageOrFail(req, res);
|
|
1004
|
+
if (!found) return;
|
|
1005
|
+
const { pageId } = found;
|
|
1006
|
+
const lifecycle = pageReadsOrFail(res);
|
|
1007
|
+
if (!lifecycle) return;
|
|
1008
|
+
if (typeof lifecycle.updateHeldTaskDescription !== 'function') {
|
|
1009
|
+
return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot save a page draft. Nothing was saved.' });
|
|
1010
|
+
}
|
|
1011
|
+
const readingPage = readingPageOrFail(res, pageId);
|
|
1012
|
+
if (!readingPage) return;
|
|
1013
|
+
|
|
1014
|
+
const resolved = pages.resolveDraft({ readingPage, body: req.body, savedAt: new Date().toISOString() });
|
|
1015
|
+
if (!resolved.ok) return refusePage(res, resolved);
|
|
1016
|
+
|
|
1017
|
+
const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
|
|
1018
|
+
if (!round) {
|
|
1019
|
+
return res.fail('no_open_round', 409, {
|
|
1020
|
+
page_id: pageId,
|
|
1021
|
+
what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim).',
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
let draft = resolved.draft;
|
|
1026
|
+
const r = await lifecycle.updateHeldTaskDescription({
|
|
1027
|
+
taskId: round.id,
|
|
1028
|
+
builderId: req.builder.id,
|
|
1029
|
+
source: pages.SOURCE,
|
|
1030
|
+
// The same draft again keeps the stored block (saved_at included) and
|
|
1031
|
+
// writes nothing; see pages.saveDraftInto, which the upload shares.
|
|
1032
|
+
compose: (task) => {
|
|
1033
|
+
const s = pages.saveDraftInto(task.description, resolved.draft);
|
|
1034
|
+
if (s.ok) draft = s.draft;
|
|
1035
|
+
return s;
|
|
1036
|
+
},
|
|
1037
|
+
});
|
|
1038
|
+
switch (r.outcome) {
|
|
1039
|
+
case 'saved':
|
|
1040
|
+
case 'unchanged':
|
|
1041
|
+
return res.json({
|
|
1042
|
+
ok: true,
|
|
1043
|
+
outcome: r.outcome,
|
|
1044
|
+
page_id: pageId,
|
|
1045
|
+
round: { task_id: String(round.id), state: 'writing' },
|
|
1046
|
+
draft: { reading_hash: draft.reading_hash, saved_at: draft.saved_at, lines: draft.lines },
|
|
1047
|
+
// The editor's counter, "N of M rewritten · saved" (Editor.dc.html).
|
|
1048
|
+
counter: { changed: draft.lines.length, total: readingPage.lines.length },
|
|
1049
|
+
});
|
|
1050
|
+
case 'not_held':
|
|
1051
|
+
return refuseNotHeld(res, pageId, r, round);
|
|
1052
|
+
case 'refused':
|
|
1053
|
+
return refusePage(res, r.refusal);
|
|
1054
|
+
default:
|
|
1055
|
+
throw new Error(`updateHeldTaskDescription: unexpected outcome ${r.outcome}`);
|
|
1056
|
+
}
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
log.error({ err }, 'PUT /copy-desk/pages/:pageId/draft failed');
|
|
1059
|
+
if (!res.headersSent) res.fail('copy_page_draft_failed', 500);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
// -------------------------------------------------------------------------
|
|
1064
|
+
// GET /copy-desk/pages/:pageId/draft.docx — download the page's words as a
|
|
1065
|
+
// Word document, to work on offline (task 1004320 / BV2.TW09, ADR 0341 D12).
|
|
1066
|
+
//
|
|
1067
|
+
// The file is built from the page reading plus the open round's draft (a line
|
|
1068
|
+
// the artist has rewritten shows its rewrite). A draft saved on an older
|
|
1069
|
+
// reading is not shown, since its keys may now name other lines.
|
|
1070
|
+
// rank: any signed-in builder. The words are the page's own, and the draft is
|
|
1071
|
+
// on a task description every builder can already read; only the
|
|
1072
|
+
// upload is the holder's.
|
|
1073
|
+
// -------------------------------------------------------------------------
|
|
1074
|
+
router.get('/copy-desk/pages/:pageId/draft.docx', api.requireBuilder, async (req, res) => {
|
|
1075
|
+
try {
|
|
1076
|
+
const found = pageOrFail(req, res);
|
|
1077
|
+
if (!found) return;
|
|
1078
|
+
const { page, pageId } = found;
|
|
1079
|
+
const lifecycle = pageReadsOrFail(res);
|
|
1080
|
+
if (!lifecycle) return;
|
|
1081
|
+
const readingPage = readingPageOrFail(res, pageId);
|
|
1082
|
+
if (!readingPage) return;
|
|
1083
|
+
const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
|
|
1084
|
+
const d = round ? pages.parseDraftBlock(round.description) : null;
|
|
1085
|
+
const file = docx.composeDownload({ readingPage, title: page.title, draft: d && d.ok ? d.draft : null });
|
|
1086
|
+
res.set('Content-Type', docx.DOCX_MIME);
|
|
1087
|
+
res.set('Content-Disposition', `attachment; filename="${docx.downloadName(pageId)}"`);
|
|
1088
|
+
res.set('Cache-Control', 'no-store');
|
|
1089
|
+
res.send(file);
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
log.error({ err }, 'GET /copy-desk/pages/:pageId/draft.docx failed');
|
|
1092
|
+
if (!res.headersSent) res.fail('copy_page_docx_failed', 500);
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// -------------------------------------------------------------------------
|
|
1097
|
+
// POST /copy-desk/pages/:pageId/draft.docx — upload the Word file back into
|
|
1098
|
+
// the DRAFT (task 1004320 / BV2.TW09, ADR 0341 D12). Never to the site: the
|
|
1099
|
+
// words land in the round's draft block, exactly where the autosave puts
|
|
1100
|
+
// them, through the same port call (holder only) and the same resolver
|
|
1101
|
+
// (every target from the page reading by key, never from the file).
|
|
1102
|
+
//
|
|
1103
|
+
// Body: the .docx bytes (Content-Type: the Word type, application/zip or
|
|
1104
|
+
// application/octet-stream), at most docx.MAX_UPLOAD_BYTES.
|
|
1105
|
+
// rank: any signed-in builder; only the HOLDER of the page's writing claim.
|
|
1106
|
+
//
|
|
1107
|
+
// A MERGE, not a replace: a line is taken from the file only when the artist
|
|
1108
|
+
// changed it in Word (against the text the download showed), so a line they
|
|
1109
|
+
// rewrote in the studio after downloading keeps that rewrite.
|
|
1110
|
+
//
|
|
1111
|
+
// 200 saved | unchanged the draft, the counter, and `merge` (which lines
|
|
1112
|
+
// came from the file, and how they were matched)
|
|
1113
|
+
// 409 structure_changed a line added, dropped or reordered, or a heading
|
|
1114
|
+
// changed; each is named (section, first words)
|
|
1115
|
+
// 409 wrong_page · reading_moved · page_not_held · no_open_round
|
|
1116
|
+
// 413 upload_too_large · 415 not_a_docx
|
|
1117
|
+
// -------------------------------------------------------------------------
|
|
1118
|
+
router.post('/copy-desk/pages/:pageId/draft.docx', api.requireBuilder, docxBody, async (req, res) => {
|
|
1119
|
+
try {
|
|
1120
|
+
const found = pageOrFail(req, res);
|
|
1121
|
+
if (!found) return;
|
|
1122
|
+
const { pageId } = found;
|
|
1123
|
+
const lifecycle = pageReadsOrFail(res);
|
|
1124
|
+
if (!lifecycle) return;
|
|
1125
|
+
if (typeof lifecycle.updateHeldTaskDescription !== 'function') {
|
|
1126
|
+
return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot save a page draft. Nothing was saved.' });
|
|
1127
|
+
}
|
|
1128
|
+
const readingPage = readingPageOrFail(res, pageId);
|
|
1129
|
+
if (!readingPage) return;
|
|
1130
|
+
|
|
1131
|
+
const upload = docx.readUpload(Buffer.isBuffer(req.body) ? req.body : null);
|
|
1132
|
+
if (!upload.ok) return refusePage(res, upload);
|
|
1133
|
+
if (upload.page_id !== pageId) {
|
|
1134
|
+
return res.fail('wrong_page', 409, { page_id: pageId, file_page_id: upload.page_id, what_this_means: UPLOAD_WRONG_PAGE_MEANS });
|
|
1135
|
+
}
|
|
1136
|
+
if (upload.reading_hash !== readingPage.reading_hash) {
|
|
1137
|
+
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 });
|
|
1138
|
+
}
|
|
1139
|
+
const mapped = docx.mapUpload({ readingPage, upload });
|
|
1140
|
+
if (!mapped.ok) return refusePage(res, mapped);
|
|
1141
|
+
|
|
1142
|
+
const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
|
|
1143
|
+
if (!round) {
|
|
1144
|
+
return res.fail('no_open_round', 409, {
|
|
1145
|
+
page_id: pageId,
|
|
1146
|
+
what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim), then upload the file.',
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const savedAt = new Date().toISOString();
|
|
1151
|
+
let draft = null;
|
|
1152
|
+
let merge = null;
|
|
1153
|
+
const r = await lifecycle.updateHeldTaskDescription({
|
|
1154
|
+
taskId: round.id,
|
|
1155
|
+
builderId: req.builder.id,
|
|
1156
|
+
source: pages.SOURCE,
|
|
1157
|
+
// Merged on the LOCKED task, so the draft the file merges into is the
|
|
1158
|
+
// one stored now, not one read before the lock.
|
|
1159
|
+
compose: (task) => {
|
|
1160
|
+
const prev = pages.parseDraftBlock(task.description);
|
|
1161
|
+
const m = docx.mergeUpload({ readingPage, texts: mapped.texts, baseline: upload.baseline, draft: prev.ok ? prev.draft : null });
|
|
1162
|
+
const resolved = pages.resolveDraft({ readingPage, body: { reading_hash: readingPage.reading_hash, lines: m.lines }, savedAt });
|
|
1163
|
+
if (!resolved.ok) return resolved;
|
|
1164
|
+
const s = pages.saveDraftInto(task.description, resolved.draft);
|
|
1165
|
+
if (s.ok) { draft = s.draft; merge = m; }
|
|
1166
|
+
return s;
|
|
1167
|
+
},
|
|
1168
|
+
});
|
|
1169
|
+
switch (r.outcome) {
|
|
1170
|
+
case 'saved':
|
|
1171
|
+
case 'unchanged':
|
|
1172
|
+
return res.json({
|
|
1173
|
+
ok: true,
|
|
1174
|
+
outcome: r.outcome,
|
|
1175
|
+
page_id: pageId,
|
|
1176
|
+
round: { task_id: String(round.id), state: 'writing' },
|
|
1177
|
+
draft: { reading_hash: draft.reading_hash, saved_at: draft.saved_at, lines: draft.lines },
|
|
1178
|
+
counter: { changed: draft.lines.length, total: readingPage.lines.length },
|
|
1179
|
+
// What the editor shows after an upload ("uploaded · 3 lines from
|
|
1180
|
+
// your doc", Editor.dc.html): the lines the file changed, the draft
|
|
1181
|
+
// lines it left alone, and whether lines were matched by tag or by
|
|
1182
|
+
// position, with or without the file's record of what it showed.
|
|
1183
|
+
merge: { mode: mapped.mode, baseline: !!upload.baseline, from_file: merge.from_file, kept_from_draft: merge.kept_from_draft },
|
|
1184
|
+
});
|
|
1185
|
+
case 'not_held':
|
|
1186
|
+
return refuseNotHeld(res, pageId, r, round);
|
|
1187
|
+
case 'refused':
|
|
1188
|
+
return refusePage(res, r.refusal);
|
|
1189
|
+
default:
|
|
1190
|
+
throw new Error(`updateHeldTaskDescription: unexpected outcome ${r.outcome}`);
|
|
1191
|
+
}
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
log.error({ err }, 'POST /copy-desk/pages/:pageId/draft.docx failed');
|
|
1194
|
+
if (!res.headersSent) res.fail('copy_page_docx_upload_failed', 500);
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// -------------------------------------------------------------------------
|
|
1199
|
+
// POST /copy-desk/pages/:pageId/submit — freeze the draft and queue the page
|
|
1200
|
+
// for /tweak (task 1004319 / BV2.TW08, ADR 0341 D4, D7).
|
|
1201
|
+
//
|
|
1202
|
+
// Body: {} (it freezes the draft already on the task; nothing is taken from
|
|
1203
|
+
// the request). rank: any signed-in builder; the holder only.
|
|
1204
|
+
//
|
|
1205
|
+
// One lifecycle transaction:
|
|
1206
|
+
// * every draft line is re-resolved on the CURRENT page reading; a line the
|
|
1207
|
+
// page no longer shows is refused by name (target_gone), as are an
|
|
1208
|
+
// ambiguous one and a {…} hole added or dropped. Any refusal refuses the
|
|
1209
|
+
// submit, listing the lines, and nothing is frozen;
|
|
1210
|
+
// * a rewrite of an UNPLACED line (and of shared shell text with no
|
|
1211
|
+
// registry row) leaves the batch and files its own engineer task
|
|
1212
|
+
// (source page-tweak-unplaced, backlog), carrying the page, the line and
|
|
1213
|
+
// the wording. The page does not wait for it;
|
|
1214
|
+
// * the rest is frozen into the `page-tweak` block with each line's file,
|
|
1215
|
+
// line and string id from the reading, naming the engineer tasks;
|
|
1216
|
+
// * the artist's web claim is released and the task goes back to `ready`:
|
|
1217
|
+
// the round reads `submitted`, the /tweak queue.
|
|
1218
|
+
//
|
|
1219
|
+
// 200 submitted with the batch, the engineer tasks, and where
|
|
1220
|
+
// "next up" reads (GET /copy-desk/next)
|
|
1221
|
+
// 200 already_submitted a repeated submit by the round's artist
|
|
1222
|
+
// 409 lines_refused · nothing_to_submit · page_not_held · no_open_round
|
|
1223
|
+
// -------------------------------------------------------------------------
|
|
1224
|
+
router.post('/copy-desk/pages/:pageId/submit', api.requireBuilder, async (req, res) => {
|
|
1225
|
+
try {
|
|
1226
|
+
if (validateOrRespond(req, res, SUBMIT_BODY_SCHEMA)) return;
|
|
1227
|
+
const found = pageOrFail(req, res);
|
|
1228
|
+
if (!found) return;
|
|
1229
|
+
const { page, pageId } = found;
|
|
1230
|
+
const lifecycle = pageReadsOrFail(res);
|
|
1231
|
+
if (!lifecycle) return;
|
|
1232
|
+
if (typeof lifecycle.submitPageTweak !== 'function') {
|
|
1233
|
+
return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot submit a page. Nothing was submitted.' });
|
|
1234
|
+
}
|
|
1235
|
+
const readingPage = readingPageOrFail(res, pageId);
|
|
1236
|
+
if (!readingPage) return;
|
|
1237
|
+
|
|
1238
|
+
const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
|
|
1239
|
+
if (!round) {
|
|
1240
|
+
return res.fail('no_open_round', 409, {
|
|
1241
|
+
page_id: pageId,
|
|
1242
|
+
what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim).',
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
// A double-click: the round's own artist submitting what is already in
|
|
1246
|
+
// the queue gets the same answer, not a refusal.
|
|
1247
|
+
const again = alreadySubmitted(round, req.builder.id, pageId);
|
|
1248
|
+
if (again) return res.json(again);
|
|
1249
|
+
|
|
1250
|
+
const building = proposals.resolveTargetVersion(await lifecycle.listVersions());
|
|
1251
|
+
const submittedAt = new Date().toISOString();
|
|
1252
|
+
let frozen = null;
|
|
1253
|
+
const r = await lifecycle.submitPageTweak({
|
|
1254
|
+
taskId: round.id,
|
|
1255
|
+
builderId: req.builder.id,
|
|
1256
|
+
versionId: building ? building.id : null,
|
|
1257
|
+
freeze: (task) => {
|
|
1258
|
+
const d = pages.parseDraftBlock(task.description);
|
|
1259
|
+
if (!d.ok) return d.code === 'no_block' ? { ok: false, code: 'nothing_to_submit', detail: {} } : d;
|
|
1260
|
+
if (d.draft.page_id !== pageId) return { ok: false, code: 'wrong_page', detail: { page_id: pageId, draft_page_id: d.draft.page_id } };
|
|
1261
|
+
const f = pages.freezeBatch({ readingPage, draft: d.draft, submittedAt });
|
|
1262
|
+
if (!f.ok) return f;
|
|
1263
|
+
const probe = pages.replaceBlock(task.description, pages.FENCES.batch, '');
|
|
1264
|
+
if (!probe.ok) return probe;
|
|
1265
|
+
frozen = f;
|
|
1266
|
+
return {
|
|
1267
|
+
ok: true,
|
|
1268
|
+
unplaced: f.unplaced.map((l) => ({
|
|
1269
|
+
key: l.key,
|
|
1270
|
+
source: pages.UNPLACED_SOURCE,
|
|
1271
|
+
sourceRef: pages.unplacedSourceRef(task.id, l.key),
|
|
1272
|
+
title: pages.unplacedTaskTitle(page, l),
|
|
1273
|
+
description: pages.unplacedTaskBody(page, l, task.id),
|
|
1274
|
+
touches: Array.isArray(page.files) ? page.files : [],
|
|
1275
|
+
})),
|
|
1276
|
+
description: (ids) => pages.replaceBlock(task.description, pages.FENCES.batch,
|
|
1277
|
+
pages.composeBatchBlock({ ...f.batch, unplaced_tasks: ids })).description,
|
|
1278
|
+
};
|
|
1279
|
+
},
|
|
1280
|
+
});
|
|
1281
|
+
switch (r.outcome) {
|
|
1282
|
+
case 'submitted':
|
|
1283
|
+
return res.json({
|
|
1284
|
+
ok: true, outcome: 'submitted', page_id: pageId,
|
|
1285
|
+
round: { task_id: String(r.task.id), state: 'submitted', status: r.task.status },
|
|
1286
|
+
batch: { line_count: frozen.batch.lines.length, submitted_at: submittedAt, reading_hash: frozen.batch.reading_hash },
|
|
1287
|
+
unplaced_tasks: r.unplaced_tasks.map((u) => ({ task_id: u.id, key: u.key, created: u.created })),
|
|
1288
|
+
// "Next up" (the editor's closing card) reads the recommendation.
|
|
1289
|
+
next: { path: '/copy-desk/next' },
|
|
1290
|
+
});
|
|
1291
|
+
case 'not_held': {
|
|
1292
|
+
// A second submit that raced the first waits on the task's row
|
|
1293
|
+
// lock and then finds the claim released: read the round again,
|
|
1294
|
+
// and if the first froze it, this is the same double-click.
|
|
1295
|
+
const now = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
|
|
1296
|
+
const same = now && alreadySubmitted(now, req.builder.id, pageId);
|
|
1297
|
+
if (same) return res.json(same);
|
|
1298
|
+
return refuseNotHeld(res, pageId, r, now || round);
|
|
1299
|
+
}
|
|
1300
|
+
case 'refused':
|
|
1301
|
+
return refusePage(res, r.refusal);
|
|
1302
|
+
default:
|
|
1303
|
+
throw new Error(`submitPageTweak: unexpected outcome ${r.outcome}`);
|
|
1304
|
+
}
|
|
1305
|
+
} catch (err) {
|
|
1306
|
+
log.error({ err }, 'POST /copy-desk/pages/:pageId/submit failed');
|
|
1307
|
+
if (!res.headersSent) res.fail('copy_page_submit_failed', 500);
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
|
|
844
1311
|
return router;
|
|
845
1312
|
};
|
|
846
1313
|
|