/**
* Word comment injection with reply threading
*
* Flow:
* 1. prepareMarkdownWithMarkers() - Parse comments, detect reply relationships
* - First comment in a cluster = parent (gets markers: ⟦CMS:n⟧anchor⟦CME:n⟧)
* - Subsequent adjacent comments = replies (no markers, attach to parent)
* 2. Pandoc converts to DOCX
* 3. injectCommentsAtMarkers() - Insert comment ranges for parents only
* - Replies go in comments.xml with parent reference in commentsExtended.xml
*/
import * as fs from 'fs';
import AdmZip from 'adm-zip';
import { escapeXml } from './utils.js';
const MARKER_START_PREFIX = '⟦CMS:';
const MARKER_END_PREFIX = '⟦CME:';
const MARKER_SUFFIX = '⟧';
interface ParsedComment {
author: string;
text: string;
anchor: string | null;
start: number;
end: number;
fullMatch: string;
}
interface PreparedComment extends ParsedComment {
isReply: boolean;
parentIdx: number | null;
commentIdx: number;
anchorFromReply?: boolean;
placesParentMarkers?: boolean;
}
interface PrepareResult {
markedMarkdown: string;
comments: PreparedComment[];
}
interface CommentWithIds extends PreparedComment {
id: string;
paraId: string;
paraId2: string;
durableId: string;
parentParaId?: string;
}
interface InjectionResult {
success: boolean;
commentCount: number;
replyCount?: number;
skippedComments: number;
error?: string;
}
function generateParaId(commentIdx: number, paraNum: number): string {
// Generate 8-character uppercase hex ID matching Word format
// Word uses IDs like "3F25BC58", "0331C187"
// Must be deterministic - same inputs always produce same output
const id = 0x10000000 + (commentIdx * 0x00100000) + (paraNum * 0x00001000);
return id.toString(16).toUpperCase().padStart(8, '0');
}
/**
* Parse comments and create markers
*
* Returns:
* - markedMarkdown: markdown with markers for parent comments only
* - comments: array with author, text, isReply, parentIdx
*/
export function prepareMarkdownWithMarkers(markdown: string): PrepareResult {
// Match the comment block first; extend manually to capture an optional
// trailing `[anchor]{.mark}` span. A regex `[^\]]+` for the anchor would
// bail on the inner `]` of nested syntax (e.g. `[[0..9]]{.mark}` or
// `[*phrase*]{.mark}` after pandoc-rewriting), so we walk the brackets
// ourselves and verify a `{.mark}` suffix.
const commentPattern = /\{>>([\s\S]+?)<<\}/g;
function tryParseTrailingAnchor(
text: string,
fromIdx: number,
): { anchor: string; endIdx: number } | null {
let i = fromIdx;
while (i < text.length && /\s/.test(text[i] ?? '')) i++;
if (text[i] !== '[') return null;
let depth = 1;
let j = i + 1;
while (j < text.length) {
const ch = text[j];
if (ch === '[') depth++;
else if (ch === ']') {
depth--;
if (depth === 0) break;
}
j++;
}
if (depth !== 0) return null;
if (text.slice(j + 1, j + 8) !== '{.mark}') return null;
return { anchor: text.slice(i + 1, j), endIdx: j + 8 };
}
const REPLY_PREFIX = '↪ ';
const rawMatches: (ParsedComment & { explicitReply: boolean })[] = [];
let match: RegExpExecArray | null;
while ((match = commentPattern.exec(markdown)) !== null) {
const content = match[1] ?? '';
let author = 'Unknown';
let text = content;
const colonIdx = content.indexOf(':');
if (colonIdx > 0 && colonIdx < 30) {
author = content.slice(0, colonIdx).trim();
text = content.slice(colonIdx + 1).trim();
}
// The `↪ ` prefix is the authoritative reply signal emitted by
// `insertCommentsIntoMarkdown`. Strip it from the author before injection
// so Word displays the real name.
let explicitReply = false;
if (author.startsWith(REPLY_PREFIX)) {
explicitReply = true;
author = author.slice(REPLY_PREFIX.length).trim();
}
const commentEnd = match.index + match[0].length;
const trailing = tryParseTrailingAnchor(markdown, commentEnd);
rawMatches.push({
author,
text,
anchor: trailing ? trailing.anchor : null,
start: match.index,
end: trailing ? trailing.endIdx : commentEnd,
fullMatch: markdown.slice(match.index, trailing ? trailing.endIdx : commentEnd),
explicitReply,
});
// Advance regex lastIndex past the consumed anchor so the next iteration
// doesn't re-scan inside it (e.g. `[*emphasis*]{.mark}` would otherwise
// tempt the matcher to look for another `{>>...<<}` in the body of the
// anchor span).
if (trailing) {
commentPattern.lastIndex = trailing.endIdx;
}
}
if (rawMatches.length === 0) {
return { markedMarkdown: markdown, comments: [] };
}
// Two-mode reply detection driven by the markdown itself:
// - If any comment carries the `↪ ` author prefix, the markdown came
// through `insertCommentsIntoMarkdown` and we use prefix-only mode.
// Distinct clusters that happen to land at gap=0 (a real failure
// mode on dense reviewer docs — 298-comment paper produced 9 such
// collisions) are not misthreaded.
// - If no comment carries the prefix, the markdown was hand-typed.
// Fall back to gap < 10 adjacency for backward compat with users
// who write CriticMarkup directly.
const ADJACENT_THRESHOLD = 10;
const useExplicitMode = rawMatches.some(m => m.explicitReply);
const comments: PreparedComment[] = [];
let clusterParentIdx = -1; // Index of first comment in current cluster
let lastCommentEnd = -1;
for (let i = 0; i < rawMatches.length; i++) {
const m = rawMatches[i];
if (!m) continue;
const gap = lastCommentEnd >= 0 ? m.start - lastCommentEnd : Infinity;
const isAdjacent = useExplicitMode
? m.explicitReply
: gap < ADJACENT_THRESHOLD;
// Reset cluster if there's a gap (comments not in same cluster)
if (!isAdjacent) {
clusterParentIdx = -1;
}
if (clusterParentIdx === -1) {
// First comment in cluster = parent (regardless of author)
comments.push({
author: m.author,
text: m.text,
anchor: m.anchor,
start: m.start,
end: m.end,
fullMatch: m.fullMatch,
isReply: false,
parentIdx: null,
commentIdx: comments.length
});
clusterParentIdx = comments.length - 1;
} else {
// Subsequent comment in cluster = reply to first comment
comments.push({
author: m.author,
text: m.text,
anchor: m.anchor,
start: m.start,
end: m.end,
fullMatch: m.fullMatch,
isReply: true,
parentIdx: clusterParentIdx,
commentIdx: comments.length
});
}
lastCommentEnd = m.end;
}
// Propagate anchors from replies to parents
// If a reply has an anchor but its parent doesn't, move the anchor to the parent
// Track flags for special handling during marker generation
for (const c of comments) {
if (c.isReply && c.anchor && c.parentIdx !== null) {
const parent = comments[c.parentIdx];
if (parent && !parent.anchor) {
parent.anchor = c.anchor;
parent.anchorFromReply = true; // Parent's anchor came from a reply (markers placed by reply)
c.placesParentMarkers = true; // This reply should place the parent's markers
c.anchor = null;
}
}
}
// Build marked markdown - only parent comments get markers
// Process from end to start to preserve positions
let markedMarkdown = markdown;
for (let i = comments.length - 1; i >= 0; i--) {
const c = comments[i];
if (!c) continue;
if (c.isReply) {
// Reply: remove from document entirely (will be in comments.xml only)
// Also consume one preceding whitespace char to avoid double spaces.
// We deliberately consume at most one — walking arbitrarily backwards
// would shift positions that lower-index comments still depend on.
let removeStart = c.start;
if (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1] ?? '')) {
removeStart--;
}
// If this reply places parent's markers (anchor was propagated)
if (c.placesParentMarkers && c.parentIdx !== null) {
// Extract anchor text from the original match
const anchorMatch = c.fullMatch.match(/\[([^\]]+)\]\{\.mark\}$/);
if (anchorMatch) {
const anchorText = anchorMatch[1] ?? '';
// Output markers with PARENT's index around the anchor text
const parentIdx = c.parentIdx;
const replacement = `${MARKER_START_PREFIX}${parentIdx}${MARKER_SUFFIX}${anchorText}${MARKER_END_PREFIX}${parentIdx}${MARKER_SUFFIX}`;
markedMarkdown = markedMarkdown.slice(0, removeStart) + replacement + markedMarkdown.slice(c.end);
} else {
markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
}
} else {
markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
}
} else {
// Parent comment
if (c.anchorFromReply) {
// Anchor markers are placed by the reply, just remove this comment.
// Consume one preceding whitespace char only (see reply branch above).
let removeStart = c.start;
if (removeStart > 0 && /\s/.test(markedMarkdown[removeStart - 1] ?? '')) {
removeStart--;
}
markedMarkdown = markedMarkdown.slice(0, removeStart) + markedMarkdown.slice(c.end);
} else {
// Normal case: replace with markers
const anchor = c.anchor || '';
const replacement = `${MARKER_START_PREFIX}${i}${MARKER_SUFFIX}${anchor}${MARKER_END_PREFIX}${i}${MARKER_SUFFIX}`;
markedMarkdown = markedMarkdown.slice(0, c.start) + replacement + markedMarkdown.slice(c.end);
}
}
}
return { markedMarkdown, comments };
}
function createCommentsXml(comments: CommentWithIds[]): string {
// Word expects date without milliseconds: 2025-12-30T08:33:00Z
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
let xml = '\n';
// Minimal namespaces matching golden file structure
xml += '';
// Use a consistent rsid (8-char hex) for all comments in this batch
const rsid = '00' + (Date.now() % 0xFFFFFF).toString(16).toUpperCase().padStart(6, '0');
for (const comment of comments) {
xml += ``;
// First paragraph: rsidRDefault="00000000", annotationRef without rStyle wrapper
xml += ``;
xml += ``;
xml += `${escapeXml(comment.text)}`;
xml += ``;
if (comment.isReply) {
// Second empty paragraph: rsidRDefault matches rsidR
xml += ``;
}
xml += ``;
}
xml += '';
return xml;
}
function createCommentsExtendedXml(comments: CommentWithIds[]): string {
let xml = '\n';
// Minimal namespaces matching golden file structure
xml += '';
for (const comment of comments) {
if (comment.isReply && comment.parentParaId) {
// Reply: use paraId2 (the second/empty paragraph) and link to parent's paraId
xml += ``;
} else {
// Parent comment: use paraId (first paragraph)
xml += ``;
}
}
xml += '';
return xml;
}
function generateDurableId(index: number): string {
// Generate unique 8-char hex ID for durableId
// CRITICAL: Must stay within signed 32-bit range (< 0x7FFFFFFF = 2147483647)
// Word interprets durableIds as signed 32-bit integers
const base = 0x10000000 + (Date.now() % 0x40000000); // Base between 0x10000000 and 0x50000000
const id = (base + index * 0x01000000) % 0x7FFFFFFF; // Keep under signed 32-bit max
return id.toString(16).toUpperCase().padStart(8, '0');
}
function createCommentsIdsXml(comments: CommentWithIds[]): string {
let xml = '\n';
// Minimal namespaces matching golden file structure
xml += '';
for (const comment of comments) {
// ONE entry per comment using the LAST paragraph's paraId:
// - Parent comments (1 paragraph): use paraId
// - Reply comments (2 paragraphs): use paraId2 (the second/empty paragraph)
const useParaId = comment.isReply ? comment.paraId2 : comment.paraId;
xml += ``;
}
xml += '';
return xml;
}
function createCommentsExtensibleXml(comments: CommentWithIds[]): string {
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
let xml = '\n';
// Minimal namespaces matching golden file structure
xml += '';
for (const comment of comments) {
// ONE entry per comment using the durableId
xml += ``;
}
xml += '';
return xml;
}
// Generate deterministic user IDs for authors (no hardcoded personal data)
function createPeopleXml(comments: CommentWithIds[]): string {
// Extract unique authors
const authors = [...new Set(comments.map(c => c.author))];
let xml = '\n';
xml += '';
for (const author of authors) {
const userId = generateUserId(author);
xml += ``;
xml += ``;
xml += ``;
}
xml += '';
return xml;
}
function generateUserId(author: string): string {
// Generate a deterministic 16-char hex ID from author name
let hash = 0;
for (let i = 0; i < author.length; i++) {
hash = ((hash << 5) - hash) + author.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(16, '0').slice(0, 16);
}
/**
* Inject comments at marker positions
*/
export async function injectCommentsAtMarkers(
docxPath: string,
comments: PreparedComment[],
outputPath: string
): Promise {
try {
if (!fs.existsSync(docxPath)) {
return { success: false, commentCount: 0, skippedComments: 0, error: `File not found: ${docxPath}` };
}
if (comments.length === 0) {
fs.copyFileSync(docxPath, outputPath);
return { success: true, commentCount: 0, skippedComments: 0 };
}
const zip = new AdmZip(docxPath);
const documentEntry = zip.getEntry('word/document.xml');
if (!documentEntry) {
return { success: false, commentCount: 0, skippedComments: 0, error: 'Invalid DOCX: no document.xml' };
}
let documentXml = zip.readAsText(documentEntry);
// Assign IDs and paraIds (IDs start at 1, not 0 - Word convention)
const commentsWithIds: CommentWithIds[] = comments.map((c, idx) => ({
...c,
id: String(idx + 1),
paraId: generateParaId(idx, 1), // First paragraph (e.g., 10000001)
paraId2: generateParaId(idx, 2), // Second paragraph (e.g., 10000002)
durableId: generateDurableId(idx), // Unique ID for commentsIds/commentsExtensible
}));
// Link replies to parent paraIds
for (const c of commentsWithIds) {
if (c.isReply && c.parentIdx !== null) {
const parent = commentsWithIds[c.parentIdx];
if (parent) {
c.parentParaId = parent.paraId;
}
}
}
const injectedIds = new Set();
// Process only parent comments (non-replies) for document ranges
const parentComments = commentsWithIds.filter(c => !c.isReply);
for (let i = parentComments.length - 1; i >= 0; i--) {
const comment = parentComments[i];
if (!comment) continue;
const idx = comment.commentIdx;
const startMarker = `${MARKER_START_PREFIX}${idx}${MARKER_SUFFIX}`;
const endMarker = `${MARKER_END_PREFIX}${idx}${MARKER_SUFFIX}`;
// Pandoc duplicates inline image alt-text into
// metadata attributes AND into the visible caption paragraph. A naive
// indexOf hits the metadata-attribute occurrence first, where there is
// no element so dissectRun fails. Skip occurrences whose position
// is inside an XML tag (last unbalanced '<' before position).
// See: https://github.com/gcol33/docrev/issues/4
function findInTextContent(haystack: string, needle: string, fromIdx = 0): number {
let i = fromIdx;
while (true) {
const p = haystack.indexOf(needle, i);
if (p < 0) return -1;
const lastLt = haystack.lastIndexOf('<', p);
const lastGt = haystack.lastIndexOf('>', p);
if (lastLt > lastGt) {
i = p + 1;
continue;
}
return p;
}
}
const startPos = findInTextContent(documentXml, startMarker);
const endPos = startPos === -1
? -1
: findInTextContent(documentXml, endMarker, startPos + startMarker.length);
if (startPos === -1 || endPos === -1) continue;
// Find the runs containing each marker. Pandoc may split a single
// markdown anchor across multiple blocks when it applies styling
// mid-anchor (smart-quote substitution, *italic*, `code`, **bold**).
// The same-run path (current happy path) collapses into the multi-run
// path when start and end runs coincide.
const startRunOpen = Math.max(
documentXml.lastIndexOf('', startPos),
documentXml.lastIndexOf('', startPos);
const endRunOpen = Math.max(
documentXml.lastIndexOf('', endPos),
documentXml.lastIndexOf('', endPos);
if (
startRunOpen === -1 || startRunCloseIdx === -1 ||
endRunOpen === -1 || endRunCloseIdx === -1
) continue;
const startRunClose = startRunCloseIdx + ''.length;
const endRunClose = endRunCloseIdx + ''.length;
const startRunFull = documentXml.slice(startRunOpen, startRunClose);
const endRunFull = documentXml.slice(endRunOpen, endRunClose);
// Extract and element shape from each run. Both pieces
// are needed verbatim so a textBefore split keeps its original styling
// and so the post-anchor textAfter render keeps the end run's styling.
function dissectRun(runXml: string, marker: string): {
rPr: string;
tElement: string;
textBefore: string;
textAfter: string;
} | null {
const rPrMatch = runXml.match(/[\s\S]*?<\/w:rPr>/);
const tMatch = runXml.match(/]*>([\s\S]*?)<\/w:t>/);
if (!tMatch) return null;
const tOpenMatch = tMatch[0].match(/]*>/);
if (!tOpenMatch) return null;
const tContent = tMatch[1] ?? '';
const markerInT = tContent.indexOf(marker);
if (markerInT === -1) return null;
return {
rPr: rPrMatch ? rPrMatch[0] : '',
tElement: tOpenMatch[0],
textBefore: tContent.slice(0, markerInT),
textAfter: tContent.slice(markerInT + marker.length),
};
}
let replacement = '';
const replies = commentsWithIds.filter(c => c.isReply && c.parentIdx === comment?.commentIdx);
const emitRangeStarts = () => {
replacement += ``;
for (const reply of replies) {
replacement += ``;
}
};
const emitRangeEnds = () => {
replacement += ``;
replacement += ``;
for (const reply of replies) {
replacement += ``;
replacement += ``;
injectedIds.add(reply.id);
}
};
if (startRunOpen === endRunOpen) {
// Same-run path: both markers live inside one . Original logic.
const startInfo = dissectRun(startRunFull, startMarker);
if (!startInfo) continue;
const fullText = startInfo.textBefore + startMarker + startInfo.textAfter;
const endInTextRel = startInfo.textAfter.indexOf(endMarker);
if (endInTextRel === -1) continue;
const anchorTextSame = startInfo.textAfter.slice(0, endInTextRel);
let textAfter = startInfo.textAfter.slice(endInTextRel + endMarker.length);
let anchorText = anchorTextSame;
let textBefore = startInfo.textBefore;
// Empty anchor: borrow the next word so the comment has something
// to anchor on. Then normalize the trailing double space.
if (!anchorText && textAfter) {
const wordMatch = textAfter.match(/^\s*(\S+)/);
if (wordMatch) {
anchorText = wordMatch[1] ?? '';
textAfter = textAfter.slice(wordMatch[0].length);
}
}
if (!anchorText && textBefore.endsWith(' ') && textAfter.startsWith(' ')) {
textAfter = textAfter.slice(1);
}
// Suppress unused warning for pre-empty-anchor fullText var
void fullText;
if (textBefore) {
replacement += `${startInfo.rPr}${startInfo.tElement}${textBefore}`;
}
emitRangeStarts();
if (anchorText) {
replacement += `${startInfo.rPr}${startInfo.tElement}${anchorText}`;
}
emitRangeEnds();
if (textAfter) {
replacement += `${startInfo.rPr}${startInfo.tElement}${textAfter}`;
}
documentXml = documentXml.slice(0, startRunOpen) + replacement + documentXml.slice(startRunClose);
injectedIds.add(comment.id);
continue;
}
// Multi-run path: markers sit in different blocks because pandoc
// applied mid-anchor styling. Split the start run at the start marker,
// keep all middle runs verbatim (they carry the styled anchor portions),
// split the end run at the end marker.
const startInfo = dissectRun(startRunFull, startMarker);
const endInfo = dissectRun(endRunFull, endMarker);
if (!startInfo || !endInfo) continue;
const middle = documentXml.slice(startRunClose, endRunOpen);
if (startInfo.textBefore) {
replacement += `${startInfo.rPr}${startInfo.tElement}${startInfo.textBefore}`;
}
emitRangeStarts();
if (startInfo.textAfter) {
replacement += `${startInfo.rPr}${startInfo.tElement}${startInfo.textAfter}`;
}
replacement += middle;
if (endInfo.textBefore) {
replacement += `${endInfo.rPr}${endInfo.tElement}${endInfo.textBefore}`;
}
emitRangeEnds();
if (endInfo.textAfter) {
replacement += `${endInfo.rPr}${endInfo.tElement}${endInfo.textAfter}`;
}
documentXml = documentXml.slice(0, startRunOpen) + replacement + documentXml.slice(endRunClose);
injectedIds.add(comment.id);
}
// Add required namespaces to document.xml for comment threading
const requiredNs: Record = {
'xmlns:w14': 'http://schemas.microsoft.com/office/word/2010/wordml',
'xmlns:w15': 'http://schemas.microsoft.com/office/word/2012/wordml',
'xmlns:w16cid': 'http://schemas.microsoft.com/office/word/2016/wordml/cid',
'xmlns:w16cex': 'http://schemas.microsoft.com/office/word/2018/wordml/cex',
'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
};
// Find ]*>/);
if (docTagMatch) {
let docTag = docTagMatch[0];
let modified = false;
for (const [attr, val] of Object.entries(requiredNs)) {
if (!docTag.includes(attr)) {
docTag = docTag.replace('>', ` ${attr}="${val}">`);
modified = true;
}
}
// Add mc:Ignorable if mc namespace was added
if (modified && !docTag.includes('mc:Ignorable')) {
docTag = docTag.replace('>', ' mc:Ignorable="w14 w15 w16cid w16cex">');
}
documentXml = documentXml.replace(docTagMatch[0], docTag);
}
// Update document.xml
zip.updateFile('word/document.xml', Buffer.from(documentXml, 'utf-8'));
// All comments (parents + replies) go in comments.xml
// But only include if parent was injected
const includedComments = commentsWithIds.filter(c => {
if (!c.isReply) {
return injectedIds.has(c.id);
} else {
// Include reply if its parent was injected
const parent = c.parentIdx !== null ? commentsWithIds[c.parentIdx] : undefined;
return parent && injectedIds.has(parent.id);
}
});
// Create comments.xml
const commentsXml = createCommentsXml(includedComments);
if (zip.getEntry('word/comments.xml')) {
zip.updateFile('word/comments.xml', Buffer.from(commentsXml, 'utf-8'));
} else {
zip.addFile('word/comments.xml', Buffer.from(commentsXml, 'utf-8'));
}
// Create commentsExtended.xml with reply threading
const commentsExtXml = createCommentsExtendedXml(includedComments);
if (zip.getEntry('word/commentsExtended.xml')) {
zip.updateFile('word/commentsExtended.xml', Buffer.from(commentsExtXml, 'utf-8'));
} else {
zip.addFile('word/commentsExtended.xml', Buffer.from(commentsExtXml, 'utf-8'));
}
// Create commentsIds.xml (Word 2016+)
const commentsIdsXml = createCommentsIdsXml(includedComments);
if (zip.getEntry('word/commentsIds.xml')) {
zip.updateFile('word/commentsIds.xml', Buffer.from(commentsIdsXml, 'utf-8'));
} else {
zip.addFile('word/commentsIds.xml', Buffer.from(commentsIdsXml, 'utf-8'));
}
// Create commentsExtensible.xml (Word 2018+)
const commentsExtensibleXml = createCommentsExtensibleXml(includedComments);
if (zip.getEntry('word/commentsExtensible.xml')) {
zip.updateFile('word/commentsExtensible.xml', Buffer.from(commentsExtensibleXml, 'utf-8'));
} else {
zip.addFile('word/commentsExtensible.xml', Buffer.from(commentsExtensibleXml, 'utf-8'));
}
// Create people.xml (author definitions with Windows Live IDs)
const peopleXml = createPeopleXml(includedComments);
if (zip.getEntry('word/people.xml')) {
zip.updateFile('word/people.xml', Buffer.from(peopleXml, 'utf-8'));
} else {
zip.addFile('word/people.xml', Buffer.from(peopleXml, 'utf-8'));
}
// Update [Content_Types].xml
const contentTypesEntry = zip.getEntry('[Content_Types].xml');
if (contentTypesEntry) {
let contentTypes = zip.readAsText(contentTypesEntry);
if (!contentTypes.includes('comments.xml')) {
const insertPoint = contentTypes.lastIndexOf('');
contentTypes = contentTypes.slice(0, insertPoint) +
'\n' +
contentTypes.slice(insertPoint);
}
if (!contentTypes.includes('commentsExtended.xml')) {
const insertPoint = contentTypes.lastIndexOf('');
contentTypes = contentTypes.slice(0, insertPoint) +
'\n' +
contentTypes.slice(insertPoint);
}
if (!contentTypes.includes('commentsIds.xml')) {
const insertPoint = contentTypes.lastIndexOf('');
contentTypes = contentTypes.slice(0, insertPoint) +
'\n' +
contentTypes.slice(insertPoint);
}
if (!contentTypes.includes('commentsExtensible.xml')) {
const insertPoint = contentTypes.lastIndexOf('');
contentTypes = contentTypes.slice(0, insertPoint) +
'\n' +
contentTypes.slice(insertPoint);
}
if (!contentTypes.includes('people.xml')) {
const insertPoint = contentTypes.lastIndexOf('');
contentTypes = contentTypes.slice(0, insertPoint) +
'\n' +
contentTypes.slice(insertPoint);
}
zip.updateFile('[Content_Types].xml', Buffer.from(contentTypes, 'utf-8'));
}
// Update relationships
const relsEntry = zip.getEntry('word/_rels/document.xml.rels');
if (relsEntry) {
let rels = zip.readAsText(relsEntry);
const rIdMatches = rels.match(/rId(\d+)/g) || [];
const maxId = rIdMatches.reduce((max, r) => Math.max(max, parseInt(r.replace('rId', ''))), 0);
if (!rels.includes('comments.xml')) {
const insertPoint = rels.lastIndexOf('');
rels = rels.slice(0, insertPoint) +
`\n` +
rels.slice(insertPoint);
}
if (!rels.includes('commentsExtended.xml')) {
const insertPoint = rels.lastIndexOf('');
rels = rels.slice(0, insertPoint) +
`\n` +
rels.slice(insertPoint);
}
if (!rels.includes('commentsIds.xml')) {
const insertPoint = rels.lastIndexOf('');
rels = rels.slice(0, insertPoint) +
`\n` +
rels.slice(insertPoint);
}
if (!rels.includes('commentsExtensible.xml')) {
const insertPoint = rels.lastIndexOf('');
rels = rels.slice(0, insertPoint) +
`\n` +
rels.slice(insertPoint);
}
if (!rels.includes('people.xml')) {
const insertPoint = rels.lastIndexOf('');
rels = rels.slice(0, insertPoint) +
`\n` +
rels.slice(insertPoint);
}
zip.updateFile('word/_rels/document.xml.rels', Buffer.from(rels, 'utf-8'));
}
zip.writeZip(outputPath);
const parentCount = includedComments.filter(c => !c.isReply).length;
const replyCount = includedComments.filter(c => c.isReply).length;
return {
success: true,
commentCount: parentCount,
replyCount: replyCount,
skippedComments: comments.length - includedComments.length,
};
} catch (err: any) {
return { success: false, commentCount: 0, skippedComments: 0, error: err.message };
}
}