CommentProcessorRegistry.java

package pro.verron.officestamper.core;

import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.docx4j.wml.*;
import org.jvnet.jaxb2_commons.ppp.Child;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.expression.spel.SpelEvaluationException;
import org.springframework.expression.spel.SpelParseException;
import pro.verron.officestamper.api.*;
import pro.verron.officestamper.utils.WmlFactory;
import pro.verron.officestamper.utils.WmlUtils;

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

import static pro.verron.officestamper.core.Placeholders.findProcessors;

/**
 * Allows registration of {@link CommentProcessor} objects. Each registered
 * ICommentProcessor must implement an interface which has to be specified at
 * registration time. Provides several getter methods to access the registered
 * {@link CommentProcessor}.
 *
 * @author Joseph Verron
 * @author Tom Hombergs
 * @version ${version}
 * @since 1.0.0
 */
public class CommentProcessorRegistry {

    private static final Logger logger = LoggerFactory.getLogger(CommentProcessorRegistry.class);
    private final DocxPart source;
    private final CommentProcessors commentProcessors;
    private final ExpressionResolver expressionResolver;
    private final ExceptionResolver exceptionResolver;

    /**
     * Constructs a new CommentProcessorRegistry.
     *
     * @param source             the source part of the Word document.
     * @param expressionResolver the resolver for evaluating expressions.
     * @param commentProcessors  map of comment processor instances keyed by their respective class types.
     * @param exceptionResolver  the resolver for handling exceptions during processing.
     */
    public CommentProcessorRegistry(
            DocxPart source,
            ExpressionResolver expressionResolver,
            CommentProcessors commentProcessors,
            ExceptionResolver exceptionResolver
    ) {
        this.source = source;
        this.expressionResolver = expressionResolver;
        this.commentProcessors = commentProcessors;
        this.exceptionResolver = exceptionResolver;
    }

    public <T> void runProcessors(T expressionContext) {
        var proceedComments = new ArrayList<Comment>();

        source.streamRun()
              .forEach(run -> {
                  var comments = collectComments();
                  var runParent = StandardParagraph.from(source, (P) run.getParent());
                  var optional = runProcessorsOnRunComment(comments, expressionContext, run, runParent);
                  commentProcessors.commitChanges(source);
                  optional.ifPresent(proceedComments::add);
              });

        // we run the paragraph afterward so that the comments inside work before the whole paragraph comments
        source.streamParagraphs()
              .forEach(p -> {
                  var comments = collectComments();
                  var paragraphComment = p.getComment();
                  paragraphComment.ifPresent((pc -> {
                      var optional = runProcessorsOnParagraphComment(comments, expressionContext, p, pc.getId());
                      commentProcessors.commitChanges(source);
                      optional.ifPresent(proceedComments::add);
                  }));
              });

        source.streamParagraphs()
              .forEach(paragraph -> runProcessorsOnInlineContent(expressionContext, paragraph));

        proceedComments.forEach(CommentUtil::deleteComment);
    }

    private Map<BigInteger, Comment> collectComments() {
        var rootComments = new HashMap<BigInteger, Comment>();
        var allComments = new HashMap<BigInteger, Comment>();
        var stack = Collections.asLifoQueue(new ArrayDeque<Comment>());

        var list = WmlUtils.extractCommentElements(document());
        for (Child commentElement : list) {
            if (commentElement instanceof CommentRangeStart crs) onRangeStart(crs, allComments, stack, rootComments);
            else if (commentElement instanceof CommentRangeEnd cre) onRangeEnd(cre, allComments, stack);
            else if (commentElement instanceof R.CommentReference cr) onReference(cr, allComments);
        }
        CommentUtil.getCommentsPart(document().getParts())
                   .map(CommentUtil::extractContent)
                   .map(Comments::getComment)
                   .stream()
                   .flatMap(Collection::stream)
                   .filter(comment -> allComments.containsKey(comment.getId()))
                   .forEach(comment -> allComments.get(comment.getId())
                                                  .setComment(comment));
        return new HashMap<>(rootComments);
    }

    private <T> Optional<Comment> runProcessorsOnRunComment(
            Map<BigInteger, Comment> comments, T expressionContext, R run, Paragraph paragraph
    ) {
        return CommentUtil.getCommentAround(run, document())
                          .flatMap(c -> Optional.ofNullable(comments.get(c.getId())))
                          .flatMap(c -> {
                              var cPlaceholder = c.asPlaceholder();
                              var cComment = c.getComment();
                              comments.remove(cComment.getId());
                              commentProcessors.setContext(new ProcessorContext(paragraph, run, c, cPlaceholder));
                              return runCommentProcessors(expressionContext, cPlaceholder)
                                      ? Optional.of(c)
                                      : Optional.empty();

                          });
    }

    /**
     * Takes the first comment on the specified paragraph and tries to evaluate
     * the string within the comment against all registered
     * {@link CommentProcessor}s.
     *
     * @param comments          the comments within the document.
     * @param expressionContext the context root object
     * @param <T>               the type of the context root object.
     */
    private <T> Optional<Comment> runProcessorsOnParagraphComment(
            Map<BigInteger, Comment> comments, T expressionContext, Paragraph paragraph, BigInteger paragraphCommentId
    ) {
        if (!comments.containsKey(paragraphCommentId)) return Optional.empty();

        var c = comments.get(paragraphCommentId);
        var cPlaceholder = c.asPlaceholder();
        var cComment = c.getComment();
        comments.remove(cComment.getId());
        commentProcessors.setContext(new ProcessorContext(paragraph, null, c, cPlaceholder));
        return runCommentProcessors(expressionContext, c.asPlaceholder()) ? Optional.of(c) : Optional.empty();
    }

    /**
     * Finds all processor expressions within the specified paragraph and tries
     * to evaluate it against all registered {@link CommentProcessor}s.
     *
     * @param context   the context root object against which evaluation is done
     * @param paragraph the paragraph to process.
     * @param <T>       type of the context root object
     */
    private <T> void runProcessorsOnInlineContent(T context, Paragraph paragraph) {
        var processorContexts = findProcessors(paragraph.asString()).stream()
                                                                    .map(paragraph::processorContext)
                                                                    .toList();
        for (var processorContext : processorContexts) {
            commentProcessors.setContext(processorContext);
            var placeholder = processorContext.placeholder();
            try {
                expressionResolver.setContext(context);
                expressionResolver.resolve(placeholder);
                paragraph.replace(placeholder, WmlFactory.newRun(""));
                logger.debug("Placeholder '{}' successfully processed by a comment processor.", placeholder);
            } catch (SpelEvaluationException | SpelParseException e) {
                var message = "Placeholder '%s' failed to process.".formatted(placeholder);
                exceptionResolver.resolve(placeholder, message, e);
            }
            commentProcessors.commitChanges(source);
        }
    }

    private WordprocessingMLPackage document() {
        return source.document();
    }

    private void onRangeStart(
            CommentRangeStart crs,
            HashMap<BigInteger, Comment> allComments,
            Queue<Comment> stack,
            HashMap<BigInteger, Comment> rootComments
    ) {
        Comment comment = allComments.get(crs.getId());
        if (comment == null) {
            comment = new StandardComment(document());
            allComments.put(crs.getId(), comment);
            if (stack.isEmpty()) {
                rootComments.put(crs.getId(), comment);
            }
            else {
                stack.peek()
                     .getChildren()
                     .add(comment);
            }
        }
        comment.setCommentRangeStart(crs);
        stack.add(comment);
    }

    private void onRangeEnd(
            CommentRangeEnd cre, HashMap<BigInteger, Comment> allComments, Queue<Comment> stack
    ) {
        Comment comment = allComments.get(cre.getId());
        if (comment == null)
            throw new OfficeStamperException("Found a comment range end before the comment range start !");

        comment.setCommentRangeEnd(cre);

        if (!stack.isEmpty()) {
            var peek = stack.peek();
            if (peek.equals(comment)) stack.remove();
            else throw new OfficeStamperException("Cannot figure which comment contains the other !");
        }
    }

    private void onReference(R.CommentReference cr, HashMap<BigInteger, Comment> allComments) {
        Comment comment = allComments.get(cr.getId());
        if (comment == null) {
            comment = new StandardComment(document());
            allComments.put(cr.getId(), comment);
        }
        comment.setCommentReference(cr);
    }

    private <T> boolean runCommentProcessors(T context, Placeholder commentPlaceholder) {
        try {
            expressionResolver.setContext(context);
            expressionResolver.resolve(commentPlaceholder);
            logger.debug("Comment '{}' successfully processed by a comment processor.", commentPlaceholder);
            return true;
        } catch (SpelEvaluationException | SpelParseException e) {
            var message = "Comment '%s' failed to process.".formatted(commentPlaceholder.expression());
            exceptionResolver.resolve(commentPlaceholder, message, e);
            return false;
        }
    }

}