Support post content version control (#1617)

* feat: split post content to new table and support content version control

* feat: Improve post version management

* feat: Add post content and version record deletion

* feat: Add isInProcess attribute for post list and detail api

* feat: Add migrate sql script

* fix: Add a sql of allow origin_content to null in posts table

* feat: Assign a value to the source of the post content version record
pull/1669/head
guqing 2022-02-20 20:34:56 +08:00 committed by GitHub
parent 1ee7b58ef1
commit 923eb17577
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1695 additions and 154 deletions

View File

@ -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"

View File

@ -28,7 +28,6 @@ import run.halo.app.model.dto.post.BasePostDetailDTO;
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 class PostController {
@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 class PostController {
@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 class PostController {
@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+}")

View File

@ -63,7 +63,7 @@ public class SheetController {
@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 class SheetController {
@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 class SheetController {
@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+}")

View File

@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.ResponseBody;
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 @@ import run.halo.app.service.PostService;
/**
* @author ryanwang
* @author guqing
* @date 2019-03-21
*/
@Slf4j
@ -242,14 +244,35 @@ public class ContentFeedController {
Assert.notNull(pageable, "Pageable must not be null");
Page<Post> postPage = postService.pageBy(PostStatus.PUBLISHED, pageable);
Page<PostDetailVO> 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<PostDetailVO> convertToDetailPageVo(Page<Post> 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<PostDetailVO> 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 @@ public class ContentFeedController {
Page<Post> postPage =
postCategoryService.pagePostBy(category.getId(), PostStatus.PUBLISHED, pageable);
Page<PostDetailVO> 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<PostDetailVO> posts = convertToDetailPageVo(postPage);
return posts.getContent();
}

View File

@ -109,7 +109,7 @@ public class PostController {
if (formatDisabled) {
// Clear the format content
postDetailVO.setFormatContent(null);
postDetailVO.setContent(null);
}
if (sourceDisabled) {
@ -133,7 +133,7 @@ public class PostController {
if (formatDisabled) {
// Clear the format content
postDetailVO.setFormatContent(null);
postDetailVO.setContent(null);
}
if (sourceDisabled) {

View File

@ -78,7 +78,7 @@ public class SheetController {
if (formatDisabled) {
// Clear the format content
sheetDetailVO.setFormatContent(null);
sheetDetailVO.setContent(null);
}
if (sourceDisabled) {
@ -102,7 +102,7 @@ public class SheetController {
if (formatDisabled) {
// Clear the format content
sheetDetailVO.setFormatContent(null);
sheetDetailVO.setContent(null);
}
if (sourceDisabled) {

View File

@ -16,11 +16,12 @@ import run.halo.app.cache.AbstractStringCacheStore;
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.PostService;
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 class PostModel {
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 class PostModel {
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);

View File

@ -6,6 +6,8 @@ import org.springframework.stereotype.Component;
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 class SheetModel {
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 class SheetModel {
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 class SheetModel {
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

View File

@ -3,11 +3,15 @@ package run.halo.app.model.dto.post;
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 extends BasePostMinimalDTO> 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;
}
}

View File

@ -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;
}

View File

@ -13,11 +13,15 @@ import javax.persistence.Lob;
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 <code>Post</code> 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 class BasePost extends BaseEntity {
likes = 0L;
}
if (originalContent == null) {
originalContent = "";
}
if (formatContent == null) {
formatContent = "";
}
if (editorType == null) {
editorType = PostEditorType.MARKDOWN;
}
@ -230,6 +239,30 @@ public class BasePost extends BaseEntity {
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;
}
}

View File

@ -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;
/**
* <p>The patch log head that the current content points to.</p>
* <pre>
* \-v1-v2-v3(HEAD)-v5(draft)
* \-v4
* </pre>
* e.g. The latest version is v4.
* <li>At this time, I switch to V3, and the head points to v3.
* <li>When creating a draft (V5) in V3, the head points to V5,
* but the <code>patchLogId</code> 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);
}
}
}

View File

@ -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;
}
}
}

View File

@ -10,10 +10,13 @@ import lombok.Data;
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 class PostParam implements InputConverter<Post> {
editorType = PostEditorType.MARKDOWN;
}
return InputConverter.super.convertTo();
Post post = InputConverter.super.convertTo();
populateContent(post);
return post;
}
@Override
@ -94,7 +99,7 @@ public class PostParam implements InputConverter<Post> {
if (null == editorType) {
editorType = PostEditorType.MARKDOWN;
}
populateContent(post);
InputConverter.super.update(post);
}
@ -110,4 +115,15 @@ public class PostParam implements InputConverter<Post> {
}
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));
}
}

View File

@ -10,10 +10,13 @@ import lombok.Data;
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 @@ import run.halo.app.utils.SlugUtils;
*
* @author johnniang
* @author ryanwang
* @author guqing
* @date 2019-4-24
*/
@Data
@ -75,7 +79,9 @@ public class SheetParam implements InputConverter<Sheet> {
editorType = PostEditorType.MARKDOWN;
}
return InputConverter.super.convertTo();
Sheet sheet = InputConverter.super.convertTo();
populateContent(sheet);
return sheet;
}
@Override
@ -89,7 +95,7 @@ public class SheetParam implements InputConverter<Sheet> {
if (null == editorType) {
editorType = PostEditorType.MARKDOWN;
}
populateContent(sheet);
InputConverter.super.update(sheet);
}
@ -105,4 +111,15 @@ public class SheetParam implements InputConverter<Sheet> {
}
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));
}
}

View File

@ -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<ContentPatchLog, Integer> {
/**
* 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<ContentPatchLog> 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<ContentPatchLog> 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<ContentPatchLog> findAllByPostId(Integer postId);
}

View File

@ -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<Content, Integer> {
}

View File

@ -215,18 +215,6 @@ public interface BasePostRepository<POST extends BasePost> extends BaseRepositor
@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 @@ public interface BasePostRepository<POST extends BasePost> extends BaseRepositor
@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);
}

View File

@ -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<ContentPatchLog> removeByPostId(Integer postId);
}

View File

@ -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<Content, Integer> {
/**
* <p>Publish post content.</p>
* <ul>
* <li>Copy the latest record in {@link ContentPatchLog} to the {@link Content}.
* <li>Set status to PUBLISHED.
* <li>Set patchLogId to the latest.
* </ul>
*
* @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);
}

View File

@ -10,6 +10,8 @@ import run.halo.app.model.dto.post.BasePostDetailDTO;
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 @@ import run.halo.app.model.enums.PostStatus;
*
* @author johnniang
* @author ryanwang
* @author guqing
* @date 2019-04-24
*/
public interface BasePostService<POST extends BasePost> extends CrudService<POST, Integer> {
@ -52,6 +55,15 @@ public interface BasePostService<POST extends BasePost> extends CrudService<POST
@NonNull
POST getBySlug(@NonNull String slug);
/**
* Get post with the latest content by id.
* content from patch log.
*
* @param postId post id.
* @return post with the latest content.
*/
POST getWithLatestContentById(Integer postId);
/**
* Gets post by post status and slug.
*
@ -72,6 +84,23 @@ public interface BasePostService<POST extends BasePost> extends CrudService<POST
@NonNull
POST getBy(@NonNull PostStatus status, @NonNull Integer id);
/**
* Gets content by post id.
*
* @param id post id.
* @return a content of post.
*/
Content getContentById(Integer id);
/**
* Gets the latest content by id.
* content from patch log.
*
* @param id post id.
* @return a latest content from patchLog of post.
*/
PatchedContent getLatestContentById(Integer id);
/**
* Lists all posts by post status.
*
@ -275,7 +304,8 @@ public interface BasePostService<POST extends BasePost> extends CrudService<POST
* @return updated post
*/
@NonNull
POST updateDraftContent(@Nullable String content, @NonNull Integer postId);
POST updateDraftContent(@Nullable String content, String originalContent,
@NonNull Integer postId);
/**
* Updates post status.

View File

@ -27,10 +27,14 @@ import run.halo.app.model.dto.post.BasePostDetailDTO;
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.PostEditorType;
import run.halo.app.model.enums.PostStatus;
import run.halo.app.model.properties.PostProperties;
import run.halo.app.repository.base.BasePostRepository;
import run.halo.app.service.ContentPatchLogService;
import run.halo.app.service.ContentService;
import run.halo.app.service.OptionService;
import run.halo.app.service.base.AbstractCrudService;
import run.halo.app.service.base.BasePostService;
@ -44,6 +48,7 @@ import run.halo.app.utils.ServiceUtils;
*
* @author johnniang
* @author ryanwang
* @author guqing
* @date 2019-04-24
*/
@Slf4j
@ -54,15 +59,23 @@ public abstract class BasePostServiceImpl<POST extends BasePost>
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<POST> 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 abstract class BasePostServiceImpl<POST extends BasePost>
return postOptional.orElseThrow(() -> new NotFoundException("查询不到该文章的信息").setErrorData(id));
}
@Override
public PatchedContent getLatestContentById(Integer id) {
return contentPatchLogService.getByPostId(id);
}
@Override
public List<POST> listAllBy(PostStatus status) {
Assert.notNull(status, "Post status must not be null");
@ -283,33 +301,46 @@ public abstract class BasePostServiceImpl<POST extends BasePost>
@Transactional
public POST createOrUpdateBy(POST post) {
Assert.notNull(post, "Post must not be null");
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));
String originalContent = post.getOriginalContent();
post.setWordCount(htmlFormatWordCount(postContent.getContent()));
} else {
postContent.setContent(originalContent);
// 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));
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 abstract class BasePostServiceImpl<POST extends BasePost>
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 abstract class BasePostServiceImpl<POST extends BasePost>
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 abstract class BasePostServiceImpl<POST extends BasePost>
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 abstract class BasePostServiceImpl<POST extends BasePost>
// 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 abstract class BasePostServiceImpl<POST extends BasePost>
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 @@ public abstract class BasePostServiceImpl<POST extends BasePost>
return StringUtils.substring(text, 0, summaryLength);
}
protected <T extends BasePostSimpleDTO> 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
/**

View File

@ -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<Content> 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<ContentPatchLog> 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<ContentPatchLog> removeByPostId(Integer postId) {
List<ContentPatchLog> patchLogsToDelete = contentPatchLogRepository.findAllByPostId(postId);
contentPatchLogRepository.deleteAllInBatch(patchLogsToDelete);
return patchLogsToDelete;
}
}

View File

@ -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<Content, Integer>
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<Content> 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);
}
}

View File

@ -16,13 +16,11 @@ import java.util.Objects;
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.exception.NotFoundException;
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.PostRepository;
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<Post> 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<Post> implements PostSe
private final AuthorizationService authorizationService;
private final ContentPatchLogService postContentPatchLogService;
public PostServiceImpl(BasePostRepository<Post> basePostRepository,
OptionService optionService,
PostRepository postRepository,
@ -121,8 +127,10 @@ public class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
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 class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
this.postMetaService = postMetaService;
this.optionService = optionService;
this.authorizationService = authorizationService;
this.postContentService = contentService;
this.postContentPatchLogService = contentPatchLogService;
}
@Override
@ -269,6 +279,11 @@ public class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
.orElseThrow(() -> new NotFoundException("查询不到该文章的信息").setErrorData(slug));
}
@Override
public PatchedContent getLatestContentById(Integer id) {
return postContentPatchLogService.getByPostId(id);
}
@Override
public List<Post> removeByIds(Collection<Integer> ids) {
if (CollectionUtils.isEmpty(ids)) {
@ -282,6 +297,17 @@ public class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
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<ArchiveYearVO> listYearArchives() {
// Get all posts
@ -523,7 +549,8 @@ public class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
}
content.append("---\n\n");
content.append(post.getOriginalContent());
PatchedContent postContent = post.getContent();
content.append(postContent.getOriginalContent());
return content.toString();
}
@ -575,6 +602,10 @@ public class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
List<PostComment> 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 class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
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 class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
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 class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
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 class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
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 @@ public class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
// 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<Integer> tagIds = ServiceUtils.fetchProperty(tags, Tag::getId);
@ -794,6 +820,14 @@ public class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
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 @@ public class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
// 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 class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
@Override
public List<PostMarkdownVO> listPostMarkdowns() {
List<Post> allPostList = listAll();
List<PostMarkdownVO> result = new ArrayList(allPostList.size());
for (int i = 0; i < allPostList.size(); i++) {
Post post = allPostList.get(i);
List<PostMarkdownVO> result = new ArrayList<>(allPostList.size());
for (Post post : allPostList) {
result.add(convertToPostMarkdownVo(post));
}
return result;
@ -988,11 +1025,12 @@ public class PostServiceImpl extends BasePostServiceImpl<Post> implements PostSe
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;

View File

@ -10,7 +10,6 @@ import java.util.Optional;
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.AlreadyExistsException;
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.enums.SheetPermalinkType;
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 @@ import run.halo.app.utils.ServiceUtils;
*/
@Slf4j
@Service
public class SheetServiceImpl extends BasePostServiceImpl<Sheet> implements SheetService {
public class SheetServiceImpl extends BasePostServiceImpl<Sheet>
implements SheetService {
private final SheetRepository sheetRepository;
@ -66,19 +70,27 @@ public class SheetServiceImpl extends BasePostServiceImpl<Sheet> 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 class SheetServiceImpl extends BasePostServiceImpl<Sheet> implements Shee
.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 class SheetServiceImpl extends BasePostServiceImpl<Sheet> implements Shee
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 class SheetServiceImpl extends BasePostServiceImpl<Sheet> implements Shee
List<SheetComment> 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 class SheetServiceImpl extends BasePostServiceImpl<Sheet> implements Shee
sheetListVO.setFullPath(buildFullPath(sheet));
// Post currently drafting in process
Boolean isInProcess = sheetContentService.draftingInProgress(sheet.getId());
sheetListVO.setInProgress(isInProcess);
return sheetListVO;
});
}
@ -337,15 +368,21 @@ public class SheetServiceImpl extends BasePostServiceImpl<Sheet> implements Shee
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;
}

View File

@ -2,6 +2,7 @@ package run.halo.app.utils;
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 class JsonUtils {
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 <T> target object type
* @return object specified type
* @throws IllegalArgumentException throws when fail to convert
*/
public static <T> T jsonToObject(String json, TypeReference<T> typeReference) {
try {
return DEFAULT_JSON_MAPPER.readValue(json, typeReference);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException(e);
}
}
/**
* Converts json to the object specified type.
*

View File

@ -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<String> create(String deltasJson) {
List<Delta> deltas = JsonUtils.jsonToObject(deltasJson, new TypeReference<>() {});
Patch<String> patch = new Patch<>();
for (Delta delta : deltas) {
StringChunk sourceChunk = delta.getSource();
StringChunk targetChunk = delta.getTarget();
Chunk<String> orgChunk = new Chunk<>(sourceChunk.getPosition(), sourceChunk.getLines(),
sourceChunk.getChangePosition());
Chunk<String> 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<String> patch) {
List<AbstractDelta<String>> deltas = patch.getDeltas();
try {
return JsonUtils.objectToJson(deltas);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException(e);
}
}
public static String restoreContent(String json, String original) {
Patch<String> 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<String> patch = DiffUtils.diff(breakLine(original), breakLine(revised));
return PatchUtils.patchToJson(patch);
}
public static List<String> 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<String> lines;
private List<Integer> changePosition;
}
}

View File

@ -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;

View File

@ -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<ContentPatchLog> list = new ArrayList<>();
ContentPatchLog record1 = create(2, "<h2 id=\"关于页面\">关于页面</h2>\n"
+ "<p>这是一个自定义页面,你可以在后台的 <code>页面</code> -&gt; <code>所有页面</code> -&gt; "
+ "<code>自定义页面</code> 找到它,你可以用于新建关于页面、留言板页面等等。发挥你自己的想象力!</p>\n"
+ "<blockquote>\n"
+ "<p>这是一篇自动生成的页面,你可以在后台删除它。</p>\n"
+ "</blockquote>\n",
"## 关于页面\n"
+ "\n这是一个自定义页面你可以在后台的 `页面` -> `所有页面` -> `自定义页面` 找到它,你可以用于新"
+ "建关于页面、留言板页面等等。发挥你自己的想象力!\n"
+ "\n> 这是一篇自动生成的页面,你可以在后台删除它。",
2, new Date(), 0, 1);
list.add(record1);
ContentPatchLog record2 = create(3, "<p><strong>登高</strong></p>\n"
+ "<p>风急天高猿啸哀,渚清沙白鸟飞回。</p>\n"
+ "<p>无边落木萧萧下,不尽长江滚滚来。</p>\n"
+ "<p>万里悲秋常作客,百年多病独登台。</p>\n"
+ "<p>艰难苦恨繁霜鬓,潦倒新停浊酒杯。</p>\n",
"**登高**\n"
+ "\n"
+ "风急天高猿啸哀,渚清沙白鸟飞回。\n"
+ "\n"
+ "无边落木萧萧下,不尽长江滚滚来。\n"
+ "\n"
+ "万里悲秋常作客,百年多病独登台。\n"
+ "\n"
+ "艰难苦恨繁霜鬓,潦倒新停浊酒杯。",
3, new Date(), 0, 1);
list.add(record2);
ContentPatchLog record3 = create(4, "<p>望岳</p>\n"
+ "<p>岱宗夫如何,齐鲁青未了。</p>\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\":[\"<p>造化钟神秀,阴阳割昏晓。</p>\"],\"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\":[\"<p>造化钟神秀,阴阳割昏晓。</p>\",\"<p>荡胸生曾云,决眦入归鸟。</p>\"],"
+ "\"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\":[\"<p>造化钟神秀,阴阳割昏晓。</p>\",\"<p>荡胸生曾云,决眦入归鸟。</p>\","
+ "\"<p>会当凌绝顶,一览众山小。</p>\"],\"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\":[\"<p>造化钟神秀,阴阳割昏晓。</p>\",\"<p>荡胸生曾云,决眦入归鸟。</p>\","
+ "\"<p>会当凌绝顶,一览众山小。</p>\"],\"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<ContentPatchLog> 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("<p>望岳</p>\n<p>岱宗夫如何,齐鲁青未了。</p>\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);
}
}

View File

@ -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("<p>好雨知时节,当春乃发生。</p>\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("<p>望岳</p>\n<p>岱宗夫如何,齐鲁青未了。</p>\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\":[\"<p>造化钟神秀,阴阳割昏晓。</p>\"],\"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<ContentPatchLog> 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", "<p>国破山河在,城春草木深</p>\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, "<p>好雨知时节,当春乃发生。</p>\n", "好雨知时节,当春乃发生。\n");
assertThat(updated).isNotNull();
assertThat(updated.getId()).isEqualTo(2);
assertThat(updated.getContentDiff()).isEqualTo("[{\"source\":{\"position\":0,"
+ "\"lines\":[\"<p>望岳</p>\",\"<p>岱宗夫如何,齐鲁青未了。</p>\"],\"changePosition\":null},"
+ "\"target\":{\"position\":0,\"lines\":[\"<p>好雨知时节,当春乃发生。</p>\"],"
+ "\"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\":[\"<p>造化钟神秀,阴阳割昏晓。</p>\"],\"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("<p>望岳</p>\n"
+ "<p>岱宗夫如何,齐鲁青未了。</p>\n"
+ "<p>造化钟神秀,阴阳割昏晓。</p>\n");
assertThat(patchedContent.getOriginalContent()).isEqualTo("望岳\n\n岱宗夫如何齐鲁青未了。\n"
+ "\n造化钟神秀阴阳割昏晓。\n");
}
@Test
public void generateDiff() {
ContentDiff contentDiff =
contentPatchLogService.generateDiff(2, "<p>随风潜入夜,润物细无声。</p>", "随风潜入夜,润物细无声。");
assertThat(contentDiff).isNotNull();
assertThat(contentDiff.getDiff()).isEqualTo("[{\"source\":{\"position\":0,"
+ "\"lines\":[\"<p>望岳</p>\",\"<p>岱宗夫如何,齐鲁青未了。</p>\",\"\"],\"changePosition\":null},"
+ "\"target\":{\"position\":0,\"lines\":[\"<p>随风潜入夜,润物细无声。</p>\"],"
+ "\"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("<p>望岳</p>\n"
+ "<p>岱宗夫如何,齐鲁青未了。</p>\n"
+ "<p>造化钟神秀,阴阳割昏晓。</p>\n");
assertThat(patchedContent.getOriginalContent()).isEqualTo("望岳\n\n"
+ "岱宗夫如何,齐鲁青未了。\n\n"
+ "造化钟神秀,阴阳割昏晓。\n");
}
}