@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.
@@ -0,0 +1,818 @@
1
+ // modules/copy-desk/docx.js — the Word round trip for a page tweak (task 1004320 /
2
+ // BV2.TW09, ADR 0341 D12): download a page's words as a .docx, work offline, and
3
+ // upload the file back into the draft.
4
+ //
5
+ // NO DEPENDENCY. A .docx is a zip of XML parts, so this file carries a small zip
6
+ // writer and reader on node:zlib and a small XML reader, and nothing else. The
7
+ // spec rules out a paid service and ADR 0341 rejects a heavy library: the format
8
+ // is simple enough, and every byte of an upload is someone else's input, so a
9
+ // reader we can bound is worth more than a general one we cannot.
10
+ //
11
+ // PURE OVER BUFFERS: no fs, no DB, no network. The one require is node:zlib.
12
+ // copy_no_cms.mjs pins that, because this file handles an artist's wording.
13
+ //
14
+ // THE FILE (ADR 0341 D12):
15
+ // * the page title as the Title, and each run of lines in one section as a
16
+ // Heading 2 (a section that recurs later on the page gets a heading again);
17
+ // * one paragraph per line in page order, showing the draft's `after` (or the
18
+ // page's current text), inside a plain-text content control (w:sdt) whose
19
+ // tag is the line key. The control is RUN-level, inside the paragraph, which
20
+ // is the shape Word itself makes for a plain-text control and the one that
21
+ // stops Enter from splitting a line;
22
+ // * shared and unplaced lines in their own paragraph style, with a Word
23
+ // comment ("changes this on every page", "goes to an engineer");
24
+ // * page_id and reading_hash as custom document properties;
25
+ // * the text each line was SHOWN with, in a custom XML part. That part is
26
+ // what makes the upload a merge rather than a replace: a line is taken from
27
+ // the file only when the artist changed it in Word, so a line they rewrote
28
+ // in the studio after downloading keeps that rewrite.
29
+ //
30
+ // THE UPLOAD maps paragraphs back to lines by tag, or by position when every tag
31
+ // is gone, and refuses by name: structure_changed (a line added, dropped or
32
+ // reordered, or a heading changed), wrong_page, reading_moved. Word's own habits
33
+ // are read the way Word writes them: one line's text split across many runs,
34
+ // proofing marks, tracked insertions (the upload reads the document with its
35
+ // tracked changes accepted), and smart quotes (see foldQuotes).
36
+
37
+ 'use strict';
38
+
39
+ const zlib = require('node:zlib');
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Limits. An upload is user-supplied input: every size is capped before it is
43
+ // read, and nothing is inflated past its cap (a zip bomb stops at the cap).
44
+ // ---------------------------------------------------------------------------
45
+
46
+ // The file itself. A text-only page file is tens of kilobytes; 5 MB leaves room
47
+ // for an artist who pasted a picture into it and still bounds the request.
48
+ const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
49
+ // One inflated part. The longest page (under 500 lines) makes a document part
50
+ // well under 1 MB.
51
+ const MAX_PART_BYTES = 8 * 1024 * 1024;
52
+ const MAX_ZIP_ENTRIES = 2000;
53
+ const MAX_XML_DEPTH = 256;
54
+ const MAX_BASELINE_PARTS = 20;
55
+
56
+ const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
57
+
58
+ const NS = Object.freeze({
59
+ w: 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
60
+ wStrict: 'http://purl.oclc.org/ooxml/wordprocessingml/main',
61
+ mc: 'http://schemas.openxmlformats.org/markup-compatibility/2006',
62
+ rels: 'http://schemas.openxmlformats.org/package/2006/relationships',
63
+ custom: 'http://schemas.openxmlformats.org/officeDocument/2006/custom-properties',
64
+ vt: 'http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes',
65
+ baseline: 'urn:cloud-bongos:page-tweak:shown:1',
66
+ });
67
+ const REL = Object.freeze({
68
+ officeDocument: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument',
69
+ customProps: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties',
70
+ });
71
+
72
+ // The paragraph styles this file writes, and reads back.
73
+ const STYLE = Object.freeze({
74
+ title: 'Title',
75
+ heading: 'Heading2',
76
+ note: 'TweakNote',
77
+ shared: 'TweakShared',
78
+ unplaced: 'TweakUnplaced',
79
+ });
80
+ // The margin notes the editor shows beside these lines (Editor.dc.html).
81
+ const COMMENT_TEXT = Object.freeze({
82
+ shared: 'changes this on every page',
83
+ unplaced: 'goes to an engineer',
84
+ });
85
+ const NOTE_TEXT = 'Rewrite any line where it stands, then upload this file back into the editor. '
86
+ + 'Keep one paragraph per line: a paragraph added, removed or moved is refused. '
87
+ + 'Keep each {…} as it is: the page fills in a value there.';
88
+
89
+ const LINE_KEY_RE = /^L[0-9]{4,}$/;
90
+
91
+ function fail(code, detail) { return { ok: false, code, detail: detail || {} }; }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // ZIP: a writer (deflate, deterministic: fixed timestamps, given order) and a
95
+ // bounded reader (stored or deflate; no zip64, no encryption).
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const CRC_TABLE = (() => {
99
+ const t = new Int32Array(256);
100
+ for (let n = 0; n < 256; n++) {
101
+ let c = n;
102
+ for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
103
+ t[n] = c;
104
+ }
105
+ return t;
106
+ })();
107
+
108
+ function crc32(buf) {
109
+ let c = -1;
110
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
111
+ return (c ^ -1) >>> 0;
112
+ }
113
+
114
+ // 1980-01-01 00:00, the zip epoch: the same page makes the same bytes.
115
+ const DOS_TIME = 0;
116
+ const DOS_DATE = (1 << 5) | 1;
117
+ const UTF8_NAMES = 0x0800;
118
+
119
+ // writeZip([{ name, data }]) -> Buffer
120
+ function writeZip(entries) {
121
+ const locals = [];
122
+ const centrals = [];
123
+ let offset = 0;
124
+ for (const e of entries) {
125
+ const name = Buffer.from(e.name, 'utf8');
126
+ const data = Buffer.isBuffer(e.data) ? e.data : Buffer.from(String(e.data), 'utf8');
127
+ const packed = zlib.deflateRawSync(data);
128
+ const crc = crc32(data);
129
+ const local = Buffer.alloc(30);
130
+ local.writeUInt32LE(0x04034b50, 0);
131
+ local.writeUInt16LE(20, 4);
132
+ local.writeUInt16LE(UTF8_NAMES, 6);
133
+ local.writeUInt16LE(8, 8);
134
+ local.writeUInt16LE(DOS_TIME, 10);
135
+ local.writeUInt16LE(DOS_DATE, 12);
136
+ local.writeUInt32LE(crc, 14);
137
+ local.writeUInt32LE(packed.length, 18);
138
+ local.writeUInt32LE(data.length, 22);
139
+ local.writeUInt16LE(name.length, 26);
140
+ local.writeUInt16LE(0, 28);
141
+ const central = Buffer.alloc(46);
142
+ central.writeUInt32LE(0x02014b50, 0);
143
+ central.writeUInt16LE(20, 4);
144
+ central.writeUInt16LE(20, 6);
145
+ central.writeUInt16LE(UTF8_NAMES, 8);
146
+ central.writeUInt16LE(8, 10);
147
+ central.writeUInt16LE(DOS_TIME, 12);
148
+ central.writeUInt16LE(DOS_DATE, 14);
149
+ central.writeUInt32LE(crc, 16);
150
+ central.writeUInt32LE(packed.length, 20);
151
+ central.writeUInt32LE(data.length, 24);
152
+ central.writeUInt16LE(name.length, 28);
153
+ central.writeUInt32LE(offset, 42);
154
+ locals.push(local, name, packed);
155
+ centrals.push(central, name);
156
+ offset += local.length + name.length + packed.length;
157
+ }
158
+ const cd = Buffer.concat(centrals);
159
+ const end = Buffer.alloc(22);
160
+ end.writeUInt32LE(0x06054b50, 0);
161
+ end.writeUInt16LE(entries.length, 8);
162
+ end.writeUInt16LE(entries.length, 10);
163
+ end.writeUInt32LE(cd.length, 12);
164
+ end.writeUInt32LE(offset, 16);
165
+ return Buffer.concat([...locals, cd, end]);
166
+ }
167
+
168
+ function isZip(buf) {
169
+ return Buffer.isBuffer(buf) && buf.length >= 4 && buf.readUInt32LE(0) === 0x04034b50;
170
+ }
171
+
172
+ // readZip(buf, { want }) -> { ok, names, parts: Map(name -> Buffer) } | refusal
173
+ //
174
+ // Reads the central directory, then inflates ONLY the entries `want(name)`
175
+ // accepts, each capped at MAX_PART_BYTES. Sizes and offsets come from the
176
+ // central directory (Word writes data descriptors, so a local header's sizes can
177
+ // be zero). Every bound is checked before a byte is read.
178
+ function readZip(buf, { want = () => true, maxPartBytes = MAX_PART_BYTES } = {}) {
179
+ const bad = (reason) => fail('not_a_docx', { reason });
180
+ if (!isZip(buf)) return bad('not a zip file');
181
+ const min = Math.max(0, buf.length - 22 - 0xFFFF);
182
+ let eocd = -1;
183
+ for (let i = buf.length - 22; i >= min; i--) {
184
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
185
+ }
186
+ if (eocd < 0) return bad('the zip has no directory (truncated?)');
187
+ const count = buf.readUInt16LE(eocd + 10);
188
+ const cdSize = buf.readUInt32LE(eocd + 12);
189
+ const cdOffset = buf.readUInt32LE(eocd + 16);
190
+ if (count === 0xFFFF || cdSize === 0xFFFFFFFF || cdOffset === 0xFFFFFFFF) return bad('zip64 is not read here');
191
+ if (count > MAX_ZIP_ENTRIES) return bad(`more than ${MAX_ZIP_ENTRIES} entries`);
192
+ if (cdOffset + cdSize > eocd) return bad('the zip directory is out of bounds');
193
+
194
+ const names = [];
195
+ const parts = new Map();
196
+ let p = cdOffset;
197
+ for (let n = 0; n < count; n++) {
198
+ if (p + 46 > eocd || buf.readUInt32LE(p) !== 0x02014b50) return bad('a zip directory entry is corrupt');
199
+ const flags = buf.readUInt16LE(p + 8);
200
+ const method = buf.readUInt16LE(p + 10);
201
+ const crc = buf.readUInt32LE(p + 16);
202
+ const csize = buf.readUInt32LE(p + 20);
203
+ const usize = buf.readUInt32LE(p + 24);
204
+ const nlen = buf.readUInt16LE(p + 28);
205
+ const elen = buf.readUInt16LE(p + 30);
206
+ const clen = buf.readUInt16LE(p + 32);
207
+ const lho = buf.readUInt32LE(p + 42);
208
+ if (p + 46 + nlen > eocd) return bad('a zip entry name is out of bounds');
209
+ const name = buf.toString('utf8', p + 46, p + 46 + nlen).replace(/\\/g, '/').replace(/^\/+/, '');
210
+ p += 46 + nlen + elen + clen;
211
+ names.push(name);
212
+ if (!want(name)) continue;
213
+ if (parts.has(name)) return bad(`the zip holds ${name} twice`);
214
+ if (flags & 1) return bad('the file is encrypted');
215
+ if (usize > maxPartBytes) return fail('upload_too_large', { part: name, max_bytes: maxPartBytes });
216
+ if (lho + 30 > buf.length || buf.readUInt32LE(lho) !== 0x04034b50) return bad(`the entry ${name} is corrupt`);
217
+ const start = lho + 30 + buf.readUInt16LE(lho + 26) + buf.readUInt16LE(lho + 28);
218
+ if (start + csize > buf.length) return bad(`the entry ${name} is out of bounds`);
219
+ const raw = buf.subarray(start, start + csize);
220
+ let data;
221
+ if (method === 0) {
222
+ data = Buffer.from(raw);
223
+ } else if (method === 8) {
224
+ try {
225
+ data = zlib.inflateRawSync(raw, { maxOutputLength: maxPartBytes });
226
+ } catch (err) {
227
+ if (err && (err.code === 'ERR_BUFFER_TOO_LARGE' || err instanceof RangeError)) {
228
+ return fail('upload_too_large', { part: name, max_bytes: maxPartBytes });
229
+ }
230
+ return bad(`the entry ${name} does not inflate`);
231
+ }
232
+ } else {
233
+ return bad(`the entry ${name} uses compression method ${method}`);
234
+ }
235
+ if (data.length !== usize || crc32(data) !== crc) return bad(`the entry ${name} is corrupt`);
236
+ parts.set(name, data);
237
+ }
238
+ return { ok: true, names, parts };
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // XML. A small, namespace-aware reader into a light tree, and escaping for the
243
+ // writer. It never resolves an entity beyond the five predefined ones and
244
+ // character references, and refuses a DOCTYPE, so no entity can expand.
245
+ // ---------------------------------------------------------------------------
246
+
247
+ function escapeXml(s) {
248
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' }[c]))
249
+ // A character XML 1.0 cannot carry at all (control characters) is dropped.
250
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, '');
251
+ }
252
+
253
+ const NAMED_ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
254
+ function decodeEntities(s) {
255
+ return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (m, e) => {
256
+ if (e[0] === '#') {
257
+ const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
258
+ return Number.isFinite(code) && code >= 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : m;
259
+ }
260
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, e) ? NAMED_ENTITIES[e] : m;
261
+ });
262
+ }
263
+
264
+ const ATTR_RE = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
265
+
266
+ // parseXml(text) -> root element { name, local, ns, attrs, scope, children }
267
+ // Children are elements or strings. Throws on malformed input; the callers
268
+ // catch it and answer not_a_docx.
269
+ function parseXml(text) {
270
+ const src = String(text).replace(/^\uFEFF/, '');
271
+ const root = { name: '#root', local: '#root', ns: null, attrs: {}, scope: { xml: 'http://www.w3.org/XML/1998/namespace' }, children: [] };
272
+ const stack = [root];
273
+ let i = 0;
274
+ while (i < src.length) {
275
+ const lt = src.indexOf('<', i);
276
+ if (lt < 0) { pushText(stack, src.slice(i)); break; }
277
+ if (lt > i) pushText(stack, src.slice(i, lt));
278
+ if (src.startsWith('<!--', lt)) {
279
+ const end = src.indexOf('-->', lt + 4);
280
+ if (end < 0) throw new Error('unterminated comment');
281
+ i = end + 3;
282
+ } else if (src.startsWith('<?', lt)) {
283
+ const end = src.indexOf('?>', lt + 2);
284
+ if (end < 0) throw new Error('unterminated processing instruction');
285
+ i = end + 2;
286
+ } else if (src.startsWith('<![CDATA[', lt)) {
287
+ const end = src.indexOf(']]>', lt + 9);
288
+ if (end < 0) throw new Error('unterminated CDATA');
289
+ stack[stack.length - 1].children.push(src.slice(lt + 9, end));
290
+ i = end + 3;
291
+ } else if (src.startsWith('<!', lt)) {
292
+ throw new Error('a DOCTYPE or declaration is not read here');
293
+ } else if (src[lt + 1] === '/') {
294
+ const end = src.indexOf('>', lt);
295
+ if (end < 0) throw new Error('unterminated end tag');
296
+ const name = src.slice(lt + 2, end).trim();
297
+ const top = stack.pop();
298
+ if (!top || top.name !== name || !stack.length) throw new Error(`mismatched end tag ${name}`);
299
+ i = end + 1;
300
+ } else {
301
+ // Find the tag's end, stepping over quoted attribute values.
302
+ let j = lt + 1;
303
+ let quote = null;
304
+ for (; j < src.length; j++) {
305
+ const c = src[j];
306
+ if (quote) { if (c === quote) quote = null; } else if (c === '"' || c === "'") quote = c; else if (c === '>') break;
307
+ }
308
+ if (j >= src.length) throw new Error('unterminated tag');
309
+ let body = src.slice(lt + 1, j);
310
+ const selfClosing = body.endsWith('/');
311
+ if (selfClosing) body = body.slice(0, -1);
312
+ const m = /^([^\s/>]+)/.exec(body);
313
+ if (!m) throw new Error('a tag has no name');
314
+ const name = m[1];
315
+ const attrs = {};
316
+ for (const a of body.slice(name.length).matchAll(ATTR_RE)) attrs[a[1]] = decodeEntities(a[2] != null ? a[2] : a[3]);
317
+ const parent = stack[stack.length - 1];
318
+ const scope = { ...parent.scope };
319
+ for (const [k, v] of Object.entries(attrs)) {
320
+ if (k === 'xmlns') scope[''] = v;
321
+ else if (k.startsWith('xmlns:')) scope[k.slice(6)] = v;
322
+ }
323
+ const colon = name.indexOf(':');
324
+ const prefix = colon < 0 ? '' : name.slice(0, colon);
325
+ const el = { name, local: colon < 0 ? name : name.slice(colon + 1), ns: scope[prefix] || null, attrs, scope, children: [] };
326
+ parent.children.push(el);
327
+ if (!selfClosing) {
328
+ if (stack.length > MAX_XML_DEPTH) throw new Error('the XML is nested too deeply');
329
+ stack.push(el);
330
+ }
331
+ i = j + 1;
332
+ }
333
+ }
334
+ if (stack.length !== 1) throw new Error('unclosed element');
335
+ const top = root.children.find((c) => typeof c !== 'string');
336
+ if (!top) throw new Error('no root element');
337
+ return top;
338
+ }
339
+
340
+ function pushText(stack, raw) {
341
+ stack[stack.length - 1].children.push(decodeEntities(raw));
342
+ }
343
+
344
+ const isW = (el) => el && typeof el !== 'string' && (el.ns === NS.w || el.ns === NS.wStrict);
345
+ const isWEl = (el, local) => isW(el) && el.local === local;
346
+ const kids = (el) => (el.children || []).filter((c) => typeof c !== 'string');
347
+
348
+ // An attribute by namespace and local name (w:val), resolved through the scope.
349
+ function attrNs(el, ns, local) {
350
+ for (const [k, v] of Object.entries(el.attrs || {})) {
351
+ const colon = k.indexOf(':');
352
+ if (colon < 0) continue;
353
+ if (k.slice(colon + 1) === local && (el.scope[k.slice(0, colon)] === ns || (ns === NS.w && el.scope[k.slice(0, colon)] === NS.wStrict))) return v;
354
+ }
355
+ return null;
356
+ }
357
+ const wVal = (el) => (el ? attrNs(el, NS.w, 'val') : null);
358
+ const firstW = (el, local) => (el ? kids(el).find((c) => isWEl(c, local)) || null : null);
359
+
360
+ function textOf(el) {
361
+ return (el.children || []).map((c) => (typeof c === 'string' ? c : textOf(c))).join('');
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // The download.
366
+ // ---------------------------------------------------------------------------
367
+
368
+ // The lines as the file shows them: the draft's `after` where the draft is on
369
+ // this very reading, else the page's current text. A draft saved on an older
370
+ // reading is not shown: its keys may name other lines now.
371
+ function shownLines(readingPage, draft) {
372
+ const after = new Map();
373
+ if (draft && draft.reading_hash === readingPage.reading_hash) {
374
+ for (const l of draft.lines || []) after.set(l.key, l.after);
375
+ }
376
+ return (readingPage.lines || []).map((l) => ({
377
+ key: l.key,
378
+ section: l.section == null ? '' : String(l.section),
379
+ placement: l.placement,
380
+ text: after.has(l.key) ? after.get(l.key) : l.text,
381
+ }));
382
+ }
383
+
384
+ // The consecutive runs of one section, in page order: each run is a heading.
385
+ function sectionRuns(lines) {
386
+ const runs = [];
387
+ for (const l of lines) {
388
+ const section = l.section == null ? '' : String(l.section);
389
+ const last = runs[runs.length - 1];
390
+ if (last && last.section === section) last.keys.push(l.key);
391
+ else runs.push({ section, keys: [l.key] });
392
+ }
393
+ return runs;
394
+ }
395
+
396
+ const XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
397
+ const W_DECL = `xmlns:w="${NS.w}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`;
398
+
399
+ function run(text, rStyle) {
400
+ const rPr = rStyle ? `<w:rPr><w:rStyle w:val="${rStyle}"/></w:rPr>` : '';
401
+ return `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
402
+ }
403
+ function styledPara(style, inner) {
404
+ return `<w:p><w:pPr><w:pStyle w:val="${style}"/></w:pPr>${inner}</w:p>`;
405
+ }
406
+
407
+ function documentXml({ title, lines }) {
408
+ const body = [styledPara(STYLE.title, run(title)), styledPara(STYLE.note, run(NOTE_TEXT))];
409
+ const comments = [];
410
+ let current = null;
411
+ lines.forEach((l, idx) => {
412
+ if (current === null || l.section !== current) {
413
+ current = l.section;
414
+ body.push(styledPara(STYLE.heading, run(l.section)));
415
+ }
416
+ const note = COMMENT_TEXT[l.placement];
417
+ const style = note ? STYLE[l.placement] : null;
418
+ const sdt = `<w:sdt><w:sdtPr><w:tag w:val="${escapeXml(l.key)}"/><w:id w:val="${idx + 1}"/><w:text/></w:sdtPr>`
419
+ + `<w:sdtContent>${run(l.text)}</w:sdtContent></w:sdt>`;
420
+ let inner = sdt;
421
+ if (note) {
422
+ const id = comments.length;
423
+ comments.push(note);
424
+ inner = `<w:commentRangeStart w:id="${id}"/>${sdt}<w:commentRangeEnd w:id="${id}"/>`
425
+ + `<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="${id}"/></w:r>`;
426
+ }
427
+ body.push(style ? styledPara(style, inner) : `<w:p>${inner}</w:p>`);
428
+ });
429
+ const sect = '<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr>';
430
+ return {
431
+ document: `${XML_HEAD}<w:document ${W_DECL}><w:body>${body.join('')}${sect}</w:body></w:document>`,
432
+ comments: `${XML_HEAD}<w:comments ${W_DECL}>${comments.map((c, id) => `<w:comment w:id="${id}" w:author="Tweak Mode" w:initials="TM">`
433
+ + `<w:p><w:pPr><w:pStyle w:val="CommentText"/></w:pPr>${run(c)}</w:p></w:comment>`).join('')}</w:comments>`,
434
+ };
435
+ }
436
+
437
+ const STYLES_XML = `${XML_HEAD}<w:styles ${W_DECL}>`
438
+ + '<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/><w:sz w:val="22"/></w:rPr></w:rPrDefault>'
439
+ + '<w:pPrDefault><w:pPr><w:spacing w:after="120"/></w:pPr></w:pPrDefault></w:docDefaults>'
440
+ + '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>'
441
+ + '<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:sz w:val="48"/></w:rPr></w:style>'
442
+ + '<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:before="240" w:after="80"/><w:outlineLvl w:val="1"/></w:pPr><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style>'
443
+ + '<w:style w:type="paragraph" w:customStyle="1" w:styleId="TweakNote"><w:name w:val="Tweak note"/><w:basedOn w:val="Normal"/><w:rPr><w:i/><w:color w:val="666666"/><w:sz w:val="20"/></w:rPr></w:style>'
444
+ + '<w:style w:type="paragraph" w:customStyle="1" w:styleId="TweakShared"><w:name w:val="Tweak shared line"/><w:basedOn w:val="Normal"/><w:rPr><w:color w:val="1F5F99"/></w:rPr></w:style>'
445
+ + '<w:style w:type="paragraph" w:customStyle="1" w:styleId="TweakUnplaced"><w:name w:val="Tweak engineer line"/><w:basedOn w:val="Normal"/><w:rPr><w:i/><w:color w:val="8A5A00"/></w:rPr></w:style>'
446
+ + '<w:style w:type="character" w:styleId="CommentReference"><w:name w:val="annotation reference"/><w:rPr><w:sz w:val="16"/></w:rPr></w:style>'
447
+ + '<w:style w:type="paragraph" w:styleId="CommentText"><w:name w:val="annotation text"/><w:basedOn w:val="Normal"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style>'
448
+ + '</w:styles>';
449
+
450
+ function customPropsXml(props) {
451
+ const items = Object.entries(props).map(([name, value], i) => `<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="${i + 2}" name="${escapeXml(name)}">`
452
+ + `<vt:lpwstr>${escapeXml(value)}</vt:lpwstr></property>`);
453
+ return `${XML_HEAD}<Properties xmlns="${NS.custom}" xmlns:vt="${NS.vt}">${items.join('')}</Properties>`;
454
+ }
455
+
456
+ function baselineXml(lines) {
457
+ return `${XML_HEAD}<shown xmlns="${NS.baseline}">${lines.map((l) => `<line key="${escapeXml(l.key)}">${escapeXml(l.text)}</line>`).join('')}</shown>`;
458
+ }
459
+
460
+ // A GUID for the custom XML part, derived from the page so the same download
461
+ // makes the same bytes.
462
+ function guidFor(seed) {
463
+ const h = [0, 1, 2, 3].map((i) => crc32(Buffer.from(`${i}:${seed}`)).toString(16).padStart(8, '0')).join('').toUpperCase();
464
+ return `{${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}}`;
465
+ }
466
+
467
+ // composeDownload({ readingPage, title, draft }) -> Buffer (the .docx)
468
+ function composeDownload({ readingPage, title, draft }) {
469
+ const lines = shownLines(readingPage, draft);
470
+ const pageTitle = String(title || readingPage.title || readingPage.id);
471
+ const doc = documentXml({ title: pageTitle, lines });
472
+ const rels = (items) => `${XML_HEAD}<Relationships xmlns="${NS.rels}">${items.map(([id, type, target]) => `<Relationship Id="${id}" Type="${type}" Target="${target}"/>`).join('')}</Relationships>`;
473
+ const OD = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
474
+ return writeZip([
475
+ { name: '[Content_Types].xml', data: `${XML_HEAD}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`
476
+ + '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
477
+ + '<Default Extension="xml" ContentType="application/xml"/>'
478
+ + '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
479
+ + '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
480
+ + '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>'
481
+ + '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
482
+ + '<Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>'
483
+ + '<Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/>'
484
+ + '</Types>' },
485
+ { name: '_rels/.rels', data: rels([
486
+ ['rId1', REL.officeDocument, 'word/document.xml'],
487
+ ['rId2', 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties', 'docProps/core.xml'],
488
+ ['rId3', REL.customProps, 'docProps/custom.xml'],
489
+ ]) },
490
+ { name: 'word/document.xml', data: doc.document },
491
+ { name: 'word/_rels/document.xml.rels', data: rels([
492
+ ['rId1', `${OD}/styles`, 'styles.xml'],
493
+ ['rId2', `${OD}/comments`, 'comments.xml'],
494
+ ['rId3', `${OD}/customXml`, '../customXml/item1.xml'],
495
+ ]) },
496
+ { name: 'word/styles.xml', data: STYLES_XML },
497
+ { name: 'word/comments.xml', data: doc.comments },
498
+ { name: 'docProps/core.xml', data: `${XML_HEAD}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/">`
499
+ + `<dc:title>${escapeXml(`Tweak: ${pageTitle}`)}</dc:title></cp:coreProperties>` },
500
+ { name: 'docProps/custom.xml', data: customPropsXml({ page_id: readingPage.id, reading_hash: readingPage.reading_hash || '' }) },
501
+ { name: 'customXml/item1.xml', data: baselineXml(lines) },
502
+ { name: 'customXml/itemProps1.xml', data: `${XML_HEAD}<ds:datastoreItem ds:itemID="${guidFor(`${readingPage.id}/${readingPage.reading_hash}`)}" xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml"><ds:schemaRefs/></ds:datastoreItem>` },
503
+ { name: 'customXml/_rels/item1.xml.rels', data: rels([['rId1', `${OD}/customXmlProps`, 'itemProps1.xml']]) },
504
+ ]);
505
+ }
506
+
507
+ // The download's file name: the page id, with the colon a file system refuses.
508
+ function downloadName(pageId) {
509
+ return `tweak-${String(pageId).replace(/[^a-z0-9-]+/gi, '-')}.docx`;
510
+ }
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // The upload: read the file.
514
+ // ---------------------------------------------------------------------------
515
+
516
+ function relTarget(relsXml, type) {
517
+ if (!relsXml) return null;
518
+ const root = parseXml(relsXml);
519
+ const rel = kids(root).find((r) => r.local === 'Relationship' && r.attrs.Type === type);
520
+ return rel ? String(rel.attrs.Target || '').replace(/^\/+/, '') : null;
521
+ }
522
+
523
+ function readCustomProps(xml) {
524
+ const out = {};
525
+ if (!xml) return out;
526
+ const root = parseXml(xml);
527
+ for (const prop of kids(root).filter((c) => c.local === 'property')) {
528
+ const name = prop.attrs.name;
529
+ const value = kids(prop)[0];
530
+ if (name && value) out[name] = textOf(value);
531
+ }
532
+ return out;
533
+ }
534
+
535
+ function readBaseline(xml) {
536
+ const root = parseXml(xml);
537
+ if (root.ns !== NS.baseline || root.local !== 'shown') return null;
538
+ const out = new Map();
539
+ for (const l of kids(root).filter((c) => c.local === 'line')) {
540
+ if (typeof l.attrs.key === 'string' && !out.has(l.attrs.key)) out.set(l.attrs.key, textOf(l));
541
+ }
542
+ return out;
543
+ }
544
+
545
+ // A paragraph's visible text, as Word stores it: every w:t in every run, in
546
+ // order, however many runs one sentence was split into. Tracked insertions
547
+ // count and tracked deletions do not (w:delText is skipped, and w:del and
548
+ // w:moveFrom are skipped whole), so the text is the document with its changes
549
+ // accepted. Field instructions, run and paragraph properties and the Fallback
550
+ // copy of alternate content are not text.
551
+ const SKIP_SUBTREES = new Set(['pPr', 'rPr', 'del', 'moveFrom', 'instrText', 'delText', 'delInstrText', 'sdtPr', 'sdtEndPr', 'footnoteReference', 'endnoteReference']);
552
+ function paragraphText(p) {
553
+ let out = '';
554
+ const walk = (el) => {
555
+ for (const c of el.children || []) {
556
+ if (typeof c === 'string') continue;
557
+ if (c.ns === NS.mc && c.local === 'Fallback') continue;
558
+ if (isW(c)) {
559
+ if (SKIP_SUBTREES.has(c.local)) continue;
560
+ if (c.local === 't') { out += textOf(c); continue; }
561
+ if (c.local === 'tab' || c.local === 'br' || c.local === 'cr') { out += ' '; continue; }
562
+ if (c.local === 'noBreakHyphen') { out += '-'; continue; }
563
+ }
564
+ walk(c);
565
+ }
566
+ };
567
+ walk(p);
568
+ return out;
569
+ }
570
+
571
+ // The line tags inside a paragraph: its run-level content controls.
572
+ function inlineTags(p) {
573
+ const tags = [];
574
+ const walk = (el) => {
575
+ for (const c of kids(el)) {
576
+ if (isWEl(c, 'sdt')) {
577
+ const tag = wVal(firstW(firstW(c, 'sdtPr'), 'tag'));
578
+ if (tag != null) tags.push(tag);
579
+ }
580
+ if (!(isW(c) && SKIP_SUBTREES.has(c.local))) walk(c);
581
+ }
582
+ };
583
+ walk(p);
584
+ return tags;
585
+ }
586
+
587
+ // Every paragraph of the body, in order: { style, text, tags }. A block-level
588
+ // content control (the shape other editors may turn ours into) lends its tag to
589
+ // the paragraphs inside it. Tables and other containers are walked into.
590
+ function bodyParagraphs(body) {
591
+ const out = [];
592
+ const walk = (el, blockTags) => {
593
+ for (const c of kids(el)) {
594
+ if (c.ns === NS.mc && c.local === 'Fallback') continue;
595
+ if (isWEl(c, 'p')) {
596
+ const style = wVal(firstW(firstW(c, 'pPr'), 'pStyle'));
597
+ const tags = [...new Set([...blockTags, ...inlineTags(c)].filter((t) => LINE_KEY_RE.test(t)))];
598
+ out.push({ style: style || null, text: paragraphText(c), tags });
599
+ } else if (isWEl(c, 'sdt')) {
600
+ const tag = wVal(firstW(firstW(c, 'sdtPr'), 'tag'));
601
+ walk(firstW(c, 'sdtContent') || { children: [] }, tag != null ? [...blockTags, tag] : blockTags);
602
+ } else if (!(isW(c) && (c.local === 'sectPr' || SKIP_SUBTREES.has(c.local)))) {
603
+ walk(c, blockTags);
604
+ }
605
+ }
606
+ };
607
+ walk(body, []);
608
+ return out;
609
+ }
610
+
611
+ // readUpload(buf) -> { ok, page_id, reading_hash, paragraphs, baseline } | refusal
612
+ // not_a_docx not a zip, not a Word document, or unreadable
613
+ // upload_too_large a part inflates past its cap
614
+ function readUpload(buf) {
615
+ if (!Buffer.isBuffer(buf) || !buf.length) return fail('not_a_docx', { reason: 'the request carried no file' });
616
+ if (buf.length > MAX_UPLOAD_BYTES) return fail('upload_too_large', { max_bytes: MAX_UPLOAD_BYTES });
617
+ const want = (name) => name === '_rels/.rels' || /^word\/[^/]+\.xml$/i.test(name) || name === 'docProps/custom.xml' || /^customXml\/item\d+\.xml$/i.test(name);
618
+ const zip = readZip(buf, { want: (name) => want(name) });
619
+ if (!zip.ok) return zip;
620
+ try {
621
+ const rootRels = zip.parts.get('_rels/.rels');
622
+ const mainName = relTarget(rootRels && rootRels.toString('utf8'), REL.officeDocument) || 'word/document.xml';
623
+ const main = zip.parts.get(mainName);
624
+ if (!main) return fail('not_a_docx', { reason: 'the file has no Word document part' });
625
+ const docRoot = parseXml(main.toString('utf8'));
626
+ if (!isWEl(docRoot, 'document')) return fail('not_a_docx', { reason: 'the main part is not a Word document' });
627
+ const body = firstW(docRoot, 'body');
628
+ if (!body) return fail('not_a_docx', { reason: 'the Word document has no body' });
629
+ const propsName = relTarget(rootRels && rootRels.toString('utf8'), REL.customProps) || 'docProps/custom.xml';
630
+ const propsPart = zip.parts.get(propsName) || zip.parts.get('docProps/custom.xml');
631
+ const props = readCustomProps(propsPart ? propsPart.toString('utf8') : null);
632
+ let baseline = null;
633
+ const baselineParts = [...zip.parts.keys()].filter((n) => /^customXml\/item\d+\.xml$/i.test(n)).sort().slice(0, MAX_BASELINE_PARTS);
634
+ for (const n of baselineParts) {
635
+ try { baseline = readBaseline(zip.parts.get(n).toString('utf8')); } catch { baseline = null; }
636
+ if (baseline) break;
637
+ }
638
+ return {
639
+ ok: true,
640
+ page_id: typeof props.page_id === 'string' ? props.page_id : null,
641
+ reading_hash: typeof props.reading_hash === 'string' ? props.reading_hash : null,
642
+ paragraphs: bodyParagraphs(body),
643
+ baseline,
644
+ };
645
+ } catch (err) {
646
+ return fail('not_a_docx', { reason: `the file's XML is unreadable (${err.message})` });
647
+ }
648
+ }
649
+
650
+ // ---------------------------------------------------------------------------
651
+ // The upload: map paragraphs to lines, and refuse a changed structure by name.
652
+ // ---------------------------------------------------------------------------
653
+
654
+ function tidy(raw) {
655
+ return String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
656
+ }
657
+ function firstWords(text, n = 6) {
658
+ const words = tidy(text).split(' ').filter(Boolean);
659
+ return words.length > n ? `${words.slice(0, n).join(' ')}…` : words.join(' ');
660
+ }
661
+
662
+ // Word turns every quote an artist types into a curly one (AutoFormat as you
663
+ // type). The page's own text decides which it keeps: a line whose current text
664
+ // has no curly quote is folded back to straight quotes, so a line is never
665
+ // "changed" by an autocorrect alone and the page keeps its own convention.
666
+ const CURLY_RE = /[\u2018\u2019\u201A\u201B\u201C\u201D\u201E\u201F]/;
667
+ function foldQuotes(text) {
668
+ return String(text).replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"');
669
+ }
670
+ function pageQuotes(pageText, text) {
671
+ return CURLY_RE.test(pageText) ? text : foldQuotes(text);
672
+ }
673
+
674
+ // The keys NOT on the longest run of keys already in page order: the ones that
675
+ // moved. (A line moved down reads as that one line moving, not as every line it
676
+ // jumped over.)
677
+ function movedKeys(keys, order) {
678
+ const idx = keys.map((k) => order.get(k));
679
+ const n = idx.length;
680
+ const len = new Array(n).fill(1);
681
+ const prev = new Array(n).fill(-1);
682
+ let best = -1;
683
+ for (let i = 0; i < n; i++) {
684
+ for (let j = 0; j < i; j++) if (idx[j] < idx[i] && len[j] + 1 > len[i]) { len[i] = len[j] + 1; prev[i] = j; }
685
+ if (best < 0 || len[i] > len[best]) best = i;
686
+ }
687
+ const keep = new Set();
688
+ for (let i = best; i >= 0; i = prev[i]) keep.add(keys[i]);
689
+ return keys.filter((k) => !keep.has(k));
690
+ }
691
+
692
+ function headingChanges(expected, found) {
693
+ const out = [];
694
+ for (let i = 0; i < Math.max(expected.length, found.length); i++) {
695
+ const e = i < expected.length ? expected[i] : null;
696
+ const f = i < found.length ? found[i] : null;
697
+ if (e !== f) out.push({ change: 'heading_changed', expected: e, found: f });
698
+ }
699
+ return out;
700
+ }
701
+
702
+ // mapUpload({ readingPage, upload }) -> { ok, mode, texts: Map(key -> text) } | structure_changed
703
+ //
704
+ // By TAG when any paragraph carries a line tag; by POSITION when every tag is
705
+ // gone, which needs the same section headings and the same number of lines in
706
+ // each. Paragraphs styled as the Title or the note, and empty untagged
707
+ // paragraphs (an artist's spacing), are not lines.
708
+ function mapUpload({ readingPage, upload }) {
709
+ const lines = readingPage.lines || [];
710
+ const byKey = new Map(lines.map((l) => [l.key, l]));
711
+ const order = new Map(lines.map((l, i) => [l.key, i]));
712
+ const runs = sectionRuns(lines);
713
+ const expectedHeadings = runs.map((r) => tidy(r.section));
714
+ const isHeading = (p) => /^heading\s*2$/i.test(String(p.style || ''));
715
+ const isFrame = (p) => /^title$/i.test(String(p.style || '')) || p.style === STYLE.note;
716
+ const paras = upload.paragraphs.filter((p) => !isFrame(p) && !(p.tags.length === 0 && !isHeading(p) && !tidy(p.text)));
717
+ const moved = [];
718
+ const named = (change, key, extra) => {
719
+ const l = byKey.get(key);
720
+ return { change, key, section: l ? (l.section == null ? null : l.section) : null, first_words: firstWords(l ? l.text : ''), ...(extra || {}) };
721
+ };
722
+ const refuse = () => fail('structure_changed', { mode, moved });
723
+
724
+ const tagged = paras.some((p) => p.tags.length);
725
+ const mode = tagged ? 'tag' : 'position';
726
+ if (tagged) {
727
+ const headings = [];
728
+ let heading = null;
729
+ const seen = [];
730
+ const texts = new Map();
731
+ for (const p of paras) {
732
+ if (isHeading(p) && !p.tags.length) { heading = tidy(p.text); headings.push(heading); continue; }
733
+ if (!p.tags.length) { moved.push({ change: 'added', key: null, section: heading, first_words: firstWords(p.text) }); continue; }
734
+ const [key, ...joined] = p.tags;
735
+ for (const other of joined) moved.push(named('dropped', other, { joined_to: key }));
736
+ if (!byKey.has(key) || texts.has(key)) {
737
+ moved.push({ change: 'added', key: byKey.has(key) ? key : null, section: heading, first_words: firstWords(p.text) });
738
+ continue;
739
+ }
740
+ seen.push(key);
741
+ texts.set(key, p.text);
742
+ }
743
+ for (const l of lines) if (!texts.has(l.key) && !moved.some((m) => m.key === l.key)) moved.push(named('dropped', l.key));
744
+ for (const key of movedKeys(seen, order)) moved.push(named('reordered', key));
745
+ moved.push(...headingChanges(expectedHeadings, headings));
746
+ if (moved.length) return refuse();
747
+ return { ok: true, mode, texts };
748
+ }
749
+
750
+ // By position: group the paragraphs under their headings.
751
+ const groups = [];
752
+ for (const p of paras) {
753
+ if (isHeading(p)) { groups.push({ section: tidy(p.text), paras: [] }); continue; }
754
+ if (!groups.length) groups.push({ section: null, paras: [] });
755
+ groups[groups.length - 1].paras.push(p);
756
+ }
757
+ const headingMoves = headingChanges(expectedHeadings, groups.map((g) => g.section));
758
+ if (headingMoves.length) { moved.push(...headingMoves); return refuse(); }
759
+ const texts = new Map();
760
+ runs.forEach((r, i) => {
761
+ const got = groups[i].paras;
762
+ got.slice(r.keys.length).forEach((p) => moved.push({ change: 'added', key: null, section: r.section, first_words: firstWords(p.text) }));
763
+ r.keys.slice(got.length).forEach((k) => moved.push(named('dropped', k)));
764
+ r.keys.forEach((k, j) => { if (j < got.length) texts.set(k, got[j].text); });
765
+ });
766
+ if (moved.length) return refuse();
767
+ return { ok: true, mode, texts };
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // The upload: merge into the draft.
772
+ // ---------------------------------------------------------------------------
773
+
774
+ // mergeUpload({ readingPage, texts, baseline, draft }) ->
775
+ // { lines: [{ key, after }], from_file: [keys], kept_from_draft: [keys] }
776
+ //
777
+ // With the file's baseline (the text each line was shown with at download), a
778
+ // line is taken from the file only when the artist changed it there; every
779
+ // other line keeps what the draft says now, so a rewrite made in the studio
780
+ // after the download survives. Without it (an editor dropped the part), the
781
+ // file is the whole intended state. The lines go to pages.resolveDraft, the
782
+ // autosave's own resolver, which drops unchanged lines and takes every target
783
+ // from the reading.
784
+ function mergeUpload({ readingPage, texts, baseline, draft }) {
785
+ const draftAfter = new Map();
786
+ if (draft && draft.reading_hash === readingPage.reading_hash) {
787
+ for (const l of draft.lines || []) draftAfter.set(l.key, l.after);
788
+ }
789
+ const same = (a, b) => foldQuotes(tidy(a)) === foldQuotes(tidy(b));
790
+ const lines = [];
791
+ const fromFile = [];
792
+ const kept = [];
793
+ for (const l of readingPage.lines || []) {
794
+ const current = draftAfter.has(l.key) ? draftAfter.get(l.key) : l.text;
795
+ const uploaded = texts.has(l.key) ? tidy(pageQuotes(l.text, texts.get(l.key))) : current;
796
+ const changedInFile = baseline && baseline.has(l.key) ? !same(uploaded, baseline.get(l.key)) : !same(uploaded, current);
797
+ const after = changedInFile ? uploaded : current;
798
+ if (changedInFile && !same(after, current)) fromFile.push(l.key);
799
+ else if (draftAfter.has(l.key)) kept.push(l.key);
800
+ lines.push({ key: l.key, after });
801
+ }
802
+ return { lines, from_file: fromFile, kept_from_draft: kept };
803
+ }
804
+
805
+ module.exports = {
806
+ DOCX_MIME,
807
+ MAX_UPLOAD_BYTES,
808
+ MAX_PART_BYTES,
809
+ crc32,
810
+ writeZip,
811
+ readZip,
812
+ parseXml,
813
+ composeDownload,
814
+ downloadName,
815
+ readUpload,
816
+ mapUpload,
817
+ mergeUpload,
818
+ };