Skip to content

Commit f63ae0a

Browse files
authored
fix(super-editor): handle partial comment file-sets and clean up stale parts on export (#2123)
1 parent 0895a93 commit f63ae0a

File tree

14 files changed

+1211
-322
lines changed

14 files changed

+1211
-322
lines changed

packages/super-editor/src/core/DocxZipper.js

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import JSZip from 'jszip';
33
import { getContentTypesFromXml, base64ToUint8Array } from './super-converter/helpers.js';
44
import { ensureXmlString, isXmlLike } from './encoding-helpers.js';
55
import { DOCX } from '@superdoc/common';
6+
import { COMMENT_FILE_BASENAMES } from './super-converter/constants.js';
67

78
/**
89
* Class to handle unzipping and zipping of docx files
@@ -143,9 +144,13 @@ class DocxZipper {
143144
(el) => el.name === 'Override' && el.attributes.PartName === '/word/commentsExtensible.xml',
144145
);
145146

147+
/**
148+
* Check if a file will exist in the final zip output.
149+
* A null value in updatedDocs means the file is explicitly deleted.
150+
*/
146151
const hasFile = (filename) => {
147152
if (updatedDocs && Object.prototype.hasOwnProperty.call(updatedDocs, filename)) {
148-
return true;
153+
return updatedDocs[filename] !== null;
149154
}
150155
if (!docx?.files) return false;
151156
if (!fromJson) return Boolean(docx.files[filename]);
@@ -205,9 +210,23 @@ class DocxZipper {
205210
}
206211
});
207212

213+
// Prune stale comment Override entries for parts that will not exist in the final zip.
214+
const commentPartNames = COMMENT_FILE_BASENAMES.map((name) => `/word/${name}`);
215+
const staleOverridePartNames = commentPartNames.filter((partName) => {
216+
const filename = partName.slice(1); // strip leading /
217+
return !hasFile(filename);
218+
});
219+
208220
const beginningString = '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">';
209221
let updatedContentTypesXml = contentTypesXml.replace(beginningString, `${beginningString}${typesString}`);
210222

223+
// Remove Override elements for comment parts that no longer exist
224+
for (const partName of staleOverridePartNames) {
225+
const escapedPartName = partName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
226+
const overrideRegex = new RegExp(`\\s*<Override[^>]*PartName="${escapedPartName}"[^>]*/>`, 'g');
227+
updatedContentTypesXml = updatedContentTypesXml.replace(overrideRegex, '');
228+
}
229+
211230
// Include any header/footer targets referenced from document relationships
212231
let relationshipsXml = updatedDocs['word/_rels/document.xml.rels'];
213232
if (!relationshipsXml) {
@@ -298,10 +317,13 @@ class DocxZipper {
298317
zip.file(file.name, content);
299318
}
300319

301-
// Replace updated docs
320+
// Replace updated docs (null = delete from zip)
302321
Object.keys(updatedDocs).forEach((key) => {
303-
const content = updatedDocs[key];
304-
zip.file(key, content);
322+
if (updatedDocs[key] === null) {
323+
zip.remove(key);
324+
} else {
325+
zip.file(key, updatedDocs[key]);
326+
}
305327
});
306328

307329
Object.keys(media).forEach((path) => {
@@ -337,9 +359,13 @@ class DocxZipper {
337359
});
338360
await Promise.all(filePromises);
339361

340-
// Make replacements of updated docs
362+
// Make replacements of updated docs (null = delete from zip)
341363
Object.keys(updatedDocs).forEach((key) => {
342-
unzippedOriginalDocx.file(key, updatedDocs[key]);
364+
if (updatedDocs[key] === null) {
365+
unzippedOriginalDocx.remove(key);
366+
} else {
367+
unzippedOriginalDocx.file(key, updatedDocs[key]);
368+
}
343369
});
344370

345371
Object.keys(media).forEach((path) => {

packages/super-editor/src/core/DocxZipper.test.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,44 @@ describe('DocxZipper - updateContentTypes', () => {
256256
expect(updatedContentTypes).toContain('/word/header1.xml');
257257
expect(updatedContentTypes).toContain('/word/footer1.xml');
258258
});
259+
260+
it('removes stale comment overrides when updated docs mark comment files as deleted', async () => {
261+
const zipper = new DocxZipper();
262+
const zip = new JSZip();
263+
264+
const contentTypes = `<?xml version="1.0" encoding="UTF-8"?>
265+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
266+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
267+
<Default Extension="xml" ContentType="application/xml"/>
268+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
269+
<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>
270+
<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>
271+
<Override PartName="/word/commentsExtended.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"/>
272+
<Override PartName="/word/commentsIds.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml"/>
273+
<Override PartName="/word/commentsExtensible.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml"/>
274+
</Types>`;
275+
zip.file('[Content_Types].xml', contentTypes);
276+
zip.file(
277+
'word/document.xml',
278+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
279+
);
280+
281+
const updatedDocs = {
282+
'word/comments.xml': null,
283+
'word/commentsExtended.xml': null,
284+
'word/commentsIds.xml': null,
285+
'word/commentsExtensible.xml': null,
286+
};
287+
288+
await zipper.updateContentTypes(zip, {}, false, updatedDocs);
289+
290+
const updatedContentTypes = await zip.file('[Content_Types].xml').async('string');
291+
expect(updatedContentTypes).toContain('PartName="/word/document.xml"');
292+
expect(updatedContentTypes).not.toContain('PartName="/word/comments.xml"');
293+
expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtended.xml"');
294+
expect(updatedContentTypes).not.toContain('PartName="/word/commentsIds.xml"');
295+
expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtensible.xml"');
296+
});
259297
});
260298

261299
describe('DocxZipper - exportFromCollaborativeDocx media handling', () => {
@@ -299,3 +337,119 @@ describe('DocxZipper - exportFromCollaborativeDocx media handling', () => {
299337
expect(Array.from(img2)).toEqual([87, 111, 114, 108, 100]);
300338
});
301339
});
340+
341+
describe('DocxZipper - comment file deletion', () => {
342+
const contentTypesWithComments = `<?xml version="1.0" encoding="UTF-8"?>
343+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
344+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
345+
<Default Extension="xml" ContentType="application/xml"/>
346+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
347+
<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>
348+
<Override PartName="/word/commentsExtended.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"/>
349+
<Override PartName="/word/commentsIds.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml"/>
350+
<Override PartName="/word/commentsExtensible.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml"/>
351+
</Types>`;
352+
353+
const updatedDocsWithCommentDeletes = {
354+
'word/document.xml': '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
355+
'word/comments.xml': null,
356+
'word/commentsExtended.xml': null,
357+
'word/commentsIds.xml': null,
358+
'word/commentsExtensible.xml': null,
359+
};
360+
361+
it('removes stale comment files in collaborative export path when null sentinels are provided', async () => {
362+
const zipper = new DocxZipper();
363+
const docx = [
364+
{ name: '[Content_Types].xml', content: contentTypesWithComments },
365+
{
366+
name: 'word/document.xml',
367+
content: '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
368+
},
369+
{
370+
name: 'word/comments.xml',
371+
content: '<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
372+
},
373+
{
374+
name: 'word/commentsExtended.xml',
375+
content: '<w15:commentsEx xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"/>',
376+
},
377+
{
378+
name: 'word/commentsIds.xml',
379+
content: '<w16cid:commentsIds xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid"/>',
380+
},
381+
{
382+
name: 'word/commentsExtensible.xml',
383+
content: '<w16cex:commentsExtensible xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex"/>',
384+
},
385+
];
386+
387+
const result = await zipper.updateZip({
388+
docx,
389+
updatedDocs: updatedDocsWithCommentDeletes,
390+
media: {},
391+
fonts: {},
392+
isHeadless: true,
393+
});
394+
395+
const readBack = await new JSZip().loadAsync(result);
396+
expect(readBack.file('word/comments.xml')).toBeNull();
397+
expect(readBack.file('word/commentsExtended.xml')).toBeNull();
398+
expect(readBack.file('word/commentsIds.xml')).toBeNull();
399+
expect(readBack.file('word/commentsExtensible.xml')).toBeNull();
400+
401+
const updatedContentTypes = await readBack.file('[Content_Types].xml').async('string');
402+
expect(updatedContentTypes).not.toContain('PartName="/word/comments.xml"');
403+
expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtended.xml"');
404+
expect(updatedContentTypes).not.toContain('PartName="/word/commentsIds.xml"');
405+
expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtensible.xml"');
406+
});
407+
408+
it('removes stale comment files in original-file export path when null sentinels are provided', async () => {
409+
const zipper = new DocxZipper();
410+
const originalZip = new JSZip();
411+
originalZip.file('[Content_Types].xml', contentTypesWithComments);
412+
originalZip.file(
413+
'word/document.xml',
414+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
415+
);
416+
originalZip.file(
417+
'word/comments.xml',
418+
'<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
419+
);
420+
originalZip.file(
421+
'word/commentsExtended.xml',
422+
'<w15:commentsEx xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"/>',
423+
);
424+
originalZip.file(
425+
'word/commentsIds.xml',
426+
'<w16cid:commentsIds xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid"/>',
427+
);
428+
originalZip.file(
429+
'word/commentsExtensible.xml',
430+
'<w16cex:commentsExtensible xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex"/>',
431+
);
432+
const originalDocxFile = await originalZip.generateAsync({ type: 'nodebuffer' });
433+
434+
const result = await zipper.updateZip({
435+
docx: [],
436+
updatedDocs: updatedDocsWithCommentDeletes,
437+
originalDocxFile,
438+
media: {},
439+
fonts: {},
440+
isHeadless: true,
441+
});
442+
443+
const readBack = await new JSZip().loadAsync(result);
444+
expect(readBack.file('word/comments.xml')).toBeNull();
445+
expect(readBack.file('word/commentsExtended.xml')).toBeNull();
446+
expect(readBack.file('word/commentsIds.xml')).toBeNull();
447+
expect(readBack.file('word/commentsExtensible.xml')).toBeNull();
448+
449+
const updatedContentTypes = await readBack.file('[Content_Types].xml').async('string');
450+
expect(updatedContentTypes).not.toContain('PartName="/word/comments.xml"');
451+
expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtended.xml"');
452+
expect(updatedContentTypes).not.toContain('PartName="/word/commentsIds.xml"');
453+
expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtensible.xml"');
454+
});
455+
});

packages/super-editor/src/core/Editor.ts

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { createLinkedChildEditor } from '@core/child-editor/index.js';
4747
import { unflattenListsInHtml } from './inputRules/html/html-helpers.js';
4848
import { SuperValidator } from '@core/super-validator/index.js';
4949
import { createDocFromMarkdown, createDocFromHTML } from '@core/helpers/index.js';
50+
import { COMMENT_FILE_BASENAMES } from '@core/super-converter/constants.js';
5051
import { isHeadless } from '../utils/headless-helpers.js';
5152
import { canUseDOM } from '../utils/canUseDOM.js';
5253
import { buildSchemaSummary } from './schema-summary.js';
@@ -2586,7 +2587,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
25862587
getUpdatedDocs?: boolean;
25872588
fieldsHighlightColor?: string | null;
25882589
compression?: 'DEFLATE' | 'STORE';
2589-
} = {}): Promise<Blob | ArrayBuffer | Buffer | Record<string, string> | ProseMirrorJSON | string | undefined> {
2590+
} = {}): Promise<Blob | ArrayBuffer | Buffer | Record<string, string | null> | ProseMirrorJSON | string | undefined> {
25902591
try {
25912592
// Use provided comments, or fall back to imported comments from converter
25922593
const effectiveComments = comments ?? this.converter.comments ?? [];
@@ -2654,7 +2655,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
26542655
const coreXmlData = this.converter.convertedXml['docProps/core.xml'];
26552656
const coreXml = coreXmlData?.elements?.[0] ? this.converter.schemaToXml(coreXmlData.elements[0]) : null;
26562657

2657-
const updatedDocs: Record<string, string> = {
2658+
const updatedDocs: Record<string, string | null> = {
26582659
...this.options.customUpdatedFiles,
26592660
'word/document.xml': String(documentXml),
26602661
'docProps/custom.xml': String(customXml),
@@ -2677,26 +2678,15 @@ export class Editor extends EventEmitter<EditorEventMap> {
26772678
updatedDocs['word/_rels/footnotes.xml.rels'] = String(footnotesRelsXml);
26782679
}
26792680

2680-
if (preparedComments.length) {
2681-
const commentsXml = this.converter.schemaToXml(this.converter.convertedXml['word/comments.xml'].elements[0]);
2682-
updatedDocs['word/comments.xml'] = String(commentsXml);
2683-
2684-
const commentsExtended = this.converter.convertedXml['word/commentsExtended.xml'];
2685-
if (commentsExtended?.elements?.[0]) {
2686-
const commentsExtendedXml = this.converter.schemaToXml(commentsExtended.elements[0]);
2687-
updatedDocs['word/commentsExtended.xml'] = String(commentsExtendedXml);
2688-
}
2689-
2690-
const commentsExtensible = this.converter.convertedXml['word/commentsExtensible.xml'];
2691-
if (commentsExtensible?.elements?.[0]) {
2692-
const commentsExtensibleXml = this.converter.schemaToXml(commentsExtensible.elements[0]);
2693-
updatedDocs['word/commentsExtensible.xml'] = String(commentsExtensibleXml);
2694-
}
2695-
2696-
const commentsIds = this.converter.convertedXml['word/commentsIds.xml'];
2697-
if (commentsIds?.elements?.[0]) {
2698-
const commentsIdsXml = this.converter.schemaToXml(commentsIds.elements[0]);
2699-
updatedDocs['word/commentsIds.xml'] = String(commentsIdsXml);
2681+
// Serialize each comment file if it exists in convertedXml, otherwise mark as null
2682+
// for deletion from the zip (removes stale originals).
2683+
const commentFiles = COMMENT_FILE_BASENAMES.map((name) => `word/${name}`);
2684+
for (const path of commentFiles) {
2685+
const data = this.converter.convertedXml[path];
2686+
if (data?.elements?.[0]) {
2687+
updatedDocs[path] = String(this.converter.schemaToXml(data.elements[0]));
2688+
} else {
2689+
updatedDocs[path] = null;
27002690
}
27012691
}
27022692

0 commit comments

Comments
 (0)