diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.js index 521684ea91..0aac4d86db 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.js @@ -37,7 +37,6 @@ export const getCommentDefinition = (comment, commentId, allComments, editor) => const attributes = { 'w:id': String(commentId), 'w:author': comment.creatorName || comment.importedAuthor?.name, - 'w:email': comment.creatorEmail || comment.importedAuthor?.email, 'w:date': toIsoNoFractional(comment.createdTime), 'w:initials': getInitials(comment.creatorName), 'w:done': comment.resolvedTime ? '1' : '0', @@ -48,6 +47,7 @@ export const getCommentDefinition = (comment, commentId, allComments, editor) => 'custom:trackedChangeType': comment.trackedChangeType, 'custom:trackedChangeDisplayType': comment.trackedChangeDisplayType || null, 'custom:trackedDeletedText': comment.deletedText || null, + 'custom:authorEmail': comment.creatorEmail || comment.importedAuthor?.email, }; // Add the w15:paraIdParent attribute if the comment has a parent @@ -132,7 +132,6 @@ export const updateCommentsXml = (commentDefs = [], commentsXml) => { commentDef.attributes = { 'w:id': commentDef.attributes['w:id'], 'w:author': commentDef.attributes['w:author'], - 'w:email': commentDef.attributes['w:email'], 'w:date': commentDef.attributes['w:date'], 'w:initials': commentDef.attributes['w:initials'], 'custom:internalId': commentDef.attributes['custom:internalId'], @@ -141,6 +140,7 @@ export const updateCommentsXml = (commentDefs = [], commentsXml) => { 'custom:trackedChangeType': commentDef.attributes['custom:trackedChangeType'], 'custom:trackedChangeDisplayType': commentDef.attributes['custom:trackedChangeDisplayType'], 'custom:trackedDeletedText': commentDef.attributes['custom:trackedDeletedText'], + 'custom:authorEmail': commentDef.attributes['custom:authorEmail'], 'xmlns:custom': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', }; }); @@ -400,10 +400,14 @@ export const prepareCommentsXmlFilesForExport = ({ relationships.push(generateRelationship('comments.xml')); emittedTargets.add('comments.xml'); + const forceWordThreadingProfile = + threadingProfile?.defaultStyle === 'range-based' && exportStrategy !== 'google-docs'; + const effectiveThreadingProfile = forceWordThreadingProfile ? 'word' : threadingProfile || exportStrategy; + const commentsExtendedXml = updateCommentsExtendedXml( commentsWithParaIds, updatedXml['word/commentsExtended.xml'], - threadingProfile || exportStrategy, + effectiveThreadingProfile, ); // Only add the file and relationship if we're actually generating commentsExtended.xml diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.test.js index 617acadd71..bc861da6b5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.test.js @@ -381,12 +381,65 @@ describe('prepareCommentsXmlFilesForExport', () => { expect(result.removedTargets).toHaveLength(0); }); }); + + describe('threading profile overrides', () => { + it('forces Word-style threading when export strategy is Word but profile is range-based', () => { + const threadingProfile = { + defaultStyle: 'range-based', + mixed: false, + fileSet: { + hasCommentsExtended: false, + hasCommentsExtensible: false, + hasCommentsIds: false, + }, + }; + + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs, + commentsWithParaIds, + exportType: 'external', + threadingProfile, + }); + + expect(result.documentXml['word/commentsExtended.xml']).toBeDefined(); + const rel = result.relationships.find((r) => r.attributes.Target === 'commentsExtended.xml'); + expect(rel).toBeDefined(); + }); + + it('still honors Google Docs export strategy when all comments originate from Google Docs', () => { + const threadingProfile = { + defaultStyle: 'range-based', + mixed: false, + fileSet: { + hasCommentsExtended: false, + hasCommentsExtensible: false, + hasCommentsIds: false, + }, + }; + + const googleComments = [makeComment({ origin: 'google-docs' })]; + + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs, + commentsWithParaIds: googleComments, + exportType: 'external', + threadingProfile, + }); + + expect(result.documentXml['word/commentsExtended.xml']).toBeUndefined(); + const rel = result.relationships.find((r) => r.attributes.Target === 'commentsExtended.xml'); + expect(rel).toBeUndefined(); + }); + }); }); describe('getCommentDefinition', () => { it('preserves tracked change display metadata for exported tracked-change comments', () => { const definition = getCommentDefinition( makeComment({ + creatorEmail: 'author@example.com', trackedChange: true, trackedChangeType: 'trackFormat', trackedChangeText: 'https://example.com', @@ -400,6 +453,8 @@ describe('getCommentDefinition', () => { expect(definition.attributes['custom:trackedChangeType']).toBe('trackFormat'); expect(definition.attributes['custom:trackedChangeText']).toBe('https://example.com'); expect(definition.attributes['custom:trackedChangeDisplayType']).toBe('hyperlinkAdded'); + expect(definition.attributes['custom:authorEmail']).toBe('author@example.com'); + expect(definition.attributes['w:email']).toBeUndefined(); }); }); @@ -609,5 +664,31 @@ describe('updateCommentsXml', () => { const lastParagraph = updatedComment.elements[updatedComment.elements.length - 1]; expect(lastParagraph.attributes['w14:paraId']).toBe('ABC12345'); + expect(updatedComment.attributes['w:email']).toBeUndefined(); + expect(updatedComment.attributes['custom:authorEmail']).toBeUndefined(); + }); + + it('preserves custom author email attribute and omits w:email', () => { + const commentDef = { + type: 'element', + name: 'w:comment', + attributes: { + 'w:id': '1', + 'w:author': 'Author', + 'w:initials': 'A', + 'w15:paraId': 'EMAIL123', + 'custom:authorEmail': 'author@example.com', + }, + elements: [{ type: 'element', name: 'w:p', attributes: {}, elements: [] }], + }; + const commentsXml = { + elements: [{ elements: [] }], + }; + + const result = updateCommentsXml([commentDef], commentsXml); + const updatedComment = result.elements[0].elements[0]; + + expect(updatedComment.attributes['w:email']).toBeUndefined(); + expect(updatedComment.attributes['custom:authorEmail']).toBe('author@example.com'); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js index aa4b7431b5..1d36f9bd3b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js @@ -35,7 +35,7 @@ export function importCommentData({ docx, editor, converter }) { const { attributes } = el; const importedId = attributes['w:id']; const authorName = attributes['w:author']; - const authorEmail = attributes['w:email']; + const authorEmail = attributes['w:email'] ?? attributes['custom:authorEmail']; const initials = attributes['w:initials']; const createdDate = attributes['w:date']; const internalId = attributes['custom:internalId']; diff --git a/packages/super-editor/src/editors/v1/tests/import/documentCommentsImporter.unit.test.js b/packages/super-editor/src/editors/v1/tests/import/documentCommentsImporter.unit.test.js index f9e5541484..9453264852 100644 --- a/packages/super-editor/src/editors/v1/tests/import/documentCommentsImporter.unit.test.js +++ b/packages/super-editor/src/editors/v1/tests/import/documentCommentsImporter.unit.test.js @@ -42,6 +42,7 @@ const buildDocx = ({ comments = [], extended = [], documentRanges = [] } = {}) = 'custom:trackedChangeType': comment.trackedChangeType, 'custom:trackedChangeDisplayType': comment.trackedChangeDisplayType, 'custom:trackedDeletedText': comment.trackedDeletedText, + ...(comment.customAuthorEmail ? { 'custom:authorEmail': comment.customAuthorEmail } : {}), }, elements: comment.elements ?? [{ fakeParaId: comment.paraId ?? `para-${comment.id}` }], })); @@ -257,6 +258,22 @@ describe('importCommentData metadata parsing', () => { const [comment] = importCommentData({ docx }); expect(comment.elements).toHaveLength(2); }); + + it('reads custom:authorEmail when w:email is absent', () => { + const docx = buildDocx({ + comments: [ + { + id: 6, + author: 'Custom Email', + customAuthorEmail: 'custom@example.com', + }, + ], + }); + delete docx['word/comments.xml'].elements[0].elements[0].attributes['w:email']; + + const [comment] = importCommentData({ docx }); + expect(comment.creatorEmail).toBe('custom@example.com'); + }); }); describe('importCommentData extended metadata', () => {