@bongos/core 1.19.1061 → 1.19.1063
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +94 -34
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +178 -4
- package/docs/api-reference.md +6 -3
- package/docs/copy-inventory.md +44 -7
- package/docs/copy-registry.json +393 -26
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +36 -4
- package/docs/page-readings.json +34 -1
- package/modules/copy-desk/docx.js +818 -0
- package/modules/copy-desk/page-status.js +63 -0
- package/modules/copy-desk/pages.js +17 -0
- package/modules/copy-desk/routes/copy-desk.js +198 -9
- package/modules/copy-desk/tests/copy_docx.mjs +347 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +32 -3
- package/modules/copy-desk/tests/fixtures/word-docx.mjs +131 -0
- package/modules/hall-ui/public/tweak-editor-lib.js +137 -0
- package/modules/hall-ui/public/tweak-editor.css +437 -0
- package/modules/hall-ui/public/tweak-editor.html +111 -0
- package/modules/hall-ui/public/tweak-editor.js +447 -0
- package/modules/hall-ui/public/tweak-editor.states.json +99 -0
- package/modules/hall-ui/records/tweak-editor.md +32 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/scripts/hall-preview/server.js +21 -0
- package/src/bongos/serve-internal.js +5 -1
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_docx.mjs +279 -0
- package/tests/copy_desk_page_editor.mjs +181 -0
- package/tests/fitness.mjs +7 -2
- package/tests/hall_audit.mjs +5 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_tweak_editor.mjs +432 -0
|
@@ -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);
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
// which no renderer reads — and the wording reaches a person only after somebody
|
|
22
22
|
// claims that task and ships the diff. So the four things pinned here are:
|
|
23
23
|
//
|
|
24
|
-
// * the write surface is EXACTLY the verbs the module defines (
|
|
25
|
-
//
|
|
24
|
+
// * the write surface is EXACTLY the verbs the module defines (eight since
|
|
25
|
+
// TW09: the page draft, its Word upload and the submit write the round
|
|
26
|
+
// TASK, never a row),
|
|
26
27
|
// * no column on copy_desk_flags could hold replacement text,
|
|
27
28
|
// * nothing in this module writes a file, and
|
|
28
29
|
// * the ONLY thing that writes a surface file is the applier, it lives outside
|
|
@@ -64,7 +65,8 @@ const stripComments = (src) => src
|
|
|
64
65
|
// reads: pages.js (the page-tweak block format), page-status.js (the derived
|
|
65
66
|
// status, drift and tally) and page-data.js (the read side of the page
|
|
66
67
|
// inventory and readings). They are held to the same two rules as the rest.
|
|
67
|
-
|
|
68
|
+
// TW09 (task 1004320) added docx.js, the Word round trip over Buffers.
|
|
69
|
+
const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'routes/copy-desk.js'];
|
|
68
70
|
|
|
69
71
|
console.log('copy-desk: NO LIVE CMS');
|
|
70
72
|
|
|
@@ -81,6 +83,10 @@ await test('the write surface is exactly the verbs the module defines', () => {
|
|
|
81
83
|
// flag row whose target is a page id. Neither stores wording.
|
|
82
84
|
'POST /copy-desk/pages/:pageId/asks',
|
|
83
85
|
'POST /copy-desk/pages/:pageId/claim',
|
|
86
|
+
// TW09 (task 1004320, ADR 0341 D11, D12): the Word upload. It lands in the
|
|
87
|
+
// round's DRAFT through the autosave's own port call and resolver, never a
|
|
88
|
+
// row here, a file, or the site.
|
|
89
|
+
'POST /copy-desk/pages/:pageId/draft.docx',
|
|
84
90
|
// TW08 (task 1004319, ADR 0341 D4, D11): the draft autosave and the
|
|
85
91
|
// submit. Both write the page round's TASK DESCRIPTION through the
|
|
86
92
|
// lifecycle port, the place ADR 0233 lets wording rest; neither writes a
|
|
@@ -220,6 +226,29 @@ await test('the draft and the submit write only the round task, through the port
|
|
|
220
226
|
assert.equal(/\b(before|section|placement|file|string_id)\s*:/.test(schemas), false, 'the draft and submit schemas name no target field');
|
|
221
227
|
});
|
|
222
228
|
|
|
229
|
+
await test('the Word round trip is pure over Buffers, and its upload writes only the draft, through the port', () => {
|
|
230
|
+
// docx.js handles an artist's wording like pages.js does, so it is held to
|
|
231
|
+
// the same shape: its one require is node:zlib (a zip is deflate), and it
|
|
232
|
+
// reaches no fs, pool or network.
|
|
233
|
+
const lib = stripComments(read('docx.js'));
|
|
234
|
+
assert.deepEqual([...lib.matchAll(/require\(\s*'([^']+)'\s*\)/g)].map((m) => m[1]), ['node:zlib'],
|
|
235
|
+
'docx.js may require node:zlib and nothing else');
|
|
236
|
+
for (const forbidden of ['fs.', 'pool', 'fetch(']) {
|
|
237
|
+
assert.equal(lib.includes(forbidden), false, `docx.js must stay pure — it must not reach for ${forbidden}`);
|
|
238
|
+
}
|
|
239
|
+
const src = stripComments(read('routes/copy-desk.js'));
|
|
240
|
+
const start = src.indexOf("router.post('/copy-desk/pages/:pageId/draft.docx'");
|
|
241
|
+
assert.ok(start >= 0, 'the upload is mounted');
|
|
242
|
+
const next = src.indexOf('router.', start + 1);
|
|
243
|
+
const h = src.slice(start, next === -1 ? undefined : next);
|
|
244
|
+
assert.ok(/lifecycle\.updateHeldTaskDescription\(/.test(h), 'the upload lands on the task via the autosave\'s port call (holder only)');
|
|
245
|
+
assert.ok(/pages\.resolveDraft\(\{ readingPage,/.test(h), 'the upload is resolved from the reading by the autosave\'s resolver');
|
|
246
|
+
assert.ok(/pages\.saveDraftInto\(/.test(h), 'the upload writes the draft block through the autosave\'s writer');
|
|
247
|
+
assert.ok(/readingPageOrFail\(res, pageId\)/.test(h), 'the upload resolves against the page reading');
|
|
248
|
+
assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE |composeBatchBlock|submitPageTweak/i.test(h), false,
|
|
249
|
+
'the upload writes the draft and nothing else: no row, and never the frozen batch');
|
|
250
|
+
});
|
|
251
|
+
|
|
223
252
|
await test('the module manifest declares no seam another module could write copy through', () => {
|
|
224
253
|
const manifest = JSON.parse(read('module.json'));
|
|
225
254
|
assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// modules/copy-desk/tests/fixtures/word-docx.mjs — edit a downloaded page .docx
|
|
2
|
+
// the way Word stores an edit (task 1004320 / BV2.TW09, ADR 0341 D12).
|
|
3
|
+
//
|
|
4
|
+
// The Word round-trip tests must not prove the reader against its own writer
|
|
5
|
+
// only. So this fixture re-packs a file the way Word does, with its OWN zip
|
|
6
|
+
// writer (data descriptors after each entry, zero sizes in the local headers,
|
|
7
|
+
// entries in another order), and rewrites a line's paragraph the way Word
|
|
8
|
+
// leaves one after typing: the text split across several runs with their own
|
|
9
|
+
// properties and revision ids, proofing marks between them, smart quotes, and
|
|
10
|
+
// tracked insertions and deletions.
|
|
11
|
+
//
|
|
12
|
+
// Shared by modules/copy-desk/tests/copy_docx.mjs and
|
|
13
|
+
// tests/copy_desk_page_docx.mjs (a fixture, not a test: the runner discovers
|
|
14
|
+
// tests/*.mjs only, not this folder).
|
|
15
|
+
|
|
16
|
+
import zlib from 'node:zlib';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const docx = require('../../docx.js');
|
|
21
|
+
|
|
22
|
+
// Every entry of a zip, by the module's own reader (reading is not what these
|
|
23
|
+
// tests doubt; the re-pack below is independent of the module's writer).
|
|
24
|
+
export function unzipAll(buf) {
|
|
25
|
+
const z = docx.readZip(buf);
|
|
26
|
+
if (!z.ok) throw new Error(`unzip: ${z.code} ${JSON.stringify(z.detail)}`);
|
|
27
|
+
return new Map([...z.parts].map(([k, v]) => [k, v.toString('utf8')]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Pack entries the way Word writes a .docx: general-purpose flag bit 3 (sizes
|
|
31
|
+
// and CRC in a data descriptor after the data, zeros in the local header),
|
|
32
|
+
// deflated, in the order given.
|
|
33
|
+
export function wordZip(entries) {
|
|
34
|
+
const out = [];
|
|
35
|
+
const central = [];
|
|
36
|
+
let offset = 0;
|
|
37
|
+
for (const [name, text] of entries) {
|
|
38
|
+
const data = Buffer.from(text, 'utf8');
|
|
39
|
+
const packed = zlib.deflateRawSync(data, { level: 6 });
|
|
40
|
+
const crc = docx.crc32(data);
|
|
41
|
+
const nameBuf = Buffer.from(name, 'utf8');
|
|
42
|
+
const local = Buffer.alloc(30);
|
|
43
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
44
|
+
local.writeUInt16LE(20, 4);
|
|
45
|
+
local.writeUInt16LE(0x0008 | 0x0800, 6);
|
|
46
|
+
local.writeUInt16LE(8, 8);
|
|
47
|
+
local.writeUInt16LE(0x6000, 10);
|
|
48
|
+
local.writeUInt16LE(0x5b3c, 12);
|
|
49
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
50
|
+
const desc = Buffer.alloc(16);
|
|
51
|
+
desc.writeUInt32LE(0x08074b50, 0);
|
|
52
|
+
desc.writeUInt32LE(crc, 4);
|
|
53
|
+
desc.writeUInt32LE(packed.length, 8);
|
|
54
|
+
desc.writeUInt32LE(data.length, 12);
|
|
55
|
+
const c = Buffer.alloc(46);
|
|
56
|
+
c.writeUInt32LE(0x02014b50, 0);
|
|
57
|
+
c.writeUInt16LE(45, 4);
|
|
58
|
+
c.writeUInt16LE(20, 6);
|
|
59
|
+
c.writeUInt16LE(0x0008 | 0x0800, 8);
|
|
60
|
+
c.writeUInt16LE(8, 10);
|
|
61
|
+
c.writeUInt16LE(0x6000, 12);
|
|
62
|
+
c.writeUInt16LE(0x5b3c, 14);
|
|
63
|
+
c.writeUInt32LE(crc, 16);
|
|
64
|
+
c.writeUInt32LE(packed.length, 20);
|
|
65
|
+
c.writeUInt32LE(data.length, 24);
|
|
66
|
+
c.writeUInt16LE(nameBuf.length, 28);
|
|
67
|
+
c.writeUInt32LE(offset, 42);
|
|
68
|
+
out.push(local, nameBuf, packed, desc);
|
|
69
|
+
central.push(c, nameBuf);
|
|
70
|
+
offset += 30 + nameBuf.length + packed.length + 16;
|
|
71
|
+
}
|
|
72
|
+
const cd = Buffer.concat(central);
|
|
73
|
+
const end = Buffer.alloc(22);
|
|
74
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
75
|
+
end.writeUInt16LE(entries.length, 8);
|
|
76
|
+
end.writeUInt16LE(entries.length, 10);
|
|
77
|
+
end.writeUInt32LE(cd.length, 12);
|
|
78
|
+
end.writeUInt32LE(offset, 16);
|
|
79
|
+
return Buffer.concat([...out, cd, end]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Re-pack a downloaded file after changing its document part (and optionally
|
|
83
|
+
// other parts), in reverse entry order, the way a save by another program
|
|
84
|
+
// reorders them.
|
|
85
|
+
export function resave(buf, { document, drop = [], parts = {} } = {}) {
|
|
86
|
+
const all = unzipAll(buf);
|
|
87
|
+
if (document) all.set('word/document.xml', document(all.get('word/document.xml')));
|
|
88
|
+
for (const [k, v] of Object.entries(parts)) all.set(k, v);
|
|
89
|
+
for (const k of drop) all.delete(k);
|
|
90
|
+
return wordZip([...all].reverse());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The paragraph that holds a line's content control, as it stands.
|
|
94
|
+
const LINE_PARA = (key) => new RegExp(`<w:p>(?:(?!<w:p>).)*?<w:tag w:val="${key}"/>.*?</w:p>`, 's');
|
|
95
|
+
const LINE_PARA_STYLED = (key) => new RegExp(`<w:p><w:pPr>(?:(?!<w:p>).)*?<w:tag w:val="${key}"/>.*?</w:p>`, 's');
|
|
96
|
+
export function lineParagraph(xml, key) {
|
|
97
|
+
const m = LINE_PARA_STYLED(key).exec(xml) || LINE_PARA(key).exec(xml);
|
|
98
|
+
if (!m) throw new Error(`no paragraph for ${key}`);
|
|
99
|
+
return m[0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Replace the runs inside a line's content control with Word-shaped runs.
|
|
103
|
+
export function typeInto(xml, key, runsXml) {
|
|
104
|
+
const para = lineParagraph(xml, key);
|
|
105
|
+
const edited = para.replace(/<w:sdtContent>[\s\S]*?<\/w:sdtContent>/, () => `<w:sdtContent>${runsXml}</w:sdtContent>`);
|
|
106
|
+
return xml.replace(para, () => edited);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Word's runs for a typed sentence: each part its own run, some with
|
|
110
|
+
// properties and revision ids, proofing marks between them.
|
|
111
|
+
export function wordRuns(parts) {
|
|
112
|
+
return parts.map((p, i) => {
|
|
113
|
+
const rPr = i % 2 ? '<w:rPr><w:rFonts w:ascii="Calibri"/><w:lang w:val="en-GB"/></w:rPr>' : '';
|
|
114
|
+
const proof = i === 1 ? '<w:proofErr w:type="spellStart"/>' : i === 2 ? '<w:proofErr w:type="spellEnd"/>' : '';
|
|
115
|
+
return `${proof}<w:r w:rsidR="00A1B2C${i}" w:rsidRPr="00D4E5F${i}">${rPr}<w:t xml:space="preserve">${p}</w:t></w:r>`;
|
|
116
|
+
}).join('');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A tracked change: the insertion is text now, the deletion is not.
|
|
120
|
+
export function trackedInsert(text) {
|
|
121
|
+
return `<w:ins w:id="91" w:author="Artist" w:date="2026-09-28T10:00:00Z"><w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:ins>`;
|
|
122
|
+
}
|
|
123
|
+
export function trackedDelete(text) {
|
|
124
|
+
return `<w:del w:id="92" w:author="Artist" w:date="2026-09-28T10:00:00Z"><w:r><w:delText xml:space="preserve">${text}</w:delText></w:r></w:del>`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Remove every content control, keeping its runs: what an editor that does
|
|
128
|
+
// not keep controls leaves behind (the upload then maps by position).
|
|
129
|
+
export function stripControls(xml) {
|
|
130
|
+
return xml.replace(/<w:sdt><w:sdtPr>[\s\S]*?<\/w:sdtPr><w:sdtContent>([\s\S]*?)<\/w:sdtContent><\/w:sdt>/g, (m, inner) => inner);
|
|
131
|
+
}
|