CommentUtil.java

package pro.verron.officestamper.core;

import org.docx4j.openpackaging.exceptions.Docx4JException;
import org.docx4j.openpackaging.exceptions.InvalidFormatException;
import org.docx4j.openpackaging.packages.OpcPackage;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.docx4j.openpackaging.parts.PartName;
import org.docx4j.openpackaging.parts.Parts;
import org.docx4j.openpackaging.parts.WordprocessingML.CommentsPart;
import org.docx4j.wml.*;
import org.docx4j.wml.R.CommentReference;
import pro.verron.officestamper.api.Comment;
import pro.verron.officestamper.api.DocxPart;
import pro.verron.officestamper.api.OfficeStamperException;
import pro.verron.officestamper.utils.wml.DocxIterator;

import java.math.BigInteger;
import java.util.*;

import static java.util.function.Function.identity;
import static java.util.stream.Collectors.toMap;

/// Utility class for working with comments in a DOCX document.
///
/// @author Joseph Verron
/// @author Tom Hombergs
/// @version ${version}
/// @since 1.0.0
public class CommentUtil {
    private static final PartName WORD_COMMENTS_PART_NAME;

    static {
        try {
            WORD_COMMENTS_PART_NAME = new PartName("/word/comments.xml");
        } catch (InvalidFormatException e) {
            throw new OfficeStamperException(e);
        }
    }

    private CommentUtil() {
        throw new OfficeStamperException("Utility class shouldn't be instantiated");
    }

    /// Retrieves the comment associated with a given paragraph content within a WordprocessingMLPackage document.
    ///
    /// @param contentAccessor the content accessor to search for comments.
    /// @param document        the WordprocessingMLPackage document containing the paragraph and its comments.
    /// @return a collection of found comments.
    public static Collection<Comments.Comment> getCommentFor(ContentAccessor contentAccessor, OpcPackage document) {
        var comments = getCommentsPart(document.getParts()).map(CommentUtil::extractContent)
                                                           .map(Comments::getComment)
                                                           .stream()
                                                           .flatMap(Collection::stream)
                                                           .toList();

        var result = new ArrayList<Comments.Comment>();
        var commentIterator = new DocxIterator(contentAccessor).selectClass(CommentRangeStart.class);
        while (commentIterator.hasNext()) {
            var crs = commentIterator.next();
            findCommentById(comments, crs.getId()).ifPresent(result::add);
        }
        return result;
    }

    /// Retrieves the CommentsPart from the given Parts object.
    ///
    /// @param parts the Parts object containing the various parts of the document.
    ///
    /// @return an Optional containing the CommentsPart if found, or an empty Optional if not found.
    public static Optional<CommentsPart> getCommentsPart(Parts parts) {
        return Optional.ofNullable((CommentsPart) parts.get(WORD_COMMENTS_PART_NAME));
    }

    /// Extracts the contents of a given [CommentsPart].
    ///
    /// @param commentsPart the [CommentsPart] from which content will be extracted
    ///
    /// @return the [Comments] instance containing the content of the provided comments part
    ///
    /// @throws OfficeStamperException if an error occurs while retrieving the content
    public static Comments extractContent(CommentsPart commentsPart) {
        try {
            return commentsPart.getContents();
        } catch (Docx4JException e) {
            throw new OfficeStamperException("Error while searching comment.", e);
        }
    }

    private static Optional<Comments.Comment> findCommentById(List<Comments.Comment> comments, BigInteger id) {
        for (Comments.Comment comment : comments) {
            if (id.equals(comment.getId())) {
                return Optional.of(comment);
            }
        }
        return Optional.empty();
    }

    /// Returns the string value of the specified comment object.
    ///
    /// @param comment a [Comment] object
    public static void deleteComment(Comment comment) {
        CommentRangeEnd end = comment.getCommentRangeEnd();
        ContentAccessor endParent = (ContentAccessor) end.getParent();
        endParent.getContent()
                 .remove(end);
        CommentRangeStart start = comment.getCommentRangeStart();
        var parent = start.getParent();
        ContentAccessor startParent = (ContentAccessor) parent;
        startParent.getContent()
                   .remove(start);
        if (startParent instanceof CTSmartTagRun tag && tag.getContent()
                                                           .isEmpty()) ((ContentAccessor) tag.getParent()).getContent()
                                                                                                          .remove(tag);
        CommentReference reference = comment.getCommentReference();
        if (reference != null) {
            ContentAccessor referenceParent = (ContentAccessor) reference.getParent();
            referenceParent.getContent()
                           .remove(reference);
        }
    }

    /// Creates a [Comment] object.
    ///
    /// @param docxPart the document part.
    /// @param crs the comment range start.
    /// @param document the document.
    /// @param contentAccessor the content accessor.
    ///
    /// @return the comment.
    public static Comment comment(
            DocxPart docxPart,
            CommentRangeStart crs,
            WordprocessingMLPackage document,
            ContentAccessor contentAccessor
    ) {
        var iterator = new DocxIterator(contentAccessor).slice(crs, null);
        CommentRangeEnd cre = null;
        CommentReference cr = null;
        var commentId = crs.getId();
        while (iterator.hasNext() && (cr == null || cre == null)) {
            var element = iterator.next();
            if (element instanceof CommentRangeEnd found && cre == null && Objects.equals(found.getId(), commentId)) {
                cre = found;
            }
            else if (element instanceof CommentReference found && cr == null && Objects.equals(found.getId(),
                    commentId)) {
                cr = found;
            }
        }

        if (cre == null) throw new IllegalStateException("Could not find comment range end or reference");

        var comment = comment(document, commentId);


        return new StandardComment(docxPart, (CTSmartTagRun) crs.getParent(), crs, cre, comment, cr);
    }

    private static Comments.Comment comment(WordprocessingMLPackage document, BigInteger commentId) {
        return getCommentsPart(document.getParts()).map(CommentUtil::extractContent)
                                                   .map(Comments::getComment)
                                                   .stream()
                                                   .flatMap(Collection::stream)
                                                   .collect(toMap(Comments.Comment::getId, identity()))
                                                   .get(commentId);
    }
}