diff --git a/src/docxCommentLogging.ts b/src/docxCommentLogging.ts index 45e5afb..8088c2b 100644 --- a/src/docxCommentLogging.ts +++ b/src/docxCommentLogging.ts @@ -39,6 +39,28 @@ export interface DocxCommentsLogSummary { const MAX_COMMENT_TEXT_CHARS = 500; +type ParagraphNode = NonNullable[number]; +type ParagraphChildNode = NonNullable[number]; +type RunChildNode = NonNullable[number]; + +function extractTextFromRunChild(runChild: RunChildNode): string { + return runChild.type === 'text' && typeof runChild.text === 'string' ? runChild.text : ''; +} + +function extractTextFromParagraphChild(child: ParagraphChildNode): string { + if (child.type !== 'run' || !child.content) { + return ''; + } + return child.content.map(extractTextFromRunChild).join(''); +} + +function extractTextFromParagraph(paragraph: ParagraphNode): string { + if (!paragraph.content) { + return ''; + } + return paragraph.content.map(extractTextFromParagraphChild).join(''); +} + export function extractDocxCommentPlainText(comment: DocxCommentLogSource): string { if (typeof comment.text === 'string' && comment.text.length > 0) { return truncateCommentText(comment.text); @@ -49,18 +71,8 @@ export function extractDocxCommentPlainText(comment: DocxCommentLogSource): stri return ''; } - const parts: string[] = []; - for (const paragraph of paragraphs) { - for (const child of paragraph.content ?? []) { - if (child.type !== 'run') continue; - for (const runChild of child.content ?? []) { - if (runChild.type === 'text' && typeof runChild.text === 'string') { - parts.push(runChild.text); - } - } - } - } - return truncateCommentText(parts.join('')); + const rawText = paragraphs.map(extractTextFromParagraph).join(''); + return truncateCommentText(rawText); } export function truncateCommentText(text: string, maxChars = MAX_COMMENT_TEXT_CHARS): string { diff --git a/tests/docx-comment-logging.test.mjs b/tests/docx-comment-logging.test.mjs index d0f8b5f..de4e23b 100644 --- a/tests/docx-comment-logging.test.mjs +++ b/tests/docx-comment-logging.test.mjs @@ -54,3 +54,53 @@ test("summarizeDocxComments includes replies with parentId", async () => { assert.equal(summary.comments[1].parentId, 1); assert.equal(summary.comments[1].text, "add more polish"); }); + +test("extractDocxCommentPlainText handles explicit text, nested runs, edge cases, and truncation", async () => { + const { extractDocxCommentPlainText } = await loadCommentLoggingModule(); + + // Explicit text takes precedence + assert.equal( + extractDocxCommentPlainText({ id: 10, text: "Explicit comment text" }), + "Explicit comment text" + ); + + // Empty comment / no content + assert.equal(extractDocxCommentPlainText({ id: 11 }), ""); + assert.equal(extractDocxCommentPlainText({ id: 12, content: [] }), ""); + assert.equal(extractDocxCommentPlainText({ id: 13, content: [{ content: [] }] }), ""); + + // Non-run and non-text child nodes ignored gracefully + const mixedContent = [ + { + content: [ + { type: "image", content: [] }, + { + type: "run", + content: [ + { type: "bold_flag", text: undefined }, + { type: "text", text: "Hello " }, + { type: "text", text: "world!" }, + ], + }, + ], + }, + { + content: [ + { + type: "run", + content: [{ type: "text", text: " Next line." }], + }, + ], + }, + ]; + assert.equal( + extractDocxCommentPlainText({ id: 14, content: mixedContent }), + "Hello world! Next line." + ); + + // Text truncation over 500 chars + const longStr = "A".repeat(600); + const truncated = extractDocxCommentPlainText({ id: 15, text: longStr }); + assert.equal(truncated.length, 501); // 500 chars + '…' + assert.ok(truncated.endsWith("…")); +});