From 1d308b64bdd60827330c5159495a0bf02c228611 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Feb 2026 15:57:39 -0800 Subject: [PATCH] fix(super-editor): handle partial comment file-sets and clean up stale parts on export --- packages/super-editor/src/core/DocxZipper.js | 38 +- .../super-editor/src/core/DocxZipper.test.js | 154 ++++++ packages/super-editor/src/core/Editor.ts | 34 +- .../core/super-converter/SuperConverter.js | 91 +++- .../super-converter/SuperConverter.test.js | 99 ++++ .../src/core/super-converter/constants.js | 16 + .../super-converter/exporter-docx-defs.js | 151 ------ .../v2/exporter/commentsExporter.js | 180 ++++--- .../v2/exporter/commentsExporter.test.js | 444 ++++++++++++++++-- .../collaboration/collaboration-helpers.js | 17 +- .../collaboration/collaboration.test.js | 26 + .../src/tests/data/gdocs-single-comment.docx | Bin 0 -> 7550 bytes .../src/tests/data/nested-comments-gdocs.docx | Bin 0 -> 7986 bytes .../export/commentThreadingProfile.test.js | 283 +++++++++++ 14 files changed, 1211 insertions(+), 322 deletions(-) create mode 100644 packages/super-editor/src/tests/data/gdocs-single-comment.docx create mode 100644 packages/super-editor/src/tests/data/nested-comments-gdocs.docx create mode 100644 packages/super-editor/src/tests/export/commentThreadingProfile.test.js diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 6340464537..9fc3d5a405 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -3,6 +3,7 @@ import JSZip from 'jszip'; import { getContentTypesFromXml, base64ToUint8Array } from './super-converter/helpers.js'; import { ensureXmlString, isXmlLike } from './encoding-helpers.js'; import { DOCX } from '@superdoc/common'; +import { COMMENT_FILE_BASENAMES } from './super-converter/constants.js'; /** * Class to handle unzipping and zipping of docx files @@ -143,9 +144,13 @@ class DocxZipper { (el) => el.name === 'Override' && el.attributes.PartName === '/word/commentsExtensible.xml', ); + /** + * Check if a file will exist in the final zip output. + * A null value in updatedDocs means the file is explicitly deleted. + */ const hasFile = (filename) => { if (updatedDocs && Object.prototype.hasOwnProperty.call(updatedDocs, filename)) { - return true; + return updatedDocs[filename] !== null; } if (!docx?.files) return false; if (!fromJson) return Boolean(docx.files[filename]); @@ -205,9 +210,23 @@ class DocxZipper { } }); + // Prune stale comment Override entries for parts that will not exist in the final zip. + const commentPartNames = COMMENT_FILE_BASENAMES.map((name) => `/word/${name}`); + const staleOverridePartNames = commentPartNames.filter((partName) => { + const filename = partName.slice(1); // strip leading / + return !hasFile(filename); + }); + const beginningString = ''; let updatedContentTypesXml = contentTypesXml.replace(beginningString, `${beginningString}${typesString}`); + // Remove Override elements for comment parts that no longer exist + for (const partName of staleOverridePartNames) { + const escapedPartName = partName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const overrideRegex = new RegExp(`\\s*]*PartName="${escapedPartName}"[^>]*/>`, 'g'); + updatedContentTypesXml = updatedContentTypesXml.replace(overrideRegex, ''); + } + // Include any header/footer targets referenced from document relationships let relationshipsXml = updatedDocs['word/_rels/document.xml.rels']; if (!relationshipsXml) { @@ -298,10 +317,13 @@ class DocxZipper { zip.file(file.name, content); } - // Replace updated docs + // Replace updated docs (null = delete from zip) Object.keys(updatedDocs).forEach((key) => { - const content = updatedDocs[key]; - zip.file(key, content); + if (updatedDocs[key] === null) { + zip.remove(key); + } else { + zip.file(key, updatedDocs[key]); + } }); Object.keys(media).forEach((path) => { @@ -337,9 +359,13 @@ class DocxZipper { }); await Promise.all(filePromises); - // Make replacements of updated docs + // Make replacements of updated docs (null = delete from zip) Object.keys(updatedDocs).forEach((key) => { - unzippedOriginalDocx.file(key, updatedDocs[key]); + if (updatedDocs[key] === null) { + unzippedOriginalDocx.remove(key); + } else { + unzippedOriginalDocx.file(key, updatedDocs[key]); + } }); Object.keys(media).forEach((path) => { diff --git a/packages/super-editor/src/core/DocxZipper.test.js b/packages/super-editor/src/core/DocxZipper.test.js index b8295d1413..8af0a5a68e 100644 --- a/packages/super-editor/src/core/DocxZipper.test.js +++ b/packages/super-editor/src/core/DocxZipper.test.js @@ -256,6 +256,44 @@ describe('DocxZipper - updateContentTypes', () => { expect(updatedContentTypes).toContain('/word/header1.xml'); expect(updatedContentTypes).toContain('/word/footer1.xml'); }); + + it('removes stale comment overrides when updated docs mark comment files as deleted', async () => { + const zipper = new DocxZipper(); + const zip = new JSZip(); + + const contentTypes = ` + + + + + + + + + + `; + zip.file('[Content_Types].xml', contentTypes); + zip.file( + 'word/document.xml', + '', + ); + + const updatedDocs = { + 'word/comments.xml': null, + 'word/commentsExtended.xml': null, + 'word/commentsIds.xml': null, + 'word/commentsExtensible.xml': null, + }; + + await zipper.updateContentTypes(zip, {}, false, updatedDocs); + + const updatedContentTypes = await zip.file('[Content_Types].xml').async('string'); + expect(updatedContentTypes).toContain('PartName="/word/document.xml"'); + expect(updatedContentTypes).not.toContain('PartName="/word/comments.xml"'); + expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtended.xml"'); + expect(updatedContentTypes).not.toContain('PartName="/word/commentsIds.xml"'); + expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtensible.xml"'); + }); }); describe('DocxZipper - exportFromCollaborativeDocx media handling', () => { @@ -299,3 +337,119 @@ describe('DocxZipper - exportFromCollaborativeDocx media handling', () => { expect(Array.from(img2)).toEqual([87, 111, 114, 108, 100]); }); }); + +describe('DocxZipper - comment file deletion', () => { + const contentTypesWithComments = ` + + + + + + + + + `; + + const updatedDocsWithCommentDeletes = { + 'word/document.xml': '', + 'word/comments.xml': null, + 'word/commentsExtended.xml': null, + 'word/commentsIds.xml': null, + 'word/commentsExtensible.xml': null, + }; + + it('removes stale comment files in collaborative export path when null sentinels are provided', async () => { + const zipper = new DocxZipper(); + const docx = [ + { name: '[Content_Types].xml', content: contentTypesWithComments }, + { + name: 'word/document.xml', + content: '', + }, + { + name: 'word/comments.xml', + content: '', + }, + { + name: 'word/commentsExtended.xml', + content: '', + }, + { + name: 'word/commentsIds.xml', + content: '', + }, + { + name: 'word/commentsExtensible.xml', + content: '', + }, + ]; + + const result = await zipper.updateZip({ + docx, + updatedDocs: updatedDocsWithCommentDeletes, + media: {}, + fonts: {}, + isHeadless: true, + }); + + const readBack = await new JSZip().loadAsync(result); + expect(readBack.file('word/comments.xml')).toBeNull(); + expect(readBack.file('word/commentsExtended.xml')).toBeNull(); + expect(readBack.file('word/commentsIds.xml')).toBeNull(); + expect(readBack.file('word/commentsExtensible.xml')).toBeNull(); + + const updatedContentTypes = await readBack.file('[Content_Types].xml').async('string'); + expect(updatedContentTypes).not.toContain('PartName="/word/comments.xml"'); + expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtended.xml"'); + expect(updatedContentTypes).not.toContain('PartName="/word/commentsIds.xml"'); + expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtensible.xml"'); + }); + + it('removes stale comment files in original-file export path when null sentinels are provided', async () => { + const zipper = new DocxZipper(); + const originalZip = new JSZip(); + originalZip.file('[Content_Types].xml', contentTypesWithComments); + originalZip.file( + 'word/document.xml', + '', + ); + originalZip.file( + 'word/comments.xml', + '', + ); + originalZip.file( + 'word/commentsExtended.xml', + '', + ); + originalZip.file( + 'word/commentsIds.xml', + '', + ); + originalZip.file( + 'word/commentsExtensible.xml', + '', + ); + const originalDocxFile = await originalZip.generateAsync({ type: 'nodebuffer' }); + + const result = await zipper.updateZip({ + docx: [], + updatedDocs: updatedDocsWithCommentDeletes, + originalDocxFile, + media: {}, + fonts: {}, + isHeadless: true, + }); + + const readBack = await new JSZip().loadAsync(result); + expect(readBack.file('word/comments.xml')).toBeNull(); + expect(readBack.file('word/commentsExtended.xml')).toBeNull(); + expect(readBack.file('word/commentsIds.xml')).toBeNull(); + expect(readBack.file('word/commentsExtensible.xml')).toBeNull(); + + const updatedContentTypes = await readBack.file('[Content_Types].xml').async('string'); + expect(updatedContentTypes).not.toContain('PartName="/word/comments.xml"'); + expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtended.xml"'); + expect(updatedContentTypes).not.toContain('PartName="/word/commentsIds.xml"'); + expect(updatedContentTypes).not.toContain('PartName="/word/commentsExtensible.xml"'); + }); +}); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 7f806c7369..ae4e4dcce1 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -47,6 +47,7 @@ import { createLinkedChildEditor } from '@core/child-editor/index.js'; import { unflattenListsInHtml } from './inputRules/html/html-helpers.js'; import { SuperValidator } from '@core/super-validator/index.js'; import { createDocFromMarkdown, createDocFromHTML } from '@core/helpers/index.js'; +import { COMMENT_FILE_BASENAMES } from '@core/super-converter/constants.js'; import { isHeadless } from '../utils/headless-helpers.js'; import { canUseDOM } from '../utils/canUseDOM.js'; import { buildSchemaSummary } from './schema-summary.js'; @@ -2586,7 +2587,7 @@ export class Editor extends EventEmitter { getUpdatedDocs?: boolean; fieldsHighlightColor?: string | null; compression?: 'DEFLATE' | 'STORE'; - } = {}): Promise | ProseMirrorJSON | string | undefined> { + } = {}): Promise | ProseMirrorJSON | string | undefined> { try { // Use provided comments, or fall back to imported comments from converter const effectiveComments = comments ?? this.converter.comments ?? []; @@ -2654,7 +2655,7 @@ export class Editor extends EventEmitter { const coreXmlData = this.converter.convertedXml['docProps/core.xml']; const coreXml = coreXmlData?.elements?.[0] ? this.converter.schemaToXml(coreXmlData.elements[0]) : null; - const updatedDocs: Record = { + const updatedDocs: Record = { ...this.options.customUpdatedFiles, 'word/document.xml': String(documentXml), 'docProps/custom.xml': String(customXml), @@ -2677,26 +2678,15 @@ export class Editor extends EventEmitter { updatedDocs['word/_rels/footnotes.xml.rels'] = String(footnotesRelsXml); } - if (preparedComments.length) { - const commentsXml = this.converter.schemaToXml(this.converter.convertedXml['word/comments.xml'].elements[0]); - updatedDocs['word/comments.xml'] = String(commentsXml); - - const commentsExtended = this.converter.convertedXml['word/commentsExtended.xml']; - if (commentsExtended?.elements?.[0]) { - const commentsExtendedXml = this.converter.schemaToXml(commentsExtended.elements[0]); - updatedDocs['word/commentsExtended.xml'] = String(commentsExtendedXml); - } - - const commentsExtensible = this.converter.convertedXml['word/commentsExtensible.xml']; - if (commentsExtensible?.elements?.[0]) { - const commentsExtensibleXml = this.converter.schemaToXml(commentsExtensible.elements[0]); - updatedDocs['word/commentsExtensible.xml'] = String(commentsExtensibleXml); - } - - const commentsIds = this.converter.convertedXml['word/commentsIds.xml']; - if (commentsIds?.elements?.[0]) { - const commentsIdsXml = this.converter.schemaToXml(commentsIds.elements[0]); - updatedDocs['word/commentsIds.xml'] = String(commentsIdsXml); + // Serialize each comment file if it exists in convertedXml, otherwise mark as null + // for deletion from the zip (removes stale originals). + const commentFiles = COMMENT_FILE_BASENAMES.map((name) => `word/${name}`); + for (const path of commentFiles) { + const data = this.converter.convertedXml[path]; + if (data?.elements?.[0]) { + updatedDocs[path] = String(this.converter.schemaToXml(data.elements[0])); + } else { + updatedDocs[path] = null; } } diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 969616a1ac..ac95aef706 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -14,6 +14,7 @@ import { import { prepareFootnotesXmlForExport } from './v2/exporter/footnotesExporter.js'; import { DocxHelpers } from './docx-helpers/index.js'; import { mergeRelationshipElements } from './relationship-helpers.js'; +import { COMMENT_RELATIONSHIP_TYPES } from './constants.js'; const FONT_FAMILY_FALLBACKS = Object.freeze({ swiss: 'Arial, sans-serif', @@ -190,6 +191,9 @@ class SuperConverter { this.inlineDocumentFonts = []; this.commentThreadingProfile = null; + /** @type {string[]} Warnings emitted during export */ + this.exportWarnings = []; + // Store custom highlight colors this.docHiglightColors = new Set([]); @@ -1098,6 +1102,9 @@ class SuperConverter { exportJsonOnly = false, fieldsHighlightColor, ) { + // Reset export warnings for this export cycle + this.exportWarnings = []; + // Filter out synthetic tracked change comments - they shouldn't be exported to comments.xml const exportableComments = comments.filter((c) => !c.trackedChange); const commentsWithParaIds = exportableComments.map((c) => prepareCommentParaIds(c)); @@ -1144,26 +1151,42 @@ class SuperConverter { editor, ); - // Update content types and comments files as needed - let updatedXml = { ...this.convertedXml }; - let commentsRels = []; - if (comments.length) { - const { documentXml, relationships } = this.#prepareCommentsXmlFilesForExport({ - defs: params.exportedCommentDefs, - exportType: commentsExportType, - commentsWithParaIds, - }); - updatedXml = { ...documentXml }; - commentsRels = relationships; - } + // Update content types and comments files as needed — always run so cleanup + // happens even when all comments have been removed + const { + documentXml, + relationships: commentsRels, + removedTargets, + } = this.#prepareCommentsXmlFilesForExport({ + defs: params.exportedCommentDefs, + exportType: commentsExportType, + commentsWithParaIds, + }); + const updatedXml = { ...documentXml }; this.convertedXml = { ...this.convertedXml, ...updatedXml }; + // Physically remove comment parts that the exporter deleted from documentXml. + // The spread merge above only adds/overwrites keys — absent keys survive from + // the old this.convertedXml. Without this, Editor.ts sees stale data and + // serializes comment files that should have been null-sentinelled. + if (removedTargets?.length) { + for (const target of removedTargets) { + const key = target.startsWith('word/') ? target : `word/${target}`; + delete this.convertedXml[key]; + } + } + const headFootRels = this.#exportProcessHeadersFooters({ isFinalDoc }); // Update the rels table this.#exportProcessNewRelationships([...params.relationships, ...commentsRels, ...footnotesRels, ...headFootRels]); + // Prune relationships for comment parts that were removed + if (removedTargets?.length) { + this.#pruneCommentRelationships(removedTargets); + } + // Store SuperDoc version SuperConverter.setStoredSuperdocVersion(this.convertedXml); @@ -1238,7 +1261,12 @@ class SuperConverter { * Update comments files and relationships depending on export type */ #prepareCommentsXmlFilesForExport({ defs, exportType, commentsWithParaIds }) { - const { documentXml, relationships } = prepareCommentsXmlFilesForExport({ + const { + documentXml, + relationships, + removedTargets = [], + warnings = [], + } = prepareCommentsXmlFilesForExport({ exportType, convertedXml: this.convertedXml, defs, @@ -1246,7 +1274,11 @@ class SuperConverter { threadingProfile: this.commentThreadingProfile, }); - return { documentXml, relationships }; + if (warnings.length) { + this.exportWarnings.push(...warnings); + } + + return { documentXml, relationships, removedTargets }; } #exportProcessHeadersFooters({ isFinalDoc = false }) { @@ -1392,6 +1424,37 @@ class SuperConverter { relationships.elements = mergeRelationshipElements(relationships.elements, rels); } + /** + * Remove relationship entries for comment parts that are no longer being emitted. + * Matches by both normalized target AND comment relationship type to avoid + * accidentally pruning unrelated relationships. + * @param {string[]} removedTargets - bare filenames like 'commentsExtended.xml' + */ + #pruneCommentRelationships(removedTargets) { + const relsData = this.convertedXml['word/_rels/document.xml.rels']; + const relationships = relsData.elements.find((x) => x.name === 'Relationships'); + if (!relationships?.elements) return; + + const normalizeTarget = (target) => { + if (!target) return ''; + return target + .replace(/^\.\//, '') + .replace(/^\//, '') + .replace(/^word\//, ''); + }; + + const removedSet = new Set(removedTargets.map(normalizeTarget)); + + relationships.elements = relationships.elements.filter((rel) => { + const type = rel.attributes?.Type; + const target = normalizeTarget(rel.attributes?.Target); + if (COMMENT_RELATIONSHIP_TYPES.has(type) && removedSet.has(target)) { + return false; + } + return true; + }); + } + async #exportProcessMediaFiles(media = {}) { const processedData = { ...(this.convertedXml.media || {}), diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.test.js b/packages/super-editor/src/core/super-converter/SuperConverter.test.js index c07c0fb760..4267f103c0 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.test.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.test.js @@ -1324,3 +1324,102 @@ describe('SuperConverter styles fallback', () => { expect(styles?.elements?.[0]?.elements?.[0]?.attributes?.['w:styleId']).toBe('AltStyle2'); }); }); + +describe('SuperConverter comment cleanup on export', () => { + const makeCommentCleanupDocx = () => [ + { + name: 'word/document.xml', + content: ` + + Test + `, + }, + { + name: 'word/_rels/document.xml.rels', + content: ` + + + + + + + + `, + }, + { + name: 'docProps/custom.xml', + content: ` + + `, + }, + { + name: 'word/numbering.xml', + content: ` + `, + }, + { + name: 'word/comments.xml', + content: ` + `, + }, + { + name: 'word/commentsExtended.xml', + content: ` + `, + }, + { + name: 'word/commentsIds.xml', + content: ` + `, + }, + { + name: 'word/commentsExtensible.xml', + content: ` + `, + }, + ]; + + it('removes stale comment files and prunes only comment relationships when no comments remain', async () => { + const converter = new SuperConverter({ docx: makeCommentCleanupDocx() }); + converter.numbering = { abstracts: {}, definitions: {} }; + + const exportToXmlJsonSpy = vi.spyOn(converter, 'exportToXmlJson').mockReturnValue({ + result: converter.convertedXml['word/document.xml'].elements[0], + params: { + relationships: [], + media: {}, + exportedCommentDefs: [], + }, + }); + + await converter.exportToDocx({}, {}, {}, false, 'external', [], null, false, null); + + expect(converter.convertedXml['word/comments.xml']).toBeUndefined(); + expect(converter.convertedXml['word/commentsExtended.xml']).toBeUndefined(); + expect(converter.convertedXml['word/commentsIds.xml']).toBeUndefined(); + expect(converter.convertedXml['word/commentsExtensible.xml']).toBeUndefined(); + + const relationships = + converter.convertedXml['word/_rels/document.xml.rels'].elements.find((el) => el.name === 'Relationships') + .elements || []; + + expect( + relationships.some((rel) => + [ + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments', + 'http://schemas.microsoft.com/office/2011/relationships/commentsExtended', + 'http://schemas.microsoft.com/office/2016/09/relationships/commentsIds', + 'http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible', + ].includes(rel.attributes?.Type), + ), + ).toBe(false); + + // Non-comment relationships are retained even if they share a target name. + expect(relationships.some((rel) => rel.attributes?.Type?.includes('/hyperlink'))).toBe(true); + expect( + relationships.some((rel) => rel.attributes?.Type?.includes('/header') && rel.attributes?.Id === 'rId6'), + ).toBe(true); + + exportToXmlJsonSpy.mockRestore(); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/constants.js b/packages/super-editor/src/core/super-converter/constants.js index 869fc81c37..4fe1a7ef5c 100644 --- a/packages/super-editor/src/core/super-converter/constants.js +++ b/packages/super-editor/src/core/super-converter/constants.js @@ -2,3 +2,19 @@ export const HYPERLINK_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; export const HEADER_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header'; export const FOOTER_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer'; + +/** Bare filenames for all OOXML comment support parts */ +export const COMMENT_FILE_BASENAMES = [ + 'comments.xml', + 'commentsExtended.xml', + 'commentsIds.xml', + 'commentsExtensible.xml', +]; + +// Comment-related relationship types (used for pruning stale rels on export) +export const COMMENT_RELATIONSHIP_TYPES = new Set([ + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments', + 'http://schemas.microsoft.com/office/2011/relationships/commentsExtended', + 'http://schemas.microsoft.com/office/2016/09/relationships/commentsIds', + 'http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible', +]); diff --git a/packages/super-editor/src/core/super-converter/exporter-docx-defs.js b/packages/super-editor/src/core/super-converter/exporter-docx-defs.js index b420d96714..99bbaa9f08 100644 --- a/packages/super-editor/src/core/super-converter/exporter-docx-defs.js +++ b/packages/super-editor/src/core/super-converter/exporter-docx-defs.js @@ -6,7 +6,6 @@ * @property {Object} COMMENTS_IDS_XML_DEF - XML definition for comment identifiers. * @property {Object} DOCUMENT_RELS_XML_DEF - XML definition for document relationships. * @property {Object} PEOPLE_XML_DEF - XML definition for people-related information. - * @property {Object} CONTENT_TYPES - XML definition for custom settings. */ export const DEFAULT_DOCX_DEFS = { @@ -1258,155 +1257,6 @@ export const PEOPLE_XML_DEF = { ], }; -export const CONTENT_TYPES = { - declaration: { - attributes: { - version: '1.0', - encoding: 'UTF-8', - standalone: 'yes', - }, - }, - elements: [ - { - type: 'element', - name: 'Types', - attributes: { - xmlns: 'http://schemas.openxmlformats.org/package/2006/content-types', - }, - elements: [ - { - type: 'element', - name: 'Default', - attributes: { - Extension: 'rels', - ContentType: 'application/vnd.openxmlformats-package.relationships+xml', - }, - }, - { - type: 'element', - name: 'Default', - attributes: { - Extension: 'xml', - ContentType: 'application/xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/document.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/styles.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/settings.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/webSettings.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/comments.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/commentsExtended.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/commentsIds.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/commentsExtensible.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/fontTable.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/people.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/theme/theme1.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.theme+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/docProps/core.xml', - ContentType: 'application/vnd.openxmlformats-package.core-properties+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/docProps/app.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.extended-properties+xml', - }, - }, - { - type: 'element', - name: 'Override', - attributes: { - PartName: '/word/numbering.xml', - ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml', - }, - }, - ], - }, - ], -}; - /** * @type {CommentsXmlDefinitions} */ @@ -1417,5 +1267,4 @@ export const COMMENTS_XML_DEFINITIONS = { COMMENTS_IDS_XML_DEF, DOCUMENT_RELS_XML_DEF, PEOPLE_XML_DEF, - CONTENT_TYPES, }; diff --git a/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.js b/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.js index ada9dea835..0e66ac6271 100644 --- a/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.js +++ b/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.js @@ -2,6 +2,7 @@ import { translator as wPTranslator } from '@converter/v3/handlers/w/p'; import { carbonCopy } from '../../../utilities/carbonCopy.js'; import { COMMENT_REF, COMMENTS_XML_DEFINITIONS } from '../../exporter-docx-defs.js'; import { generateRandom32BitHex } from '../../../helpers/generateDocxRandomId.js'; +import { COMMENT_FILE_BASENAMES } from '../../constants.js'; /** * Insert w15:paraId into the comments @@ -148,7 +149,7 @@ export const updateCommentsXml = (commentDefs = [], commentsXml) => { /** * Determine export strategy based on comment origins - * @param {Array[Object]} comments The comments list + * @param {Object[]} comments The comments list * @returns {'word' | 'google-docs' | 'unknown'} The export strategy to use */ export const determineExportStrategy = (comments) => { @@ -175,7 +176,7 @@ const resolveThreadingStyle = (comment, threadingProfile) => { /** * This function updates the commentsExtended.xml structure with the comments list. * - * @param {Array[Object]} comments The comments list + * @param {Object[]} comments The comments list * @param {Object} commentsExtendedXml The commentsExtended.xml structure as JSON * @param {import('@superdoc/common').CommentThreadingProfile | 'word' | 'google-docs' | 'unknown'} threadingProfile * @returns {Object | null} The updated commentsExtended structure, or null if it shouldn't be generated @@ -240,41 +241,46 @@ export const updateCommentsExtendedXml = (comments = [], commentsExtendedXml, th }; /** - * Update commentsIds.xml and commentsExtensible.xml together since they have to - * share the same durableId for each comment. + * Update commentsIds.xml and/or commentsExtensible.xml. + * Either part may be null — only the provided parts are populated. + * Both share the same durable IDs when both are present. * - * @param {Array[Object]} comments The comments list - * @param {Object} commentsIds The commentsIds.xml structure as JSON - * @param {Object} extensible The commentsExtensible.xml structure as JSON - * @returns {Object} The updated commentsIds and commentsExtensible structures + * @param {Object[]} comments The comments list + * @param {Object | null} commentsIds The commentsIds.xml structure as JSON (null to skip) + * @param {Object | null} extensible The commentsExtensible.xml structure as JSON (null to skip) + * @returns {Object} The updated commentsIds and commentsExtensible structures (null for skipped parts) */ export const updateCommentsIdsAndExtensible = (comments = [], commentsIds, extensible) => { - const documentIdsUpdated = carbonCopy(commentsIds); - const extensibleUpdated = carbonCopy(extensible); + const documentIdsUpdated = commentsIds ? carbonCopy(commentsIds) : null; + const extensibleUpdated = extensible ? carbonCopy(extensible) : null; + + if (documentIdsUpdated) documentIdsUpdated.elements[0].elements = []; + if (extensibleUpdated) extensibleUpdated.elements[0].elements = []; - documentIdsUpdated.elements[0].elements = []; - extensibleUpdated.elements[0].elements = []; comments.forEach((comment) => { const newDurableId = generateRandom32BitHex(); - const newCommentIdDef = { - type: 'element', - name: 'w16cid:commentId', - attributes: { - 'w16cid:paraId': comment.commentParaId, - 'w16cid:durableId': newDurableId, - }, - }; - documentIdsUpdated.elements[0].elements.push(newCommentIdDef); - const newExtensible = { - type: 'element', - name: 'w16cex:commentExtensible', - attributes: { - 'w16cex:durableId': newDurableId, - 'w16cex:dateUtc': toIsoNoFractional(comment.createdTime), - }, - }; - extensibleUpdated.elements[0].elements.push(newExtensible); + if (documentIdsUpdated) { + documentIdsUpdated.elements[0].elements.push({ + type: 'element', + name: 'w16cid:commentId', + attributes: { + 'w16cid:paraId': comment.commentParaId, + 'w16cid:durableId': newDurableId, + }, + }); + } + + if (extensibleUpdated) { + extensibleUpdated.elements[0].elements.push({ + type: 'element', + name: 'w16cex:commentExtensible', + attributes: { + 'w16cex:durableId': newDurableId, + 'w16cex:dateUtc': toIsoNoFractional(comment.createdTime), + }, + }); + } }); return { @@ -283,15 +289,6 @@ export const updateCommentsIdsAndExtensible = (comments = [], commentsIds, exten }; }; -/** - * Generate the ocument.xml.rels definition - * - * @returns {Object} The updated document rels XML structure - */ -export const updateDocumentRels = () => { - return COMMENTS_XML_DEFINITIONS.DOCUMENT_RELS_XML_DEF; -}; - /** * Generate initial comments XML structure with no content * @@ -312,33 +309,12 @@ export const generateConvertedXmlWithCommentFiles = (convertedXml, fileSet = nul if (includeExtended) newXml['word/commentsExtended.xml'] = COMMENTS_XML_DEFINITIONS.COMMENTS_EXTENDED_XML_DEF; if (includeExtensible) newXml['word/commentsExtensible.xml'] = COMMENTS_XML_DEFINITIONS.COMMENTS_EXTENSIBLE_XML_DEF; if (includeIds) newXml['word/commentsIds.xml'] = COMMENTS_XML_DEFINITIONS.COMMENTS_IDS_XML_DEF; - newXml['[Content_Types].xml'] = COMMENTS_XML_DEFINITIONS.CONTENT_TYPES; + // Do NOT overwrite [Content_Types].xml here — DocxZipper.updateContentTypes() is the + // authoritative source that builds content types at zip-assembly time based on which + // files actually exist in updatedDocs. return newXml; }; -/** - * Get the comments files converted to XML - * - * @param {Object} converter The converter instance - * @returns {Object} The comments files converted to XML - */ -export const getCommentsFilesConverted = (converter, convertedXml) => { - const commentsXml = convertedXml['word/comments.xml']; - const commentsExtendedXml = convertedXml['word/commentsExtended.xml']; - const commentsIdsXml = convertedXml['word/commentsExtensible.xml']; - const commentsExtensibleXml = convertedXml['word/commentsIds.xml']; - const contentTypes = convertedXml['[Content_Types].xml']; - - return { - ...convertedXml, - 'word/comments.xml': converter.schemaToXml(commentsXml.elements[0]), - 'word/commentsExtended.xml': converter.schemaToXml(commentsExtendedXml.elements[0]), - 'word/commentsIds.xml': converter.schemaToXml(commentsIdsXml.elements[0]), - 'word/commentsExtensible.xml': converter.schemaToXml(commentsExtensibleXml.elements[0]), - '[Content_Types].xml': converter.schemaToXml(contentTypes.elements[0]), - }; -}; - /** * Remove comments files from the converted XML * @@ -368,11 +344,19 @@ export const generateRelationship = (target) => { return { ...rel }; }; +/** @type {readonly string[]} All possible comment support file targets */ +const ALL_COMMENT_TARGETS = COMMENT_FILE_BASENAMES; + /** * Generate comments files into convertedXml * - * @param {Object} param0 - * @returns + * @param {Object} params + * @param {Object} params.convertedXml Current converted XML map + * @param {Object[]} params.defs Export-ready `w:comment` definitions + * @param {Object[]} params.commentsWithParaIds Comments enriched with generated `commentParaId` + * @param {'clean' | string} params.exportType Export mode + * @param {import('@superdoc/common').CommentThreadingProfile | null} params.threadingProfile + * @returns {{ documentXml: Object, relationships: Object[], removedTargets: string[], warnings: string[] }} */ export const prepareCommentsXmlFilesForExport = ({ convertedXml, @@ -382,17 +366,34 @@ export const prepareCommentsXmlFilesForExport = ({ threadingProfile, }) => { const relationships = []; + const warnings = []; if (exportType === 'clean') { const documentXml = removeCommentsFilesFromConvertedXml(convertedXml); - return { documentXml, relationships }; + // Clean export: all comment parts are intentionally removed — no warnings + return { documentXml, relationships, removedTargets: ALL_COMMENT_TARGETS, warnings }; } + const hasComments = commentsWithParaIds && commentsWithParaIds.length > 0; + + // When all comments have been removed, clean up all comment parts + if (!hasComments) { + const documentXml = removeCommentsFilesFromConvertedXml(convertedXml); + const removedTargets = [...ALL_COMMENT_TARGETS]; + if (threadingProfile?.fileSet) { + warnings.push('All comments removed — cleaning up imported comment support files'); + } + return { documentXml, relationships, removedTargets, warnings }; + } + + const emittedTargets = new Set(); + const exportStrategy = determineExportStrategy(commentsWithParaIds); const updatedXml = generateConvertedXmlWithCommentFiles(convertedXml, threadingProfile?.fileSet); updatedXml['word/comments.xml'] = updateCommentsXml(defs, updatedXml['word/comments.xml']); relationships.push(generateRelationship('comments.xml')); + emittedTargets.add('comments.xml'); const commentsExtendedXml = updateCommentsExtendedXml( commentsWithParaIds, @@ -405,27 +406,54 @@ export const prepareCommentsXmlFilesForExport = ({ if (commentsExtendedXml !== null) { updatedXml['word/commentsExtended.xml'] = commentsExtendedXml; relationships.push(generateRelationship('commentsExtended.xml')); + emittedTargets.add('commentsExtended.xml'); } else { - // Remove the file from the XML structure so the importer uses range-based threading delete updatedXml['word/commentsExtended.xml']; + if (threadingProfile?.fileSet?.hasCommentsExtended) { + warnings.push('commentsExtended.xml removed — export strategy does not require it'); + } } - // Generate updates for documentIds.xml and commentsExtensible.xml here - // We do them at the same time as we need them to generate and share durable IDs between them - if (updatedXml['word/commentsIds.xml'] && updatedXml['word/commentsExtensible.xml']) { + // Generate updates for commentsIds.xml and/or commentsExtensible.xml independently. + // They share durable IDs when both are present, but either can exist without the other. + const hasIds = !!updatedXml['word/commentsIds.xml']; + const hasExtensible = !!updatedXml['word/commentsExtensible.xml']; + + if (hasIds !== hasExtensible) { + const present = hasIds ? 'commentsIds.xml' : 'commentsExtensible.xml'; + const absent = hasIds ? 'commentsExtensible.xml' : 'commentsIds.xml'; + warnings.push(`Partial comment file-set: ${present} present without ${absent}`); + } + + if (hasIds || hasExtensible) { const { documentIdsUpdated, extensibleUpdated } = updateCommentsIdsAndExtensible( commentsWithParaIds, - updatedXml['word/commentsIds.xml'], - updatedXml['word/commentsExtensible.xml'], + hasIds ? updatedXml['word/commentsIds.xml'] : null, + hasExtensible ? updatedXml['word/commentsExtensible.xml'] : null, ); - updatedXml['word/commentsIds.xml'] = documentIdsUpdated; - updatedXml['word/commentsExtensible.xml'] = extensibleUpdated; - relationships.push(generateRelationship('commentsIds.xml')); - relationships.push(generateRelationship('commentsExtensible.xml')); + if (documentIdsUpdated) { + updatedXml['word/commentsIds.xml'] = documentIdsUpdated; + relationships.push(generateRelationship('commentsIds.xml')); + emittedTargets.add('commentsIds.xml'); + } + if (extensibleUpdated) { + updatedXml['word/commentsExtensible.xml'] = extensibleUpdated; + relationships.push(generateRelationship('commentsExtensible.xml')); + emittedTargets.add('commentsExtensible.xml'); + } + } + + if (!threadingProfile && hasComments) { + warnings.push('Comments exist but no threading profile detected — using default export shape'); } + // Compute comment targets that are not emitted in this export cycle + const removedTargets = ALL_COMMENT_TARGETS.filter((target) => !emittedTargets.has(target)); + return { relationships, documentXml: updatedXml, + removedTargets, + warnings, }; }; diff --git a/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.test.js b/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.test.js index 272e9c868f..eeb1a90575 100644 --- a/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.test.js @@ -2,75 +2,419 @@ import { updateCommentsExtendedXml, updateCommentsIdsAndExtensible, updateCommentsXml, + prepareCommentsXmlFilesForExport, + removeCommentsFilesFromConvertedXml, toIsoNoFractional, } from './commentsExporter.js'; -describe('updateCommentsIdsAndExtensible', () => { - const comments = [ +// --- Shared fixtures --- + +const makeComment = (overrides = {}) => ({ + commentId: 'test-comment-1', + creatorName: 'Mary Jones', + createdTime: 1764111660000, + importedAuthor: { name: 'Mary Jones (imported)' }, + isInternal: false, + commentText: 'Here is a comment', + commentParaId: '126B0C7F', + ...overrides, +}); + +const makeCommentsIds = () => ({ + declaration: {}, + elements: [ { - commentId: '4cfaa5f7-252f-4e4a-be19-14dc6157e84d', - creatorName: 'Mary Jones', - createdTime: 1764111660000, - importedAuthor: { - name: 'Mary Jones (imported)', - }, - isInternal: false, - commentText: 'Here is a comment', - commentParaId: '126B0C7F', + type: 'element', + name: 'w16cid:commentsIds', + attributes: {}, + elements: [], }, - ]; + ], +}); - const commentsIds = { - declaration: {}, // Omitting for readability - elements: [ - { - type: 'element', - name: 'w16cid:commentsIds', - attributes: {}, // Omitting for readability - elements: [], - }, - ], - }; +const makeExtensible = () => ({ + declaration: {}, + elements: [ + { + type: 'element', + name: 'w16cex:commentsExtensible', + attributes: {}, + elements: [], + }, + ], +}); - const extensible = { - declaration: {}, // Omitting for readability +/** + * Build a minimal convertedXml structure for testing prepareCommentsXmlFilesForExport. + * The function generates fresh skeletons internally, so we only need document.xml + * and the comment-related entries that match the fileSet profile. + */ +const makeConvertedXml = () => ({ + 'word/document.xml': { elements: [{ elements: [] }] }, + 'word/_rels/document.xml.rels': { elements: [ { - type: 'element', - name: 'w16cex:commentsExtensible', - attributes: {}, // Omitting for readability + name: 'Relationships', + attributes: { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' }, elements: [], }, ], - }; + }, +}); + +/** Minimal comment def that updateCommentsXml expects (post-getCommentDefinition) */ +const makeCommentDef = (id = '0', paraId = '126B0C7F') => ({ + type: 'element', + name: 'w:comment', + attributes: { + 'w:id': id, + 'w:author': 'Author', + 'w:date': '2025-01-01T00:00:00Z', + 'w:initials': 'A', + 'w15:paraId': paraId, + }, + elements: [{ type: 'element', name: 'w:p', attributes: {}, elements: [] }], +}); - it('should update the comments ids and extensible when created time is provided', () => { +// ============================================================================= +// updateCommentsIdsAndExtensible +// ============================================================================= + +describe('updateCommentsIdsAndExtensible', () => { + const comments = [makeComment()]; + const commentsIds = makeCommentsIds(); + const extensible = makeExtensible(); + + it('populates both parts when both are provided', () => { const result = updateCommentsIdsAndExtensible(comments, commentsIds, extensible); - const elements = result.extensibleUpdated.elements[0].elements; - expect(elements.length).toEqual(1); - expect(elements[0].type).toEqual('element'); - expect(elements[0].name).toEqual('w16cex:commentExtensible'); - expect(elements[0].attributes['w16cex:durableId']).toEqual(expect.any(String)); - expect(elements[0].attributes['w16cex:dateUtc']).toEqual(toIsoNoFractional(comments[0].createdTime)); + expect(result.documentIdsUpdated.elements[0].elements).toHaveLength(1); + expect(result.extensibleUpdated.elements[0].elements).toHaveLength(1); + + // Durable IDs must match between the two parts + const idsId = result.documentIdsUpdated.elements[0].elements[0].attributes['w16cid:durableId']; + const extId = result.extensibleUpdated.elements[0].elements[0].attributes['w16cex:durableId']; + expect(idsId).toBe(extId); + }); + + it('populates only commentsIds when extensible is null', () => { + const result = updateCommentsIdsAndExtensible(comments, commentsIds, null); + expect(result.documentIdsUpdated.elements[0].elements).toHaveLength(1); + expect(result.extensibleUpdated).toBeNull(); }); - it('should update the comments ids and extensible when created time is not provided', () => { - const commentsWithoutCreatedTime = comments.map((comment) => { - return { - ...comment, - createdTime: undefined, + it('populates only extensible when commentsIds is null', () => { + const result = updateCommentsIdsAndExtensible(comments, null, extensible); + expect(result.documentIdsUpdated).toBeNull(); + expect(result.extensibleUpdated.elements[0].elements).toHaveLength(1); + }); + + it('returns both null when both inputs are null', () => { + const result = updateCommentsIdsAndExtensible(comments, null, null); + expect(result.documentIdsUpdated).toBeNull(); + expect(result.extensibleUpdated).toBeNull(); + }); + + it('formats dateUtc correctly when createdTime is provided', () => { + const result = updateCommentsIdsAndExtensible(comments, commentsIds, extensible); + const el = result.extensibleUpdated.elements[0].elements[0]; + expect(el.attributes['w16cex:dateUtc']).toEqual(toIsoNoFractional(comments[0].createdTime)); + }); + + it('formats dateUtc with current time when createdTime is undefined', () => { + const before = Date.now(); + const commentsNoTime = comments.map((c) => ({ ...c, createdTime: undefined })); + const result = updateCommentsIdsAndExtensible(commentsNoTime, commentsIds, extensible); + const after = Date.now(); + const el = result.extensibleUpdated.elements[0].elements[0]; + const actual = el.attributes['w16cex:dateUtc']; + // Allow either second in case of boundary crossing + const valid = [toIsoNoFractional(before), toIsoNoFractional(after)]; + expect(valid).toContain(actual); + }); +}); + +// ============================================================================= +// prepareCommentsXmlFilesForExport +// ============================================================================= + +describe('prepareCommentsXmlFilesForExport', () => { + const commentsWithParaIds = [makeComment()]; + const defs = [makeCommentDef()]; + + describe('partial file-set handling', () => { + it('populates commentsIds.xml even when commentsExtensible.xml is absent', () => { + const threadingProfile = { + defaultStyle: 'commentsExtended', + mixed: false, + fileSet: { + hasCommentsExtended: true, + hasCommentsExtensible: false, + hasCommentsIds: true, + }, }; + + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs, + commentsWithParaIds, + exportType: 'external', + threadingProfile, + }); + + // commentsIds.xml should be populated + const idsXml = result.documentXml['word/commentsIds.xml']; + expect(idsXml).toBeDefined(); + expect(idsXml.elements[0].elements).toHaveLength(1); + expect(idsXml.elements[0].elements[0].name).toBe('w16cid:commentId'); + + // commentsExtensible.xml should NOT exist + expect(result.documentXml['word/commentsExtensible.xml']).toBeUndefined(); + + // Relationship for commentsIds should be present + const idsRel = result.relationships.find((r) => r.attributes.Target === 'commentsIds.xml'); + expect(idsRel).toBeDefined(); + + // Relationship for commentsExtensible should NOT be present + const extRel = result.relationships.find((r) => r.attributes.Target === 'commentsExtensible.xml'); + expect(extRel).toBeUndefined(); + }); + + it('populates commentsExtensible.xml even when commentsIds.xml is absent', () => { + const threadingProfile = { + defaultStyle: 'commentsExtended', + mixed: false, + fileSet: { + hasCommentsExtended: true, + hasCommentsExtensible: true, + hasCommentsIds: false, + }, + }; + + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs, + commentsWithParaIds, + exportType: 'external', + threadingProfile, + }); + + // commentsExtensible.xml should be populated + const extXml = result.documentXml['word/commentsExtensible.xml']; + expect(extXml).toBeDefined(); + expect(extXml.elements[0].elements).toHaveLength(1); + + // commentsIds.xml should NOT exist + expect(result.documentXml['word/commentsIds.xml']).toBeUndefined(); }); - const result = updateCommentsIdsAndExtensible(commentsWithoutCreatedTime, commentsIds, extensible); - const elements = result.extensibleUpdated.elements[0].elements; - expect(elements.length).toEqual(1); - expect(elements[0].type).toEqual('element'); - expect(elements[0].name).toEqual('w16cex:commentExtensible'); - expect(elements[0].attributes['w16cex:durableId']).toEqual(expect.any(String)); - expect(elements[0].attributes['w16cex:dateUtc']).toEqual(toIsoNoFractional(Date.now())); + }); + + describe('removedTargets tracking', () => { + it('returns removedTargets for parts not emitted', () => { + const threadingProfile = { + defaultStyle: 'commentsExtended', + mixed: false, + fileSet: { + hasCommentsExtended: true, + hasCommentsExtensible: false, + hasCommentsIds: true, + }, + }; + + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs, + commentsWithParaIds, + exportType: 'external', + threadingProfile, + }); + + expect(result.removedTargets).toContain('commentsExtensible.xml'); + expect(result.removedTargets).not.toContain('comments.xml'); + expect(result.removedTargets).not.toContain('commentsIds.xml'); + expect(result.removedTargets).not.toContain('commentsExtended.xml'); + }); + + it('returns all targets as removed for clean export', () => { + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs, + commentsWithParaIds, + exportType: 'clean', + threadingProfile: null, + }); + + expect(result.removedTargets).toHaveLength(4); + expect(result.removedTargets).toContain('comments.xml'); + expect(result.removedTargets).toContain('commentsExtended.xml'); + expect(result.removedTargets).toContain('commentsIds.xml'); + expect(result.removedTargets).toContain('commentsExtensible.xml'); + }); + }); + + describe('zero-comments cleanup', () => { + it('removes all comment files when there are no comments', () => { + const threadingProfile = { + defaultStyle: 'commentsExtended', + mixed: false, + fileSet: { + hasCommentsExtended: true, + hasCommentsExtensible: true, + hasCommentsIds: true, + }, + }; + + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs: [], + commentsWithParaIds: [], + exportType: 'external', + threadingProfile, + }); + + expect(result.documentXml['word/comments.xml']).toBeUndefined(); + expect(result.documentXml['word/commentsExtended.xml']).toBeUndefined(); + expect(result.documentXml['word/commentsIds.xml']).toBeUndefined(); + expect(result.documentXml['word/commentsExtensible.xml']).toBeUndefined(); + expect(result.removedTargets).toHaveLength(4); + expect(result.relationships).toHaveLength(0); + }); + }); + + describe('warnings', () => { + it('warns about partial file-set', () => { + const threadingProfile = { + defaultStyle: 'commentsExtended', + mixed: false, + fileSet: { + hasCommentsExtended: true, + hasCommentsExtensible: false, + hasCommentsIds: true, + }, + }; + + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs, + commentsWithParaIds, + exportType: 'external', + threadingProfile, + }); + + expect(result.warnings.some((w) => w.includes('Partial comment file-set'))).toBe(true); + }); + + it('does not warn on clean export', () => { + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs, + commentsWithParaIds, + exportType: 'clean', + threadingProfile: null, + }); + + expect(result.warnings).toHaveLength(0); + }); + + it('warns when all comments are removed and profile had files', () => { + const threadingProfile = { + defaultStyle: 'commentsExtended', + mixed: false, + fileSet: { + hasCommentsExtended: true, + hasCommentsExtensible: true, + hasCommentsIds: true, + }, + }; + + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs: [], + commentsWithParaIds: [], + exportType: 'external', + threadingProfile, + }); + + expect(result.warnings.some((w) => w.includes('All comments removed'))).toBe(true); + }); + + it('warns when comments exist but no threading profile', () => { + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs, + commentsWithParaIds, + exportType: 'external', + threadingProfile: null, + }); + + expect(result.warnings.some((w) => w.includes('no threading profile'))).toBe(true); + }); + }); + + describe('full file-set', () => { + it('populates all four files when all are present', () => { + const threadingProfile = { + defaultStyle: 'commentsExtended', + mixed: false, + fileSet: { + hasCommentsExtended: true, + hasCommentsExtensible: true, + hasCommentsIds: true, + }, + }; + + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs, + commentsWithParaIds, + exportType: 'external', + threadingProfile, + }); + + expect(result.documentXml['word/comments.xml']).toBeDefined(); + expect(result.documentXml['word/commentsExtended.xml']).toBeDefined(); + expect(result.documentXml['word/commentsIds.xml']).toBeDefined(); + expect(result.documentXml['word/commentsExtensible.xml']).toBeDefined(); + + // All four relationships should be present + expect(result.relationships).toHaveLength(4); + expect(result.removedTargets).toHaveLength(0); + }); + }); +}); + +// ============================================================================= +// removeCommentsFilesFromConvertedXml +// ============================================================================= + +describe('removeCommentsFilesFromConvertedXml', () => { + it('does not mutate the original object', () => { + const original = { + 'word/comments.xml': { elements: [] }, + 'word/commentsExtended.xml': { elements: [] }, + 'word/commentsExtensible.xml': { elements: [] }, + 'word/commentsIds.xml': { elements: [] }, + 'word/document.xml': { elements: [] }, + }; + const result = removeCommentsFilesFromConvertedXml(original); + + // Original still has the keys + expect(original['word/comments.xml']).toBeDefined(); + + // Result does not + expect(result['word/comments.xml']).toBeUndefined(); + expect(result['word/commentsExtended.xml']).toBeUndefined(); + expect(result['word/commentsExtensible.xml']).toBeUndefined(); + expect(result['word/commentsIds.xml']).toBeUndefined(); + + // Non-comment files preserved + expect(result['word/document.xml']).toBeDefined(); }); }); +// ============================================================================= +// updateCommentsExtendedXml (existing tests) +// ============================================================================= + describe('updateCommentsExtendedXml', () => { it('uses threadingParentCommentId for threaded replies when parent is tracked', () => { const comments = [ @@ -150,6 +494,10 @@ describe('updateCommentsExtendedXml', () => { }); }); +// ============================================================================= +// updateCommentsXml (existing tests) +// ============================================================================= + describe('updateCommentsXml', () => { it('stamps w14:paraId on the final paragraph for multi-paragraph comments', () => { const commentDef = { diff --git a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js index fa79d65974..e145f9eb41 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js @@ -34,9 +34,10 @@ export const updateYdocDocxData = async (editor, ydoc) => { Object.keys(newXml).forEach((key) => { const fileIndex = docx.findIndex((item) => item.name === key); const existingContent = fileIndex > -1 ? docx[fileIndex].content : null; + const newContent = newXml[key]; // Skip if content hasn't changed - if (existingContent === newXml[key]) { + if (existingContent === newContent) { return; } @@ -44,10 +45,16 @@ export const updateYdocDocxData = async (editor, ydoc) => { if (fileIndex > -1) { docx.splice(fileIndex, 1); } - docx.push({ - name: key, - content: newXml[key], - }); + + // A null value means the file was deleted during export (e.g. comment + // parts removed). Only add entries with real content — pushing + // { content: null } would crash parseXmlToJson on next hydration. + if (newContent != null) { + docx.push({ + name: key, + content: newContent, + }); + } }); // Only transact if there were actual changes OR this is initial setup. diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration.test.js index ece6035d24..5cd6c3ef14 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.test.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.test.js @@ -256,6 +256,32 @@ describe('collaboration helpers', () => { ); }); + it('does not persist null comment xml payloads into meta.docx', async () => { + const existingDocx = [ + { name: 'word/document.xml', content: '' }, + { name: 'word/comments.xml', content: '' }, + { name: 'word/commentsExtended.xml', content: '' }, + ]; + const ydoc = createYDocStub({ docxValue: existingDocx }); + const metas = ydoc._maps.metas; + + const editor = { + options: { ydoc, user: { id: 'user-null-comments' } }, + exportDocx: vi.fn().mockResolvedValue({ + 'word/document.xml': '', + 'word/comments.xml': null, + 'word/commentsExtended.xml': null, + }), + }; + + await updateYdocDocxData(editor); + + const persistedDocx = metas.set.mock.calls.at(-1)?.[1] || []; + expect(persistedDocx.some((file) => file.name === 'word/comments.xml')).toBe(false); + expect(persistedDocx.some((file) => file.name === 'word/commentsExtended.xml')).toBe(false); + expect(persistedDocx.every((file) => typeof file.content === 'string')).toBe(true); + }); + it('triggers transaction when new file is added', async () => { const existingDocx = [{ name: 'word/document.xml', content: '' }]; const ydoc = createYDocStub({ docxValue: existingDocx }); diff --git a/packages/super-editor/src/tests/data/gdocs-single-comment.docx b/packages/super-editor/src/tests/data/gdocs-single-comment.docx new file mode 100644 index 0000000000000000000000000000000000000000..a00cde279275a79a3ee02eb9c6dfb26edbe4c93f GIT binary patch literal 7550 zcma)h1yqz>*ES8(jdXWQNC*;_~Klr}i z`}p{O@3X!=Yt6dvS##a@IcM*)_qDI1u7rq02#1D-1{b2wYz+6C;oo1o+dF}{?OdTi z3nyzkD=rTxqyVO5*U5wXT5U{}=71KyHcS?YSZ1%VxeoO)Btie8psH)_%#{WQU598w z2S4`CzBl13pBWX5VpmAyrHf*3-doIu!q<;73f6Z{T!rWnwExU%+M}a3M3A1M5M3OG8|1^)hfr$fad=b_YhIZ1hp(}g z@GvnVf6te~Ow0zS&tGW9zx^buZYpMj>^AT!=^Z8~;p{DpSBgs-OSaiqUvE;|%n)r; z0CYY}>!vt!bylY&__`9uNB1}#Y#(R94-+NP)!PC{Q}j`i>C=(PB;)&TzGK&*aS38w zazwUqij~?gwPx0S6<0^1>c+ZG;YDJL^z4f+yx33AlKR|)$oFWH8yFDGc~nVf2{S|x zbs)dX&^*RACBLPm0kf#to45X$lC?~(?l<#}-WJKR6dd-}^LJo-F#6)cO%=}GDoRY& zKBBT6+~gJ92P3$RP~RvP7H;9drM?tF3XRbqJ2)J>9mP)P?ow6{Lz{~BJ@wl}0#hYu zcxv}c8bmQH5I$!syt)!9TCl??X&yWrTq+72oce!tG2Z{U*xACx<=(;0zdW3z6unYzQy&X55{%Uzj3b)(x@0z_(!7y8t2SaO zmy4B1hd4lo!bca^tqmi+Xz|N>9+Xn04(QLPAr-=ZH_)#4zZMO{71uQPUvnvmG-Bq1bQ=iUCJhCnowk zf5t^~phw8x5zw8&KAQBWy%1pTtRtmYc`F)3SeQGAMCJ$EjMDL^xch-CHxg);R|x7N z6Ra3ZcMT^ishYsYGf)3HLy8K-S7XVR!^G2yaY*4y50+*_iL#GjAB%gn;&d)!tFaao zRm%ezloXldPPHvot1t4$nL2r9I<8>iE#sh3HzO@A9whq6LrX!u^UAE$WdG@>5GwC72=)G3&V7D_Arm`!zZ&&~y>g7vf z|NbrWsD7DDXO`C34^%?@!$Hn*#n+$F9hiO=Ls%wd7T76LHq z_I+ueF?+nXt$xmW%#|_wZk{5CoZwN|M?@y%cPUF<-c|+Zz3;6*r!-RY^%5?qDHAE6<{f19a%Qix8GBL1w6KK} z3bnSfeTqw~N58eiEz60ukn&Y)EK?RrTC-7H9#}_K>pC`LT4uZ?khi)#h?``}&0td-KyP*gklV`XL%?rA(iyA;7_b(f&`-Xl@UMTG+Y#ipISpGy6#%-1g`2Uvmau zCD)K4=emOZ_$A1|WQU!L(~bOvG0@9&%e6^!X4QoPg24!nJRB8oKVPe?(XI3rKb9U) zgml___Vl$@=kNj(*JG_=YH|H@W9q??=AFN~&L2Td$(UPsDTP zbsA>$@>U@zWfK%=h*(r8Kbpb%B*N7JVH0z0CF3x2?ASAZrWP+l$YVpQBU=d1%PGtk zp>5ZqRC=7whZ#H46&1eNWX!I6Df|@AJgD=Nqyk$*VT>-7lJj705ag~V49Y6fY#1A! zL|VM$2Fu0LW>2urdyFsq&9!Vd`~&C(f>`ePW4@3g-sTioyG86#4=>!0DmCNXxEhpN zqkb+DhF;}-ou%VM_!AEy3?aI#tJ~Dr(pC5l&-uYqWKYaMA^-#xw+}ZMNIqO4VVA?b z2@xwzN6nAwT99aD*Yp}68Htx{^yZXUi>bR6T|muaYhgd1GzY(8b~yWK;eQDaIC|2) zC=J&Yy7sv0Js>`tPyrFSUG=CYNZj1=NhCQD*qp zx4qrll)n?Q8L`M+4~J7RKOHP@RJ}1IxQh>PE$@RTo&*W1eDO|eQA(KPH@F7#ETl9n zayGkfu8<&)3q(k-Eahz9k~tR#`^Y+5WujRXm7pMIRhx>~Gdo&cn+FxQEeDxR5_|8m z?0452(_yWX&3Jr^uWo$dZtR#{?pgSqov~a4xb2Z5aP;oDj2R14x_VpB!+MyJq&%qo zbIb>0R$Cq6k(onRUgF3)S(tg$+S1SB{`4nXoz^CAiod`fAx*}Mjn-nB$u`Om_?I!| zy`*Vq`?y(jv)qdRI1DRDiMo%O)q|fM<3RW+$e#R#{GHm&r>^E;;~FA>9qbO|lt9~i z`J{=5BDjcWft)6;g$Dao?y8ig{ntWB<+rl3mp6wi7wv(HxU1-=0iVfQNr@@6q*6?0 zlDuti$`VGlGC(Ay2|738O*jJpLEzoz`jQr0Z%)xAbhI0$5JNCN`MKQi^*6}#NyN;| zi(gE@H8hNK7kihg@~8kou38ID}$+M7(%9gxgUo>>RgDl5=oelSjb!t8hV zk%LNM74b3lAlaLQyu)?tbBje#h(HIkhMjkzfodtPv22M|;@EQX%p-!J5E(35U!a1( za;qDLWo;0RSwZ}Y{H%2oEoJX!zHEYPSV$6PdA$snF@+O-;Klhikr3m7!7;?3JmgVP z6k$k&fc_f$H*d8`XKP;lA8}oB2}0oMyV(ZTc_Ar+bEz`t++I3&aLS>0jCfWMqGO_} zemgNQOCjvgZ=@9;YXbA_45?l=?fH|_8p=9MocAB#U{|Luvpv&)bybgk5i$ReVJxvDB%rwI9k(2f5nV9f0|X zS$Q`vjGD*yah$;<%nm<(h`0YlscghK^uPeDLaBmeHyGuPuY}S9=&yWvuyETCe3UL^ zJ!g^Cq`UAMpo0fatF~oOjU9eL5TNL_vfI)|Jbwy2rRb15EE{bJYVE$Wd;5zGWSghE z*59*%`hR2tIR9Y-E}jsJU)gsldCzWvhw$}#z`6f%n+jSSFTaRu`5En=l94++b2u$= z40IkFW^%LJ{fPDvi+X;ot)G9a4;6Krc%PvCSFu7+rK}WCc5CEt!5Jk?mZR52*xmQJ zcSEWOVu^pR#A6v}tH}J5{mtpcPs*j7l1|w-CFup@pA}%Me6xufftqo%QS_yvB+B{O zE?OK*gxzdwdU#60EEK@N>6ss5+zuSCKV>M7rN|f;2;nPNhdklptJ7abn~$B4K2522 zXtdHuqANWnH3)l$})sljI&Fn3}&8||p%i$`cW2>rrHow-C22mTc~iklwu>>1?wkGnJ_nv^s> zCq}SaWSg!P{qrcHm;4KI9Zy=7&X0s^43V*!i{P(`X@szNpony*9#mb^nXcF|YHLVD zsL5NB&;1b^<6j^4M(?q{O)JF*Qx9fP+)Q7P!4AG5lIC7ZxY?cV<#wEd;Yl?n_@++Ri*?)@nnkDBLwNX@kN5l10+g~!gDMY?MmMDHlr}!fltUzS=X-$HdFw30LL6~~oQ?w`gWbXK-mE$S3*-H`k+L%&ApVDFIJo(8w^^WL$O zVcVQ^QC}o>e8b{3dZ|J}rPx?Jj}(fYhO2-^o%RY7Un45)&UEOr`Gn$RksAC>?Q3SF zP2Oo)CYe3}SOz|P^`K_A`~XQNwsgqugG~tToh~^rp!ZtFDhVCY4f%?ye~rr(KV)@IY+ama9AC}BO7>h3#O-Nz?& z{U|SoYMQ_C)boVoA*owuoK4i`=($4JJa-x#SbT({ychCeBsGM<@&5 zhdq;DSfv0yLPc;M6Vy8z)Wz?F(K>{axmaVogvey;OZnrGF}hKco(^x+l9ignCECv2uF<7i2A zWAG+0eTZzcD@AoAx%;d9oF+AadS`E+B3XVs3~x2iLhHtnHk@O%k+!^7LQSf?I3j zG7sKv9L4+QnVyzO-*g3_XxF#`imgb`i6Oe``5~!tZ|DhM%wBm-@podRRR5G>!tgem z5m~yNL0Ux0Dmryk%{`M_s9;Pk$|#$x=wloOGcK{yvkPrUS%v3FwB`HQ8%Of8A7Z_u zG5#FVvh>|A-(n<-g6d`WPn9rveKkL&E%qH@?Mak(VZK1lab#kx{6xq%x#llmAdMw%y`4?Kpau zvR6ZjIl%W+pXL}i$|0m8Tyc3*Bgj{7VpWINs`uT~%G<4+`8@Jb3Oc0o}gmW#e*AiFJpww&sI+g!tjOD zaQo{TP}SFuaHgpz@~7(Gr>=Gf0GbYuTV8(gUd{P*wg^58p*CGB@rToNn8$wctT9V?oB;5AkY3V4i(PM+V;^$x)%(JH>$X@R@f_^3cL)<;L_h zYQUo}yTpcOJ72HY8f9yx;S0Qife@qRL0m`RBav1cHK9mjw+WCG9kOG12h4d+D|J0s zH-8Y-#p20Nund>Q6hE3L9@)3TihBN5^7iM-6YMBedRTEUA6>-m^^MCv^YEkd)@iTwhdQK2#;FrsAF7MTf=5LMePaq|++nE-1S;o52U@ zw~i-CB%V@055ZNsPV_>$N}m86fBWuTukt#e+}mE_2uCD{eYETpJvgqqW~ONP6=o}t zaS}N;+=M%!SPP`@H>%*b{=zLq;_7WJXWZIJ5WKK9F;Jh)W30|*zOQYoMxs!HHEBM9T`t%W45z1g_ zC=MfC-}pdnCjKfgX8*ZZ)7{W` z5USj{pP;%)mt(e8%f)SE8?{fwt=I8VWT;unk!IiB?W9%P&CdeTVwdHU_tbYsr$;CD z;&&&Zp(q!x+odVRv+>?+GYLzGK11ljMCeyfj|SobOSK@~0o}Z+)|lIw=)Ubs`jv4pWE#DhU%PFWvx)T-d- z&ehuDh|s94!qH4yQr!57W?hJeSA)f;_Z*EkZmFPVb(Zt{Bd$yNoMu8-9)Tv1MChQh zOSCL*a=3zIn0wDtBX{*sK7uQiYBL!r9e^cJLBA(j??`{8znC}XZOq329k8a8JKe{K zu7W^$)L_S{lW-VQsu56A<6NW$+j*{jj-iw;B=;55I@WMw%;)u2OSp338dhYXB4_lC zsH_p=f_k!y9<@>v!rkzFN?kso_^x#ettvrd=``OGs(2ESR;InuEAf~d$5+@}L~@TD zqeTN-qeq(^rOUi;_ww#EhQ7ctzqPS!)!^>>RKLYVBJOk5e8NQ4w}^zk>f`MDHT`SP z=ErxLqiu|%O_k-;HGG2O6I$nCPwL8;qCUqN+$8}rzlQce;_(tguNi`0(pCgwAdU3O zJN$U0vtk!UfV&35M=+?wW>9PhE(@uJxw}0t065oes&Xg#_u52WT{=m(L6RWV1~jgc z(XbBd5CN{1Ode*`OwH7_)Plx7#Sa4ZQcE=Ht%?r1-y6a5gu`K3KgFzgu!hJn-)Kz& z34vLUlLW5Pd&!RqUokO`^bOP^GcQI?cdvfOq^v2|soyv3?^0MK>#%Wa-$oSjkBk$ZF9|SyG8W1byxyHaYHny}`7~bF z6M`=6lEaX1YS!MGHGt593zI3u2Y$oY*4C?jzGEaD`*8p{XCVh3pHL(N9hr8qwDJVW zQq|-uRZ&!gxK=59!nM2n*(37&>E`$a`y7V_6?8dJ!)5k-;de5WoBE683GaevE2d)4 zq)+cl1bm?E`aWVrsvqEOSHD|E?(9!wSoxNzEGGp_aDzW?+DU+|sel#N6p-TTor?i|Qwgky>CPGM=6s$4X+ zEew!!W&LHVGOZ@(NVuI>_B0oyI(oxQ0xs%4Tgfk!(HyM%*jCYHlrCYyE% z1H6!vOS;xfbUg+I8L5P|5!{RXOeAj?KEO=wP#+2_tzm93$9t@lBp+s(shtMo+C3|RIqE#^9|sUa zjtk`ZU;MBiqPXJZYv4k8D!B6FTwK4Wt`oydq7LPDVnA+~X`>ka&A~`Ben6O2U+H_c z$q&a6_C>!C(sL605(6M8ISDe#>9iD-vRD^W=zQ`$CTeM!D>S3lwgRf6$ zeEOd|;ohN=V7Db;uYty9Tu;j#R){jE2m*I+Yv-9CJmD}ojHGa1^huB(JOPhD2=~`P z*YD>e4|3Pv?f*aa`V;@VqW#cW{bg(SZ~TS-{Y>Rg`0qO0-{Fo(|AzlAg8vEsUC;UZ z=zpUA8~!)x?oa&h9pi)Q^Oqgnv+941{y!SfpWxpK_}|AqiuG^s?{xc5`0vE?fu;Xt zEx7-N{};~w=aYUXb`M1LFGIRtl;3FUpTOU@=mYQj%l7Yqzo!2u3;gpDzsJ_YUjEBW cNdMQJt*(T8pA_KW(C2NBLDyZ literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/nested-comments-gdocs.docx b/packages/super-editor/src/tests/data/nested-comments-gdocs.docx new file mode 100644 index 0000000000000000000000000000000000000000..93613138cde8e1e777fccd526f4bd0f22a565569 GIT binary patch literal 7986 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 w?=Q + (converter.comments ?? []).map((comment) => { + const elements = Array.isArray(comment.elements) && comment.elements.length ? comment.elements : undefined; + return { + ...comment, + commentJSON: comment.commentJSON ?? elements, + }; + }); + +// --------------------------------------------------------------------------- +// Scenario 1 – Partial profile (3 of 4 files) +// nested-comments.docx has: comments.xml, commentsExtended.xml, commentsIds.xml +// It does NOT have commentsExtensible.xml. +// --------------------------------------------------------------------------- +describe('Partial threading profile (nested-comments.docx)', () => { + let docx, media, mediaFiles, fonts; + + beforeAll(async () => { + ({ docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('nested-comments.docx')); + }); + + it('preserves commentsIds.xml and omits commentsExtensible.xml in updatedDocs', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const comments = prepareCommentsForExport(editor.converter); + expect(comments.length).toBeGreaterThan(0); + + const updatedDocs = await editor.exportDocx({ + comments, + commentsType: 'external', + getUpdatedDocs: true, + }); + + // comments.xml must be present (string) + expect(updatedDocs['word/comments.xml']).toEqual(expect.any(String)); + + // commentsExtended.xml must be present (string) + expect(updatedDocs['word/commentsExtended.xml']).toEqual(expect.any(String)); + + // commentsIds.xml must be present — this is the key Fix 1 assertion. + // Before the fix, the &&-guard dropped it because commentsExtensible was absent. + expect(updatedDocs['word/commentsIds.xml']).toEqual(expect.any(String)); + + // commentsExtensible.xml must be null (was never in the original) + expect(updatedDocs['word/commentsExtensible.xml']).toBeNull(); + } finally { + editor.destroy(); + } + }); + + it('produces a zip without commentsExtensible.xml', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const comments = prepareCommentsForExport(editor.converter); + const blob = await editor.exportDocx({ + comments, + commentsType: 'external', + }); + + const zipper = new DocxZipper(); + const zip = await zipper.unzip(blob); + + expect(zip.file('word/comments.xml')).not.toBeNull(); + expect(zip.file('word/commentsExtended.xml')).not.toBeNull(); + expect(zip.file('word/commentsIds.xml')).not.toBeNull(); + expect(zip.file('word/commentsExtensible.xml')).toBeNull(); + + // Content types must reference the three present files but NOT commentsExtensible + const contentTypes = await zip.file('[Content_Types].xml').async('string'); + expect(contentTypes).toContain('/word/comments.xml'); + expect(contentTypes).toContain('/word/commentsExtended.xml'); + expect(contentTypes).toContain('/word/commentsIds.xml'); + expect(contentTypes).not.toContain('/word/commentsExtensible.xml'); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 2 – Google Docs profile, no threading (comments.xml only) +// gdocs-single-comment.docx has: comments.xml with 1 non-threaded comment. +// No commentsExtended / commentsIds / commentsExtensible. +// Since there are no threaded comments, the exporter should NOT fabricate +// auxiliary files — the range-based threading model is preserved. +// --------------------------------------------------------------------------- +describe('Google Docs profile without threading (gdocs-single-comment.docx)', () => { + let docx, media, mediaFiles, fonts; + + beforeAll(async () => { + ({ docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('gdocs-single-comment.docx')); + }); + + it('emits only comments.xml — no auxiliary files fabricated', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const comments = prepareCommentsForExport(editor.converter); + expect(comments.length).toBeGreaterThan(0); + + const updatedDocs = await editor.exportDocx({ + comments, + commentsType: 'external', + getUpdatedDocs: true, + }); + + // comments.xml must be present + expect(updatedDocs['word/comments.xml']).toEqual(expect.any(String)); + + // The three auxiliary files must all be null (removed / never existed) + expect(updatedDocs['word/commentsExtended.xml']).toBeNull(); + expect(updatedDocs['word/commentsIds.xml']).toBeNull(); + expect(updatedDocs['word/commentsExtensible.xml']).toBeNull(); + } finally { + editor.destroy(); + } + }); + + it('produces a zip with only comments.xml', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const comments = prepareCommentsForExport(editor.converter); + const blob = await editor.exportDocx({ + comments, + commentsType: 'external', + }); + + const zipper = new DocxZipper(); + const zip = await zipper.unzip(blob); + + expect(zip.file('word/comments.xml')).not.toBeNull(); + expect(zip.file('word/commentsExtended.xml')).toBeNull(); + expect(zip.file('word/commentsIds.xml')).toBeNull(); + expect(zip.file('word/commentsExtensible.xml')).toBeNull(); + + const contentTypes = await zip.file('[Content_Types].xml').async('string'); + expect(contentTypes).toContain('/word/comments.xml'); + expect(contentTypes).not.toContain('/word/commentsExtended.xml'); + expect(contentTypes).not.toContain('/word/commentsIds.xml'); + expect(contentTypes).not.toContain('/word/commentsExtensible.xml'); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 2b – Google Docs profile WITH threading +// nested-comments-gdocs.docx has: comments.xml only, but comments include +// threaded replies. The exporter correctly fabricates commentsExtended.xml +// so Word can display the thread — this is intentional. +// --------------------------------------------------------------------------- +describe('Google Docs profile with threading (nested-comments-gdocs.docx)', () => { + let docx, media, mediaFiles, fonts; + + beforeAll(async () => { + ({ docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('nested-comments-gdocs.docx')); + }); + + it('fabricates commentsExtended.xml for threaded comments', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const comments = prepareCommentsForExport(editor.converter); + expect(comments.length).toBeGreaterThan(1); + + const updatedDocs = await editor.exportDocx({ + comments, + commentsType: 'external', + getUpdatedDocs: true, + }); + + // comments.xml must be present + expect(updatedDocs['word/comments.xml']).toEqual(expect.any(String)); + + // commentsExtended.xml IS fabricated because threaded replies exist — + // Word needs it to display the threading correctly. + expect(updatedDocs['word/commentsExtended.xml']).toEqual(expect.any(String)); + + // commentsIds and commentsExtensible remain null (not in original, not needed) + expect(updatedDocs['word/commentsIds.xml']).toBeNull(); + expect(updatedDocs['word/commentsExtensible.xml']).toBeNull(); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 3 – Clean export (zero comment files) +// Uses nested-comments.docx (3-file profile) to prove all parts are removed. +// --------------------------------------------------------------------------- +describe('Clean export strips all comment files', () => { + let docx, media, mediaFiles, fonts; + + beforeAll(async () => { + ({ docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('nested-comments.docx')); + }); + + it('sets all four comment files to null in updatedDocs', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const updatedDocs = await editor.exportDocx({ + commentsType: 'clean', + getUpdatedDocs: true, + }); + + for (const file of COMMENT_FILES) { + expect(updatedDocs[file]).toBeNull(); + } + + // Content types must not reference any comment file + const contentTypes = updatedDocs['[Content_Types].xml']; + expect(contentTypes).toBeDefined(); + for (const file of COMMENT_FILES) { + expect(contentTypes).not.toContain(`/${file}`); + } + } finally { + editor.destroy(); + } + }); + + it('produces a zip with zero comment files', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const blob = await editor.exportDocx({ commentsType: 'clean' }); + const zipper = new DocxZipper(); + const zip = await zipper.unzip(blob); + + for (const file of COMMENT_FILES) { + expect(zip.file(file)).toBeNull(); + } + + const contentTypes = await zip.file('[Content_Types].xml').async('string'); + for (const file of COMMENT_FILES) { + expect(contentTypes).not.toContain(`/${file}`); + } + + // Relationships should not reference any comment files + const rels = await zip.file('word/_rels/document.xml.rels').async('string'); + expect(rels).not.toContain('comments.xml'); + expect(rels).not.toContain('commentsExtended.xml'); + expect(rels).not.toContain('commentsIds.xml'); + expect(rels).not.toContain('commentsExtensible.xml'); + } finally { + editor.destroy(); + } + }); +});