diff --git a/application/src/main/java/run/halo/app/content/AbstractContentService.java b/application/src/main/java/run/halo/app/content/AbstractContentService.java index f5c47460bf..91005f97f9 100644 --- a/application/src/main/java/run/halo/app/content/AbstractContentService.java +++ b/application/src/main/java/run/halo/app/content/AbstractContentService.java @@ -1,14 +1,21 @@ package run.halo.app.content; +import static com.github.difflib.UnifiedDiffUtils.generateUnifiedDiff; +import static run.halo.app.content.PatchUtils.breakHtml; import static run.halo.app.extension.index.query.QueryFactory.and; import static run.halo.app.extension.index.query.QueryFactory.equal; import static run.halo.app.extension.index.query.QueryFactory.isNull; +import com.github.difflib.DiffUtils; +import com.github.difflib.patch.Patch; import java.security.Principal; import java.time.Duration; import java.time.Instant; +import java.util.List; import java.util.UUID; import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.experimental.Accessors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.OptimisticLockingFailureException; @@ -26,6 +33,7 @@ import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.infra.exception.NotFoundException; /** * Abstract Service for {@link Snapshot}. @@ -186,9 +194,58 @@ protected Snapshot determineRawAndContentPatch(Snapshot snapshotToUse, return snapshotToUse; } + /** + * Returns the unified diff content of the right compared to the left. + */ + protected Mono generateContentDiffBy(String leftSnapshot, String rightSnapshot, + String baseSnapshot) { + if (StringUtils.isBlank(leftSnapshot) || StringUtils.isBlank(rightSnapshot)) { + return Mono.error(new IllegalArgumentException( + "The leftSnapshot and rightSnapshot must not be blank.")); + } + if (StringUtils.isBlank(baseSnapshot)) { + return Mono.error(new IllegalArgumentException("The baseSnapshot must not be blank.")); + } + + var contentDiffDo = new ContentDiffDo(); + var originalMono = getContent(leftSnapshot, baseSnapshot) + .switchIfEmpty(Mono.error(new NotFoundException("The leftSnapshot not found."))) + .doOnNext(contentWrapper -> contentDiffDo.setOriginal(contentWrapper.getContent())); + var revisedMono = getContent(rightSnapshot, baseSnapshot) + .switchIfEmpty(Mono.error(new NotFoundException("The rightSnapshot not found."))) + .doOnNext(contentWrapper -> contentDiffDo.setRevised(contentWrapper.getContent())); + + return Mono.when(originalMono, revisedMono) + .then(Mono.fromSupplier(() -> { + var diffLines = generateContentUnifiedDiff(contentDiffDo.getOriginal(), + contentDiffDo.getRevised()); + return PatchUtils.highlightDiffChanges(diffLines); + })); + } + + static List generateContentUnifiedDiff(String original, String revised) { + Assert.notNull(original, "The original text must not be null."); + Assert.notNull(revised, "The revised text must not be null."); + var originalLines = breakHtml(original); + Patch patch = DiffUtils.diff(originalLines, breakHtml(revised)); + var unifiedDiff = generateUnifiedDiff("left", "right", + originalLines, patch, 10); + if (unifiedDiff.size() < 3) { + return List.of(); + } + return unifiedDiff.subList(2, unifiedDiff.size()); + } + protected Mono getContextUsername() { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .map(Principal::getName); } + + @Data + @Accessors(chain = true) + private static class ContentDiffDo { + private String original; + private String revised; + } } diff --git a/application/src/main/java/run/halo/app/content/PatchUtils.java b/application/src/main/java/run/halo/app/content/PatchUtils.java index 1813c7516a..2d64f9fe4e 100644 --- a/application/src/main/java/run/halo/app/content/PatchUtils.java +++ b/application/src/main/java/run/halo/app/content/PatchUtils.java @@ -11,8 +11,11 @@ import com.github.difflib.patch.Patch; import com.github.difflib.patch.PatchFailedException; import com.google.common.base.Splitter; +import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import lombok.Data; import org.apache.commons.lang3.StringUtils; import run.halo.app.infra.utils.JsonUtils; @@ -22,7 +25,8 @@ * @since 2.0.0 */ public class PatchUtils { - private static final String DELIMITER = "\n"; + public static final String DELIMITER = "\n"; + static final Pattern HTML_OPEN_TAG_PATTERN = Pattern.compile("(<[^>]+>)|([^<]+)"); private static final Splitter lineSplitter = Splitter.on(DELIMITER); public static Patch create(String deltasJson) { @@ -72,6 +76,49 @@ public static List breakLine(String content) { return lineSplitter.splitToList(content); } + /** + *

It will generate a unified diff html for the given diff lines.

+ *

It will wrap the following classes to the html elements:

+ *
    + *
  • diff-html-add: added elements
  • + *
  • diff-html-remove: deleted elements
  • + *
+ */ + public static String highlightDiffChanges(List diffLines) { + final var sb = new StringBuilder(); + for (String line : diffLines) { + if (line.startsWith("+")) { + sb.append(wrapLine(line.substring(1), "diff-html-add")); + } else if (line.startsWith("-")) { + sb.append(wrapLine(line.substring(1), "diff-html-remove")); + } else { + sb.append(line).append("\n"); + } + } + return sb.toString(); + } + + private static String wrapLine(String line, String className) { + return "
" + line + "
\n"; + } + + /** + * Break line for html. + */ + public static List breakHtml(String compressedHtml) { + Matcher matcher = HTML_OPEN_TAG_PATTERN.matcher(compressedHtml); + List elements = new ArrayList<>(); + while (matcher.find()) { + if (matcher.group(1) != null) { + elements.add(matcher.group(1)); + } else if (matcher.group(2) != null) { + List lines = breakLine(matcher.group(2)); + elements.addAll(lines); + } + } + return elements; + } + @Data public static class Delta { private StringChunk source; diff --git a/application/src/main/java/run/halo/app/content/PostService.java b/application/src/main/java/run/halo/app/content/PostService.java index cf4c0b44a3..296d8003bb 100644 --- a/application/src/main/java/run/halo/app/content/PostService.java +++ b/application/src/main/java/run/halo/app/content/PostService.java @@ -50,4 +50,12 @@ public interface PostService { Mono revertToSpecifiedSnapshot(String postName, String snapshotName); Mono deleteContent(String postName, String snapshotName); + + /** + *

Returns the unified diff content of the right compared to the left.

+ *

If the left snapshot is blank, the releaseSnapshot will be used as the left snapshot.

+ *

If the right snapshot is blank, the headSnapshot will be used as the right snapshot.

+ */ + Mono generateContentDiff(String postName, String leftSnapshotName, + String rightSnapshotName); } diff --git a/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java index 566df88c1e..ab72a61cd1 100644 --- a/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java +++ b/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java @@ -6,6 +6,7 @@ import java.time.Instant; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.function.Function; import java.util.function.UnaryOperator; import lombok.extern.slf4j.Slf4j; @@ -355,6 +356,23 @@ public Mono deleteContent(String postName, String snapshotName) }); } + @Override + public Mono generateContentDiff(String postName, String leftSnapshotName, + String rightSnapshotName) { + return client.get(Post.class, postName) + .flatMap(post -> { + String ensuredLeftSnapshotName = Optional.ofNullable(leftSnapshotName) + .filter(StringUtils::isNotBlank) + .orElse(post.getSpec().getReleaseSnapshot()); + + String ensuredRightSnapshotName = Optional.ofNullable(rightSnapshotName) + .filter(StringUtils::isNotBlank) + .orElse(post.getSpec().getHeadSnapshot()); + return generateContentDiffBy(ensuredLeftSnapshotName, ensuredRightSnapshotName, + post.getSpec().getBaseSnapshot()); + }); + } + private Mono updatePostWithRetry(Post post, UnaryOperator func) { return client.update(func.apply(post)) .onErrorResume(OptimisticLockingFailureException.class, diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java index 42161266ac..748aed7489 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java @@ -119,6 +119,28 @@ public RouterFunction endpoint() { .response(responseBuilder() .implementationArray(ListedSnapshotDto.class)) ) + .GET("posts/{name}/diff-content", this::diffContent, + builder -> builder.operationId("diffPostContent") + .description("Generate diff content between two snapshots.") + .tag(tag) + .parameter(parameterBuilder().name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class) + ) + .parameter(parameterBuilder() + .name("leftSnapshot") + .in(ParameterIn.QUERY) + .required(true) + .implementation(String.class)) + .parameter(parameterBuilder() + .name("rightSnapshot") + .in(ParameterIn.QUERY) + .required(true) + .implementation(String.class)) + .response(responseBuilder() + .implementation(String.class)) + ) .POST("posts", this::draftPost, builder -> builder.operationId("DraftPost") .description("Draft a post.") @@ -238,6 +260,18 @@ public RouterFunction endpoint() { .build(); } + private Mono diffContent(ServerRequest request) { + final var postName = request.pathVariable("name"); + var leftSnapshotName = request.queryParam("leftSnapshot") + .orElse(null); + var rightSnapshotName = request.queryParam("rightSnapshot") + .orElse(null); + return client.get(Post.class, postName) + .flatMap(post -> postService.generateContentDiff(postName, leftSnapshotName, + rightSnapshotName)) + .flatMap(diff -> ServerResponse.ok().bodyValue(diff)); + } + private Mono deleteContent(ServerRequest request) { final var postName = request.pathVariable("name"); final var snapshotName = request.queryParam("snapshotName").orElseThrow(); diff --git a/application/src/main/resources/extensions/role-template-post.yaml b/application/src/main/resources/extensions/role-template-post.yaml index d48246b0c1..3be87c8788 100644 --- a/application/src/main/resources/extensions/role-template-post.yaml +++ b/application/src/main/resources/extensions/role-template-post.yaml @@ -37,5 +37,5 @@ rules: resources: [ "posts" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] - resources: [ "posts", "posts/head-content", "posts/release-content", "posts/snapshot", "posts/content" ] + resources: [ "posts", "posts/head-content", "posts/release-content", "posts/snapshot", "posts/content", "posts/diff-content" ] verbs: [ "get", "list" ] diff --git a/application/src/test/java/run/halo/app/content/AbstractContentServiceTest.java b/application/src/test/java/run/halo/app/content/AbstractContentServiceTest.java new file mode 100644 index 0000000000..4e1c7a97f3 --- /dev/null +++ b/application/src/test/java/run/halo/app/content/AbstractContentServiceTest.java @@ -0,0 +1,26 @@ +package run.halo.app.content; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link AbstractContentService}. + * + * @author guqing + * @since 2.16.0 + */ +class AbstractContentServiceTest { + + @Test + void generateContentUnifiedDiff() { + List diff = AbstractContentService.generateContentUnifiedDiff("line1\nline2", ""); + var result = String.join(PatchUtils.DELIMITER, diff); + assertThat(result).isEqualToIgnoringNewLines(""" + @@ -1,2 +1,0 @@ + -line1 + -line2 + """); + } +} diff --git a/application/src/test/java/run/halo/app/content/PatchUtilsTest.java b/application/src/test/java/run/halo/app/content/PatchUtilsTest.java new file mode 100644 index 0000000000..b19a81fc7d --- /dev/null +++ b/application/src/test/java/run/halo/app/content/PatchUtilsTest.java @@ -0,0 +1,72 @@ +package run.halo.app.content; + +import static com.github.difflib.UnifiedDiffUtils.generateUnifiedDiff; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.difflib.DiffUtils; +import com.github.difflib.patch.Patch; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link PatchUtils}. + * + * @author guqing + * @since 2.16.0 + */ +class PatchUtilsTest { + + @Test + void breakHtml() { + var result = PatchUtils.breakHtml("abc\nadasdas\nafasdfdsa"); + assertThat(result).containsExactly("abc", "adasdas", "afasdfdsa"); + + result = PatchUtils.breakHtml("\n

Line one

\n\n"); + assertThat(result).containsExactly("", "", "

", "Line one", "

", "", + ""); + } + + @Test + void generateUnifiedDiffForHtml() { + String originalHtml = """ + + +

Line one

+

Line two

+ + + """; + String revisedHtml = """ + + +

Line one

+

Line three

+

New line

+ + + """; + var originalLines = PatchUtils.breakHtml(originalHtml); + Patch patch = DiffUtils.diff(originalLines, PatchUtils.breakHtml(revisedHtml)); + var unifiedDiff = generateUnifiedDiff("left", "right", + originalLines, patch, 10); + var highlighted = PatchUtils.highlightDiffChanges(unifiedDiff); + assertThat(highlighted).isEqualToIgnoringWhitespace(""" +
-- left
+
++ right
+ @@ -1,10 +1,13 @@ + + +

+ Line one +

+

+

Line two
+
Line three
+

+

+
New line
+

+ + + """); + } +} \ No newline at end of file