CommentProcessorRegistry.java

package pro.verron.officestamper.core;

import org.docx4j.XmlUtils;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.docx4j.wml.Comments;
import org.docx4j.wml.P;
import org.docx4j.wml.R;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.expression.spel.SpelEvaluationException;
import org.springframework.expression.spel.SpelParseException;
import org.springframework.lang.Nullable;
import pro.verron.officestamper.api.Comment;
import pro.verron.officestamper.api.CommentProcessor;
import pro.verron.officestamper.api.DocxPart;
import pro.verron.officestamper.api.ExceptionResolver;

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

import static pro.verron.officestamper.core.CommentCollectorWalker.collectComments;
import static pro.verron.officestamper.core.CommentUtil.getCommentString;

/**
 * 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 Map<Class<?>, ?> 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,
            Map<Class<?>, ?> 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.streamParagraphs()
              .map(P::getContent)
              .flatMap(Collection::stream)
              .map(XmlUtils::unwrap)
              .filter(R.class::isInstance)
              .map(R.class::cast)
              .forEach(run -> {
                  var comments = collectComments(source);
                  var runParent = (P) run.getParent();
                  var optional = runProcessorsOnRunComment(comments, expressionContext, run, runParent);
                  if (optional.isPresent()) {
                      var comment = optional.get();
                      for (Object processor : commentProcessors.values()) {
                          var commentProcessor = (CommentProcessor) processor;
                          commentProcessor.commitChanges(source);
                          commentProcessor.reset();
                      }
                      proceedComments.add(comment);
                  }
              });
        // we run the paragraph afterward so that the comments inside work before the whole paragraph comments
        source.streamParagraphs()
              .forEach(p -> {
                  var document = source.document();
                  var comments = collectComments(source);
                  var optional = runProcessorsOnParagraphComment(document, comments, expressionContext, p);
                  if (optional.isPresent()) {
                      for (Object processor : commentProcessors.values()) {
                          var commentProcessor = (CommentProcessor) processor;
                          commentProcessor.commitChanges(source);
                          commentProcessor.reset();
                      }
                      proceedComments.add(optional.get());
                  }
              });
        source.streamParagraphs()
              .forEach(paragraph -> runProcessorsOnInlineContent(expressionContext, paragraph));
        for (Comment comment : proceedComments) {
            CommentUtil.deleteComment(comment);
        }
    }

    private <T> Optional<Comment> runProcessorsOnRunComment(
            Map<BigInteger, Comment> comments,
            T expressionContext,
            R run,
            P paragraph
    ) {
        return CommentUtil
                .getCommentAround(run, source.document())
                .flatMap(c -> runCommentProcessors(
                        comments,
                        expressionContext,
                        c,
                        paragraph, run
                ));
    }

    /**
     * Takes the first comment on the specified paragraph and tries to evaluate
     * the string within the comment against all registered
     * {@link CommentProcessor}s.
     *
     * @param document          the Word document.
     * @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(
            WordprocessingMLPackage document,
            Map<BigInteger, Comment> comments,
            T expressionContext,
            P paragraph
    ) {
        return CommentUtil
                .getCommentFor(paragraph, document)
                .flatMap(c -> runCommentProcessors(
                        comments,
                        expressionContext,
                        c,
                        paragraph,
                        null
                ));
    }

    /**
     * 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,
            P paragraph
    ) {
        var paragraphWrapper = new StandardParagraph(paragraph);
        var text = paragraphWrapper.asString();
        var placeholders = Placeholders.findProcessors(text);

        for (var placeholder : placeholders) {
            for (var processor : commentProcessors.values()) {
                ((CommentProcessor) processor).setParagraph(paragraph);
            }

            try {
                expressionResolver.setContext(context);
                expressionResolver.resolve(placeholder);
                paragraphWrapper.replace(placeholder, RunUtil.create(""));
                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);
            }
            for (var processor : commentProcessors.values()) {
                ((CommentProcessor) processor).commitChanges(source);
            }
        }
    }

    private <T> Optional<Comment> runCommentProcessors(
            Map<BigInteger, Comment> comments,
            T context,
            Comments.Comment comment,
            P paragraph,
            @Nullable R run
    ) {
        Comment commentWrapper = comments.get(comment.getId());

        if (Objects.isNull(commentWrapper)) {
            // no comment to process
            return Optional.empty();
        }

        var placeholder = getCommentString(comment);

        for (final Object processor : commentProcessors.values()) {
            ((CommentProcessor) processor).setParagraph(paragraph);
            ((CommentProcessor) processor).setCurrentRun(run);
            ((CommentProcessor) processor).setCurrentCommentWrapper(commentWrapper);
        }

        try {
            expressionResolver.setContext(context);
            expressionResolver.resolve(placeholder);
            comments.remove(comment.getId());
            logger.debug("Comment '{}' successfully processed by a comment processor.", placeholder.expression());
            return Optional.of(commentWrapper);
        } catch (SpelEvaluationException | SpelParseException e) {
            var message = "Comment '%s' failed to process.".formatted(placeholder.expression());
            exceptionResolver.resolve(placeholder, message, e);
            return Optional.empty();
        }
    }
}