@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.
@@ -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 prev = pages.parseDraftBlock(task.description);
1004
- // The same draft again: keep the stored block, saved_at included.
1005
- if (prev.ok && pages.sameDraft(prev.draft, resolved.draft)) {
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>&amp;lt; &#x2019; &unknown;</a>').children[0], '&lt; \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);