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("\nLine 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