diff --git a/build.gradle b/build.gradle index 35f1b47ce2..72ca3b927a 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,7 @@ ext { huaweiObsVersion = '3.21.8.1' templateInheritanceVersion = "0.4.RELEASE" jsoupVersion = '1.14.3' + diffUtilsVersion = '4.11' } dependencies { @@ -121,6 +122,7 @@ dependencies { implementation "net.sf.image4j:image4j:$image4jVersion" implementation "org.flywaydb:flyway-core:$flywayVersion" implementation "com.google.zxing:core:$zxingVersion" + implementation "io.github.java-diff-utils:java-diff-utils:$diffUtilsVersion" implementation "org.iq80.leveldb:leveldb:$levelDbVersion" runtimeOnly "com.h2database:h2:$h2Version" diff --git a/src/main/java/run/halo/app/controller/admin/api/PostController.java b/src/main/java/run/halo/app/controller/admin/api/PostController.java index c406c5e5c1..60858502af 100644 --- a/src/main/java/run/halo/app/controller/admin/api/PostController.java +++ b/src/main/java/run/halo/app/controller/admin/api/PostController.java @@ -28,7 +28,6 @@ import run.halo.app.model.dto.post.BasePostMinimalDTO; import run.halo.app.model.dto.post.BasePostSimpleDTO; import run.halo.app.model.entity.Post; -import run.halo.app.model.enums.PostPermalinkType; import run.halo.app.model.enums.PostStatus; import run.halo.app.model.params.PostContentParam; import run.halo.app.model.params.PostParam; @@ -103,7 +102,7 @@ public Page pageByStatus( @GetMapping("{postId:\\d+}") @ApiOperation("Gets a post") public PostDetailVO getBy(@PathVariable("postId") Integer postId) { - Post post = postService.getById(postId); + Post post = postService.getWithLatestContentById(postId); return postService.convertToDetailVo(post, true); } @@ -131,7 +130,7 @@ public PostDetailVO updateBy(@Valid @RequestBody PostParam postParam, @RequestParam(value = "autoSave", required = false, defaultValue = "false") Boolean autoSave ) { // Get the post info - Post postToUpdate = postService.getById(postId); + Post postToUpdate = postService.getWithLatestContentById(postId); postParam.update(postToUpdate); return postService.updateBy(postToUpdate, postParam.getTagIds(), postParam.getCategoryIds(), @@ -161,9 +160,9 @@ public BasePostDetailDTO updateDraftBy( @PathVariable("postId") Integer postId, @RequestBody PostContentParam contentParam) { // Update draft content - Post post = postService.updateDraftContent(contentParam.getContent(), postId); - - return new BasePostDetailDTO().convertFrom(post); + Post post = postService.updateDraftContent(contentParam.getContent(), + contentParam.getContent(), postId); + return postService.convertToDetail(post); } @DeleteMapping("{postId:\\d+}") diff --git a/src/main/java/run/halo/app/controller/admin/api/SheetController.java b/src/main/java/run/halo/app/controller/admin/api/SheetController.java index 5bed6dcb60..94e32407dc 100644 --- a/src/main/java/run/halo/app/controller/admin/api/SheetController.java +++ b/src/main/java/run/halo/app/controller/admin/api/SheetController.java @@ -63,7 +63,7 @@ public SheetController(SheetService sheetService, @GetMapping("{sheetId:\\d+}") @ApiOperation("Gets a sheet") public SheetDetailVO getBy(@PathVariable("sheetId") Integer sheetId) { - Sheet sheet = sheetService.getById(sheetId); + Sheet sheet = sheetService.getWithLatestContentById(sheetId); return sheetService.convertToDetailVo(sheet); } @@ -98,7 +98,7 @@ public SheetDetailVO updateBy( @RequestBody @Valid SheetParam sheetParam, @RequestParam(value = "autoSave", required = false, defaultValue = "false") Boolean autoSave) { - Sheet sheetToUpdate = sheetService.getById(sheetId); + Sheet sheetToUpdate = sheetService.getWithLatestContentById(sheetId); sheetParam.update(sheetToUpdate); @@ -127,9 +127,9 @@ public BasePostDetailDTO updateDraftBy( @PathVariable("sheetId") Integer sheetId, @RequestBody PostContentParam contentParam) { // Update draft content - Sheet sheet = sheetService.updateDraftContent(contentParam.getContent(), sheetId); - - return new BasePostDetailDTO().convertFrom(sheet); + Sheet sheet = sheetService.updateDraftContent(contentParam.getContent(), + contentParam.getContent(), sheetId); + return sheetService.convertToDetail(sheet); } @DeleteMapping("{sheetId:\\d+}") diff --git a/src/main/java/run/halo/app/controller/content/ContentFeedController.java b/src/main/java/run/halo/app/controller/content/ContentFeedController.java index 822f992de8..633de978d2 100644 --- a/src/main/java/run/halo/app/controller/content/ContentFeedController.java +++ b/src/main/java/run/halo/app/controller/content/ContentFeedController.java @@ -30,6 +30,7 @@ import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer; import run.halo.app.model.dto.CategoryDTO; import run.halo.app.model.entity.Category; +import run.halo.app.model.entity.Content; import run.halo.app.model.entity.Post; import run.halo.app.model.enums.PostStatus; import run.halo.app.model.vo.PostDetailVO; @@ -40,6 +41,7 @@ /** * @author ryanwang + * @author guqing * @date 2019-03-21 */ @Slf4j @@ -242,14 +244,35 @@ private List buildPosts(@NonNull Pageable pageable) { Assert.notNull(pageable, "Pageable must not be null"); Page postPage = postService.pageBy(PostStatus.PUBLISHED, pageable); + Page posts = convertToDetailPageVo(postPage); + return posts.getContent(); + } + + /** + * Converts to a page of detail vo. + * Notes: this method will escape the XML tag characters in the post content and summary. + * + * @param postPage post page must not be null + * @return a page of post detail vo that content and summary escaped. + */ + @NonNull + private Page convertToDetailPageVo(Page postPage) { + Assert.notNull(postPage, "The postPage must not be null."); + + // Populate post content + postPage.getContent().forEach(post -> { + Content postContent = postService.getContentById(post.getId()); + post.setContent(Content.PatchedContent.of(postContent)); + }); + Page posts = postService.convertToDetailVo(postPage); posts.getContent().forEach(postDetailVO -> { - postDetailVO.setFormatContent( - RegExUtils.replaceAll(postDetailVO.getFormatContent(), XML_INVALID_CHAR, "")); + postDetailVO.setContent( + RegExUtils.replaceAll(postDetailVO.getContent(), XML_INVALID_CHAR, "")); postDetailVO .setSummary(RegExUtils.replaceAll(postDetailVO.getSummary(), XML_INVALID_CHAR, "")); }); - return posts.getContent(); + return posts; } /** @@ -266,13 +289,7 @@ private List buildCategoryPosts(@NonNull Pageable pageable, Page postPage = postCategoryService.pagePostBy(category.getId(), PostStatus.PUBLISHED, pageable); - Page posts = postService.convertToDetailVo(postPage); - posts.getContent().forEach(postDetailVO -> { - postDetailVO.setFormatContent( - RegExUtils.replaceAll(postDetailVO.getFormatContent(), XML_INVALID_CHAR, "")); - postDetailVO - .setSummary(RegExUtils.replaceAll(postDetailVO.getSummary(), XML_INVALID_CHAR, "")); - }); + Page posts = convertToDetailPageVo(postPage); return posts.getContent(); } diff --git a/src/main/java/run/halo/app/controller/content/api/PostController.java b/src/main/java/run/halo/app/controller/content/api/PostController.java index 10e82a1b53..2ca313eecf 100644 --- a/src/main/java/run/halo/app/controller/content/api/PostController.java +++ b/src/main/java/run/halo/app/controller/content/api/PostController.java @@ -109,7 +109,7 @@ public PostDetailVO getBy(@PathVariable("postId") Integer postId, if (formatDisabled) { // Clear the format content - postDetailVO.setFormatContent(null); + postDetailVO.setContent(null); } if (sourceDisabled) { @@ -133,7 +133,7 @@ public PostDetailVO getBy(@RequestParam("slug") String slug, if (formatDisabled) { // Clear the format content - postDetailVO.setFormatContent(null); + postDetailVO.setContent(null); } if (sourceDisabled) { diff --git a/src/main/java/run/halo/app/controller/content/api/SheetController.java b/src/main/java/run/halo/app/controller/content/api/SheetController.java index efe5b71369..c8b959d71b 100644 --- a/src/main/java/run/halo/app/controller/content/api/SheetController.java +++ b/src/main/java/run/halo/app/controller/content/api/SheetController.java @@ -78,7 +78,7 @@ public SheetDetailVO getBy(@PathVariable("sheetId") Integer sheetId, if (formatDisabled) { // Clear the format content - sheetDetailVO.setFormatContent(null); + sheetDetailVO.setContent(null); } if (sourceDisabled) { @@ -102,7 +102,7 @@ public SheetDetailVO getBy(@RequestParam("slug") String slug, if (formatDisabled) { // Clear the format content - sheetDetailVO.setFormatContent(null); + sheetDetailVO.setContent(null); } if (sourceDisabled) { diff --git a/src/main/java/run/halo/app/controller/content/model/PostModel.java b/src/main/java/run/halo/app/controller/content/model/PostModel.java index 784c82076a..253adf4887 100644 --- a/src/main/java/run/halo/app/controller/content/model/PostModel.java +++ b/src/main/java/run/halo/app/controller/content/model/PostModel.java @@ -16,11 +16,12 @@ import run.halo.app.exception.ForbiddenException; import run.halo.app.exception.NotFoundException; import run.halo.app.model.entity.Category; +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.Content.PatchedContent; import run.halo.app.model.entity.Post; import run.halo.app.model.entity.PostMeta; import run.halo.app.model.entity.Tag; import run.halo.app.model.enums.EncryptTypeEnum; -import run.halo.app.model.enums.PostEditorType; import run.halo.app.model.enums.PostStatus; import run.halo.app.model.vo.ArchiveYearVO; import run.halo.app.model.vo.PostListVO; @@ -33,12 +34,12 @@ import run.halo.app.service.PostTagService; import run.halo.app.service.TagService; import run.halo.app.service.ThemeService; -import run.halo.app.utils.MarkdownUtils; /** * Post Model * * @author ryanwang + * @author guqing * @date 2020-01-07 */ @Component @@ -116,12 +117,13 @@ public String content(Post post, String token, Model model) { return "common/template/" + POST_PASSWORD_TEMPLATE; } - post = postService.getById(post.getId()); - - if (post.getEditorType().equals(PostEditorType.MARKDOWN)) { - post.setFormatContent(MarkdownUtils.renderHtml(post.getOriginalContent())); + if (StringUtils.isNotBlank(token)) { + post = postService.getWithLatestContentById(post.getId()); } else { - post.setFormatContent(post.getOriginalContent()); + post = postService.getById(post.getId()); + // Set post content + Content postContent = postService.getContentById(post.getId()); + post.setContent(PatchedContent.of(postContent)); } postService.publishVisitEvent(post.getId()); @@ -148,7 +150,7 @@ public String content(Post post, String token, Model model) { model.addAttribute("meta_description", post.getMetaDescription()); } else { model.addAttribute("meta_description", - postService.generateDescription(post.getFormatContent())); + postService.generateDescription(post.getContent().getContent())); } model.addAttribute("is_post", true); diff --git a/src/main/java/run/halo/app/controller/content/model/SheetModel.java b/src/main/java/run/halo/app/controller/content/model/SheetModel.java index 9d1551787b..39e2a4f92f 100644 --- a/src/main/java/run/halo/app/controller/content/model/SheetModel.java +++ b/src/main/java/run/halo/app/controller/content/model/SheetModel.java @@ -6,6 +6,8 @@ import org.springframework.ui.Model; import run.halo.app.cache.AbstractStringCacheStore; import run.halo.app.exception.ForbiddenException; +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.Content.PatchedContent; import run.halo.app.model.entity.Sheet; import run.halo.app.model.entity.SheetMeta; import run.halo.app.model.enums.PostEditorType; @@ -61,6 +63,9 @@ public String content(Sheet sheet, String token, Model model) { if (StringUtils.isEmpty(token)) { sheet = sheetService.getBy(PostStatus.PUBLISHED, sheet.getSlug()); + //Set sheet content + Content content = sheetService.getContentById(sheet.getId()); + sheet.setContent(PatchedContent.of(content)); } else { // verify token String cachedToken = cacheStore.getAny(token, String.class) @@ -69,11 +74,14 @@ public String content(Sheet sheet, String token, Model model) { throw new ForbiddenException("您没有该页面的访问权限"); } // render markdown to html when preview sheet + PatchedContent sheetContent = sheetService.getLatestContentById(sheet.getId()); if (sheet.getEditorType().equals(PostEditorType.MARKDOWN)) { - sheet.setFormatContent(MarkdownUtils.renderHtml(sheet.getOriginalContent())); + sheetContent.setContent( + MarkdownUtils.renderHtml(sheetContent.getOriginalContent())); } else { - sheet.setFormatContent(sheet.getOriginalContent()); + sheetContent.setContent(sheetContent.getOriginalContent()); } + sheet.setContent(sheetContent); } sheetService.publishVisitEvent(sheet.getId()); @@ -94,7 +102,7 @@ public String content(Sheet sheet, String token, Model model) { model.addAttribute("meta_description", sheet.getMetaDescription()); } else { model.addAttribute("meta_description", - sheetService.generateDescription(sheet.getFormatContent())); + sheetService.generateDescription(sheet.getContent().getContent())); } // sheet and post all can use diff --git a/src/main/java/run/halo/app/model/dto/post/BasePostDetailDTO.java b/src/main/java/run/halo/app/model/dto/post/BasePostDetailDTO.java index 90cffa9e70..e6c40b7edc 100644 --- a/src/main/java/run/halo/app/model/dto/post/BasePostDetailDTO.java +++ b/src/main/java/run/halo/app/model/dto/post/BasePostDetailDTO.java @@ -3,11 +3,15 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; +import org.springframework.lang.NonNull; +import run.halo.app.model.entity.BasePost; +import run.halo.app.model.entity.Content.PatchedContent; /** * Base post detail output dto. * * @author johnniang + * @author guqing */ @Data @ToString @@ -16,7 +20,29 @@ public class BasePostDetailDTO extends BasePostSimpleDTO { private String originalContent; - private String formatContent; + private String content; private Long commentCount; + + @Override + @NonNull + @SuppressWarnings("unchecked") + public T convertFrom(@NonNull BasePost domain) { + BasePostDetailDTO postDetailDTO = super.convertFrom(domain); + PatchedContent content = domain.getContent(); + postDetailDTO.setContent(content.getContent()); + postDetailDTO.setOriginalContent(content.getOriginalContent()); + return (T) postDetailDTO; + } + + /** + * Compatible with the formatContent attribute existing in the old version + * it will be removed in v2.0 + * + * @return formatted post content + */ + @Deprecated(since = "1.5.0", forRemoval = true) + public String getFormatContent() { + return this.content; + } } diff --git a/src/main/java/run/halo/app/model/dto/post/BasePostSimpleDTO.java b/src/main/java/run/halo/app/model/dto/post/BasePostSimpleDTO.java index 725ddb8e26..b0aaf8a39d 100644 --- a/src/main/java/run/halo/app/model/dto/post/BasePostSimpleDTO.java +++ b/src/main/java/run/halo/app/model/dto/post/BasePostSimpleDTO.java @@ -32,6 +32,8 @@ public class BasePostSimpleDTO extends BasePostMinimalDTO { private Long wordCount; + private Boolean inProgress; + public boolean isTopped() { return this.topPriority != null && this.topPriority > 0; } diff --git a/src/main/java/run/halo/app/model/entity/BasePost.java b/src/main/java/run/halo/app/model/entity/BasePost.java index 8fbac2cad3..ca6b334422 100644 --- a/src/main/java/run/halo/app/model/entity/BasePost.java +++ b/src/main/java/run/halo/app/model/entity/BasePost.java @@ -13,11 +13,15 @@ import javax.persistence.Table; import javax.persistence.Temporal; import javax.persistence.TemporalType; +import javax.persistence.Transient; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.GenericGenerator; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import run.halo.app.model.entity.Content.PatchedContent; import run.halo.app.model.enums.PostEditorType; import run.halo.app.model.enums.PostStatus; @@ -81,7 +85,7 @@ public class BasePost extends BaseEntity { /** * Original content,not format. */ - @Column(name = "original_content", nullable = false) + @Column(name = "original_content") @Lob private String originalContent; @@ -171,6 +175,19 @@ public class BasePost extends BaseEntity { @ColumnDefault("0") private Long wordCount; + /** + * Post content version. + */ + @ColumnDefault("1") + private Integer version; + + /** + * This extra field don't correspond to any columns in the Post table because we + * don't want to save this value. + */ + @Transient + private PatchedContent content; + @Override public void prePersist() { super.prePersist(); @@ -215,14 +232,6 @@ public void prePersist() { likes = 0L; } - if (originalContent == null) { - originalContent = ""; - } - - if (formatContent == null) { - formatContent = ""; - } - if (editorType == null) { editorType = PostEditorType.MARKDOWN; } @@ -230,6 +239,30 @@ public void prePersist() { if (wordCount == null || wordCount < 0) { wordCount = 0L; } + + if (version == null || version < 0) { + version = 1; + } } + /** + * Gets post content. + * + * @return a {@link PatchedContent} if present,otherwise an empty object + */ + @NonNull + public PatchedContent getContent() { + if (this.content == null) { + PatchedContent patchedContent = new PatchedContent(); + patchedContent.setOriginalContent(""); + patchedContent.setContent(""); + return patchedContent; + } + return content; + } + + @Nullable + public PatchedContent getContentOfNullable() { + return this.content; + } } diff --git a/src/main/java/run/halo/app/model/entity/Content.java b/src/main/java/run/halo/app/model/entity/Content.java new file mode 100644 index 0000000000..d9a9634b68 --- /dev/null +++ b/src/main/java/run/halo/app/model/entity/Content.java @@ -0,0 +1,123 @@ +package run.halo.app.model.entity; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Lob; +import javax.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.annotations.ColumnDefault; +import org.springframework.util.Assert; +import run.halo.app.model.enums.PostStatus; + +/** + * Post content. + * + * @author guqing + * @date 2021-12-18 + */ +@Data +@Entity +@Table(name = "contents") +@EqualsAndHashCode(callSuper = true) +public class Content extends BaseEntity { + + @Id + @Column(name = "post_id") + private Integer id; + + @Column(name = "status") + @ColumnDefault("1") + private PostStatus status; + + /** + * PatchLog from which the current content comes. + */ + private Integer patchLogId; + + /** + *

The patch log head that the current content points to.

+ *
+     *     \-v1-v2-v3(HEAD)-v5(draft)
+     *           \-v4
+     * 
+ * e.g. The latest version is v4. + *
  • At this time, I switch to V3, and the head points to v3. + *
  • When creating a draft (V5) in V3, the head points to V5, + * but the patchLogId of the current content still points to the record + * where V3 is located + */ + private Integer headPatchLogId; + + @Lob + private String content; + + @Lob + private String originalContent; + + @Override + protected void prePersist() { + super.prePersist(); + + if (originalContent == null) { + originalContent = ""; + } + + if (content == null) { + content = ""; + } + } + + /** + * V1 based content differentiation. + * + * @author guqing + * @since 2021-12-20 + */ + @Data + public static class ContentDiff { + + private String diff; + + private String originalDiff; + } + + /** + * The actual content of the post obtained by applying patch to V1 version. + * + * @author guqing + * @since 2021-12-20 + */ + @Data + public static class PatchedContent { + + private String content; + + private String originalContent; + + public PatchedContent() { + } + + public PatchedContent(String content, String originalContent) { + this.content = content; + this.originalContent = originalContent; + } + + /** + * Create {@link PatchedContent} from {@link Content}. + * + * @param content a {@link Content} must not be null + */ + public PatchedContent(Content content) { + Assert.notNull(content, "The content must not be null."); + this.content = content.getContent(); + this.originalContent = content.getOriginalContent(); + } + + public static PatchedContent of(Content postContent) { + return new PatchedContent(postContent); + } + } + +} diff --git a/src/main/java/run/halo/app/model/entity/ContentPatchLog.java b/src/main/java/run/halo/app/model/entity/ContentPatchLog.java new file mode 100644 index 0000000000..9c99a0437e --- /dev/null +++ b/src/main/java/run/halo/app/model/entity/ContentPatchLog.java @@ -0,0 +1,80 @@ +package run.halo.app.model.entity; + +import java.util.Date; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.Lob; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.GenericGenerator; +import run.halo.app.model.enums.PostStatus; + +/** + * Content patch log entity. + * + * @author guqing + * @date 2021-12-18 + */ +@Data +@Entity +@EqualsAndHashCode(callSuper = true) +@Table(name = "content_patch_logs", indexes = { + @Index(name = "idx_post_id", columnList = "post_id"), + @Index(name = "idx_status", columnList = "status"), + @Index(name = "idx_version", columnList = "version")}) +public class ContentPatchLog extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY, generator = "custom-id") + @GenericGenerator(name = "custom-id", strategy = "run.halo.app.model.entity.support" + + ".CustomIdGenerator") + private Integer id; + + @Column(name = "post_id") + private Integer postId; + + @Lob + @Column(name = "content_diff") + private String contentDiff; + + @Lob + @Column(name = "original_content_diff") + private String originalContentDiff; + + @Column(name = "version", nullable = false) + private Integer version; + + @ColumnDefault("1") + @Column(name = "status") + private PostStatus status; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "publish_time") + private Date publishTime; + + /** + * Current version of the source patch log id, default value is 0. + */ + @Column(name = "source_id", nullable = false) + private Integer sourceId; + + @Override + protected void prePersist() { + super.prePersist(); + if (version == null) { + version = 1; + } + + if (sourceId == null) { + sourceId = 0; + } + } +} diff --git a/src/main/java/run/halo/app/model/params/PostParam.java b/src/main/java/run/halo/app/model/params/PostParam.java index 9048f91c55..b21a9fa6c5 100644 --- a/src/main/java/run/halo/app/model/params/PostParam.java +++ b/src/main/java/run/halo/app/model/params/PostParam.java @@ -10,10 +10,13 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.util.CollectionUtils; import run.halo.app.model.dto.base.InputConverter; +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.Content.PatchedContent; import run.halo.app.model.entity.Post; import run.halo.app.model.entity.PostMeta; import run.halo.app.model.enums.PostEditorType; import run.halo.app.model.enums.PostStatus; +import run.halo.app.utils.MarkdownUtils; import run.halo.app.utils.SlugUtils; /** @@ -80,7 +83,9 @@ public Post convertTo() { editorType = PostEditorType.MARKDOWN; } - return InputConverter.super.convertTo(); + Post post = InputConverter.super.convertTo(); + populateContent(post); + return post; } @Override @@ -94,7 +99,7 @@ public void update(Post post) { if (null == editorType) { editorType = PostEditorType.MARKDOWN; } - + populateContent(post); InputConverter.super.update(post); } @@ -110,4 +115,15 @@ public Set getPostMetas() { } return postMetaSet; } + + private void populateContent(Post post) { + Content postContent = new Content(); + if (PostEditorType.MARKDOWN.equals(editorType)) { + postContent.setContent(MarkdownUtils.renderHtml(originalContent)); + } else { + postContent.setContent(postContent.getOriginalContent()); + } + postContent.setOriginalContent(originalContent); + post.setContent(PatchedContent.of(postContent)); + } } diff --git a/src/main/java/run/halo/app/model/params/SheetParam.java b/src/main/java/run/halo/app/model/params/SheetParam.java index b0b994fed8..65a3c2f21b 100644 --- a/src/main/java/run/halo/app/model/params/SheetParam.java +++ b/src/main/java/run/halo/app/model/params/SheetParam.java @@ -10,10 +10,13 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.util.CollectionUtils; import run.halo.app.model.dto.base.InputConverter; +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.Content.PatchedContent; import run.halo.app.model.entity.Sheet; import run.halo.app.model.entity.SheetMeta; import run.halo.app.model.enums.PostEditorType; import run.halo.app.model.enums.PostStatus; +import run.halo.app.utils.MarkdownUtils; import run.halo.app.utils.SlugUtils; /** @@ -21,6 +24,7 @@ * * @author johnniang * @author ryanwang + * @author guqing * @date 2019-4-24 */ @Data @@ -75,7 +79,9 @@ public Sheet convertTo() { editorType = PostEditorType.MARKDOWN; } - return InputConverter.super.convertTo(); + Sheet sheet = InputConverter.super.convertTo(); + populateContent(sheet); + return sheet; } @Override @@ -89,7 +95,7 @@ public void update(Sheet sheet) { if (null == editorType) { editorType = PostEditorType.MARKDOWN; } - + populateContent(sheet); InputConverter.super.update(sheet); } @@ -105,4 +111,15 @@ public Set getSheetMetas() { } return sheetMetasSet; } + + private void populateContent(Sheet sheet) { + Content sheetContent = new Content(); + if (PostEditorType.MARKDOWN.equals(editorType)) { + sheetContent.setContent(MarkdownUtils.renderHtml(originalContent)); + } else { + sheetContent.setContent(sheetContent.getOriginalContent()); + } + sheetContent.setOriginalContent(originalContent); + sheet.setContent(PatchedContent.of(sheetContent)); + } } diff --git a/src/main/java/run/halo/app/repository/ContentPatchLogRepository.java b/src/main/java/run/halo/app/repository/ContentPatchLogRepository.java new file mode 100644 index 0000000000..c29d28492e --- /dev/null +++ b/src/main/java/run/halo/app/repository/ContentPatchLogRepository.java @@ -0,0 +1,73 @@ +package run.halo.app.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.Query; +import run.halo.app.model.entity.ContentPatchLog; +import run.halo.app.model.enums.PostStatus; +import run.halo.app.repository.base.BaseRepository; + +/** + * Content patch log repository. + * + * @author guqing + * @since 2022-01-04 + */ +public interface ContentPatchLogRepository extends BaseRepository { + + /** + * Finds the latest version by post id and post status. + * + * @param postId post id + * @param status record status to query + * @return a {@link ContentPatchLog} record + */ + ContentPatchLog findFirstByPostIdAndStatusOrderByVersionDesc(Integer postId, PostStatus status); + + /** + * Finds the latest version by post id. + * + * @param postId post id to query + * @return a {@link ContentPatchLog} record of the latest version queried bby post id + */ + ContentPatchLog findFirstByPostIdOrderByVersionDesc(Integer postId); + + /** + * Finds all records below the specified version number by post id and status. + * + * @param postId post id + * @param version version number + * @param status record status + * @return records below the specified version + */ + @Query("from ContentPatchLog c where c.postId = :postId and c.version <= :version and c" + + ".status=:status order by c.version desc") + List findByPostIdAndStatusAndVersionLessThan(Integer postId, Integer version, + PostStatus status); + + /** + * Finds by post id and version + * + * @param postId post id + * @param version version number + * @return a {@link ContentPatchLog} record queried by post id and version + */ + ContentPatchLog findByPostIdAndVersion(Integer postId, Integer version); + + /** + * Finds all records by post id and status and based on version number descending order + * + * @param postId post id + * @param status status + * @return a list of {@link ContentPatchLog} queried by post id and status + */ + List findAllByPostIdAndStatusOrderByVersionDesc(Integer postId, + PostStatus status); + + /** + * Finds all records by post id. + * + * @param postId post id to query + * @return a list of {@link ContentPatchLog} queried by post id + */ + List findAllByPostId(Integer postId); +} diff --git a/src/main/java/run/halo/app/repository/ContentRepository.java b/src/main/java/run/halo/app/repository/ContentRepository.java new file mode 100644 index 0000000000..7389e6c577 --- /dev/null +++ b/src/main/java/run/halo/app/repository/ContentRepository.java @@ -0,0 +1,14 @@ +package run.halo.app.repository; + +import run.halo.app.model.entity.Content; +import run.halo.app.repository.base.BaseRepository; + +/** + * Base content repository. + * + * @author guqing + * @date 2021-12-18 + */ +public interface ContentRepository extends BaseRepository { + +} diff --git a/src/main/java/run/halo/app/repository/base/BasePostRepository.java b/src/main/java/run/halo/app/repository/base/BasePostRepository.java index 7771f6bd2a..1dab5392eb 100644 --- a/src/main/java/run/halo/app/repository/base/BasePostRepository.java +++ b/src/main/java/run/halo/app/repository/base/BasePostRepository.java @@ -215,18 +215,6 @@ Page findAllByStatusAndVisitsAfter(@NonNull PostStatus status, @NonNull Lo @Query("update BasePost p set p.likes = p.likes + :likes where p.id = :postId") int updateLikes(@Param("likes") long likes, @Param("postId") @NonNull Integer postId); - /** - * Updates post original content. - * - * @param content content could be blank but disallow to be null - * @param postId post id must not be null - * @return updated rows - */ - @Modifying - @Query("update BasePost p set p.originalContent = :content where p.id = :postId") - int updateOriginalContent(@Param("content") @NonNull String content, - @Param("postId") @NonNull Integer postId); - /** * Updates post status by post id. * @@ -238,16 +226,4 @@ int updateOriginalContent(@Param("content") @NonNull String content, @Query("update BasePost p set p.status = :status where p.id = :postId") int updateStatus(@Param("status") @NonNull PostStatus status, @Param("postId") @NonNull Integer postId); - - /** - * Updates post format content by post id. - * - * @param formatContent format content must not be null. - * @param postId post id must not be null. - * @return updated rows. - */ - @Modifying - @Query("update BasePost p set p.formatContent = :formatContent where p.id = :postId") - int updateFormatContent(@Param("formatContent") @NonNull String formatContent, - @Param("postId") @NonNull Integer postId); } diff --git a/src/main/java/run/halo/app/service/ContentPatchLogService.java b/src/main/java/run/halo/app/service/ContentPatchLogService.java new file mode 100644 index 0000000000..1c30ce1f43 --- /dev/null +++ b/src/main/java/run/halo/app/service/ContentPatchLogService.java @@ -0,0 +1,92 @@ +package run.halo.app.service; + +import java.util.List; +import run.halo.app.exception.NotFoundException; +import run.halo.app.model.entity.Content.ContentDiff; +import run.halo.app.model.entity.Content.PatchedContent; +import run.halo.app.model.entity.ContentPatchLog; + +/** + * Content patch log service. + * + * @author guqing + * @since 2022-01-04 + */ +public interface ContentPatchLogService { + + /** + * Create or update content patch log by post content. + * + * @param postId post id must not be null. + * @param content post formatted content must not be null. + * @param originalContent post original content must not be null. + * @return created or updated content patch log record. + */ + ContentPatchLog createOrUpdate(Integer postId, String content, String originalContent); + + /** + * Apply content patch to v1. + * + * @param patchLog content patch log + * @return real content of the post. + */ + PatchedContent applyPatch(ContentPatchLog patchLog); + + /** + * generate content diff based v1. + * + * @param postId post id must not be null. + * @param content post formatted content must not be null. + * @param originalContent post original content must not be null. + * @return a content diff object. + */ + ContentDiff generateDiff(Integer postId, String content, String originalContent); + + /** + * Creates or updates the {@link ContentPatchLog}. + * + * @param contentPatchLog param to create or update + */ + void save(ContentPatchLog contentPatchLog); + + /** + * Gets the patch log record of the draft status of the content by post id. + * + * @param postId post id. + * @return content patch log record. + */ + ContentPatchLog getDraftByPostId(Integer postId); + + /** + * Gets content patch log by id. + * + * @param id id + * @return a content patch log + * @throws NotFoundException if record not found. + */ + ContentPatchLog getById(Integer id); + + /** + * Gets content patch log by post id. + * + * @param postId a post id + * @return a real content of post. + */ + PatchedContent getByPostId(Integer postId); + + /** + * Gets real post content by id. + * + * @param id id + * @return Actual content of patches applied based on V1 version. + */ + PatchedContent getPatchedContentById(Integer id); + + /** + * Permanently delete post contentPatchLog by post id. + * + * @param postId post id + * @return deleted post content patch logs. + */ + List removeByPostId(Integer postId); +} diff --git a/src/main/java/run/halo/app/service/ContentService.java b/src/main/java/run/halo/app/service/ContentService.java new file mode 100644 index 0000000000..8919aff7d2 --- /dev/null +++ b/src/main/java/run/halo/app/service/ContentService.java @@ -0,0 +1,45 @@ +package run.halo.app.service; + +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.ContentPatchLog; +import run.halo.app.service.base.CrudService; + +/** + * Base content service interface. + * + * @author guqing + * @date 2022-01-07 + */ +public interface ContentService extends CrudService { + + /** + *

    Publish post content.

    + *
      + *
    • Copy the latest record in {@link ContentPatchLog} to the {@link Content}. + *
    • Set status to PUBLISHED. + *
    • Set patchLogId to the latest. + *
    + * + * @param postId post id + * @return published content record. + */ + Content publishContent(Integer postId); + + /** + * If the content record does not exist, it will be created; otherwise, it will be updated. + * + * @param postId post id + * @param content post format content + * @param originalContent post original content + */ + void createOrUpdateDraftBy(Integer postId, String content, String originalContent); + + /** + * There is a draft being drafted. + * + * @param postId post id + * @return {@code true} if find a draft record from {@link ContentPatchLog}, + * otherwise {@code false} + */ + Boolean draftingInProgress(Integer postId); +} diff --git a/src/main/java/run/halo/app/service/base/BasePostService.java b/src/main/java/run/halo/app/service/base/BasePostService.java index 23502dcd51..7ec2daf886 100644 --- a/src/main/java/run/halo/app/service/base/BasePostService.java +++ b/src/main/java/run/halo/app/service/base/BasePostService.java @@ -10,6 +10,8 @@ import run.halo.app.model.dto.post.BasePostMinimalDTO; import run.halo.app.model.dto.post.BasePostSimpleDTO; import run.halo.app.model.entity.BasePost; +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.Content.PatchedContent; import run.halo.app.model.enums.PostStatus; /** @@ -17,6 +19,7 @@ * * @author johnniang * @author ryanwang + * @author guqing * @date 2019-04-24 */ public interface BasePostService extends CrudService { @@ -52,6 +55,15 @@ public interface BasePostService extends CrudService extends CrudService extends CrudService private final OptionService optionService; + private final ContentService contentService; + + private final ContentPatchLogService contentPatchLogService; + private static final Pattern summaryPattern = Pattern.compile("\t|\r|\n"); private static final Pattern BLANK_PATTERN = Pattern.compile("\\s"); public BasePostServiceImpl(BasePostRepository basePostRepository, - OptionService optionService) { + OptionService optionService, + ContentService contentService, + ContentPatchLogService contentPatchLogService) { super(basePostRepository); this.basePostRepository = basePostRepository; this.optionService = optionService; + this.contentService = contentService; + this.contentPatchLogService = contentPatchLogService; } @Override @@ -111,6 +124,11 @@ public POST getBy(PostStatus status, Integer id) { return postOptional.orElseThrow(() -> new NotFoundException("查询不到该文章的信息").setErrorData(id)); } + @Override + public PatchedContent getLatestContentById(Integer id) { + return contentPatchLogService.getByPostId(id); + } + @Override public List listAllBy(PostStatus status) { Assert.notNull(status, "Post status must not be null"); @@ -283,33 +301,46 @@ public void increaseLike(Integer postId) { @Transactional public POST createOrUpdateBy(POST post) { Assert.notNull(post, "Post must not be null"); - - String originalContent = post.getOriginalContent(); - - // CS304 issue link : https://github.com/halo-dev/halo/issues/1224 - // Render content and set word count - if (post.getEditorType().equals(PostEditorType.MARKDOWN)) { - post.setFormatContent(MarkdownUtils.renderHtml(post.getOriginalContent())); - - post.setWordCount(htmlFormatWordCount(post.getFormatContent())); - } else { - post.setFormatContent(originalContent); - - post.setWordCount(htmlFormatWordCount(originalContent)); + PostStatus postStatus = post.getStatus(); + PatchedContent postContent = post.getContent(); + String originalContent = postContent.getOriginalContent(); + if (originalContent != null) { + // CS304 issue link : https://github.com/halo-dev/halo/issues/1224 + // Render content and set word count + if (post.getEditorType().equals(PostEditorType.MARKDOWN)) { + postContent.setContent(MarkdownUtils.renderHtml(originalContent)); + + post.setWordCount(htmlFormatWordCount(postContent.getContent())); + } else { + postContent.setContent(originalContent); + + post.setWordCount(htmlFormatWordCount(originalContent)); + } + post.setContent(postContent); } + POST savedPost; // Create or update post if (ServiceUtils.isEmptyId(post.getId())) { // The sheet will be created - return create(post); + savedPost = create(post); + contentService.createOrUpdateDraftBy(post.getId(), + postContent.getContent(), postContent.getOriginalContent()); + } else { + // The sheet will be updated + // Set edit time + post.setEditTime(DateUtils.now()); + contentService.createOrUpdateDraftBy(post.getId(), + postContent.getContent(), postContent.getOriginalContent()); + // Update it + savedPost = update(post); } - // The sheet will be updated - // Set edit time - post.setEditTime(DateUtils.now()); - - // Update it - return update(post); + if (PostStatus.PUBLISHED.equals(post.getStatus()) + || PostStatus.INTIMATE.equals(post.getStatus())) { + contentService.publishContent(post.getId()); + } + return savedPost; } @Override @@ -319,8 +350,11 @@ public POST filterIfEncrypt(POST post) { if (StringUtils.isNotBlank(post.getPassword())) { String tip = "The post is encrypted by author"; post.setSummary(tip); - post.setOriginalContent(tip); - post.setFormatContent(tip); + + Content postContent = new Content(); + postContent.setContent(tip); + postContent.setOriginalContent(tip); + post.setContent(PatchedContent.of(postContent)); } return post; @@ -358,9 +392,11 @@ public BasePostSimpleDTO convertToSimple(POST post) { BasePostSimpleDTO basePostSimpleDTO = new BasePostSimpleDTO().convertFrom(post); // Set summary - if (StringUtils.isBlank(basePostSimpleDTO.getSummary())) { - basePostSimpleDTO.setSummary(generateSummary(post.getFormatContent())); - } + generateAndSetSummaryIfAbsent(post, basePostSimpleDTO); + + // Post currently drafting in process + Boolean isInProcess = contentService.draftingInProgress(post.getId()); + basePostSimpleDTO.setInProgress(isInProcess); return basePostSimpleDTO; } @@ -387,30 +423,33 @@ public Page convertToSimple(Page postPage) { public BasePostDetailDTO convertToDetail(POST post) { Assert.notNull(post, "Post must not be null"); - return new BasePostDetailDTO().convertFrom(post); + BasePostDetailDTO postDetail = new BasePostDetailDTO().convertFrom(post); + + // Post currently drafting in process + Boolean isInProcess = contentService.draftingInProgress(post.getId()); + postDetail.setInProgress(isInProcess); + + return postDetail; } @Override - @Transactional - public POST updateDraftContent(String content, Integer postId) { + @Transactional(rollbackFor = Exception.class) + public POST updateDraftContent(String content, String originalContent, Integer postId) { Assert.isTrue(!ServiceUtils.isEmptyId(postId), "Post id must not be empty"); - if (content == null) { - content = ""; + if (originalContent == null) { + originalContent = ""; } POST post = getById(postId); - - if (!StringUtils.equals(content, post.getOriginalContent())) { - // If content is different with database, then update database - int updatedRows = basePostRepository.updateOriginalContent(content, postId); - if (updatedRows != 1) { - throw new ServiceException( - "Failed to update original content of post with id " + postId); - } - // Set the content - post.setOriginalContent(content); + if (PostEditorType.MARKDOWN.equals(post.getEditorType())) { + content = MarkdownUtils.renderHtml(originalContent); + } else { + content = originalContent; } + contentService.createOrUpdateDraftBy(postId, content, originalContent); + + post.setContent(getLatestContentById(postId)); return post; } @@ -438,15 +477,8 @@ public POST updateStatus(PostStatus status, Integer postId) { // Sync content if (PostStatus.PUBLISHED.equals(status)) { // If publish this post, then convert the formatted content - String formatContent = MarkdownUtils.renderHtml(post.getOriginalContent()); - int updatedRows = basePostRepository.updateFormatContent(formatContent, postId); - - if (updatedRows != 1) { - throw new ServiceException( - "Failed to update post format content of post with id " + postId); - } - - post.setFormatContent(formatContent); + Content postContent = contentService.publishContent(postId); + post.setContent(PatchedContent.of(postContent)); } return post; @@ -495,6 +527,12 @@ public POST update(POST post) { return super.update(post); } + @Override + public Content getContentById(Integer postId) { + Assert.notNull(postId, "The postId must not be null."); + return contentService.getById(postId); + } + /** * Check if the slug is exist. * @@ -535,6 +573,22 @@ protected String generateSummary(@NonNull String htmlContent) { return StringUtils.substring(text, 0, summaryLength); } + protected void generateAndSetSummaryIfAbsent(POST post, + T postVo) { + Assert.notNull(post, "The post must not be null."); + if (StringUtils.isNotBlank(postVo.getSummary())) { + return; + } + + PatchedContent patchedContent = post.getContentOfNullable(); + if (patchedContent == null) { + Content postContent = getContentById(post.getId()); + postVo.setSummary(generateSummary(postContent.getContent())); + } else { + postVo.setSummary(generateSummary(patchedContent.getContent())); + } + } + // CS304 issue link : https://github.com/halo-dev/halo/issues/1224 /** diff --git a/src/main/java/run/halo/app/service/impl/ContentPatchLogServiceImpl.java b/src/main/java/run/halo/app/service/impl/ContentPatchLogServiceImpl.java new file mode 100644 index 0000000000..ef8ae934b5 --- /dev/null +++ b/src/main/java/run/halo/app/service/impl/ContentPatchLogServiceImpl.java @@ -0,0 +1,238 @@ +package run.halo.app.service.impl; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.springframework.data.domain.Example; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; +import run.halo.app.exception.NotFoundException; +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.Content.ContentDiff; +import run.halo.app.model.entity.Content.PatchedContent; +import run.halo.app.model.entity.ContentPatchLog; +import run.halo.app.model.enums.PostStatus; +import run.halo.app.repository.ContentPatchLogRepository; +import run.halo.app.repository.ContentRepository; +import run.halo.app.service.ContentPatchLogService; +import run.halo.app.utils.PatchUtils; + +/** + * Content patch log service. + * + * @author guqing + * @since 2022-01-04 + */ +@Service +public class ContentPatchLogServiceImpl implements ContentPatchLogService { + + /** + * base version of content patch log. + */ + public static final int BASE_VERSION = 1; + + private final ContentPatchLogRepository contentPatchLogRepository; + + private final ContentRepository contentRepository; + + public ContentPatchLogServiceImpl(ContentPatchLogRepository contentPatchLogRepository, + ContentRepository contentRepository) { + this.contentPatchLogRepository = contentPatchLogRepository; + this.contentRepository = contentRepository; + } + + /** + * Gets post content by post id. + * + * @param postId post id + * @return a post content of postId + */ + protected Optional getContentByPostId(Integer postId) { + return contentRepository.findById(postId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ContentPatchLog createOrUpdate(Integer postId, String content, String originalContent) { + Integer version = getVersionByPostId(postId); + if (existDraftBy(postId)) { + return updateDraftBy(postId, content, originalContent); + } + return createDraftContent(postId, version, content, originalContent); + } + + private Integer getVersionByPostId(Integer postId) { + Integer version; + ContentPatchLog latestPatchLog = + contentPatchLogRepository.findFirstByPostIdOrderByVersionDesc(postId); + + if (latestPatchLog == null) { + // There is no patchLog record + version = 1; + } else if (PostStatus.PUBLISHED.equals(latestPatchLog.getStatus())) { + // There is no draft, a draft record needs to be created + // so the version number needs to be incremented + version = latestPatchLog.getVersion() + 1; + } else { + // There is a draft record,Only the content needs to be updated + // so the version number remains unchanged + version = latestPatchLog.getVersion(); + } + return version; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void save(ContentPatchLog contentPatchLog) { + contentPatchLogRepository.save(contentPatchLog); + } + + private ContentPatchLog createDraftContent(Integer postId, Integer version, + String formatContent, String originalContent) { + ContentPatchLog contentPatchLog = + buildPatchLog(postId, version, formatContent, originalContent); + + // Sets the upstream version of the current version. + Integer sourceId = getContentByPostId(postId) + .map(Content::getPatchLogId) + .orElse(0); + contentPatchLog.setSourceId(sourceId); + + contentPatchLogRepository.save(contentPatchLog); + return contentPatchLog; + } + + private ContentPatchLog buildPatchLog(Integer postId, Integer version, String formatContent, + String originalContent) { + ContentPatchLog contentPatchLog = new ContentPatchLog(); + if (Objects.equals(version, BASE_VERSION)) { + contentPatchLog.setContentDiff(formatContent); + contentPatchLog.setOriginalContentDiff(originalContent); + } else { + ContentDiff contentDiff = generateDiff(postId, formatContent, originalContent); + contentPatchLog.setContentDiff(contentDiff.getDiff()); + contentPatchLog.setOriginalContentDiff(contentDiff.getOriginalDiff()); + } + contentPatchLog.setPostId(postId); + contentPatchLog.setStatus(PostStatus.DRAFT); + ContentPatchLog latestPatchLog = + contentPatchLogRepository.findFirstByPostIdOrderByVersionDesc(postId); + if (latestPatchLog != null) { + contentPatchLog.setVersion(latestPatchLog.getVersion() + 1); + } else { + contentPatchLog.setVersion(BASE_VERSION); + } + + return contentPatchLog; + } + + private boolean existDraftBy(Integer postId) { + ContentPatchLog contentPatchLog = new ContentPatchLog(); + contentPatchLog.setPostId(postId); + contentPatchLog.setStatus(PostStatus.DRAFT); + Example example = Example.of(contentPatchLog); + return contentPatchLogRepository.exists(example); + } + + private ContentPatchLog updateDraftBy(Integer postId, String formatContent, + String originalContent) { + ContentPatchLog draftPatchLog = + contentPatchLogRepository.findFirstByPostIdAndStatusOrderByVersionDesc(postId, + PostStatus.DRAFT); + // Is the draft version 1 + if (Objects.equals(draftPatchLog.getVersion(), BASE_VERSION)) { + // If it is V1, modify the content directly. + draftPatchLog.setContentDiff(formatContent); + draftPatchLog.setOriginalContentDiff(originalContent); + contentPatchLogRepository.save(draftPatchLog); + return draftPatchLog; + } + // Generate content diff. + ContentDiff contentDiff = generateDiff(postId, formatContent, originalContent); + draftPatchLog.setContentDiff(contentDiff.getDiff()); + draftPatchLog.setOriginalContentDiff(contentDiff.getOriginalDiff()); + contentPatchLogRepository.save(draftPatchLog); + return draftPatchLog; + } + + @Override + public PatchedContent applyPatch(ContentPatchLog patchLog) { + Assert.notNull(patchLog, "The contentRecord must not be null."); + Assert.notNull(patchLog.getVersion(), "The contentRecord.version must not be null."); + Assert.notNull(patchLog.getPostId(), "The contentRecord.postId must not be null."); + + PatchedContent patchedContent = new PatchedContent(); + if (patchLog.getVersion() == BASE_VERSION) { + patchedContent.setContent(patchLog.getContentDiff()); + patchedContent.setOriginalContent(patchLog.getOriginalContentDiff()); + return patchedContent; + } + + ContentPatchLog baseContentRecord = + contentPatchLogRepository.findByPostIdAndVersion(patchLog.getPostId(), BASE_VERSION); + + String content = PatchUtils.restoreContent(patchLog.getContentDiff(), + baseContentRecord.getContentDiff()); + patchedContent.setContent(content); + + String originalContent = PatchUtils.restoreContent(patchLog.getOriginalContentDiff(), + baseContentRecord.getOriginalContentDiff()); + patchedContent.setOriginalContent(originalContent); + return patchedContent; + } + + @Override + public ContentDiff generateDiff(Integer postId, String formatContent, String originalContent) { + ContentPatchLog basePatchLog = + contentPatchLogRepository.findByPostIdAndVersion(postId, BASE_VERSION); + + ContentDiff contentDiff = new ContentDiff(); + String contentChanges = + PatchUtils.diffToJsonPatch(basePatchLog.getContentDiff(), formatContent); + contentDiff.setDiff(contentChanges); + + String originalContentChanges = + PatchUtils.diffToJsonPatch(basePatchLog.getOriginalContentDiff(), originalContent); + contentDiff.setOriginalDiff(originalContentChanges); + return contentDiff; + } + + @Override + public ContentPatchLog getDraftByPostId(Integer postId) { + return contentPatchLogRepository.findFirstByPostIdAndStatusOrderByVersionDesc(postId, + PostStatus.DRAFT); + } + + @Override + public PatchedContent getByPostId(Integer postId) { + ContentPatchLog contentPatchLog = + contentPatchLogRepository.findFirstByPostIdOrderByVersionDesc(postId); + if (contentPatchLog == null) { + throw new NotFoundException( + "Post content patch log was not found or has been deleted."); + } + return applyPatch(contentPatchLog); + } + + @Override + public PatchedContent getPatchedContentById(Integer id) { + ContentPatchLog contentPatchLog = getById(id); + return applyPatch(contentPatchLog); + } + + @Override + public ContentPatchLog getById(Integer id) { + return contentPatchLogRepository.findById(id) + .orElseThrow(() -> new NotFoundException( + "Post content patch log was not found or has been deleted.")); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List removeByPostId(Integer postId) { + List patchLogsToDelete = contentPatchLogRepository.findAllByPostId(postId); + contentPatchLogRepository.deleteAllInBatch(patchLogsToDelete); + return patchLogsToDelete; + } +} diff --git a/src/main/java/run/halo/app/service/impl/ContentServiceImpl.java b/src/main/java/run/halo/app/service/impl/ContentServiceImpl.java new file mode 100644 index 0000000000..8f38c023a7 --- /dev/null +++ b/src/main/java/run/halo/app/service/impl/ContentServiceImpl.java @@ -0,0 +1,107 @@ +package run.halo.app.service.impl; + +import java.util.Date; +import java.util.Objects; +import java.util.Optional; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; +import run.halo.app.exception.NotFoundException; +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.Content.PatchedContent; +import run.halo.app.model.entity.ContentPatchLog; +import run.halo.app.model.enums.PostStatus; +import run.halo.app.repository.ContentRepository; +import run.halo.app.service.ContentPatchLogService; +import run.halo.app.service.ContentService; +import run.halo.app.service.base.AbstractCrudService; + +/** + * Base content service implementation. + * + * @author guqing + * @date 2022-01-07 + */ +@Service +public class ContentServiceImpl extends AbstractCrudService + implements ContentService { + + private final ContentRepository contentRepository; + + private final ContentPatchLogService contentPatchLogService; + + protected ContentServiceImpl(ContentRepository contentRepository, + ContentPatchLogService contentPatchLogService) { + super(contentRepository); + this.contentRepository = contentRepository; + this.contentPatchLogService = contentPatchLogService; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void createOrUpdateDraftBy(Integer postId, String content, + String originalContent) { + Assert.notNull(postId, "The postId must not be null."); + // First, we need to save the contentPatchLog + ContentPatchLog contentPatchLog = + contentPatchLogService.createOrUpdate(postId, content, originalContent); + + // then update the value of headPatchLogId field. + Optional savedContentOptional = contentRepository.findById(postId); + if (savedContentOptional.isPresent()) { + Content savedContent = savedContentOptional.get(); + savedContent.setHeadPatchLogId(contentPatchLog.getId()); + contentRepository.save(savedContent); + return; + } + + // If the content record does not exist, it needs to be created + Content postContent = new Content(); + postContent.setPatchLogId(contentPatchLog.getId()); + postContent.setContent(content); + postContent.setOriginalContent(originalContent); + postContent.setId(postId); + postContent.setStatus(PostStatus.DRAFT); + postContent.setHeadPatchLogId(contentPatchLog.getId()); + contentRepository.save(postContent); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Content publishContent(Integer postId) { + ContentPatchLog contentPatchLog = contentPatchLogService.getDraftByPostId(postId); + if (contentPatchLog == null) { + return contentRepository.getById(postId); + } + contentPatchLog.setStatus(PostStatus.PUBLISHED); + contentPatchLog.setPublishTime(new Date()); + contentPatchLogService.save(contentPatchLog); + + Content postContent = getById(postId); + postContent.setPatchLogId(contentPatchLog.getId()); + postContent.setStatus(PostStatus.PUBLISHED); + + PatchedContent patchedContent = contentPatchLogService.applyPatch(contentPatchLog); + postContent.setContent(patchedContent.getContent()); + postContent.setOriginalContent(patchedContent.getOriginalContent()); + + contentRepository.save(postContent); + + return postContent; + } + + @Override + @NonNull + public Content getById(@NonNull Integer postId) { + Assert.notNull(postId, "The postId must not be null."); + return contentRepository.findById(postId) + .orElseThrow(() -> new NotFoundException("content was not found or has been deleted")); + } + + @Override + public Boolean draftingInProgress(Integer postId) { + ContentPatchLog draft = contentPatchLogService.getDraftByPostId(postId); + return Objects.nonNull(draft); + } +} diff --git a/src/main/java/run/halo/app/service/impl/PostServiceImpl.java b/src/main/java/run/halo/app/service/impl/PostServiceImpl.java index e5effb7108..4dcc021a36 100644 --- a/src/main/java/run/halo/app/service/impl/PostServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/PostServiceImpl.java @@ -16,13 +16,11 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; -import javax.persistence.criteria.CriteriaBuilder.In; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; import javax.persistence.criteria.Subquery; import javax.validation.constraints.NotNull; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; @@ -41,6 +39,8 @@ import run.halo.app.model.dto.post.BasePostMinimalDTO; import run.halo.app.model.dto.post.BasePostSimpleDTO; import run.halo.app.model.entity.Category; +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.Content.PatchedContent; import run.halo.app.model.entity.Post; import run.halo.app.model.entity.PostCategory; import run.halo.app.model.entity.PostComment; @@ -63,6 +63,8 @@ import run.halo.app.repository.base.BasePostRepository; import run.halo.app.service.AuthorizationService; import run.halo.app.service.CategoryService; +import run.halo.app.service.ContentPatchLogService; +import run.halo.app.service.ContentService; import run.halo.app.service.OptionService; import run.halo.app.service.PostCategoryService; import run.halo.app.service.PostCommentService; @@ -99,6 +101,8 @@ public class PostServiceImpl extends BasePostServiceImpl implements PostSe private final PostTagService postTagService; + private final ContentService postContentService; + private final PostCategoryService postCategoryService; private final PostCommentService postCommentService; @@ -111,6 +115,8 @@ public class PostServiceImpl extends BasePostServiceImpl implements PostSe private final AuthorizationService authorizationService; + private final ContentPatchLogService postContentPatchLogService; + public PostServiceImpl(BasePostRepository basePostRepository, OptionService optionService, PostRepository postRepository, @@ -121,8 +127,10 @@ public PostServiceImpl(BasePostRepository basePostRepository, PostCommentService postCommentService, ApplicationEventPublisher eventPublisher, PostMetaService postMetaService, - AuthorizationService authorizationService) { - super(basePostRepository, optionService); + AuthorizationService authorizationService, + ContentService contentService, + ContentPatchLogService contentPatchLogService) { + super(basePostRepository, optionService, contentService, contentPatchLogService); this.postRepository = postRepository; this.tagService = tagService; this.categoryService = categoryService; @@ -133,6 +141,8 @@ public PostServiceImpl(BasePostRepository basePostRepository, this.postMetaService = postMetaService; this.optionService = optionService; this.authorizationService = authorizationService; + this.postContentService = contentService; + this.postContentPatchLogService = contentPatchLogService; } @Override @@ -269,6 +279,11 @@ public Post getBy(Integer year, Integer month, Integer day, String slug, PostSta .orElseThrow(() -> new NotFoundException("查询不到该文章的信息").setErrorData(slug)); } + @Override + public PatchedContent getLatestContentById(Integer id) { + return postContentPatchLogService.getByPostId(id); + } + @Override public List removeByIds(Collection ids) { if (CollectionUtils.isEmpty(ids)) { @@ -282,6 +297,17 @@ public Post getBySlug(String slug) { return super.getBySlug(slug); } + @Override + public Post getWithLatestContentById(Integer postId) { + Post post = getById(postId); + Content postContent = getContentById(postId); + // Use the head pointer stored in the post content. + PatchedContent patchedContent = + postContentPatchLogService.getPatchedContentById(postContent.getHeadPatchLogId()); + post.setContent(patchedContent); + return post; + } + @Override public List listYearArchives() { // Get all posts @@ -523,7 +549,8 @@ public String exportMarkdown(Post post) { } content.append("---\n\n"); - content.append(post.getOriginalContent()); + PatchedContent postContent = post.getContent(); + content.append(postContent.getOriginalContent()); return content.toString(); } @@ -575,6 +602,10 @@ public Post removeById(Integer postId) { List postComments = postCommentService.removeByPostId(postId); log.debug("Removed post comments: [{}]", postComments); + // Remove post content + Content postContent = postContentService.removeById(postId); + log.debug("Removed post content: [{}]", postContent); + Post deletedPost = super.removeById(postId); // Log it @@ -614,9 +645,7 @@ public Page convertToListVo(Page postPage, boolean queryEncryp return postPage.map(post -> { PostListVO postListVO = new PostListVO().convertFrom(post); - if (StringUtils.isBlank(postListVO.getSummary())) { - postListVO.setSummary(generateSummary(post.getFormatContent())); - } + generateAndSetSummaryIfAbsent(post, postListVO); Optional.ofNullable(tagListMap.get(post.getId())).orElseGet(LinkedList::new); @@ -646,6 +675,10 @@ public Page convertToListVo(Page postPage, boolean queryEncryp postListVO.setFullPath(buildFullPath(post)); + // Post currently drafting in process + Boolean isInProcess = postContentService.draftingInProgress(post.getId()); + postListVO.setInProgress(isInProcess); + return postListVO; }); } @@ -678,9 +711,7 @@ public List convertToListVo(List posts, boolean queryEncryptCa return posts.stream().map(post -> { PostListVO postListVO = new PostListVO().convertFrom(post); - if (StringUtils.isBlank(postListVO.getSummary())) { - postListVO.setSummary(generateSummary(post.getFormatContent())); - } + generateAndSetSummaryIfAbsent(post, postListVO); Optional.ofNullable(tagListMap.get(post.getId())).orElseGet(LinkedList::new); @@ -742,9 +773,7 @@ public BasePostSimpleDTO convertToSimple(Post post) { BasePostSimpleDTO basePostSimpleDTO = new BasePostSimpleDTO().convertFrom(post); // Set summary - if (StringUtils.isBlank(basePostSimpleDTO.getSummary())) { - basePostSimpleDTO.setSummary(generateSummary(post.getFormatContent())); - } + generateAndSetSummaryIfAbsent(post, basePostSimpleDTO); basePostSimpleDTO.setFullPath(buildFullPath(post)); @@ -767,10 +796,7 @@ private PostDetailVO convertTo(@NonNull Post post, @Nullable List tags, // Convert to base detail vo PostDetailVO postDetailVO = new PostDetailVO().convertFrom(post); - - if (StringUtils.isBlank(postDetailVO.getSummary())) { - postDetailVO.setSummary(generateSummary(post.getFormatContent())); - } + generateAndSetSummaryIfAbsent(post, postDetailVO); // Extract ids Set tagIds = ServiceUtils.fetchProperty(tags, Tag::getId); @@ -794,6 +820,14 @@ private PostDetailVO convertTo(@NonNull Post post, @Nullable List tags, postDetailVO.setFullPath(buildFullPath(post)); + PatchedContent postContent = post.getContent(); + postDetailVO.setContent(postContent.getContent()); + postDetailVO.setOriginalContent(postContent.getOriginalContent()); + + // Post currently drafting in process + Boolean inProgress = postContentService.draftingInProgress(post.getId()); + postDetailVO.setInProgress(inProgress); + return postDetailVO; } @@ -903,6 +937,10 @@ private PostDetailVO createOrUpdate(@NonNull Post post, Set tagIds, // Remove authorization every time an post is created or updated. authorizationService.deletePostAuthorization(post.getId()); + // get draft content by head patch log id + Content postContent = postContentService.getById(post.getId()); + post.setContent( + postContentPatchLogService.getPatchedContentById(postContent.getHeadPatchLogId())); // Convert to post detail vo return convertTo(post, tags, categories, postMetaList); } @@ -943,9 +981,8 @@ public void publishVisitEvent(Integer postId) { @Override public List listPostMarkdowns() { List allPostList = listAll(); - List result = new ArrayList(allPostList.size()); - for (int i = 0; i < allPostList.size(); i++) { - Post post = allPostList.get(i); + List result = new ArrayList<>(allPostList.size()); + for (Post post : allPostList) { result.add(convertToPostMarkdownVo(post)); } return result; @@ -988,11 +1025,12 @@ private PostMarkdownVO convertToPostMarkdownVo(Post post) { tagContent.append(" | ").append(tagName); } } - frontMatter.append("tags: ").append(tagContent.toString()).append("\n"); + frontMatter.append("tags: ").append(tagContent).append("\n"); frontMatter.append("---\n"); postMarkdownVO.setFrontMatter(frontMatter.toString()); - postMarkdownVO.setOriginalContent(post.getOriginalContent()); + PatchedContent postContent = post.getContent(); + postMarkdownVO.setOriginalContent(postContent.getOriginalContent()); postMarkdownVO.setTitle(post.getTitle()); postMarkdownVO.setSlug(post.getSlug()); return postMarkdownVO; diff --git a/src/main/java/run/halo/app/service/impl/SheetServiceImpl.java b/src/main/java/run/halo/app/service/impl/SheetServiceImpl.java index c41b324396..243960a08f 100644 --- a/src/main/java/run/halo/app/service/impl/SheetServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/SheetServiceImpl.java @@ -10,7 +10,6 @@ import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -24,6 +23,8 @@ import run.halo.app.exception.NotFoundException; import run.halo.app.model.dto.IndependentSheetDTO; import run.halo.app.model.dto.post.BasePostMinimalDTO; +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.Content.PatchedContent; import run.halo.app.model.entity.Sheet; import run.halo.app.model.entity.SheetComment; import run.halo.app.model.entity.SheetMeta; @@ -34,6 +35,8 @@ import run.halo.app.model.vo.SheetDetailVO; import run.halo.app.model.vo.SheetListVO; import run.halo.app.repository.SheetRepository; +import run.halo.app.service.ContentPatchLogService; +import run.halo.app.service.ContentService; import run.halo.app.service.OptionService; import run.halo.app.service.SheetCommentService; import run.halo.app.service.SheetMetaService; @@ -52,7 +55,8 @@ */ @Slf4j @Service -public class SheetServiceImpl extends BasePostServiceImpl implements SheetService { +public class SheetServiceImpl extends BasePostServiceImpl + implements SheetService { private final SheetRepository sheetRepository; @@ -66,19 +70,27 @@ public class SheetServiceImpl extends BasePostServiceImpl implements Shee private final OptionService optionService; + private final ContentService sheetContentService; + + private final ContentPatchLogService sheetContentPatchLogService; + public SheetServiceImpl(SheetRepository sheetRepository, ApplicationEventPublisher eventPublisher, SheetCommentService sheetCommentService, + ContentService sheetContentService, SheetMetaService sheetMetaService, ThemeService themeService, - OptionService optionService) { - super(sheetRepository, optionService); + OptionService optionService, + ContentPatchLogService sheetContentPatchLogService) { + super(sheetRepository, optionService, sheetContentService, sheetContentPatchLogService); this.sheetRepository = sheetRepository; this.eventPublisher = eventPublisher; this.sheetCommentService = sheetCommentService; this.sheetMetaService = sheetMetaService; this.themeService = themeService; this.optionService = optionService; + this.sheetContentService = sheetContentService; + this.sheetContentPatchLogService = sheetContentPatchLogService; } @Override @@ -160,6 +172,17 @@ public Sheet getBySlug(String slug) { .orElseThrow(() -> new NotFoundException("查询不到该页面的信息").setErrorData(slug)); } + @Override + public Sheet getWithLatestContentById(Integer postId) { + Sheet sheet = getById(postId); + Content sheetContent = getContentById(postId); + // Use the head pointer stored in the post content. + PatchedContent patchedContent = + sheetContentPatchLogService.getPatchedContentById(sheetContent.getHeadPatchLogId()); + sheet.setContent(patchedContent); + return sheet; + } + @Override public Sheet getBy(PostStatus status, String slug) { Assert.notNull(status, "Sheet status must not be null"); @@ -208,7 +231,7 @@ public String exportMarkdown(Sheet sheet) { content.append("comments: ").append(!sheet.getDisallowComment()).append("\n"); content.append("---\n\n"); - content.append(sheet.getOriginalContent()); + content.append(sheet.getContent().getOriginalContent()); return content.toString(); } @@ -258,6 +281,10 @@ public Sheet removeById(Integer id) { List sheetComments = sheetCommentService.removeByPostId(id); log.debug("Removed sheet comments: [{}]", sheetComments); + // Remove sheet content + Content sheetContent = sheetContentService.removeById(id); + log.debug("Removed sheet content: [{}]", sheetContent); + Sheet sheet = super.removeById(id); // Log it @@ -286,6 +313,10 @@ public Page convertToListVo(Page sheetPage) { sheetListVO.setFullPath(buildFullPath(sheet)); + // Post currently drafting in process + Boolean isInProcess = sheetContentService.draftingInProgress(sheet.getId()); + sheetListVO.setInProgress(isInProcess); + return sheetListVO; }); } @@ -337,15 +368,21 @@ private SheetDetailVO convertTo(@NonNull Sheet sheet, List metas) { sheetDetailVO.setMetaIds(metaIds); sheetDetailVO.setMetas(sheetMetaService.convertTo(metas)); - if (StringUtils.isBlank(sheetDetailVO.getSummary())) { - sheetDetailVO.setSummary(generateSummary(sheet.getFormatContent())); - } + generateAndSetSummaryIfAbsent(sheet, sheetDetailVO); sheetDetailVO.setCommentCount(sheetCommentService.countByStatusAndPostId( CommentStatus.PUBLISHED, sheet.getId())); sheetDetailVO.setFullPath(buildFullPath(sheet)); + PatchedContent sheetContent = sheet.getContent(); + sheetDetailVO.setContent(sheetContent.getContent()); + sheetDetailVO.setOriginalContent(sheetContent.getOriginalContent()); + + // Sheet currently drafting in process + Boolean inProgress = sheetContentService.draftingInProgress(sheet.getId()); + sheetDetailVO.setInProgress(inProgress); + return sheetDetailVO; } diff --git a/src/main/java/run/halo/app/utils/JsonUtils.java b/src/main/java/run/halo/app/utils/JsonUtils.java index 0289a3611d..c93d224fbd 100644 --- a/src/main/java/run/halo/app/utils/JsonUtils.java +++ b/src/main/java/run/halo/app/utils/JsonUtils.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategy; @@ -71,6 +72,23 @@ public static T jsonToObject(@NonNull String json, @NonNull Class type) return jsonToObject(json, type, DEFAULT_JSON_MAPPER); } + /** + * Converts json to the object specified type. + * + * @param json json content must not be blank + * @param typeReference object type reference must not be null + * @param target object type + * @return object specified type + * @throws IllegalArgumentException throws when fail to convert + */ + public static T jsonToObject(String json, TypeReference typeReference) { + try { + return DEFAULT_JSON_MAPPER.readValue(json, typeReference); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(e); + } + } + /** * Converts json to the object specified type. * diff --git a/src/main/java/run/halo/app/utils/PatchUtils.java b/src/main/java/run/halo/app/utils/PatchUtils.java new file mode 100644 index 0000000000..1081eb2995 --- /dev/null +++ b/src/main/java/run/halo/app/utils/PatchUtils.java @@ -0,0 +1,95 @@ +package run.halo.app.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.github.difflib.DiffUtils; +import com.github.difflib.patch.AbstractDelta; +import com.github.difflib.patch.ChangeDelta; +import com.github.difflib.patch.Chunk; +import com.github.difflib.patch.DeleteDelta; +import com.github.difflib.patch.DeltaType; +import com.github.difflib.patch.InsertDelta; +import com.github.difflib.patch.Patch; +import com.github.difflib.patch.PatchFailedException; +import com.google.common.base.Splitter; +import java.util.List; +import lombok.Data; + +/** + * Content patch utilities. + * + * @author guqing + * @date 2021-12-19 + */ +public class PatchUtils { + + private static final Splitter lineSplitter = Splitter.on('\n'); + + public static Patch create(String deltasJson) { + List deltas = JsonUtils.jsonToObject(deltasJson, new TypeReference<>() {}); + Patch patch = new Patch<>(); + for (Delta delta : deltas) { + StringChunk sourceChunk = delta.getSource(); + StringChunk targetChunk = delta.getTarget(); + Chunk orgChunk = new Chunk<>(sourceChunk.getPosition(), sourceChunk.getLines(), + sourceChunk.getChangePosition()); + Chunk revChunk = new Chunk<>(targetChunk.getPosition(), targetChunk.getLines(), + targetChunk.getChangePosition()); + switch (delta.getType()) { + case DELETE: + patch.addDelta(new DeleteDelta<>(orgChunk, revChunk)); + break; + case INSERT: + patch.addDelta(new InsertDelta<>(orgChunk, revChunk)); + break; + case CHANGE: + patch.addDelta(new ChangeDelta<>(orgChunk, revChunk)); + break; + default: + throw new IllegalArgumentException("Unsupported delta type."); + } + } + return patch; + } + + public static String patchToJson(Patch patch) { + List> deltas = patch.getDeltas(); + try { + return JsonUtils.objectToJson(deltas); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(e); + } + } + + public static String restoreContent(String json, String original) { + Patch patch = PatchUtils.create(json); + try { + return String.join("\n", patch.applyTo(breakLine(original))); + } catch (PatchFailedException e) { + throw new RuntimeException(e); + } + } + + public static String diffToJsonPatch(String original, String revised) { + Patch patch = DiffUtils.diff(breakLine(original), breakLine(revised)); + return PatchUtils.patchToJson(patch); + } + + public static List breakLine(String content) { + return lineSplitter.splitToList(content); + } + + @Data + public static class Delta { + private StringChunk source; + private StringChunk target; + private DeltaType type; + } + + @Data + public static class StringChunk { + private int position; + private List lines; + private List changePosition; + } +} diff --git a/src/main/resources/migration/V6__migrate_create_contents_table.sql b/src/main/resources/migration/V6__migrate_create_contents_table.sql new file mode 100644 index 0000000000..37744ddd2b --- /dev/null +++ b/src/main/resources/migration/V6__migrate_create_contents_table.sql @@ -0,0 +1,34 @@ +-- Migrate post content to contents table +INSERT INTO contents(post_id, status, patch_log_id, head_patch_log_id, content, original_content, create_time, + update_time) +SELECT id, + status, + id, + id, + format_content, + original_content, + create_time, + update_time +FROM posts; + +-- Create content_patch_logs record by posts and contents table record +INSERT INTO content_patch_logs(id, post_id, content_diff, original_content_diff, version, status, publish_time, + source_id, create_time, update_time) +SELECT p.id, + p.id, + c.content, + c.original_content, + 1, + p.status, + p.create_time, + 0, + p.create_time, + p.update_time +FROM contents c + INNER JOIN posts p ON p.id = c.post_id; + +-- Allow the original_content to be null +alter table posts + modify format_content longtext null; +alter table posts + modify original_content longtext null; diff --git a/src/test/java/run/halo/app/repository/ContentPatchLogRepositoryTest.java b/src/test/java/run/halo/app/repository/ContentPatchLogRepositoryTest.java new file mode 100644 index 0000000000..1a33f26111 --- /dev/null +++ b/src/test/java/run/halo/app/repository/ContentPatchLogRepositoryTest.java @@ -0,0 +1,177 @@ +package run.halo.app.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import run.halo.app.model.entity.ContentPatchLog; +import run.halo.app.model.enums.PostStatus; + +/** + * Content patch log repository test. + * + * @author guqing + * @date 2022-02-19 + */ +@DataJpaTest +@AutoConfigureDataJpa +public class ContentPatchLogRepositoryTest { + @Autowired + private ContentPatchLogRepository contentPatchLogRepository; + + /** + * Creates some test data for {@link ContentPatchLog}. + */ + @BeforeEach + public void setUp() { + List list = new ArrayList<>(); + + ContentPatchLog record1 = create(2, "

    关于页面

    \n" + + "

    这是一个自定义页面,你可以在后台的 页面 -> 所有页面 -> " + + "自定义页面 找到它,你可以用于新建关于页面、留言板页面等等。发挥你自己的想象力!

    \n" + + "
    \n" + + "

    这是一篇自动生成的页面,你可以在后台删除它。

    \n" + + "
    \n", + "## 关于页面\n" + + "\n这是一个自定义页面,你可以在后台的 `页面` -> `所有页面` -> `自定义页面` 找到它,你可以用于新" + + "建关于页面、留言板页面等等。发挥你自己的想象力!\n" + + "\n> 这是一篇自动生成的页面,你可以在后台删除它。", + 2, new Date(), 0, 1); + list.add(record1); + + ContentPatchLog record2 = create(3, "

    登高

    \n" + + "

    风急天高猿啸哀,渚清沙白鸟飞回。

    \n" + + "

    无边落木萧萧下,不尽长江滚滚来。

    \n" + + "

    万里悲秋常作客,百年多病独登台。

    \n" + + "

    艰难苦恨繁霜鬓,潦倒新停浊酒杯。

    \n", + "**登高**\n" + + "\n" + + "风急天高猿啸哀,渚清沙白鸟飞回。\n" + + "\n" + + "无边落木萧萧下,不尽长江滚滚来。\n" + + "\n" + + "万里悲秋常作客,百年多病独登台。\n" + + "\n" + + "艰难苦恨繁霜鬓,潦倒新停浊酒杯。", + 3, new Date(), 0, 1); + list.add(record2); + + ContentPatchLog record3 = create(4, "

    望岳

    \n" + + "

    岱宗夫如何,齐鲁青未了。

    \n", + "望岳\n" + + "\n" + + "岱宗夫如何,齐鲁青未了。\n", + 4, new Date(), 0, 1); + list.add(record3); + + ContentPatchLog record4 = create(5, "[{\"source\":{\"position\":2,\"lines\":[]," + + "\"changePosition\":null},\"target\":{\"position\":2," + + "\"lines\":[\"

    造化钟神秀,阴阳割昏晓。

    \"],\"changePosition\":null}," + + "\"type\":\"INSERT\"}]", + "[{\"source\":{\"position\":4,\"lines\":[],\"changePosition\":null}," + + "\"target\":{\"position\":4,\"lines\":[\"造化钟神秀,阴阳割昏晓。\",\"\"]," + + "\"changePosition\":null},\"type\":\"INSERT\"}]", + 4, new Date(), 4, 2); + list.add(record4); + + ContentPatchLog record5 = create(6, "[{\"source\":{\"position\":2,\"lines\":[]," + + "\"changePosition\":null},\"target\":{\"position\":2," + + "\"lines\":[\"

    造化钟神秀,阴阳割昏晓。

    \",\"

    荡胸生曾云,决眦入归鸟。

    \"]," + + "\"changePosition\":null},\"type\":\"INSERT\"}]", + "[{\"source\":{\"position\":4,\"lines\":[],\"changePosition\":null}," + + "\"target\":{\"position\":4,\"lines\":[\"造化钟神秀,阴阳割昏晓。\",\"\",\"荡胸生曾云,决眦入归鸟。\"," + + "\"\"],\"changePosition\":null},\"type\":\"INSERT\"}]", + 4, new Date(), 5, 3); + list.add(record5); + + ContentPatchLog record6 = create(7, "[{\"source\":{\"position\":2,\"lines\":[]," + + "\"changePosition\":null},\"target\":{\"position\":2," + + "\"lines\":[\"

    造化钟神秀,阴阳割昏晓。

    \",\"

    荡胸生曾云,决眦入归鸟。

    \"," + + "\"

    会当凌绝顶,一览众山小。

    \"],\"changePosition\":null},\"type\":\"INSERT\"}]", + "[{\"source\":{\"position\":4,\"lines\":[],\"changePosition\":null}," + + "\"target\":{\"position\":4,\"lines\":[\"造化钟神秀,阴阳割昏晓。\",\"\",\"荡胸生曾云,决眦入归鸟。\"," + + "\"\",\"会当凌绝顶,一览众山小。\"],\"changePosition\":null},\"type\":\"INSERT\"}]", + 4, new Date(), 6, 4); + list.add(record6); + + ContentPatchLog record7 = create(8, "[{\"source\":{\"position\":2,\"lines\":[]," + + "\"changePosition\":null},\"target\":{\"position\":2," + + "\"lines\":[\"

    造化钟神秀,阴阳割昏晓。

    \",\"

    荡胸生曾云,决眦入归鸟。

    \"," + + "\"

    会当凌绝顶,一览众山小。

    \"],\"changePosition\":null},\"type\":\"INSERT\"}]", + "[{\"source\":{\"position\":4,\"lines\":[],\"changePosition\":null}," + + "\"target\":{\"position\":4,\"lines\":[\"造化钟神秀,阴阳割昏晓。\",\"\",\"荡胸生曾云,决眦入归鸟。\"," + + "\"\",\"会当凌绝顶,一览众山小。\"],\"changePosition\":null},\"type\":\"INSERT\"}]", + 4, new Date(), 7, 5); + list.add(record7); + + // Save records + contentPatchLogRepository.saveAll(list); + } + + private ContentPatchLog create(Integer id, String contentDiff, String originalContentDiff, + Integer postId, Date publishTime, Integer sourceId, Integer version) { + ContentPatchLog record = new ContentPatchLog(); + record.setId(id); + record.setCreateTime(new Date()); + record.setUpdateTime(new Date()); + record.setContentDiff(contentDiff); + record.setOriginalContentDiff(originalContentDiff); + record.setPostId(postId); + record.setPublishTime(publishTime); + record.setSourceId(sourceId); + record.setStatus(PostStatus.PUBLISHED); + record.setVersion(version); + return record; + } + + @Test + public void findAllByPostId() { + List patchLogs = contentPatchLogRepository.findAllByPostId(4); + assertThat(patchLogs).isNotEmpty(); + assertThat(patchLogs).hasSize(5); + } + + @Test + public void findByPostIdAndVersion() { + ContentPatchLog v1 = contentPatchLogRepository.findByPostIdAndVersion(4, 1); + + assertThat(v1).isNotNull(); + assertThat(v1.getOriginalContentDiff()).isEqualTo("望岳\n\n岱宗夫如何,齐鲁青未了。\n"); + assertThat(v1.getContentDiff()).isEqualTo("

    望岳

    \n

    岱宗夫如何,齐鲁青未了。

    \n"); + assertThat(v1.getSourceId()).isEqualTo(0); + assertThat(v1.getStatus()).isEqualTo(PostStatus.PUBLISHED); + } + + @Test + public void findFirstByPostIdOrderByVersionDesc() { + ContentPatchLog latest = + contentPatchLogRepository.findFirstByPostIdOrderByVersionDesc(4); + + assertThat(latest).isNotNull(); + assertThat(latest.getId()).isEqualTo(8); + assertThat(latest.getVersion()).isEqualTo(5); + } + + @Test + public void findFirstByPostIdAndStatusOrderByVersionDesc() { + // finds the latest draft record + ContentPatchLog latestDraft = + contentPatchLogRepository.findFirstByPostIdAndStatusOrderByVersionDesc(4, + PostStatus.DRAFT); + assertThat(latestDraft).isNull(); + + // finds the latest published record + ContentPatchLog latestPublished = + contentPatchLogRepository.findFirstByPostIdAndStatusOrderByVersionDesc(4, + PostStatus.PUBLISHED); + assertThat(latestPublished).isNotNull(); + assertThat(latestPublished.getId()).isEqualTo(8); + assertThat(latestPublished.getVersion()).isEqualTo(5); + } +} diff --git a/src/test/java/run/halo/app/service/impl/ContentPatchLogServiceImplTest.java b/src/test/java/run/halo/app/service/impl/ContentPatchLogServiceImplTest.java new file mode 100644 index 0000000000..d8999cb883 --- /dev/null +++ b/src/test/java/run/halo/app/service/impl/ContentPatchLogServiceImplTest.java @@ -0,0 +1,188 @@ +package run.halo.app.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.Date; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Example; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import run.halo.app.model.entity.Content; +import run.halo.app.model.entity.Content.ContentDiff; +import run.halo.app.model.entity.Content.PatchedContent; +import run.halo.app.model.entity.ContentPatchLog; +import run.halo.app.model.enums.PostStatus; +import run.halo.app.repository.ContentPatchLogRepository; +import run.halo.app.repository.ContentRepository; +import run.halo.app.service.ContentPatchLogService; + +/** + * Test for content path log service implementation. + * + * @author guqing + * @date 2022-02-19 + */ +@ExtendWith(SpringExtension.class) +public class ContentPatchLogServiceImplTest { + @MockBean + private ContentPatchLogRepository contentPatchLogRepository; + + @MockBean + private ContentRepository contentRepository; + + private ContentPatchLogService contentPatchLogService; + + @BeforeEach + public void setUp() { + contentPatchLogService = + new ContentPatchLogServiceImpl(contentPatchLogRepository, contentRepository); + + Content content = new Content(); + content.setId(2); + content.setContent("好雨知时节,当春乃发生。"); + content.setOriginalContent("

    好雨知时节,当春乃发生。

    \n"); + content.setPatchLogId(1); + content.setHeadPatchLogId(2); + content.setStatus(PostStatus.PUBLISHED); + content.setCreateTime(new Date(1645281361)); + content.setUpdateTime(new Date(1645281361)); + + ContentPatchLog contentPatchLogV1 = new ContentPatchLog(); + contentPatchLogV1.setId(1); + contentPatchLogV1.setSourceId(0); + contentPatchLogV1.setPostId(2); + contentPatchLogV1.setVersion(1); + contentPatchLogV1.setStatus(PostStatus.PUBLISHED); + contentPatchLogV1.setCreateTime(new Date()); + contentPatchLogV1.setUpdateTime(new Date()); + contentPatchLogV1.setContentDiff("

    望岳

    \n

    岱宗夫如何,齐鲁青未了。

    \n"); + contentPatchLogV1.setOriginalContentDiff("望岳\n\n岱宗夫如何,齐鲁青未了。\n"); + + ContentPatchLog contentPatchLogV2 = new ContentPatchLog(); + contentPatchLogV2.setId(2); + contentPatchLogV2.setSourceId(1); + contentPatchLogV2.setPostId(2); + contentPatchLogV2.setVersion(2); + contentPatchLogV2.setStatus(PostStatus.DRAFT); + contentPatchLogV2.setCreateTime(new Date()); + contentPatchLogV2.setUpdateTime(new Date()); + contentPatchLogV2.setContentDiff("[{\"source\":{\"position\":2,\"lines\":[]," + + "\"changePosition\":null},\"target\":{\"position\":2," + + "\"lines\":[\"

    造化钟神秀,阴阳割昏晓。

    \"],\"changePosition\":null},\"type\":\"INSERT\"}]"); + contentPatchLogV2.setOriginalContentDiff("[{\"source\":{\"position\":4,\"lines\":[]," + + "\"changePosition\":null},\"target\":{\"position\":4,\"lines\":[\"造化钟神秀,阴阳割昏晓。\"," + + "\"\"],\"changePosition\":null},\"type\":\"INSERT\"}]"); + + when(contentRepository.findById(1)).thenReturn(Optional.empty()); + when(contentRepository.findById(2)).thenReturn(Optional.of(content)); + + when(contentPatchLogRepository.findById(1)).thenReturn(Optional.of(contentPatchLogV1)); + when(contentPatchLogRepository.getById(1)).thenReturn(contentPatchLogV1); + + when(contentPatchLogRepository.findById(2)).thenReturn(Optional.of(contentPatchLogV2)); + when(contentPatchLogRepository.getById(2)).thenReturn(contentPatchLogV2); + + ContentPatchLog contentPatchLogExample = new ContentPatchLog(); + contentPatchLogExample.setPostId(2); + contentPatchLogExample.setStatus(PostStatus.DRAFT); + Example example = Example.of(contentPatchLogExample); + when(contentPatchLogRepository.exists(example)).thenReturn(true); + + when(contentPatchLogRepository.findFirstByPostIdAndStatusOrderByVersionDesc(2, + PostStatus.DRAFT)).thenReturn(contentPatchLogV2); + + when(contentPatchLogRepository.findByPostIdAndVersion(2, 1)) + .thenReturn(contentPatchLogV1); + + } + + @Test + public void createOrUpdate() { + // record will be created + ContentPatchLog created = + contentPatchLogService.createOrUpdate(1, "国破山河在,城春草木深\n", "

    国破山河在,城春草木深

    \n"); + assertThat(created).isNotNull(); + assertThat(created.getVersion()).isEqualTo(1); + assertThat(created.getStatus()).isEqualTo(PostStatus.DRAFT); + assertThat(created.getSourceId()).isEqualTo(0); + assertThat(created.getContentDiff()).isEqualTo(created.getContentDiff()); + assertThat(created.getOriginalContentDiff()).isEqualTo(created.getOriginalContentDiff()); + + // record will be updated + ContentPatchLog updated = + contentPatchLogService.createOrUpdate(2, "

    好雨知时节,当春乃发生。

    \n", "好雨知时节,当春乃发生。\n"); + assertThat(updated).isNotNull(); + assertThat(updated.getId()).isEqualTo(2); + assertThat(updated.getContentDiff()).isEqualTo("[{\"source\":{\"position\":0," + + "\"lines\":[\"

    望岳

    \",\"

    岱宗夫如何,齐鲁青未了。

    \"],\"changePosition\":null}," + + "\"target\":{\"position\":0,\"lines\":[\"

    好雨知时节,当春乃发生。

    \"]," + + "\"changePosition\":null},\"type\":\"CHANGE\"}]"); + assertThat(updated.getOriginalContentDiff()).isEqualTo("[{\"source" + + "\":{\"position\":0,\"lines\":[\"望岳\"],\"changePosition\":null}," + + "\"target\":{\"position\":0,\"lines\":[\"好雨知时节,当春乃发生。\"],\"changePosition\":null}," + + "\"type\":\"CHANGE\"},{\"source\":{\"position\":2,\"lines\":[\"岱宗夫如何,齐鲁青未了。\",\"\"]," + + "\"changePosition\":null},\"target\":{\"position\":2,\"lines\":[]," + + "\"changePosition\":null},\"type\":\"DELETE\"}]"); + } + + @Test + public void applyPatch() { + ContentPatchLog contentPatchLogV2 = new ContentPatchLog(); + contentPatchLogV2.setId(2); + contentPatchLogV2.setSourceId(1); + contentPatchLogV2.setPostId(2); + contentPatchLogV2.setVersion(2); + contentPatchLogV2.setStatus(PostStatus.DRAFT); + contentPatchLogV2.setCreateTime(new Date()); + contentPatchLogV2.setUpdateTime(new Date()); + contentPatchLogV2.setContentDiff("[{\"source\":{\"position\":2,\"lines\":[]," + + "\"changePosition\":null},\"target\":{\"position\":2," + + "\"lines\":[\"

    造化钟神秀,阴阳割昏晓。

    \"],\"changePosition\":null},\"type\":\"INSERT\"}]"); + contentPatchLogV2.setOriginalContentDiff("[{\"source\":{\"position\":4,\"lines\":[]," + + "\"changePosition\":null},\"target\":{\"position\":4,\"lines\":[\"造化钟神秀,阴阳割昏晓。\"," + + "\"\"],\"changePosition\":null},\"type\":\"INSERT\"}]"); + + PatchedContent patchedContent = + contentPatchLogService.applyPatch(contentPatchLogV2); + + assertThat(patchedContent).isNotNull(); + assertThat(patchedContent.getContent()).isEqualTo("

    望岳

    \n" + + "

    岱宗夫如何,齐鲁青未了。

    \n" + + "

    造化钟神秀,阴阳割昏晓。

    \n"); + assertThat(patchedContent.getOriginalContent()).isEqualTo("望岳\n\n岱宗夫如何,齐鲁青未了。\n" + + "\n造化钟神秀,阴阳割昏晓。\n"); + } + + @Test + public void generateDiff() { + ContentDiff contentDiff = + contentPatchLogService.generateDiff(2, "

    随风潜入夜,润物细无声。

    ", "随风潜入夜,润物细无声。"); + + assertThat(contentDiff).isNotNull(); + assertThat(contentDiff.getDiff()).isEqualTo("[{\"source\":{\"position\":0," + + "\"lines\":[\"

    望岳

    \",\"

    岱宗夫如何,齐鲁青未了。

    \",\"\"],\"changePosition\":null}," + + "\"target\":{\"position\":0,\"lines\":[\"

    随风潜入夜,润物细无声。

    \"]," + + "\"changePosition\":null},\"type\":\"CHANGE\"}]"); + assertThat(contentDiff.getOriginalDiff()).isEqualTo("[{\"source\":{\"position\":0," + + "\"lines\":[\"望岳\",\"\",\"岱宗夫如何,齐鲁青未了。\",\"\"],\"changePosition\":null}," + + "\"target\":{\"position\":0,\"lines\":[\"随风潜入夜,润物细无声。\"],\"changePosition\":null}," + + "\"type\":\"CHANGE\"}]"); + } + + @Test + public void getPatchedContentById() { + PatchedContent patchedContent = contentPatchLogService.getPatchedContentById(2); + + assertThat(patchedContent).isNotNull(); + assertThat(patchedContent.getContent()).isEqualTo("

    望岳

    \n" + + "

    岱宗夫如何,齐鲁青未了。

    \n" + + "

    造化钟神秀,阴阳割昏晓。

    \n"); + assertThat(patchedContent.getOriginalContent()).isEqualTo("望岳\n\n" + + "岱宗夫如何,齐鲁青未了。\n\n" + + "造化钟神秀,阴阳割昏晓。\n"); + } +}