UseJunior Book a Demo

safe-docx · Comments

Threaded replies from addCommentReply

When reading threaded comments from a Word document, root comments need to stay separate from replies so the thread tree (i.e., a root comment with nested replies) stays nested. Reply links live in a separate OOXML part from the comment text, so a reader has to combine both sources before returning the comment structure.

getComments reads comment text from word/comments.xml and reply relationships from word/commentsExtended.xml. Because addCommentReply writes each reply as its own comment element and links it back to the parent comment, getComments uses those links to attach replies under their root comment instead of returning them as top-level comments.[1]

Below is a test scenario of the baseline successful case of getComments: builds threaded replies from addCommentReply.

The scenario

Given a root comment with two replies,
When reading comments via getComments,
Then

  • only one root comment is returned at top level.
  • root comment text is "Root comment".
  • root comment has two replies.
  • first reply text is "Reply one" by "Replier".
  • second reply text is "Reply two".

The test fixture

The fixture creates a document with a root comment, adds two replies to that root comment, and then reads the comments back through getComments.[2]

Below is the test fixture code.

test('builds threaded replies from addCommentReply', async ({ given, when, then, and }: AllureBddContext) => {
  let zip: DocxZip;
  let doc: Document;
  let comments: Awaited<ReturnType<typeof getComments>>;

  await given('a root comment with two replies', async () => {
    const bodyXml = '<w:p><w:r><w:t>Hello</w:t></w:r></w:p>';
    const buf = await makeDocxBuffer(bodyXml);
    zip = await loadZip(buf);
    await bootstrapCommentParts(zip);
    const docXml = await zip.readText('word/document.xml');
    doc = parseXml(docXml);
    const p = doc.getElementsByTagNameNS(W_NS, W.p).item(0) as Element;
    const root = await addComment(doc, zip, { paragraphEl: p, start: 0, end: 5, author: 'Author', text: 'Root comment' });
    await addCommentReply(doc, zip, { parentCommentId: root.commentId, author: 'Replier', text: 'Reply one' });
    await addCommentReply(doc, zip, { parentCommentId: root.commentId, author: 'Replier2', text: 'Reply two' });
  });

  await when('reading comments via getComments', async () => {
    comments = await getComments(zip, doc);
  });

  await then('only one root comment is returned at top level', async () => {
    expect(comments).toHaveLength(1);
  });

  await and('root comment text is "Root comment"', async () => {
    expect(comments[0]!.text).toBe('Root comment');
  });

  await and('root comment has two replies', async () => {
    expect(comments[0]!.replies).toHaveLength(2);
  });

  await and('first reply text is "Reply one" by "Replier"', async () => {
    expect(comments[0]!.replies[0]!.text).toBe('Reply one');
    expect(comments[0]!.replies[0]!.author).toBe('Replier');
  });

  await and('second reply text is "Reply two"', async () => {
    expect(comments[0]!.replies[1]!.text).toBe('Reply two');
  });
});

The expected result shape

The scenario asserts the returned comment tree from getComments, with one root comment and two nested replies.

Below is the result that getComments is expected to return for this scenario.

[
  {
    text: 'Root comment',
    replies: [
      {
        text: 'Reply one',
        author: 'Replier',
      },
      {
        text: 'Reply two',
      },
    ],
  },
]

Below is a description of the expected fields:

A non-obvious detail

Reply comments are stored as comment elements with their own paragraph identifiers, so the nesting is not visible from word/comments.xml alone. getComments reads word/commentsExtended.xml to find child-to-parent paragraph links, then removes reply paragraph identifiers from the root-level collection so only the parent comment remains at the top level.