@bongos/core 1.19.1061 → 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 +44 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +135 -4
- package/docs/api-reference.md +5 -3
- package/docs/module-api-changelog.md +2 -0
- package/modules/copy-desk/docx.js +818 -0
- package/modules/copy-desk/pages.js +16 -0
- package/modules/copy-desk/routes/copy-desk.js +168 -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/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -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/fitness.mjs +7 -2
|
@@ -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,6 +446,7 @@ module.exports = {
|
|
|
431
446
|
replaceBlock,
|
|
432
447
|
resolveDraft,
|
|
433
448
|
sameDraft,
|
|
449
|
+
saveDraftInto,
|
|
434
450
|
freezeBatch,
|
|
435
451
|
unplacedSourceRef,
|
|
436
452
|
unplacedTaskTitle,
|
|
@@ -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
|
|
|
@@ -125,6 +129,10 @@ const PAGE_REFUSAL_STATUS = Object.freeze({
|
|
|
125
129
|
block_schema_unknown: 409,
|
|
126
130
|
block_field_missing: 409,
|
|
127
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,
|
|
128
136
|
});
|
|
129
137
|
const PAGE_REFUSAL_MEANS = Object.freeze({
|
|
130
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.',
|
|
@@ -133,7 +141,27 @@ const PAGE_REFUSAL_MEANS = Object.freeze({
|
|
|
133
141
|
line_too_long: `A line holds at most ${pages.MAX_LINE_CHARS} characters.`,
|
|
134
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.',
|
|
135
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.`,
|
|
136
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
|
+
}
|
|
137
165
|
|
|
138
166
|
function refusePage(res, r) {
|
|
139
167
|
const status = PAGE_REFUSAL_STATUS[r.code] || 400;
|
|
@@ -999,16 +1027,12 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
999
1027
|
taskId: round.id,
|
|
1000
1028
|
builderId: req.builder.id,
|
|
1001
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.
|
|
1002
1032
|
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));
|
|
1033
|
+
const s = pages.saveDraftInto(task.description, resolved.draft);
|
|
1034
|
+
if (s.ok) draft = s.draft;
|
|
1035
|
+
return s;
|
|
1012
1036
|
},
|
|
1013
1037
|
});
|
|
1014
1038
|
switch (r.outcome) {
|
|
@@ -1036,6 +1060,141 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
1036
1060
|
}
|
|
1037
1061
|
});
|
|
1038
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
|
+
|
|
1039
1198
|
// -------------------------------------------------------------------------
|
|
1040
1199
|
// POST /copy-desk/pages/:pageId/submit — freeze the draft and queue the page
|
|
1041
1200
|
// for /tweak (task 1004319 / BV2.TW08, ADR 0341 D4, D7).
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// modules/copy-desk/tests/copy_docx.mjs — the Word round trip, pure (task
|
|
2
|
+
// 1004320 / BV2.TW09, ADR 0341 D12).
|
|
3
|
+
//
|
|
4
|
+
// What docx.js must do before any route is involved: write a file Word can
|
|
5
|
+
// open with one tagged paragraph per line; read an upload the way Word stores
|
|
6
|
+
// an edit (runs split across <w:r> elements, proofing marks, smart quotes,
|
|
7
|
+
// tracked changes, a data-descriptor zip); map it back by tag or by position;
|
|
8
|
+
// merge it into a draft without losing a rewrite made after the download; and
|
|
9
|
+
// refuse by name every change of structure. And bound every read of an
|
|
10
|
+
// upload, which is someone else's input.
|
|
11
|
+
//
|
|
12
|
+
// The whole path (the routes, the holder check, the draft block on the task)
|
|
13
|
+
// is tests/copy_desk_page_docx.mjs (root).
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import zlib from 'node:zlib';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { unzipAll, wordZip, resave, typeInto, wordRuns, trackedInsert, trackedDelete, stripControls, lineParagraph } from './fixtures/word-docx.mjs';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const docx = require('../docx.js');
|
|
22
|
+
const pages = require('../pages.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
function test(name, fn) {
|
|
27
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
28
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
32
|
+
const READING = {
|
|
33
|
+
id: 'builders:studio',
|
|
34
|
+
title: 'Studio',
|
|
35
|
+
reading_hash: 'h-now',
|
|
36
|
+
lines: [
|
|
37
|
+
L('L0001', 'top bar', 'Studio', 'shared', { file: 'shell.js', line: 40, string_id: 'sh01' }),
|
|
38
|
+
L('L0002', 'greeting', 'Welcome back', 'placed', { file: 'studio.html', line: 12, string_id: 'st02' }),
|
|
39
|
+
L('L0003', 'greeting', '{…} pages tweaked', 'placed', { file: 'studio.js', line: 88, string_id: 'st03' }),
|
|
40
|
+
L('L0004', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
41
|
+
L('L0005', 'greeting', 'It\u2019s your turn', 'placed', { file: 'studio.js', line: 90, string_id: 'st05' }),
|
|
42
|
+
L('L0006', 'footer', 'Resume', 'placed', { file: 'studio.html', line: 99, string_id: 'st06' }),
|
|
43
|
+
// A section that recurs later on the page gets its heading again.
|
|
44
|
+
L('L0007', 'greeting', 'Take the next page', 'placed', { file: 'studio.html', line: 120, string_id: 'st07' }),
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
const download = (draft = null) => docx.composeDownload({ readingPage: READING, title: 'Studio', draft });
|
|
48
|
+
const upload = (buf) => {
|
|
49
|
+
const u = docx.readUpload(buf);
|
|
50
|
+
assert.equal(u.ok, true, JSON.stringify(u));
|
|
51
|
+
return u;
|
|
52
|
+
};
|
|
53
|
+
const roundTrip = (buf, draft = null) => {
|
|
54
|
+
const u = upload(buf);
|
|
55
|
+
const m = docx.mapUpload({ readingPage: READING, upload: u });
|
|
56
|
+
if (!m.ok) return { mapped: m };
|
|
57
|
+
return { mapped: m, merged: docx.mergeUpload({ readingPage: READING, texts: m.texts, baseline: u.baseline, draft }) };
|
|
58
|
+
};
|
|
59
|
+
const draftOf = (lines) => ({ page_id: READING.id, reading_hash: READING.reading_hash, saved_at: 't', lines });
|
|
60
|
+
|
|
61
|
+
console.log('copy-desk: the Word round trip');
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// The zip, both directions, and the bounds on an upload.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
test('the zip writer and reader agree, and the reader takes a Word-shaped zip (data descriptors)', () => {
|
|
68
|
+
const entries = [{ name: 'a.xml', data: '<a>é…</a>' }, { name: 'dir/b.xml', data: 'x'.repeat(5000) }];
|
|
69
|
+
const z = docx.readZip(docx.writeZip(entries));
|
|
70
|
+
assert.equal(z.ok, true);
|
|
71
|
+
assert.deepEqual([...z.parts].map(([k, v]) => [k, v.toString('utf8')]), entries.map((e) => [e.name, e.data]));
|
|
72
|
+
const w = docx.readZip(wordZip([['one.xml', '<one/>'], ['two.xml', 'two'.repeat(99)]]));
|
|
73
|
+
assert.equal(w.ok, true, JSON.stringify(w));
|
|
74
|
+
assert.equal(w.parts.get('two.xml').toString(), 'two'.repeat(99));
|
|
75
|
+
assert.equal(docx.crc32(Buffer.from('123456789')), 0xCBF43926, 'the standard CRC-32 check value');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the same page makes the same bytes', () => {
|
|
79
|
+
assert.equal(Buffer.compare(download(), download()), 0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an upload that is not a zip, or a broken one, is not_a_docx', () => {
|
|
83
|
+
for (const bad of [Buffer.from('{"lines":[]}'), Buffer.from('PK\u0003\u0004 not really'), Buffer.alloc(0)]) {
|
|
84
|
+
assert.equal(docx.readUpload(bad).code, 'not_a_docx', bad.toString());
|
|
85
|
+
}
|
|
86
|
+
// A flipped byte inside the document part's deflated data fails its CRC (or
|
|
87
|
+
// its inflate). The first match is the local header's copy of the name.
|
|
88
|
+
const buf = Buffer.from(download());
|
|
89
|
+
const at = buf.indexOf('word/document.xml') + 'word/document.xml'.length + 40;
|
|
90
|
+
buf[at] ^= 0xFF;
|
|
91
|
+
assert.equal(docx.readUpload(buf).code, 'not_a_docx');
|
|
92
|
+
// A zip that is not a Word document.
|
|
93
|
+
assert.equal(docx.readUpload(docx.writeZip([{ name: 'hello.txt', data: 'hi' }])).detail.reason, 'the file has no Word document part');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an encrypted entry is refused, never guessed at', () => {
|
|
97
|
+
const buf = Buffer.from(download());
|
|
98
|
+
// Set the encryption flag on every central directory entry.
|
|
99
|
+
for (let i = 0; i < buf.length - 4; i++) if (buf.readUInt32LE(i) === 0x02014b50) buf.writeUInt16LE(buf.readUInt16LE(i + 8) | 1, i + 8);
|
|
100
|
+
const r = docx.readUpload(buf);
|
|
101
|
+
assert.equal(r.code, 'not_a_docx');
|
|
102
|
+
assert.match(r.detail.reason, /encrypted/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a zip bomb stops at the part cap, and an oversized file is refused before it is read', () => {
|
|
106
|
+
// 9 MB of zeros deflates to a few kilobytes: the declared size is refused,
|
|
107
|
+
// and a lying declared size is stopped by the inflate cap itself.
|
|
108
|
+
const zeros = Buffer.alloc(docx.MAX_PART_BYTES + 1024 * 1024);
|
|
109
|
+
const honest = docx.writeZip([{ name: 'word/document.xml', data: zeros }]);
|
|
110
|
+
assert.ok(honest.length < 100 * 1024);
|
|
111
|
+
assert.equal(docx.readUpload(honest).code, 'upload_too_large');
|
|
112
|
+
const liar = Buffer.from(honest);
|
|
113
|
+
for (let i = 0; i < liar.length - 4; i++) if (liar.readUInt32LE(i) === 0x02014b50) liar.writeUInt32LE(10, i + 24);
|
|
114
|
+
assert.equal(docx.readZip(liar).code, 'upload_too_large');
|
|
115
|
+
assert.equal(docx.readUpload(Buffer.alloc(docx.MAX_UPLOAD_BYTES + 1, 1)).code, 'upload_too_large');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a DOCTYPE (an entity expansion) is refused, and entities are not expanded', () => {
|
|
119
|
+
const doctype = resave(download(), { document: (xml) => xml.replace('<w:document', '<!DOCTYPE d [<!ENTITY x "boom">]><w:document') });
|
|
120
|
+
assert.equal(docx.readUpload(doctype).code, 'not_a_docx');
|
|
121
|
+
assert.equal(docx.parseXml('<a>&lt; ’ &unknown;</a>').children[0], '< \u2019 &unknown;');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// The download.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
test('the download: title, a Heading 2 per run of a section, one tagged plain-text control per line', () => {
|
|
129
|
+
const doc = unzipAll(download()).get('word/document.xml');
|
|
130
|
+
const paras = [...doc.matchAll(/<w:p>(.*?)<\/w:p>/gs)].map((m) => m[1]);
|
|
131
|
+
const style = (p) => (/<w:pStyle w:val="([^"]+)"/.exec(p) || [])[1] || null;
|
|
132
|
+
const text = (p) => [...p.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)].map((m) => m[1]).join('');
|
|
133
|
+
assert.deepEqual([style(paras[0]), text(paras[0])], ['Title', 'Studio']);
|
|
134
|
+
assert.equal(style(paras[1]), 'TweakNote');
|
|
135
|
+
const headings = paras.filter((p) => style(p) === 'Heading2').map(text);
|
|
136
|
+
assert.deepEqual(headings, ['top bar', 'greeting', 'footer', 'greeting']);
|
|
137
|
+
const lines = paras.filter((p) => /<w:tag /.test(p));
|
|
138
|
+
assert.deepEqual(lines.map((p) => /<w:tag w:val="([^"]+)"/.exec(p)[1]), READING.lines.map((l) => l.key));
|
|
139
|
+
for (const p of lines) assert.match(p, /<w:sdtPr><w:tag w:val="L\d{4}"\/><w:id w:val="\d+"\/><w:text\/><\/w:sdtPr>/, 'a plain-text control');
|
|
140
|
+
assert.equal(text(lines[4]), 'It\u2019s your turn');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('shared and unplaced lines carry their own style and a margin comment', () => {
|
|
144
|
+
const parts = unzipAll(download());
|
|
145
|
+
const doc = parts.get('word/document.xml');
|
|
146
|
+
assert.match(lineParagraph(doc, 'L0001'), /<w:pStyle w:val="TweakShared"\/>.*<w:commentReference w:id="0"\/>/s);
|
|
147
|
+
assert.match(lineParagraph(doc, 'L0004'), /<w:pStyle w:val="TweakUnplaced"\/>.*<w:commentReference w:id="1"\/>/s);
|
|
148
|
+
assert.doesNotMatch(lineParagraph(doc, 'L0002'), /pStyle|comment/);
|
|
149
|
+
const comments = [...parts.get('word/comments.xml').matchAll(/<w:comment w:id="(\d)".*?<w:t[^>]*>([^<]*)<\/w:t>/gs)].map((m) => [m[1], m[2]]);
|
|
150
|
+
assert.deepEqual(comments, [['0', 'changes this on every page'], ['1', 'goes to an engineer']]);
|
|
151
|
+
assert.match(parts.get('word/styles.xml'), /w:styleId="TweakShared"/);
|
|
152
|
+
assert.match(parts.get('word/styles.xml'), /w:styleId="Heading2"><w:name w:val="heading 2"\/>/, 'the built-in Heading 2, so Word lists it as one');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('page_id and reading_hash are custom document properties', () => {
|
|
156
|
+
const u = upload(download());
|
|
157
|
+
assert.equal(u.page_id, 'builders:studio');
|
|
158
|
+
assert.equal(u.reading_hash, 'h-now');
|
|
159
|
+
assert.match(unzipAll(download()).get('docProps/custom.xml'), /name="page_id"><vt:lpwstr>builders:studio<\/vt:lpwstr>/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('the download shows the draft over the reading, and ignores a draft saved on another reading', () => {
|
|
163
|
+
const draft = draftOf([{ key: 'L0002', section: 'greeting', before: 'Welcome back', after: 'Welcome home', placement: 'placed' }]);
|
|
164
|
+
assert.equal(upload(download(draft)).baseline.get('L0002'), 'Welcome home');
|
|
165
|
+
assert.match(unzipAll(download(draft)).get('word/document.xml'), />Welcome home</);
|
|
166
|
+
const stale = { ...draft, reading_hash: 'h-old' };
|
|
167
|
+
assert.doesNotMatch(unzipAll(download(stale)).get('word/document.xml'), />Welcome home</);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('an untouched round trip changes nothing on every line', () => {
|
|
171
|
+
const { mapped, merged } = roundTrip(download());
|
|
172
|
+
assert.equal(mapped.mode, 'tag');
|
|
173
|
+
assert.deepEqual(merged.from_file, []);
|
|
174
|
+
const resolved = pages.resolveDraft({ readingPage: READING, body: { reading_hash: 'h-now', lines: merged.lines }, savedAt: 't' });
|
|
175
|
+
assert.deepEqual(resolved.draft.lines, []);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Reading an edit the way Word stores it.
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
test('Word splits one edited line across many runs: the upload reads the whole sentence', () => {
|
|
183
|
+
const edited = resave(download(), { document: (xml) => typeInto(xml, 'L0002', wordRuns(['Wel', 'come ', 'ho', 'me, ', 'friend'])) });
|
|
184
|
+
const { merged } = roundTrip(edited);
|
|
185
|
+
assert.deepEqual(merged.from_file, ['L0002']);
|
|
186
|
+
assert.equal(merged.lines.find((l) => l.key === 'L0002').after, 'Welcome home, friend');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('smart quotes: folded back on a line the page writes with straight quotes, kept on one written curly', () => {
|
|
190
|
+
const edited = resave(download(), {
|
|
191
|
+
document: (xml) => typeInto(typeInto(xml, 'L0002', wordRuns(['Welcome back, it\u2019s \u201Cyour\u201D desk'])),
|
|
192
|
+
'L0005', wordRuns(['It\u2019s ', 'your go'])),
|
|
193
|
+
});
|
|
194
|
+
const { merged } = roundTrip(edited);
|
|
195
|
+
const after = (k) => merged.lines.find((l) => l.key === k).after;
|
|
196
|
+
assert.equal(after('L0002'), 'Welcome back, it\'s "your" desk');
|
|
197
|
+
assert.equal(after('L0005'), 'It\u2019s your go');
|
|
198
|
+
// An autocorrect alone (a quote retyped, nothing else) is not a change.
|
|
199
|
+
const retyped = resave(download(), { document: (xml) => typeInto(xml, 'L0005', wordRuns(['It\'s your turn'])) });
|
|
200
|
+
assert.deepEqual(roundTrip(retyped).merged.from_file, []);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('tracked changes read as accepted: an insertion is text, a deletion is not', () => {
|
|
204
|
+
const edited = resave(download(), {
|
|
205
|
+
document: (xml) => typeInto(xml, 'L0006', `${wordRuns(['Resume '])}${trackedDelete('now')}${trackedInsert('where you left off')}`),
|
|
206
|
+
});
|
|
207
|
+
assert.equal(roundTrip(edited).merged.lines.find((l) => l.key === 'L0006').after, 'Resume where you left off');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a tab, a line break and a different namespace prefix still read as the words', () => {
|
|
211
|
+
const edited = resave(download(), {
|
|
212
|
+
document: (xml) => typeInto(xml, 'L0002', '<w:r><w:t>Welcome</w:t><w:tab/><w:t>in</w:t><w:br/><w:t>here</w:t></w:r>')
|
|
213
|
+
.replace(/xmlns:w=/, 'xmlns:ww=').replace(/<(\/?)w:/g, '<$1ww:').replace(/ w:/g, ' ww:'),
|
|
214
|
+
});
|
|
215
|
+
assert.equal(roundTrip(edited).merged.lines.find((l) => l.key === 'L0002').after, 'Welcome in here');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// The merge.
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
test('the merge keeps a rewrite made in the studio after the download', () => {
|
|
223
|
+
const file = download(); // shows the page's own text everywhere
|
|
224
|
+
const edited = resave(file, { document: (xml) => typeInto(xml, 'L0006', wordRuns(['Pick up ', 'where you were'])) });
|
|
225
|
+
// Since the download, the artist rewrote L0002 in the studio.
|
|
226
|
+
const draft = draftOf([{ key: 'L0002', section: 'greeting', before: 'Welcome back', after: 'Hello again', placement: 'placed' }]);
|
|
227
|
+
const { merged } = roundTrip(edited, draft);
|
|
228
|
+
const after = (k) => merged.lines.find((l) => l.key === k).after;
|
|
229
|
+
assert.equal(after('L0002'), 'Hello again', 'the file did not touch L0002, so the draft keeps it');
|
|
230
|
+
assert.equal(after('L0006'), 'Pick up where you were');
|
|
231
|
+
assert.deepEqual(merged.from_file, ['L0006']);
|
|
232
|
+
assert.deepEqual(merged.kept_from_draft, ['L0002']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a line changed in Word wins over the draft; without the file\'s baseline the file is the whole state', () => {
|
|
236
|
+
const draft = draftOf([{ key: 'L0002', section: 'greeting', before: 'Welcome back', after: 'Hello again', placement: 'placed' }]);
|
|
237
|
+
const edited = resave(download(), { document: (xml) => typeInto(xml, 'L0002', wordRuns(['Hi there'])) });
|
|
238
|
+
assert.equal(roundTrip(edited, draft).merged.lines.find((l) => l.key === 'L0002').after, 'Hi there');
|
|
239
|
+
// An editor that drops the custom XML part: every line is what the file says,
|
|
240
|
+
// so the studio rewrite of L0002 is replaced by the page text the file shows.
|
|
241
|
+
const noBaseline = resave(download(), { drop: ['customXml/item1.xml'] });
|
|
242
|
+
const u = upload(noBaseline);
|
|
243
|
+
assert.equal(u.baseline, null);
|
|
244
|
+
const { merged } = roundTrip(noBaseline, draft);
|
|
245
|
+
assert.equal(merged.lines.find((l) => l.key === 'L0002').after, 'Welcome back');
|
|
246
|
+
assert.deepEqual(merged.from_file, ['L0002']);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Structure changes are refused, by name.
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
const refusal = (buf) => {
|
|
254
|
+
const { mapped } = roundTrip(buf);
|
|
255
|
+
assert.equal(mapped.ok, false);
|
|
256
|
+
assert.equal(mapped.code, 'structure_changed');
|
|
257
|
+
return mapped.detail.moved;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
test('an added paragraph is named by its section and first words', () => {
|
|
261
|
+
const buf = resave(download(), {
|
|
262
|
+
document: (xml) => { const p = lineParagraph(xml, 'L0002'); return xml.replace(p, () => `${p}<w:p><w:r><w:t>A brand new line nobody asked for today</w:t></w:r></w:p>`); },
|
|
263
|
+
});
|
|
264
|
+
assert.deepEqual(refusal(buf), [{ change: 'added', key: null, section: 'greeting', first_words: 'A brand new line nobody asked…' }]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('a dropped line is named by its key, section and first words', () => {
|
|
268
|
+
const buf = resave(download(), { document: (xml) => xml.replace(lineParagraph(xml, 'L0004'), '') });
|
|
269
|
+
assert.deepEqual(refusal(buf), [{ change: 'dropped', key: 'L0004', section: 'greeting', first_words: 'Your desk is quiet today' }]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('a moved line is named, and only the line that moved', () => {
|
|
273
|
+
const buf = resave(download(), {
|
|
274
|
+
document: (xml) => { const p = lineParagraph(xml, 'L0002'); return xml.replace(p, '').replace(lineParagraph(xml, 'L0004'), (q) => `${q}${p}`); },
|
|
275
|
+
});
|
|
276
|
+
assert.deepEqual(refusal(buf), [{ change: 'reordered', key: 'L0002', section: 'greeting', first_words: 'Welcome back' }]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('a copied control is an added line; two lines joined into one paragraph drop the second', () => {
|
|
280
|
+
const copied = resave(download(), { document: (xml) => { const p = lineParagraph(xml, 'L0006'); return xml.replace(p, () => `${p}${p}`); } });
|
|
281
|
+
assert.deepEqual(refusal(copied), [{ change: 'added', key: 'L0006', section: 'footer', first_words: 'Resume' }]);
|
|
282
|
+
const joined = resave(download(), {
|
|
283
|
+
document: (xml) => {
|
|
284
|
+
const a = lineParagraph(xml, 'L0002');
|
|
285
|
+
const b = lineParagraph(xml, 'L0003');
|
|
286
|
+
const sdtB = /<w:sdt>.*<\/w:sdt>/s.exec(b)[0];
|
|
287
|
+
return xml.replace(b, '').replace(a, () => a.replace('</w:p>', `${sdtB}</w:p>`));
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
assert.deepEqual(refusal(joined), [{ change: 'dropped', key: 'L0003', section: 'greeting', first_words: '{…} pages tweaked', joined_to: 'L0002' }]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('a changed section heading is refused', () => {
|
|
294
|
+
const buf = resave(download(), { document: (xml) => xml.replace('<w:t xml:space="preserve">footer</w:t>', '<w:t>Footer bits</w:t>') });
|
|
295
|
+
assert.deepEqual(refusal(buf), [{ change: 'heading_changed', expected: 'footer', found: 'Footer bits' }]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('empty paragraphs (spacing) and the title and note are not lines', () => {
|
|
299
|
+
const buf = resave(download(), {
|
|
300
|
+
document: (xml) => { const p = lineParagraph(xml, 'L0002'); return xml.replace(p, () => `<w:p/>${p}<w:p><w:r><w:t> </w:t></w:r></w:p>`).replace('>Studio</w:t></w:r></w:p>', '>My studio notes</w:t></w:r></w:p>'); },
|
|
301
|
+
});
|
|
302
|
+
const { mapped } = roundTrip(buf);
|
|
303
|
+
assert.equal(mapped.ok, true, JSON.stringify(mapped.detail));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// When every tag is gone: by position.
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
test('with the controls stripped, lines map by position under the same headings', () => {
|
|
311
|
+
const buf = resave(download(), { document: (xml) => stripControls(typeInto(xml, 'L0006', wordRuns(['Carry ', 'on']))) });
|
|
312
|
+
assert.doesNotMatch(unzipAll(buf).get('word/document.xml'), /<w:tag /);
|
|
313
|
+
const { mapped, merged } = roundTrip(buf);
|
|
314
|
+
assert.equal(mapped.mode, 'position');
|
|
315
|
+
assert.deepEqual(merged.from_file, ['L0006']);
|
|
316
|
+
assert.equal(merged.lines.find((l) => l.key === 'L0006').after, 'Carry on');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('by position, a section with a line more or a line fewer is refused and named', () => {
|
|
320
|
+
const extra = resave(download(), {
|
|
321
|
+
document: (xml) => { const p = lineParagraph(xml, 'L0006'); return stripControls(xml.replace(p, () => `${p}<w:p><w:r><w:t>Another line</w:t></w:r></w:p>`)); },
|
|
322
|
+
});
|
|
323
|
+
assert.deepEqual(refusal(extra), [{ change: 'added', key: null, section: 'footer', first_words: 'Another line' }]);
|
|
324
|
+
const fewer = resave(download(), { document: (xml) => stripControls(xml.replace(lineParagraph(xml, 'L0006'), '')) });
|
|
325
|
+
assert.deepEqual(refusal(fewer), [{ change: 'dropped', key: 'L0006', section: 'footer', first_words: 'Resume' }]);
|
|
326
|
+
const heading = resave(download(), { document: (xml) => stripControls(xml.replace('<w:t xml:space="preserve">top bar</w:t>', '<w:t>Top</w:t>')) });
|
|
327
|
+
assert.equal(refusal(heading)[0].change, 'heading_changed');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Every page the inventory reads round-trips untouched.
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
test('every committed page reading round-trips with no change', () => {
|
|
335
|
+
const readings = require('../../../docs/page-readings.json');
|
|
336
|
+
for (const p of readings.pages) {
|
|
337
|
+
const u = docx.readUpload(docx.composeDownload({ readingPage: p, title: p.title, draft: null }));
|
|
338
|
+
assert.equal(u.ok, true, p.id);
|
|
339
|
+
const m = docx.mapUpload({ readingPage: p, upload: u });
|
|
340
|
+
assert.equal(m.ok, true, `${p.id}: ${JSON.stringify(m.detail)}`);
|
|
341
|
+
assert.deepEqual(docx.mergeUpload({ readingPage: p, texts: m.texts, baseline: u.baseline, draft: null }).from_file, [], p.id);
|
|
342
|
+
}
|
|
343
|
+
assert.ok(zlib, 'node:zlib is the only dependency');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
console.log(`\ncopy_docx: ${passed} passed, ${failed} failed`);
|
|
347
|
+
process.exit(failed ? 1 : 0);
|