mirror of https://github.com/halo-dev/halo
feat: add post module basic implementation (#2326)
<!-- Thanks for sending a pull request! Here are some tips for you: 1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。 1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>. 2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。 2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request. 3. 请确保你已经添加并运行了适当的测试。 3. Ensure you have added or ran the appropriate tests for your PR. --> #### What type of PR is this? /kind feature /milestone 2.0 /area core <!-- 添加其中一个类别: Add one of the following kinds: /kind bug /kind cleanup /kind documentation /kind feature /kind improvement 适当添加其中一个或多个类别(可选): Optionally add one or more of the following kinds if applicable: /kind api-change /kind deprecation /kind failing-test /kind flake /kind regression --> #### What this PR does / why we need it: 新增文章模块的基本实现 提供创建文章,更新草稿和发布文章三个 API #### Which issue(s) this PR fixes: <!-- PR 合并时自动关闭 issue。 Automatically closes linked issue when PR is merged. 用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)` Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`. --> A part of #2322 #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? <!-- 如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。 否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change), Release Note 需要以 `action required` 开头。 If no, just write "NONE" in the release-note block below. If yes, a release note is required: Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required". --> ```release-note None ```pull/2350/head
parent
3f12d0108e
commit
b9957542f4
|
@ -43,6 +43,9 @@ ext {
|
|||
commonsLang3 = "3.12.0"
|
||||
base62 = "0.1.3"
|
||||
pf4j = "3.6.0"
|
||||
javaDiffUtils = "4.12"
|
||||
guava = "31.1-jre"
|
||||
jsoup = "1.15.2"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -68,6 +71,9 @@ dependencies {
|
|||
implementation "org.apache.commons:commons-lang3:$commonsLang3"
|
||||
implementation "io.seruco.encoding:base62:$base62"
|
||||
implementation "org.pf4j:pf4j:$pf4j"
|
||||
implementation "com.google.guava:guava:$guava"
|
||||
implementation "org.jsoup:jsoup:$jsoup"
|
||||
implementation "io.github.java-diff-utils:java-diff-utils:$javaDiffUtils"
|
||||
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
testCompileOnly 'org.projectlombok:lombok'
|
||||
|
|
|
@ -5,9 +5,11 @@ import org.springframework.context.annotation.Bean;
|
|||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import run.halo.app.content.ContentService;
|
||||
import run.halo.app.core.extension.Menu;
|
||||
import run.halo.app.core.extension.MenuItem;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.core.extension.Role;
|
||||
import run.halo.app.core.extension.RoleBinding;
|
||||
import run.halo.app.core.extension.Theme;
|
||||
|
@ -15,6 +17,7 @@ import run.halo.app.core.extension.User;
|
|||
import run.halo.app.core.extension.reconciler.MenuItemReconciler;
|
||||
import run.halo.app.core.extension.reconciler.MenuReconciler;
|
||||
import run.halo.app.core.extension.reconciler.PluginReconciler;
|
||||
import run.halo.app.core.extension.reconciler.PostReconciler;
|
||||
import run.halo.app.core.extension.reconciler.RoleBindingReconciler;
|
||||
import run.halo.app.core.extension.reconciler.RoleReconciler;
|
||||
import run.halo.app.core.extension.reconciler.ThemeReconciler;
|
||||
|
@ -120,6 +123,14 @@ public class ExtensionConfiguration {
|
|||
.extension(new Theme())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Controller postController(ExtensionClient client, ContentService contentService) {
|
||||
return new ControllerBuilder("post-controller", client)
|
||||
.reconciler(new PostReconciler(client, contentService))
|
||||
.extension(new Post())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
import run.halo.app.extension.Metadata;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public record ContentRequest(@Schema(required = true) Snapshot.SubjectRef subjectRef,
|
||||
String headSnapshotName,
|
||||
@Schema(required = true) String raw,
|
||||
@Schema(required = true) String content,
|
||||
@Schema(required = true) String rawType) {
|
||||
|
||||
public Snapshot toSnapshot() {
|
||||
Snapshot snapshot = new Snapshot();
|
||||
|
||||
Metadata metadata = new Metadata();
|
||||
metadata.setName(defaultName(subjectRef));
|
||||
snapshot.setMetadata(metadata);
|
||||
|
||||
Snapshot.SnapShotSpec snapShotSpec = new Snapshot.SnapShotSpec();
|
||||
snapShotSpec.setSubjectRef(subjectRef);
|
||||
snapShotSpec.setVersion(1);
|
||||
snapShotSpec.setRawType(rawType);
|
||||
snapShotSpec.setRawPatch(raw);
|
||||
snapShotSpec.setContentPatch(content);
|
||||
String displayVersion = Snapshot.displayVersionFrom(snapShotSpec.getVersion());
|
||||
snapShotSpec.setDisplayVersion(displayVersion);
|
||||
|
||||
snapshot.setSpec(snapShotSpec);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private String defaultName(Snapshot.SubjectRef subjectRef) {
|
||||
// example: Post-apost-v1-snapshot
|
||||
return String.join("-", subjectRef.getKind(),
|
||||
subjectRef.getName(), "v1", "snapshot");
|
||||
}
|
||||
|
||||
public String rawPatchFrom(String originalRaw) {
|
||||
// originalRaw content from v1
|
||||
return PatchUtils.diffToJsonPatch(originalRaw, this.raw);
|
||||
}
|
||||
|
||||
public String contentPatchFrom(String originalContent) {
|
||||
// originalContent from v1
|
||||
return PatchUtils.diffToJsonPatch(originalContent, this.content);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
|
||||
/**
|
||||
* Content service for {@link Snapshot}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public interface ContentService {
|
||||
|
||||
Mono<ContentWrapper> getContent(String name);
|
||||
|
||||
Mono<ContentWrapper> draftContent(ContentRequest content);
|
||||
|
||||
Mono<ContentWrapper> updateContent(ContentRequest content);
|
||||
|
||||
Mono<ContentWrapper> publish(String headSnapshotName, Snapshot.SubjectRef subjectRef);
|
||||
|
||||
Mono<Snapshot> getBaseSnapshot(Snapshot.SubjectRef subjectRef);
|
||||
|
||||
Mono<Snapshot> latestSnapshotVersion(Snapshot.SubjectRef subjectRef);
|
||||
|
||||
Mono<Snapshot> latestPublishedSnapshot(Snapshot.SubjectRef subjectRef);
|
||||
|
||||
Flux<Snapshot> listSnapshots(Snapshot.SubjectRef subjectRef);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public record ContentWrapper(@Schema(required = true) String snapshotName,
|
||||
@Schema(required = true) String raw,
|
||||
@Schema(required = true) String content,
|
||||
@Schema(required = true) String rawType) {
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Contributor from user.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
public class Contributor {
|
||||
private String displayName;
|
||||
private String avatar;
|
||||
private String name;
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import run.halo.app.core.extension.Category;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.core.extension.Tag;
|
||||
|
||||
/**
|
||||
* An aggregate object of {@link Post} and {@link Category}
|
||||
* and {@link Tag} and more for post list.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
public class ListedPost {
|
||||
|
||||
@Schema(required = true)
|
||||
private Post post;
|
||||
|
||||
@Schema(required = true)
|
||||
private List<Category> categories;
|
||||
|
||||
@Schema(required = true)
|
||||
private List<Tag> tags;
|
||||
|
||||
@Schema(required = true)
|
||||
private List<Contributor> contributors;
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
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.Collections;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class PatchUtils {
|
||||
private static final String DELIMITER = "\n";
|
||||
private static final Splitter lineSplitter = Splitter.on(DELIMITER);
|
||||
|
||||
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));
|
||||
case INSERT -> patch.addDelta(new InsertDelta<>(orgChunk, revChunk));
|
||||
case CHANGE -> patch.addDelta(new ChangeDelta<>(orgChunk, revChunk));
|
||||
default -> throw new IllegalArgumentException("Unsupported delta type.");
|
||||
}
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
public static String patchToJson(Patch<String> patch) {
|
||||
List<AbstractDelta<String>> deltas = patch.getDeltas();
|
||||
return JsonUtils.objectToJson(deltas);
|
||||
}
|
||||
|
||||
public static String applyPatch(String original, String patchJson) {
|
||||
Patch<String> patch = PatchUtils.create(patchJson);
|
||||
try {
|
||||
return String.join(DELIMITER, 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) {
|
||||
if (StringUtils.isBlank(content)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import java.util.Set;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.extension.router.ListRequest;
|
||||
|
||||
/**
|
||||
* A query object for {@link Post} list.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class PostQuery extends ListRequest {
|
||||
|
||||
private Set<String> contributors;
|
||||
|
||||
private Set<String> categories;
|
||||
|
||||
private Set<String> tags;
|
||||
// TODO add more query fields
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public record PostRequest(@Schema(required = true) Post post,
|
||||
@Schema(required = true) Content content) {
|
||||
|
||||
public ContentRequest contentRequest() {
|
||||
Snapshot.SubjectRef subjectRef =
|
||||
Snapshot.SubjectRef.of(Post.KIND, post.getMetadata().getName());
|
||||
return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.raw,
|
||||
content.content, content.rawType);
|
||||
}
|
||||
|
||||
public record Content(String raw, String content, String rawType) {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.extension.ListResult;
|
||||
|
||||
/**
|
||||
* Service for {@link Post}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public interface PostService {
|
||||
|
||||
Mono<ListResult<ListedPost>> listPost(PostQuery query);
|
||||
|
||||
Mono<Post> draftPost(PostRequest postRequest);
|
||||
|
||||
Mono<Post> updatePost(PostRequest postRequest);
|
||||
|
||||
Mono<Post> publishPost(String postName);
|
||||
}
|
|
@ -0,0 +1,235 @@
|
|||
package run.halo.app.content.impl;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.UUID;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.ContentRequest;
|
||||
import run.halo.app.content.ContentService;
|
||||
import run.halo.app.content.ContentWrapper;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
/**
|
||||
* A default implementation of {@link ContentService}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Component
|
||||
public class ContentServiceImpl implements ContentService {
|
||||
private static final Comparator<Snapshot> SNAPSHOT_COMPARATOR =
|
||||
Comparator.comparing(snapshot -> snapshot.getSpec().getVersion());
|
||||
public static Comparator<Snapshot> LATEST_SNAPSHOT_COMPARATOR = SNAPSHOT_COMPARATOR.reversed();
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
public ContentServiceImpl(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ContentWrapper> getContent(String name) {
|
||||
return client.fetch(Snapshot.class, name)
|
||||
.flatMap(snapshot -> getBaseSnapshot(snapshot.getSpec().getSubjectRef())
|
||||
.map(snapshot::applyPatch));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ContentWrapper> draftContent(ContentRequest contentRequest) {
|
||||
return getContextUsername()
|
||||
.flatMap(username -> {
|
||||
// create snapshot
|
||||
Snapshot snapshot = contentRequest.toSnapshot();
|
||||
snapshot.addContributor(username);
|
||||
return client.create(snapshot)
|
||||
.flatMap(this::restoredContent);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ContentWrapper> updateContent(ContentRequest contentRequest) {
|
||||
Assert.notNull(contentRequest, "The contentRequest must not be null");
|
||||
Assert.notNull(contentRequest.headSnapshotName(), "The headSnapshotName must not be null");
|
||||
return Mono.zip(getContextUsername(),
|
||||
client.fetch(Snapshot.class, contentRequest.headSnapshotName()))
|
||||
.flatMap(tuple -> {
|
||||
String username = tuple.getT1();
|
||||
Snapshot headSnapShot = tuple.getT2();
|
||||
return handleSnapshot(headSnapShot, contentRequest, username);
|
||||
})
|
||||
.flatMap(snapshot -> restoredContent(snapshot)
|
||||
.map(content -> new ContentWrapper(snapshot.getMetadata().getName(),
|
||||
content.raw(), content.content(), content.rawType())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ContentWrapper> publish(String headSnapshotName, Snapshot.SubjectRef subjectRef) {
|
||||
Assert.notNull(headSnapshotName, "The headSnapshotName must not be null");
|
||||
return client.fetch(Snapshot.class, headSnapshotName)
|
||||
.flatMap(snapshot -> {
|
||||
if (snapshot.isPublished()) {
|
||||
// there is nothing to publish
|
||||
return restoredContent(snapshot.getMetadata().getName(),
|
||||
subjectRef);
|
||||
}
|
||||
|
||||
Snapshot.SnapShotSpec snapshotSpec = snapshot.getSpec();
|
||||
snapshotSpec.setPublishTime(Instant.now());
|
||||
snapshotSpec.setDisplayVersion(
|
||||
Snapshot.displayVersionFrom(snapshotSpec.getVersion()));
|
||||
return client.update(snapshot)
|
||||
.then(Mono.defer(
|
||||
() -> restoredContent(snapshot.getMetadata().getName(), subjectRef))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ContentWrapper> restoredContent(String snapshotName,
|
||||
Snapshot.SubjectRef subjectRef) {
|
||||
return getBaseSnapshot(subjectRef)
|
||||
.flatMap(baseSnapshot -> client.fetch(Snapshot.class, snapshotName)
|
||||
.map(snapshot -> snapshot.applyPatch(baseSnapshot)));
|
||||
}
|
||||
|
||||
private Mono<ContentWrapper> restoredContent(Snapshot headSnapshot) {
|
||||
return getBaseSnapshot(headSnapshot.getSpec().getSubjectRef())
|
||||
.map(headSnapshot::applyPatch);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Snapshot> getBaseSnapshot(Snapshot.SubjectRef subjectRef) {
|
||||
return listSnapshots(subjectRef)
|
||||
.filter(snapshot -> snapshot.getSpec().getVersion() == 1)
|
||||
.next();
|
||||
}
|
||||
|
||||
private Mono<Snapshot> handleSnapshot(Snapshot headSnapshot, ContentRequest contentRequest,
|
||||
String username) {
|
||||
Snapshot.SubjectRef subjectRef = contentRequest.subjectRef();
|
||||
return getBaseSnapshot(subjectRef).flatMap(baseSnapshot -> {
|
||||
String baseSnapshotName = baseSnapshot.getMetadata().getName();
|
||||
return latestPublishedSnapshot(subjectRef)
|
||||
.flatMap(latestReleasedSnapshot -> {
|
||||
Snapshot newSnapshot = contentRequest.toSnapshot();
|
||||
newSnapshot.getSpec().setSubjectRef(subjectRef);
|
||||
newSnapshot.addContributor(username);
|
||||
// has released snapshot, there are 3 assumptions:
|
||||
// if headPtr != releasePtr && head is not published, then update its content
|
||||
// directly
|
||||
// if headPtr != releasePtr && head is published, then create a new snapshot
|
||||
// if headPtr == releasePtr, then create a new snapshot too
|
||||
return latestSnapshotVersion(subjectRef)
|
||||
.flatMap(latestSnapshot -> {
|
||||
String headSnapshotName = contentRequest.headSnapshotName();
|
||||
newSnapshot.getSpec()
|
||||
.setVersion(latestSnapshot.getSpec().getVersion() + 1);
|
||||
newSnapshot.getSpec().setDisplayVersion(
|
||||
Snapshot.displayVersionFrom(newSnapshot.getSpec().getVersion()));
|
||||
newSnapshot.getSpec()
|
||||
.setParentSnapshotName(headSnapshotName);
|
||||
// head is published or headPtr == releasePtr
|
||||
String releasedSnapshotName =
|
||||
latestReleasedSnapshot.getMetadata().getName();
|
||||
if (headSnapshot.isPublished() || StringUtils.equals(headSnapshotName,
|
||||
releasedSnapshotName)) {
|
||||
// create a new snapshot,done
|
||||
return createNewSnapshot(newSnapshot, baseSnapshotName,
|
||||
contentRequest);
|
||||
}
|
||||
|
||||
// otherwise update its content directly
|
||||
return updateRawAndContentToHeadSnapshot(headSnapshot, baseSnapshotName,
|
||||
contentRequest);
|
||||
});
|
||||
})
|
||||
// no released snapshot, indicating v1 now, just update the content directly
|
||||
.switchIfEmpty(Mono.defer(
|
||||
() -> updateRawAndContentToHeadSnapshot(headSnapshot, baseSnapshotName,
|
||||
contentRequest)));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Snapshot> latestSnapshotVersion(Snapshot.SubjectRef subjectRef) {
|
||||
Assert.notNull(subjectRef, "The subjectRef must not be null.");
|
||||
return listSnapshots(subjectRef)
|
||||
.sort(LATEST_SNAPSHOT_COMPARATOR)
|
||||
.next();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Snapshot> latestPublishedSnapshot(Snapshot.SubjectRef subjectRef) {
|
||||
Assert.notNull(subjectRef, "The subjectRef must not be null.");
|
||||
return listSnapshots(subjectRef)
|
||||
.filter(Snapshot::isPublished)
|
||||
.sort(LATEST_SNAPSHOT_COMPARATOR)
|
||||
.next();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<Snapshot> listSnapshots(Snapshot.SubjectRef subjectRef) {
|
||||
Assert.notNull(subjectRef, "The subjectRef must not be null.");
|
||||
return client.list(Snapshot.class, snapshot -> subjectRef.equals(snapshot.getSpec()
|
||||
.getSubjectRef()), null);
|
||||
}
|
||||
|
||||
private Mono<String> getContextUsername() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Principal::getName);
|
||||
}
|
||||
|
||||
private Mono<Snapshot> updateRawAndContentToHeadSnapshot(Snapshot snapshotToUpdate,
|
||||
String baseSnapshotName,
|
||||
ContentRequest contentRequest) {
|
||||
return client.fetch(Snapshot.class, baseSnapshotName)
|
||||
.flatMap(baseSnapshot -> {
|
||||
determineRawAndContentPatch(snapshotToUpdate,
|
||||
baseSnapshot, contentRequest);
|
||||
return client.update(snapshotToUpdate)
|
||||
.thenReturn(snapshotToUpdate);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Snapshot> createNewSnapshot(Snapshot snapshotToCreate,
|
||||
String baseSnapshotName,
|
||||
ContentRequest contentRequest) {
|
||||
return client.fetch(Snapshot.class, baseSnapshotName)
|
||||
.flatMap(baseSnapshot -> {
|
||||
determineRawAndContentPatch(snapshotToCreate,
|
||||
baseSnapshot, contentRequest);
|
||||
snapshotToCreate.getMetadata().setName(UUID.randomUUID().toString());
|
||||
snapshotToCreate.getSpec().setSubjectRef(contentRequest.subjectRef());
|
||||
return client.create(snapshotToCreate)
|
||||
.thenReturn(snapshotToCreate);
|
||||
});
|
||||
}
|
||||
|
||||
private void determineRawAndContentPatch(Snapshot snapshotToUse, Snapshot baseSnapshot,
|
||||
ContentRequest contentRequest) {
|
||||
String originalRaw = baseSnapshot.getSpec().getRawPatch();
|
||||
String originalContent = baseSnapshot.getSpec().getContentPatch();
|
||||
|
||||
// it is the v1 snapshot, set the content directly
|
||||
if (snapshotToUse.getSpec().getVersion() == 1) {
|
||||
snapshotToUse.getSpec().setRawPatch(contentRequest.raw());
|
||||
snapshotToUse.getSpec().setContentPatch(contentRequest.content());
|
||||
} else {
|
||||
// otherwise diff a patch based on the v1 snapshot
|
||||
String revisedRaw = contentRequest.rawPatchFrom(originalRaw);
|
||||
String revisedContent = contentRequest.contentPatchFrom(originalContent);
|
||||
snapshotToUse.getSpec().setRawPatch(revisedRaw);
|
||||
snapshotToUse.getSpec().setContentPatch(revisedContent);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,237 @@
|
|||
package run.halo.app.content.impl;
|
||||
|
||||
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.ContentService;
|
||||
import run.halo.app.content.Contributor;
|
||||
import run.halo.app.content.ListedPost;
|
||||
import run.halo.app.content.PostQuery;
|
||||
import run.halo.app.content.PostRequest;
|
||||
import run.halo.app.content.PostService;
|
||||
import run.halo.app.core.extension.Category;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
import run.halo.app.core.extension.Tag;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.Condition;
|
||||
import run.halo.app.infra.ConditionStatus;
|
||||
|
||||
/**
|
||||
* A default implementation of {@link PostService}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Component
|
||||
public class PostServiceImpl implements PostService {
|
||||
private static final Comparator<Post> DEFAULT_POST_COMPARATOR =
|
||||
Comparator.comparing(post -> post.getMetadata().getCreationTimestamp());
|
||||
private final ContentService contentService;
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
public PostServiceImpl(ContentService contentService, ReactiveExtensionClient client) {
|
||||
this.contentService = contentService;
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ListResult<ListedPost>> listPost(PostQuery query) {
|
||||
return client.list(Post.class, postListPredicate(query),
|
||||
DEFAULT_POST_COMPARATOR.reversed(), query.getPage(), query.getSize())
|
||||
.flatMap(listResult -> Flux.fromStream(
|
||||
listResult.get().map(this::getListedPost)
|
||||
)
|
||||
.flatMap(Function.identity())
|
||||
.collectList()
|
||||
.map(listedPosts -> new ListResult<>(listResult.getPage(), listResult.getSize(),
|
||||
listResult.getTotal(), listedPosts)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Predicate<Post> postListPredicate(PostQuery query) {
|
||||
Predicate<Post> paramPredicate = post ->
|
||||
contains(query.getCategories(), post.getSpec().getCategories())
|
||||
&& contains(query.getTags(), post.getSpec().getTags())
|
||||
&& contains(query.getContributors(), post.getStatus().getContributors());
|
||||
Predicate<Post> predicate = labelAndFieldSelectorToPredicate(query.getLabelSelector(),
|
||||
query.getFieldSelector());
|
||||
return predicate.and(paramPredicate);
|
||||
}
|
||||
|
||||
boolean contains(Collection<String> left, List<String> right) {
|
||||
// parameter is null, it means that ignore this condition
|
||||
if (left == null) {
|
||||
return true;
|
||||
}
|
||||
// else, it means that right is empty
|
||||
if (left.isEmpty()) {
|
||||
return right.isEmpty();
|
||||
}
|
||||
if (right == null) {
|
||||
return false;
|
||||
}
|
||||
return right.stream().anyMatch(left::contains);
|
||||
}
|
||||
|
||||
private Mono<ListedPost> getListedPost(Post post) {
|
||||
Assert.notNull(post, "The post must not be null.");
|
||||
ListedPost listedPost = new ListedPost();
|
||||
listedPost.setPost(post);
|
||||
return Mono.zip(listTags(post.getSpec().getTags()),
|
||||
listCategories(post.getSpec().getCategories()),
|
||||
listContributors(post.getStatusOrDefault().getContributors())
|
||||
)
|
||||
.map(tuple -> {
|
||||
List<Tag> tags = tuple.getT1();
|
||||
List<Category> categories = tuple.getT2();
|
||||
List<Contributor> contributors = tuple.getT3();
|
||||
listedPost.setTags(tags);
|
||||
listedPost.setCategories(categories);
|
||||
listedPost.setContributors(contributors);
|
||||
return listedPost;
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<List<Tag>> listTags(List<String> tagNames) {
|
||||
if (tagNames == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
return Flux.fromStream(tagNames.stream()
|
||||
.map(tagName -> client.fetch(Tag.class, tagName)))
|
||||
.flatMap(Function.identity())
|
||||
.collectList();
|
||||
}
|
||||
|
||||
private Mono<List<Category>> listCategories(List<String> categoryNames) {
|
||||
if (categoryNames == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
return Flux.fromStream(categoryNames.stream()
|
||||
.map(categoryName -> client.fetch(Category.class, categoryName)))
|
||||
.flatMap(Function.identity())
|
||||
.collectList();
|
||||
}
|
||||
|
||||
private Mono<List<Contributor>> listContributors(List<String> usernames) {
|
||||
if (usernames == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
return Flux.fromIterable(usernames)
|
||||
.map(username -> client.fetch(User.class, username)
|
||||
.map(user -> {
|
||||
Contributor contributor = new Contributor();
|
||||
contributor.setName(username);
|
||||
contributor.setDisplayName(user.getSpec().getDisplayName());
|
||||
contributor.setAvatar(user.getSpec().getAvatar());
|
||||
return contributor;
|
||||
})
|
||||
)
|
||||
.flatMap(Function.identity())
|
||||
.collectList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Post> draftPost(PostRequest postRequest) {
|
||||
return contentService.draftContent(postRequest.contentRequest())
|
||||
.flatMap(contentWrapper -> getContextUsername()
|
||||
.flatMap(username -> {
|
||||
Post post = postRequest.post();
|
||||
post.getSpec().setBaseSnapshot(contentWrapper.snapshotName());
|
||||
post.getSpec().setHeadSnapshot(contentWrapper.snapshotName());
|
||||
post.getSpec().setOwner(username);
|
||||
appendPublishedCondition(post, Post.PostPhase.DRAFT);
|
||||
return client.create(post)
|
||||
.then(Mono.defer(() ->
|
||||
client.fetch(Post.class, postRequest.post().getMetadata().getName())));
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Post> updatePost(PostRequest postRequest) {
|
||||
Post post = postRequest.post();
|
||||
return contentService.updateContent(postRequest.contentRequest())
|
||||
.flatMap(contentWrapper -> {
|
||||
post.getSpec().setHeadSnapshot(contentWrapper.snapshotName());
|
||||
return client.update(post);
|
||||
})
|
||||
.then(Mono.defer(() -> client.fetch(Post.class, post.getMetadata().getName())));
|
||||
}
|
||||
|
||||
private Mono<String> getContextUsername() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Principal::getName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Post> publishPost(String postName) {
|
||||
return client.fetch(Post.class, postName)
|
||||
.flatMap(post -> {
|
||||
Post.PostSpec spec = post.getSpec();
|
||||
// publish snapshot
|
||||
return Mono.zip(Mono.just(post),
|
||||
client.fetch(Snapshot.class, spec.getHeadSnapshot()));
|
||||
})
|
||||
.flatMap(tuple -> {
|
||||
Post post = tuple.getT1();
|
||||
Snapshot snapshot = tuple.getT2();
|
||||
|
||||
Post.PostSpec postSpec = post.getSpec();
|
||||
if (Objects.equals(true, postSpec.getPublished())) {
|
||||
// has been published before
|
||||
postSpec.setVersion(postSpec.getVersion() + 1);
|
||||
} else {
|
||||
postSpec.setPublished(true);
|
||||
}
|
||||
|
||||
if (postSpec.getPublishTime() == null) {
|
||||
postSpec.setPublishTime(Instant.now());
|
||||
}
|
||||
|
||||
// update release snapshot name and condition
|
||||
postSpec.setReleaseSnapshot(snapshot.getMetadata().getName());
|
||||
appendPublishedCondition(post, Post.PostPhase.PUBLISHED);
|
||||
|
||||
Snapshot.SubjectRef subjectRef =
|
||||
Snapshot.SubjectRef.of(Post.KIND, post.getMetadata().getName());
|
||||
return contentService.publish(snapshot.getMetadata().getName(), subjectRef)
|
||||
.flatMap(contentWrapper -> {
|
||||
post.getSpec().setReleaseSnapshot(contentWrapper.snapshotName());
|
||||
return client.update(post);
|
||||
})
|
||||
.then(Mono.defer(() -> client.fetch(Post.class, postName)));
|
||||
});
|
||||
}
|
||||
|
||||
void appendPublishedCondition(Post post, Post.PostPhase phase) {
|
||||
Assert.notNull(post, "The post must not be null.");
|
||||
Post.PostStatus status = post.getStatusOrDefault();
|
||||
status.setPhase(phase.name());
|
||||
List<Condition> conditions = status.getConditionsOrDefault();
|
||||
Condition condition = new Condition();
|
||||
conditions.add(condition);
|
||||
|
||||
condition.setType(phase.name());
|
||||
condition.setReason(phase.name());
|
||||
condition.setMessage("");
|
||||
condition.setStatus(ConditionStatus.TRUE);
|
||||
condition.setLastTransitionTime(Instant.now());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package run.halo.app.core.extension;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @see <a href="https://github.com/halo-dev/halo/issues/2322">issue#2322</a>
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@GVK(group = "content.halo.run", version = "v1alpha1",
|
||||
kind = "Category", plural = "categories", singular = "category")
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Category extends AbstractExtension {
|
||||
|
||||
@Schema(required = true)
|
||||
private CategorySpec spec;
|
||||
|
||||
@Schema
|
||||
private CategoryStatus status;
|
||||
|
||||
@Data
|
||||
public static class CategorySpec {
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String displayName;
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String slug;
|
||||
|
||||
private String description;
|
||||
|
||||
private String cover;
|
||||
|
||||
private String template;
|
||||
|
||||
@Schema(required = true, defaultValue = "0")
|
||||
private Integer priority;
|
||||
|
||||
private List<String> children;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CategoryStatus {
|
||||
|
||||
private String permalink;
|
||||
|
||||
/**
|
||||
* 包括当前和其下所有层级的文章 name (depth=max).
|
||||
*/
|
||||
private List<String> posts;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
package run.halo.app.core.extension;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.Map;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @see <a href="https://github.com/halo-dev/halo/issues/2322">issue#2322</a>
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@GVK(group = "content.halo.run", version = "v1alpha1",
|
||||
kind = Comment.KIND, plural = "comments", singular = "comment")
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Comment extends AbstractExtension {
|
||||
public static final String KIND = "Comment";
|
||||
|
||||
@Schema(required = true)
|
||||
private CommentSpec spec;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public static class CommentSpec extends BaseCommentSpec {
|
||||
|
||||
@Schema(required = true)
|
||||
private CommentSubjectRef subjectRef;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class BaseCommentSpec {
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String raw;
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String content;
|
||||
|
||||
@Schema(required = true)
|
||||
private CommentOwner owner;
|
||||
|
||||
private String userAgent;
|
||||
|
||||
private String ipAddress;
|
||||
|
||||
@Schema(required = true, defaultValue = "0")
|
||||
private Integer priority;
|
||||
|
||||
@Schema(required = true, defaultValue = "false")
|
||||
private Boolean top;
|
||||
|
||||
@Schema(required = true, defaultValue = "true")
|
||||
private Boolean allowNotification;
|
||||
|
||||
@Schema(required = true, defaultValue = "false")
|
||||
private Boolean approved;
|
||||
|
||||
@Schema(required = true, defaultValue = "false")
|
||||
private Boolean hidden;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CommentOwner {
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String kind;
|
||||
|
||||
@Schema(required = true, minLength = 1, maxLength = 64)
|
||||
private String name;
|
||||
|
||||
private String displayName;
|
||||
|
||||
private Map<String, String> annotations;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CommentSubjectRef {
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String kind;
|
||||
|
||||
@Schema(required = true, minLength = 1, maxLength = 64)
|
||||
private String name;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
package run.halo.app.core.extension;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
import run.halo.app.infra.Condition;
|
||||
|
||||
/**
|
||||
* <p>Post extension.</p>
|
||||
*
|
||||
* @author guqing
|
||||
* @see <a href="https://github.com/halo-dev/halo/issues/2322">issue#2322</a>
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@GVK(group = "content.halo.run", version = "v1alpha1", kind = Post.KIND,
|
||||
plural = "posts", singular = "post")
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Post extends AbstractExtension {
|
||||
public static final String KIND = "Post";
|
||||
public static final String CATEGORIES_ANNO = "content.halo.run/categories";
|
||||
public static final String TAGS_ANNO = "content.halo.run/tags";
|
||||
public static final String DELETED_LABEL = "content.halo.run/deleted";
|
||||
public static final String OWNER_LABEL = "content.halo.run/owner";
|
||||
public static final String VISIBLE_LABEL = "content.halo.run/visible";
|
||||
public static final String PHASE_LABEL = "content.halo.run/phase";
|
||||
|
||||
@Schema(required = true)
|
||||
private PostSpec spec;
|
||||
|
||||
@Schema
|
||||
private PostStatus status;
|
||||
|
||||
@JsonIgnore
|
||||
public PostStatus getStatusOrDefault() {
|
||||
if (this.status == null) {
|
||||
this.status = new PostStatus();
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PostSpec {
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String title;
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String slug;
|
||||
|
||||
/**
|
||||
* 文章引用到的已发布的内容,用于主题端显示.
|
||||
*/
|
||||
private String releaseSnapshot;
|
||||
|
||||
private String headSnapshot;
|
||||
|
||||
private String baseSnapshot;
|
||||
|
||||
private String owner;
|
||||
|
||||
private String template;
|
||||
|
||||
private String cover;
|
||||
|
||||
@Schema(required = true, defaultValue = "false")
|
||||
private Boolean deleted;
|
||||
|
||||
@Schema(required = true, defaultValue = "false")
|
||||
private Boolean published;
|
||||
|
||||
private Instant publishTime;
|
||||
|
||||
@Schema(required = true, defaultValue = "false")
|
||||
private Boolean pinned;
|
||||
|
||||
@Schema(required = true, defaultValue = "true")
|
||||
private Boolean allowComment;
|
||||
|
||||
@Schema(required = true, defaultValue = "PUBLIC")
|
||||
private VisibleEnum visible;
|
||||
|
||||
@Schema(required = true, defaultValue = "1")
|
||||
private Integer version;
|
||||
|
||||
@Schema(required = true, defaultValue = "0")
|
||||
private Integer priority;
|
||||
|
||||
@Schema(required = true)
|
||||
private Excerpt excerpt;
|
||||
|
||||
private List<String> categories;
|
||||
|
||||
private List<String> tags;
|
||||
|
||||
private List<Map<String, String>> htmlMetas;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PostStatus {
|
||||
@Schema(required = true)
|
||||
private String phase;
|
||||
|
||||
@Schema
|
||||
private List<Condition> conditions;
|
||||
|
||||
private String permalink;
|
||||
|
||||
private String excerpt;
|
||||
|
||||
private Boolean inProgress;
|
||||
|
||||
private List<String> contributors;
|
||||
|
||||
@JsonIgnore
|
||||
public List<Condition> getConditionsOrDefault() {
|
||||
if (this.conditions == null) {
|
||||
this.conditions = new ArrayList<>();
|
||||
}
|
||||
return conditions;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Excerpt {
|
||||
|
||||
@Schema(required = true, defaultValue = "true")
|
||||
private Boolean autoGenerate;
|
||||
|
||||
private String raw;
|
||||
}
|
||||
|
||||
public enum PostPhase {
|
||||
DRAFT,
|
||||
PENDING_APPROVAL,
|
||||
PUBLISHED
|
||||
}
|
||||
|
||||
public enum VisibleEnum {
|
||||
PUBLIC,
|
||||
INTERNAL,
|
||||
PRIVATE
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package run.halo.app.core.extension;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @see <a href="https://github.com/halo-dev/halo/issues/2322">issue#2322</a>
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@GVK(group = "content.halo.run", version = "v1alpha1",
|
||||
kind = Reply.KIND, plural = "replies", singular = "reply")
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Reply extends AbstractExtension {
|
||||
|
||||
public static final String KIND = "Reply";
|
||||
|
||||
@Schema(required = true)
|
||||
private ReplySpec spec;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public static class ReplySpec extends Comment.BaseCommentSpec {
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String commentName;
|
||||
|
||||
private String quoteReply;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
package run.halo.app.core.extension;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.springframework.util.Assert;
|
||||
import run.halo.app.content.ContentWrapper;
|
||||
import run.halo.app.content.PatchUtils;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @see <a href="https://github.com/halo-dev/halo/issues/2322">issue#2322</a>
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@GVK(group = "content.halo.run", version = "v1alpha1",
|
||||
kind = Snapshot.KIND, plural = "snapshots", singular = "snapshot")
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Snapshot extends AbstractExtension {
|
||||
public static final String KIND = "Snapshot";
|
||||
|
||||
@Schema(required = true)
|
||||
private SnapShotSpec spec;
|
||||
|
||||
@Data
|
||||
public static class SnapShotSpec {
|
||||
|
||||
@Schema(required = true)
|
||||
private SubjectRef subjectRef;
|
||||
|
||||
/**
|
||||
* such as: markdown | html | json | asciidoc | latex.
|
||||
*/
|
||||
@Schema(required = true, minLength = 1, maxLength = 50)
|
||||
private String rawType;
|
||||
|
||||
private String rawPatch;
|
||||
|
||||
private String contentPatch;
|
||||
|
||||
private String parentSnapshotName;
|
||||
|
||||
@Schema(required = true)
|
||||
private String displayVersion;
|
||||
|
||||
@Schema(required = true, defaultValue = "1")
|
||||
private Integer version;
|
||||
|
||||
private Instant publishTime;
|
||||
|
||||
private Set<String> contributors;
|
||||
|
||||
@JsonIgnore
|
||||
public Set<String> getContributorsOrDefault() {
|
||||
if (this.contributors == null) {
|
||||
this.contributors = new LinkedHashSet<>();
|
||||
}
|
||||
return this.contributors;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public static class SubjectRef {
|
||||
@Schema(required = true)
|
||||
private String kind;
|
||||
|
||||
@Schema(required = true)
|
||||
private String name;
|
||||
|
||||
public static SubjectRef of(String kind, String name) {
|
||||
SubjectRef subjectRef = new SubjectRef();
|
||||
subjectRef.setKind(kind);
|
||||
subjectRef.setName(name);
|
||||
return subjectRef;
|
||||
}
|
||||
}
|
||||
|
||||
public static String displayVersionFrom(Integer version) {
|
||||
Assert.notNull(version, "The version must not be null");
|
||||
return "v" + version;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isPublished() {
|
||||
return this.spec.getPublishTime() != null;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void addContributor(String name) {
|
||||
Assert.notNull(name, "The username must not be null.");
|
||||
Set<String> contributors = spec.getContributorsOrDefault();
|
||||
contributors.add(name);
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void setSubjectRef(String kind, String name) {
|
||||
Assert.notNull(kind, "The subject kind must not be null.");
|
||||
Assert.notNull(name, "The subject name must not be null.");
|
||||
if (spec.subjectRef == null) {
|
||||
spec.subjectRef = new SubjectRef();
|
||||
}
|
||||
spec.subjectRef.setKind(kind);
|
||||
spec.subjectRef.setName(name);
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public ContentWrapper applyPatch(Snapshot baseSnapshot) {
|
||||
Assert.notNull(baseSnapshot, "The baseSnapshot must not be null.");
|
||||
if (this.spec.version == 1) {
|
||||
return new ContentWrapper(this.getMetadata().getName(), this.spec.rawPatch,
|
||||
this.spec.contentPatch, this.spec.rawType);
|
||||
}
|
||||
String patchedContent =
|
||||
PatchUtils.applyPatch(baseSnapshot.getSpec().getContentPatch(), this.spec.contentPatch);
|
||||
String patchedRaw =
|
||||
PatchUtils.applyPatch(baseSnapshot.getSpec().getRawPatch(), this.spec.rawPatch);
|
||||
return new ContentWrapper(this.getMetadata().getName(), patchedRaw,
|
||||
patchedContent, this.spec.rawType);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
package run.halo.app.core.extension;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @see <a href="https://github.com/halo-dev/halo/issues/2322">issue#2322</a>
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@GVK(group = "content.halo.run", version = "v1alpha1",
|
||||
kind = Tag.KIND, plural = "tags", singular = "tag")
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Tag extends AbstractExtension {
|
||||
public static final String KIND = "Tag";
|
||||
|
||||
@Schema(required = true)
|
||||
private TagSpec spec;
|
||||
|
||||
@Schema
|
||||
private TagStatus status;
|
||||
|
||||
@Data
|
||||
public static class TagSpec {
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String displayName;
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String slug;
|
||||
|
||||
/**
|
||||
* Color regex explanation.
|
||||
* <pre>
|
||||
* ^ # start of the line
|
||||
* # # start with a number sign `#`
|
||||
* ( # start of (group 1)
|
||||
* [a-fA-F0-9]{6} # support z-f, A-F and 0-9, with a length of 6
|
||||
* | # or
|
||||
* [a-fA-F0-9]{3} # support z-f, A-F and 0-9, with a length of 3
|
||||
* ) # end of (group 1)
|
||||
* $ # end of the line
|
||||
* </pre>
|
||||
*/
|
||||
@Schema(pattern = "^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$")
|
||||
private String color;
|
||||
|
||||
private String cover;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TagStatus {
|
||||
|
||||
private String permalink;
|
||||
|
||||
private List<String> posts;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
||||
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import org.springdoc.core.fn.builders.schema.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.ContentRequest;
|
||||
import run.halo.app.content.ContentService;
|
||||
import run.halo.app.content.ContentWrapper;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
|
||||
/**
|
||||
* Endpoint for managing content.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Component
|
||||
public class ContentEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ContentService contentService;
|
||||
|
||||
public ContentEndpoint(ContentService contentService) {
|
||||
this.contentService = contentService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
final var tag = "api.halo.run/v1alpha1/Content";
|
||||
return SpringdocRouteBuilder.route()
|
||||
.GET("contents/{snapshotName}", this::obtainContent,
|
||||
builder -> builder.operationId("ObtainSnapshotContent")
|
||||
.description("Obtain a snapshot content.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.required(true)
|
||||
.name("snapshotName")
|
||||
.in(ParameterIn.PATH))
|
||||
.response(responseBuilder()
|
||||
.implementation(ContentWrapper.class))
|
||||
)
|
||||
.POST("contents", this::draftSnapshotContent,
|
||||
builder -> builder.operationId("DraftSnapshotContent")
|
||||
.description("Draft a snapshot content.")
|
||||
.tag(tag)
|
||||
.requestBody(requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.APPLICATION_JSON_VALUE)
|
||||
.schema(Builder.schemaBuilder()
|
||||
.implementation(ContentRequest.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(ContentWrapper.class))
|
||||
)
|
||||
.PUT("contents/{snapshotName}", this::updateSnapshotContent,
|
||||
builder -> builder.operationId("UpdateSnapshotContent")
|
||||
.description("Update a snapshot content.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.required(true)
|
||||
.name("snapshotName")
|
||||
.in(ParameterIn.PATH))
|
||||
.requestBody(requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.APPLICATION_JSON_VALUE)
|
||||
.schema(Builder.schemaBuilder()
|
||||
.implementation(ContentRequest.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(ContentWrapper.class))
|
||||
)
|
||||
.PUT("contents/{snapshotName}/publish", this::publishSnapshotContent,
|
||||
builder -> builder.operationId("PublishSnapshotContent")
|
||||
.description("Publish a snapshot content.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.required(true)
|
||||
.name("snapshotName")
|
||||
.in(ParameterIn.PATH))
|
||||
.requestBody(requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.APPLICATION_JSON_VALUE)
|
||||
.schema(Builder.schemaBuilder()
|
||||
.implementation(Snapshot.SubjectRef.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(ContentWrapper.class))
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> obtainContent(ServerRequest request) {
|
||||
String snapshotName = request.pathVariable("snapshotName");
|
||||
return contentService.getContent(snapshotName)
|
||||
.flatMap(content -> ServerResponse.ok().bodyValue(content));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> updateSnapshotContent(ServerRequest request) {
|
||||
String snapshotName = request.pathVariable("snapshotName");
|
||||
return request.bodyToMono(ContentRequest.class)
|
||||
.flatMap(content -> {
|
||||
ContentRequest contentRequest =
|
||||
new ContentRequest(content.subjectRef(), snapshotName,
|
||||
content.raw(), content.content(), content.rawType());
|
||||
return contentService.updateContent(contentRequest);
|
||||
})
|
||||
.flatMap(content -> ServerResponse.ok().bodyValue(content));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> publishSnapshotContent(ServerRequest request) {
|
||||
String snapshotName = request.pathVariable("snapshotName");
|
||||
return request.bodyToMono(Snapshot.SubjectRef.class)
|
||||
.flatMap(subjectRef -> contentService.publish(snapshotName, subjectRef))
|
||||
.flatMap(content -> ServerResponse.ok().bodyValue(content));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> draftSnapshotContent(ServerRequest request) {
|
||||
return request.bodyToMono(ContentRequest.class)
|
||||
.flatMap(contentService::draftContent)
|
||||
.flatMap(content -> ServerResponse.ok().bodyValue(content));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
||||
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import org.springdoc.core.fn.builders.schema.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.boot.convert.ApplicationConversionService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.ListedPost;
|
||||
import run.halo.app.content.PostQuery;
|
||||
import run.halo.app.content.PostRequest;
|
||||
import run.halo.app.content.PostService;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.router.QueryParamBuildUtil;
|
||||
|
||||
/**
|
||||
* Endpoint for managing posts.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Component
|
||||
public class PostEndpoint implements CustomEndpoint {
|
||||
|
||||
private final PostService postService;
|
||||
|
||||
public PostEndpoint(PostService postService) {
|
||||
this.postService = postService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
final var tag = "api.halo.run/v1alpha1/Post";
|
||||
return SpringdocRouteBuilder.route()
|
||||
.GET("posts", this::listPost, builder -> {
|
||||
builder.operationId("ListPosts")
|
||||
.description("List posts.")
|
||||
.tag(tag)
|
||||
.response(responseBuilder()
|
||||
.implementation(ListResult.generateGenericClass(ListedPost.class))
|
||||
);
|
||||
QueryParamBuildUtil.buildParametersFromType(builder, PostQuery.class);
|
||||
}
|
||||
)
|
||||
.POST("posts", this::draftPost,
|
||||
builder -> builder.operationId("DraftPost")
|
||||
.description("Draft a post.")
|
||||
.tag(tag)
|
||||
.requestBody(requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.APPLICATION_JSON_VALUE)
|
||||
.schema(Builder.schemaBuilder()
|
||||
.implementation(PostRequest.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(Post.class))
|
||||
)
|
||||
.PUT("posts/{name}", this::updatePost,
|
||||
builder -> builder.operationId("UpdateDraftPost")
|
||||
.description("Update a post.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder().name("name")
|
||||
.in(ParameterIn.PATH)
|
||||
.required(true)
|
||||
.implementation(String.class))
|
||||
.requestBody(requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.APPLICATION_JSON_VALUE)
|
||||
.schema(Builder.schemaBuilder()
|
||||
.implementation(PostRequest.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(Post.class))
|
||||
)
|
||||
.PUT("posts/{name}/publish", this::publishPost,
|
||||
builder -> builder.operationId("PublishPost")
|
||||
.description("Publish a post.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder().name("name")
|
||||
.in(ParameterIn.PATH)
|
||||
.required(true)
|
||||
.implementation(String.class))
|
||||
.response(responseBuilder()
|
||||
.implementation(Post.class))
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
Mono<ServerResponse> draftPost(ServerRequest request) {
|
||||
return request.bodyToMono(PostRequest.class)
|
||||
.flatMap(postService::draftPost)
|
||||
.flatMap(post -> ServerResponse.ok().bodyValue(post));
|
||||
}
|
||||
|
||||
Mono<ServerResponse> updatePost(ServerRequest request) {
|
||||
return request.bodyToMono(PostRequest.class)
|
||||
.flatMap(postService::updatePost)
|
||||
.flatMap(post -> ServerResponse.ok().bodyValue(post));
|
||||
}
|
||||
|
||||
Mono<ServerResponse> publishPost(ServerRequest request) {
|
||||
String name = request.pathVariable("name");
|
||||
return postService.publishPost(name)
|
||||
.flatMap(post -> ServerResponse.ok().bodyValue(post));
|
||||
}
|
||||
|
||||
Mono<ServerResponse> listPost(ServerRequest request) {
|
||||
var conversionService = ApplicationConversionService.getSharedInstance();
|
||||
var page =
|
||||
request.queryParam("page")
|
||||
.map(pageString -> conversionService.convert(pageString, Integer.class))
|
||||
.orElse(0);
|
||||
|
||||
var size = request.queryParam("size")
|
||||
.map(sizeString -> conversionService.convert(sizeString, Integer.class))
|
||||
.orElse(0);
|
||||
|
||||
var labelSelectors = request.queryParams().get("labelSelector");
|
||||
var fieldSelectors = request.queryParams().get("fieldSelector");
|
||||
PostQuery postQuery = new PostQuery();
|
||||
postQuery.setPage(page);
|
||||
postQuery.setSize(size);
|
||||
postQuery.setLabelSelector(labelSelectors);
|
||||
postQuery.setFieldSelector(fieldSelectors);
|
||||
return postService.listPost(postQuery)
|
||||
.flatMap(listedPosts -> ServerResponse.ok().bodyValue(listedPosts));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
package run.halo.app.core.extension.reconciler;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.springframework.util.Assert;
|
||||
import run.halo.app.content.ContentService;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.infra.Condition;
|
||||
import run.halo.app.infra.ConditionStatus;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* <p>Reconciler for {@link Post}.</p>
|
||||
*
|
||||
* <p>things to do:</p>
|
||||
* <ul>
|
||||
* 1. generate permalink
|
||||
* 2. generate excerpt if auto generate is enabled
|
||||
* </ul>
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class PostReconciler implements Reconciler {
|
||||
public static final String PERMALINK_PREFIX = "/permalink/posts/";
|
||||
private final ExtensionClient client;
|
||||
private final ContentService contentService;
|
||||
|
||||
public PostReconciler(ExtensionClient client, ContentService contentService) {
|
||||
this.client = client;
|
||||
this.contentService = contentService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(Post.class, request.name())
|
||||
.ifPresent(post -> {
|
||||
Post oldPost = JsonUtils.deepCopy(post);
|
||||
|
||||
doReconcile(post);
|
||||
|
||||
if (!oldPost.equals(post)) {
|
||||
client.update(post);
|
||||
}
|
||||
});
|
||||
return new Result(false, null);
|
||||
}
|
||||
|
||||
private void doReconcile(Post post) {
|
||||
String name = post.getMetadata().getName();
|
||||
Post.PostSpec spec = post.getSpec();
|
||||
Post.PostStatus status = post.getStatusOrDefault();
|
||||
if (status.getPhase() == null) {
|
||||
status.setPhase(Post.PostPhase.DRAFT.name());
|
||||
}
|
||||
// handle permalink
|
||||
if (StringUtils.isBlank(status.getPermalink())) {
|
||||
status.setPermalink(PERMALINK_PREFIX + name);
|
||||
}
|
||||
|
||||
// handle excerpt
|
||||
Post.Excerpt excerpt = spec.getExcerpt();
|
||||
if (excerpt == null) {
|
||||
excerpt = new Post.Excerpt();
|
||||
excerpt.setAutoGenerate(true);
|
||||
spec.setExcerpt(excerpt);
|
||||
}
|
||||
|
||||
if (excerpt.getAutoGenerate()) {
|
||||
contentService.getContent(spec.getHeadSnapshot())
|
||||
.subscribe(content -> {
|
||||
String contentRevised = content.content();
|
||||
status.setExcerpt(getExcerpt(contentRevised));
|
||||
});
|
||||
} else {
|
||||
status.setExcerpt(excerpt.getRaw());
|
||||
}
|
||||
|
||||
// handle contributors
|
||||
contentService.listSnapshots(Snapshot.SubjectRef.of(Post.KIND, name))
|
||||
.collectList()
|
||||
.subscribe(snapshots -> {
|
||||
List<String> contributors = snapshots.stream()
|
||||
.map(snapshot -> {
|
||||
Set<String> usernames = snapshot.getSpec().getContributors();
|
||||
return Objects.requireNonNullElseGet(usernames,
|
||||
() -> new HashSet<String>());
|
||||
})
|
||||
.flatMap(Set::stream)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
status.setContributors(contributors);
|
||||
});
|
||||
|
||||
// handle cancel publish,has released version and published is false and not handled
|
||||
if (StringUtils.isNotBlank(spec.getReleaseSnapshot())
|
||||
&& Objects.equals(false, spec.getPublished())
|
||||
&& !StringUtils.equals(status.getPhase(), Post.PostPhase.DRAFT.name())) {
|
||||
Condition condition = new Condition();
|
||||
condition.setType("CancelledPublish");
|
||||
condition.setStatus(ConditionStatus.TRUE);
|
||||
condition.setReason(condition.getType());
|
||||
condition.setMessage(StringUtils.EMPTY);
|
||||
condition.setLastTransitionTime(Instant.now());
|
||||
status.getConditionsOrDefault().add(condition);
|
||||
status.setPhase(Post.PostPhase.DRAFT.name());
|
||||
}
|
||||
|
||||
// handle logic delete
|
||||
Map<String, String> labels = getLabelsOrDefault(post);
|
||||
if (Objects.equals(spec.getDeleted(), true)) {
|
||||
labels.put(Post.DELETED_LABEL, Boolean.TRUE.toString());
|
||||
// TODO do more about logic delete such as remove router
|
||||
} else {
|
||||
labels.put(Post.DELETED_LABEL, Boolean.FALSE.toString());
|
||||
}
|
||||
// synchronize some fields to labels to query
|
||||
labels.put(Post.PHASE_LABEL, status.getPhase());
|
||||
labels.put(Post.VISIBLE_LABEL,
|
||||
Objects.requireNonNullElse(spec.getVisible(), Post.VisibleEnum.PUBLIC).name());
|
||||
labels.put(Post.OWNER_LABEL, spec.getOwner());
|
||||
}
|
||||
|
||||
private Map<String, String> getLabelsOrDefault(Post post) {
|
||||
Assert.notNull(post, "The post must not be null.");
|
||||
Map<String, String> labels = post.getMetadata().getLabels();
|
||||
if (labels == null) {
|
||||
labels = new LinkedHashMap<>();
|
||||
post.getMetadata().setLabels(labels);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private String getExcerpt(String htmlContent) {
|
||||
String shortHtmlContent = StringUtils.substring(htmlContent, 0, 500);
|
||||
String text = Jsoup.parse(shortHtmlContent).text();
|
||||
// TODO The default capture 150 words as excerpt
|
||||
return StringUtils.substring(text, 0, 150);
|
||||
}
|
||||
}
|
|
@ -105,4 +105,23 @@ public class ListResult<T> implements Streamable<T> {
|
|||
.load(ListResult.class.getClassLoader())
|
||||
.getLoaded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate generic ListResult class. Like {@code ListResult<User>}, {@code ListResult<Post>},
|
||||
* etc.
|
||||
*
|
||||
* @param type the generic type of {@link ListResult}.
|
||||
* @return generic ListResult class.
|
||||
*/
|
||||
public static <T> Class<?> generateGenericClass(Class<T> type) {
|
||||
var generic =
|
||||
TypeDescription.Generic.Builder.parameterizedType(ListResult.class, type)
|
||||
.build();
|
||||
return new ByteBuddy()
|
||||
.subclass(generic)
|
||||
.name(type.getSimpleName() + "List")
|
||||
.make()
|
||||
.load(ListResult.class.getClassLoader())
|
||||
.getLoaded();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @see
|
||||
* <a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-conditions">pod-conditions</a>
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
public class Condition {
|
||||
/**
|
||||
* type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
* example: Ready, Initialized.
|
||||
* maxLength: 316.
|
||||
*/
|
||||
@Schema(required = true, maxLength = 316,
|
||||
pattern = "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?("
|
||||
+ "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$")
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* Status is the status of the condition. Can be True, False, Unknown.
|
||||
*/
|
||||
@Schema(required = true)
|
||||
private ConditionStatus status;
|
||||
|
||||
/**
|
||||
* Last time the condition transitioned from one status to another.
|
||||
*/
|
||||
@Schema(required = true)
|
||||
private Instant lastTransitionTime;
|
||||
|
||||
/**
|
||||
* Human-readable message indicating details about last transition.
|
||||
* This may be an empty string.
|
||||
*/
|
||||
@Schema(required = true, maxLength = 32768)
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* Unique, one-word, CamelCase reason for the condition's last transition.
|
||||
*/
|
||||
@Schema(required = true, maxLength = 1024,
|
||||
pattern = "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$")
|
||||
private String reason;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public enum ConditionStatus {
|
||||
TRUE,
|
||||
FALSE,
|
||||
UNKNOWN
|
||||
}
|
|
@ -4,13 +4,19 @@ import org.springframework.boot.context.event.ApplicationStartedEvent;
|
|||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.core.extension.Category;
|
||||
import run.halo.app.core.extension.Comment;
|
||||
import run.halo.app.core.extension.Menu;
|
||||
import run.halo.app.core.extension.MenuItem;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.core.extension.Reply;
|
||||
import run.halo.app.core.extension.ReverseProxy;
|
||||
import run.halo.app.core.extension.Role;
|
||||
import run.halo.app.core.extension.RoleBinding;
|
||||
import run.halo.app.core.extension.Setting;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
import run.halo.app.core.extension.Tag;
|
||||
import run.halo.app.core.extension.Theme;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
|
@ -39,5 +45,11 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
|
|||
schemeManager.register(Theme.class);
|
||||
schemeManager.register(Menu.class);
|
||||
schemeManager.register(MenuItem.class);
|
||||
schemeManager.register(Post.class);
|
||||
schemeManager.register(Category.class);
|
||||
schemeManager.register(Tag.class);
|
||||
schemeManager.register(Snapshot.class);
|
||||
schemeManager.register(Comment.class);
|
||||
schemeManager.register(Reply.class);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.skyscreamer.jsonassert.JSONAssert;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* Tests for {@link ContentRequest}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
class ContentRequestTest {
|
||||
private ContentRequest contentRequest;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
|
||||
contentRequest = new ContentRequest(subjectRef, "snapshot-1", """
|
||||
Four score and seven
|
||||
years ago our fathers
|
||||
|
||||
brought forth on this continent
|
||||
""",
|
||||
"""
|
||||
<p>Four score and seven</p>
|
||||
<p>years ago our fathers</p>
|
||||
<br/>
|
||||
<p>brought forth on this continent</p>
|
||||
""",
|
||||
"MARKDOWN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSnapshot() throws JSONException {
|
||||
String expectedContentPath =
|
||||
"<p>Four score and seven</p>\n<p>years ago our fathers</p>\n<br/>\n<p>brought forth "
|
||||
+ "on this continent</p>\n";
|
||||
String expectedRawPatch =
|
||||
"Four score and seven\nyears ago our fathers\n\nbrought forth on this continent\n";
|
||||
Snapshot snapshot = contentRequest.toSnapshot();
|
||||
snapshot.getMetadata().setName("7b149646-ac60-4a5c-98ee-78b2dd0631b2");
|
||||
JSONAssert.assertEquals(JsonUtils.objectToJson(snapshot),
|
||||
"""
|
||||
{
|
||||
"spec": {
|
||||
"subjectRef": {
|
||||
"kind": "Post",
|
||||
"name": "test-post"
|
||||
},
|
||||
"rawType": "MARKDOWN",
|
||||
"rawPatch": "%s",
|
||||
"contentPatch": "%s",
|
||||
"parentSnapshotName": null,
|
||||
"displayVersion": "v1",
|
||||
"version": 1,
|
||||
"publishTime": null,
|
||||
"contributors": null
|
||||
},
|
||||
"apiVersion": "content.halo.run/v1alpha1",
|
||||
"kind": "Snapshot",
|
||||
"metadata": {
|
||||
"name": "7b149646-ac60-4a5c-98ee-78b2dd0631b2",
|
||||
"labels": null,
|
||||
"annotations": null,
|
||||
"version": null,
|
||||
"creationTimestamp": null,
|
||||
"deletionTimestamp": null
|
||||
}
|
||||
}
|
||||
""".formatted(expectedRawPatch, expectedContentPath),
|
||||
true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawPatchFrom() throws JSONException {
|
||||
String s = contentRequest.rawPatchFrom("""
|
||||
Four score and seven
|
||||
years ago our fathers
|
||||
""");
|
||||
JSONAssert.assertEquals(s,
|
||||
"""
|
||||
[
|
||||
{
|
||||
"source": {
|
||||
"position": 3,
|
||||
"lines": [],
|
||||
"changePosition": null
|
||||
},
|
||||
"target": {
|
||||
"position": 3,
|
||||
"lines": [
|
||||
"brought forth on this continent",
|
||||
""
|
||||
],
|
||||
"changePosition": null
|
||||
},
|
||||
"type": "INSERT"
|
||||
}
|
||||
]
|
||||
""", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentPatchFrom() throws JSONException {
|
||||
String s = contentRequest.contentPatchFrom("""
|
||||
<p>Four score and seven</p>
|
||||
<p>years ago our fathers</p>
|
||||
""");
|
||||
JSONAssert.assertEquals(s, """
|
||||
[
|
||||
{
|
||||
"source": {
|
||||
"position": 2,
|
||||
"lines": [],
|
||||
"changePosition": null
|
||||
},
|
||||
"target": {
|
||||
"position": 2,
|
||||
"lines": [
|
||||
"<br/>",
|
||||
"<p>brought forth on this continent</p>"
|
||||
],
|
||||
"changePosition": null
|
||||
},
|
||||
"type": "INSERT"
|
||||
}
|
||||
]
|
||||
""", true);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,363 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static run.halo.app.content.TestPost.snapshotV1;
|
||||
import static run.halo.app.content.TestPost.snapshotV2;
|
||||
import static run.halo.app.content.TestPost.snapshotV3;
|
||||
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.content.impl.ContentServiceImpl;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* Tests for {@link ContentService}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@WithMockUser(username = "guqing")
|
||||
@ExtendWith(SpringExtension.class)
|
||||
class ContentServiceTest {
|
||||
|
||||
@Mock
|
||||
private ReactiveExtensionClient client;
|
||||
|
||||
private ContentService contentService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
contentService = new ContentServiceImpl(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftContent() {
|
||||
Snapshot snapshotV1 = snapshotV1();
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
|
||||
snapshotV1.getSpec().setSubjectRef(subjectRef);
|
||||
|
||||
ContentRequest contentRequest =
|
||||
new ContentRequest(subjectRef, null,
|
||||
snapshotV1.getSpec().getRawPatch(),
|
||||
snapshotV1.getSpec().getContentPatch(),
|
||||
snapshotV1.getSpec().getRawType());
|
||||
|
||||
pilingBaseSnapshot(snapshotV1);
|
||||
|
||||
ContentWrapper contentWrapper =
|
||||
new ContentWrapper("snapshot-A", contentRequest.raw(),
|
||||
contentRequest.content(), snapshotV1.getSpec().getRawType());
|
||||
|
||||
ArgumentCaptor<Snapshot> captor = ArgumentCaptor.forClass(Snapshot.class);
|
||||
when(client.create(any())).thenReturn(Mono.just(snapshotV1));
|
||||
|
||||
StepVerifier.create(contentService.draftContent(contentRequest))
|
||||
.expectNext(contentWrapper)
|
||||
.expectComplete()
|
||||
.verify();
|
||||
|
||||
verify(client, times(1)).create(captor.capture());
|
||||
Snapshot snapshot = captor.getValue();
|
||||
|
||||
snapshotV1.getMetadata().setName(snapshot.getMetadata().getName());
|
||||
snapshotV1.getSpec().setSubjectRef(subjectRef);
|
||||
assertThat(snapshot).isEqualTo(snapshotV1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateContent() {
|
||||
String headSnapshot = "snapshot-A";
|
||||
Snapshot snapshotV1 = snapshotV1();
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
|
||||
|
||||
Snapshot updated = snapshotV1();
|
||||
updated.getSpec().setRawPatch("hello");
|
||||
updated.getSpec().setContentPatch("<p>hello</p>");
|
||||
updated.getSpec().setSubjectRef(subjectRef);
|
||||
ContentRequest contentRequest =
|
||||
new ContentRequest(subjectRef, headSnapshot,
|
||||
snapshotV1.getSpec().getRawPatch(),
|
||||
snapshotV1.getSpec().getContentPatch(),
|
||||
snapshotV1.getSpec().getRawType());
|
||||
|
||||
pilingBaseSnapshot(snapshotV1);
|
||||
|
||||
when(client.fetch(eq(Snapshot.class), eq(contentRequest.headSnapshotName())))
|
||||
.thenReturn(Mono.just(updated));
|
||||
|
||||
ContentWrapper contentWrapper =
|
||||
new ContentWrapper(headSnapshot, contentRequest.raw(),
|
||||
contentRequest.content(), snapshotV1.getSpec().getRawType());
|
||||
|
||||
ArgumentCaptor<Snapshot> captor = ArgumentCaptor.forClass(Snapshot.class);
|
||||
when(client.update(any())).thenReturn(Mono.just(updated));
|
||||
|
||||
StepVerifier.create(contentService.updateContent(contentRequest))
|
||||
.expectNext(contentWrapper)
|
||||
.expectComplete()
|
||||
.verify();
|
||||
|
||||
verify(client, times(1)).update(captor.capture());
|
||||
Snapshot snapshot = captor.getValue();
|
||||
|
||||
assertThat(snapshot).isEqualTo(updated);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateContentWhenHasDraftVersionButHeadPoints2Published() {
|
||||
final String headSnapshot = "snapshot-A";
|
||||
Snapshot snapshotV1 = snapshotV1();
|
||||
|
||||
Snapshot snapshotV2 = snapshotV2();
|
||||
snapshotV2.getSpec().setPublishTime(null);
|
||||
|
||||
|
||||
// v1(released),v2
|
||||
snapshotV1.getSpec().setPublishTime(Instant.now());
|
||||
pilingBaseSnapshot(snapshotV2, snapshotV1);
|
||||
|
||||
when(client.fetch(eq(Snapshot.class), eq(snapshotV2.getMetadata().getName())))
|
||||
.thenReturn(Mono.just(snapshotV2));
|
||||
when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName())))
|
||||
.thenReturn(Mono.just(snapshotV1));
|
||||
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
|
||||
|
||||
ContentRequest contentRequest =
|
||||
new ContentRequest(subjectRef, headSnapshot, "C",
|
||||
"<p>C</p>", snapshotV1.getSpec().getRawType());
|
||||
|
||||
when(client.create(any())).thenReturn(Mono.just(snapshotV3()));
|
||||
|
||||
StepVerifier.create(contentService.latestSnapshotVersion(subjectRef))
|
||||
.expectNext(snapshotV2)
|
||||
.expectComplete()
|
||||
.verify();
|
||||
|
||||
StepVerifier.create(contentService.updateContent(contentRequest))
|
||||
.consumeNextWith(created -> {
|
||||
assertThat(created.raw()).isEqualTo("C");
|
||||
assertThat(created.content()).isEqualTo("<p>C</p>");
|
||||
})
|
||||
.expectComplete()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateContentWhenHeadPoints2Published() {
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
|
||||
|
||||
// v1(released),v2
|
||||
Snapshot snapshotV1 = snapshotV1();
|
||||
snapshotV1.getSpec().setPublishTime(Instant.now());
|
||||
snapshotV1.getSpec().setSubjectRef(subjectRef);
|
||||
|
||||
Snapshot snapshotV2 = snapshotV2();
|
||||
snapshotV2.getSpec().setSubjectRef(subjectRef);
|
||||
snapshotV2.getSpec().setPublishTime(null);
|
||||
|
||||
final String headSnapshot = snapshotV2.getMetadata().getName();
|
||||
|
||||
|
||||
pilingBaseSnapshot(snapshotV2, snapshotV1);
|
||||
|
||||
when(client.fetch(eq(Snapshot.class), eq(snapshotV2.getMetadata().getName())))
|
||||
.thenReturn(Mono.just(snapshotV2));
|
||||
when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName())))
|
||||
.thenReturn(Mono.just(snapshotV1));
|
||||
|
||||
ContentRequest contentRequest =
|
||||
new ContentRequest(subjectRef, headSnapshot, "C",
|
||||
"<p>C</p>", snapshotV1.getSpec().getRawType());
|
||||
|
||||
when(client.update(any())).thenReturn(Mono.just(snapshotV2()));
|
||||
|
||||
StepVerifier.create(contentService.latestSnapshotVersion(subjectRef))
|
||||
.expectNext(snapshotV2)
|
||||
.expectComplete()
|
||||
.verify();
|
||||
|
||||
StepVerifier.create(contentService.updateContent(contentRequest))
|
||||
.consumeNextWith(updated -> {
|
||||
assertThat(updated.raw()).isEqualTo("C");
|
||||
assertThat(updated.content()).isEqualTo("<p>C</p>");
|
||||
})
|
||||
.expectComplete()
|
||||
.verify();
|
||||
|
||||
verify(client, times(1)).update(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishContent() {
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
|
||||
|
||||
// v1(released),v2
|
||||
Snapshot snapshotV1 = snapshotV1();
|
||||
snapshotV1.getSpec().setPublishTime(null);
|
||||
snapshotV1.getSpec().setSubjectRef(subjectRef);
|
||||
|
||||
final String headSnapshot = snapshotV1.getMetadata().getName();
|
||||
|
||||
pilingBaseSnapshot(snapshotV1);
|
||||
|
||||
when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName())))
|
||||
.thenReturn(Mono.just(snapshotV1));
|
||||
|
||||
when(client.update(any())).thenReturn(Mono.just(snapshotV2()));
|
||||
|
||||
StepVerifier.create(contentService.publish(headSnapshot, subjectRef))
|
||||
.expectNext()
|
||||
.consumeNextWith(p -> {
|
||||
System.out.println(JsonUtils.objectToJson(p));
|
||||
})
|
||||
.expectComplete()
|
||||
.verify();
|
||||
// has benn published,do nothing
|
||||
verify(client, times(1)).update(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishContentWhenHasPublishedThenDoNothing() {
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post");
|
||||
|
||||
// v1(released),v2
|
||||
Snapshot snapshotV1 = snapshotV1();
|
||||
snapshotV1.getSpec().setPublishTime(Instant.now());
|
||||
snapshotV1.getSpec().setSubjectRef(subjectRef);
|
||||
|
||||
final String headSnapshot = snapshotV1.getMetadata().getName();
|
||||
|
||||
pilingBaseSnapshot(snapshotV1);
|
||||
|
||||
when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName())))
|
||||
.thenReturn(Mono.just(snapshotV1));
|
||||
|
||||
when(client.update(any())).thenReturn(Mono.just(snapshotV2()));
|
||||
|
||||
StepVerifier.create(contentService.publish(headSnapshot, subjectRef))
|
||||
.expectNext()
|
||||
.consumeNextWith(p -> {
|
||||
System.out.println(JsonUtils.objectToJson(p));
|
||||
})
|
||||
.expectComplete()
|
||||
.verify();
|
||||
// has benn published,do nothing
|
||||
verify(client, times(0)).update(any());
|
||||
}
|
||||
|
||||
private void pilingBaseSnapshot(Snapshot... expected) {
|
||||
when(client.list(eq(Snapshot.class), any(), any()))
|
||||
.thenReturn(Flux.just(expected));
|
||||
}
|
||||
|
||||
@Test
|
||||
void baseSnapshotVersion() {
|
||||
String postName = "post-1";
|
||||
Snapshot snapshotV1 = snapshotV1();
|
||||
snapshotV1.getSpec().setPublishTime(Instant.now());
|
||||
snapshotV1.setSubjectRef(Post.KIND, postName);
|
||||
|
||||
Snapshot snapshotV2 = TestPost.snapshotV2();
|
||||
snapshotV2.setSubjectRef(Post.KIND, postName);
|
||||
|
||||
Snapshot snapshotV3 = TestPost.snapshotV3();
|
||||
snapshotV3.setSubjectRef(Post.KIND, postName);
|
||||
|
||||
when(client.list(eq(Snapshot.class), any(), any()))
|
||||
.thenReturn(Flux.just(snapshotV2, snapshotV1, snapshotV3));
|
||||
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName);
|
||||
StepVerifier.create(contentService.getBaseSnapshot(subjectRef))
|
||||
.expectNext(snapshotV1)
|
||||
.expectComplete()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void latestSnapshotVersion() {
|
||||
String postName = "post-1";
|
||||
Snapshot snapshotV1 = snapshotV1();
|
||||
snapshotV1.getSpec().setPublishTime(Instant.now());
|
||||
snapshotV1.setSubjectRef(Post.KIND, postName);
|
||||
Snapshot snapshotV2 = TestPost.snapshotV2();
|
||||
snapshotV2.setSubjectRef(Post.KIND, postName);
|
||||
|
||||
when(client.list(eq(Snapshot.class), any(), any()))
|
||||
.thenReturn(Flux.just(snapshotV1, snapshotV2));
|
||||
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName);
|
||||
StepVerifier.create(contentService.latestSnapshotVersion(subjectRef))
|
||||
.expectNext(snapshotV2)
|
||||
.expectComplete()
|
||||
.verify();
|
||||
|
||||
when(client.list(eq(Snapshot.class), any(), any()))
|
||||
.thenReturn(Flux.just(snapshotV1, snapshotV2, snapshotV3()));
|
||||
StepVerifier.create(contentService.latestSnapshotVersion(subjectRef))
|
||||
.expectNext(snapshotV3())
|
||||
.expectComplete()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void latestPublishedSnapshotThenV1() {
|
||||
String postName = "post-1";
|
||||
Snapshot snapshotV1 = snapshotV1();
|
||||
snapshotV1.setSubjectRef(Post.KIND, postName);
|
||||
snapshotV1.getSpec().setPublishTime(Instant.now());
|
||||
|
||||
Snapshot snapshotV2 = TestPost.snapshotV2();
|
||||
snapshotV2.setSubjectRef(Post.KIND, postName);
|
||||
snapshotV2.getSpec().setPublishTime(null);
|
||||
|
||||
when(client.list(eq(Snapshot.class), any(), any()))
|
||||
.thenReturn(Flux.just(snapshotV1, snapshotV2));
|
||||
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName);
|
||||
StepVerifier.create(contentService.latestPublishedSnapshot(subjectRef))
|
||||
.expectNext(snapshotV1)
|
||||
.expectComplete()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void latestPublishedSnapshotThenV2() {
|
||||
String postName = "post-1";
|
||||
Snapshot snapshotV1 = snapshotV1();
|
||||
snapshotV1.setSubjectRef(Post.KIND, postName);
|
||||
snapshotV1.getSpec().setPublishTime(Instant.now());
|
||||
|
||||
Snapshot snapshotV2 = TestPost.snapshotV2();
|
||||
snapshotV2.setSubjectRef(Post.KIND, postName);
|
||||
snapshotV2.getSpec().setPublishTime(Instant.now());
|
||||
|
||||
when(client.list(eq(Snapshot.class), any(), any()))
|
||||
.thenReturn(Flux.just(snapshotV2, snapshotV1));
|
||||
|
||||
Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName);
|
||||
|
||||
StepVerifier.create(contentService.latestPublishedSnapshot(subjectRef))
|
||||
.expectNext(snapshotV2)
|
||||
.expectComplete()
|
||||
.verify();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package run.halo.app.content;
|
||||
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
import run.halo.app.extension.Metadata;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class TestPost {
|
||||
public static Post postV1() {
|
||||
Post post = new Post();
|
||||
post.setKind(Post.KIND);
|
||||
post.setApiVersion(getApiVersion(Post.class));
|
||||
Metadata metadata = new Metadata();
|
||||
metadata.setName("post-A");
|
||||
post.setMetadata(metadata);
|
||||
|
||||
Post.PostSpec postSpec = new Post.PostSpec();
|
||||
post.setSpec(postSpec);
|
||||
|
||||
postSpec.setTitle("post-A");
|
||||
postSpec.setVersion(1);
|
||||
postSpec.setBaseSnapshot(snapshotV1().getMetadata().getName());
|
||||
postSpec.setHeadSnapshot("base-snapshot");
|
||||
postSpec.setReleaseSnapshot(null);
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
public static Snapshot snapshotV1() {
|
||||
Snapshot snapshot = new Snapshot();
|
||||
snapshot.setKind(Snapshot.KIND);
|
||||
snapshot.setApiVersion(getApiVersion(Snapshot.class));
|
||||
Metadata metadata = new Metadata();
|
||||
metadata.setName("snapshot-A");
|
||||
snapshot.setMetadata(metadata);
|
||||
Snapshot.SnapShotSpec spec = new Snapshot.SnapShotSpec();
|
||||
snapshot.setSpec(spec);
|
||||
|
||||
spec.setDisplayVersion("v1");
|
||||
spec.setVersion(1);
|
||||
snapshot.addContributor("guqing");
|
||||
spec.setRawType("MARKDOWN");
|
||||
spec.setRawPatch("A");
|
||||
spec.setContentPatch("<p>A</p>");
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public static Snapshot snapshotV2() {
|
||||
Snapshot snapshot = new Snapshot();
|
||||
snapshot.setKind(Snapshot.KIND);
|
||||
snapshot.setApiVersion(getApiVersion(Snapshot.class));
|
||||
Metadata metadata = new Metadata();
|
||||
metadata.setName("snapshot-B");
|
||||
snapshot.setMetadata(metadata);
|
||||
Snapshot.SnapShotSpec spec = new Snapshot.SnapShotSpec();
|
||||
snapshot.setSpec(spec);
|
||||
|
||||
spec.setDisplayVersion("v2");
|
||||
spec.setVersion(2);
|
||||
snapshot.addContributor("guqing");
|
||||
spec.setRawType("MARKDOWN");
|
||||
spec.setRawPatch(PatchUtils.diffToJsonPatch("A", "B"));
|
||||
spec.setContentPatch(PatchUtils.diffToJsonPatch("<p>A</p>", "<p>B</p>"));
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public static Snapshot snapshotV3() {
|
||||
Snapshot snapshotV3 = snapshotV2();
|
||||
snapshotV3.getMetadata().setName("snapshot-C");
|
||||
Snapshot.SnapShotSpec spec = snapshotV3.getSpec();
|
||||
spec.setDisplayVersion("v3");
|
||||
spec.setVersion(3);
|
||||
snapshotV3.addContributor("guqing");
|
||||
spec.setRawType("MARKDOWN");
|
||||
spec.setRawPatch(PatchUtils.diffToJsonPatch("B", "C"));
|
||||
spec.setContentPatch(PatchUtils.diffToJsonPatch("<p>B</p>", "<p>C</p>"));
|
||||
|
||||
return snapshotV3;
|
||||
}
|
||||
|
||||
public static String getApiVersion(Class<? extends AbstractExtension> extension) {
|
||||
GVK annotation = extension.getAnnotation(GVK.class);
|
||||
return annotation.group() + "/" + annotation.version();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
package run.halo.app.content.impl;
|
||||
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import run.halo.app.content.ContentService;
|
||||
import run.halo.app.content.PostQuery;
|
||||
import run.halo.app.content.TestPost;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
/**
|
||||
* Tests for {@link PostServiceImpl}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PostServiceImplTest {
|
||||
@Mock
|
||||
private ReactiveExtensionClient client;
|
||||
|
||||
@Mock
|
||||
private ContentService contentService;
|
||||
|
||||
private PostServiceImpl postService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
postService = new PostServiceImpl(contentService, client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listPredicate() {
|
||||
PostQuery postQuery = new PostQuery();
|
||||
postQuery.setCategories(Set.of("category1", "category2"));
|
||||
|
||||
Post post = TestPost.postV1();
|
||||
post.getSpec().setTags(null);
|
||||
post.getStatusOrDefault().setContributors(null);
|
||||
post.getSpec().setCategories(List.of("category1"));
|
||||
boolean test = postService.postListPredicate(postQuery).test(post);
|
||||
assertThat(test).isTrue();
|
||||
|
||||
post.getSpec().setTags(List.of("tag1"));
|
||||
test = postService.postListPredicate(postQuery).test(post);
|
||||
assertThat(test).isTrue();
|
||||
|
||||
// Do not include tags
|
||||
postQuery.setTags(Set.of("tag2"));
|
||||
post.getSpec().setTags(List.of("tag1"));
|
||||
test = postService.postListPredicate(postQuery).test(post);
|
||||
assertThat(test).isFalse();
|
||||
|
||||
postQuery.setTags(Set.of());
|
||||
post.getSpec().setTags(List.of());
|
||||
test = postService.postListPredicate(postQuery).test(post);
|
||||
assertThat(test).isTrue();
|
||||
|
||||
postQuery.setLabelSelector(List.of("hello"));
|
||||
test = postService.postListPredicate(postQuery).test(post);
|
||||
assertThat(test).isFalse();
|
||||
|
||||
postQuery.setLabelSelector(List.of("hello"));
|
||||
post.getMetadata().setLabels(Map.of("hello", "world"));
|
||||
test = postService.postListPredicate(postQuery).test(post);
|
||||
assertThat(test).isTrue();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.PostRequest;
|
||||
import run.halo.app.content.PostService;
|
||||
import run.halo.app.content.TestPost;
|
||||
import run.halo.app.core.extension.Post;
|
||||
|
||||
/**
|
||||
* Tests for @{@link PostEndpoint}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PostEndpointTest {
|
||||
@Mock
|
||||
private PostService postService;
|
||||
|
||||
private WebTestClient webTestClient;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
PostEndpoint postEndpoint = new PostEndpoint(postService);
|
||||
|
||||
webTestClient = WebTestClient
|
||||
.bindToRouterFunction(postEndpoint.endpoint())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftPost() {
|
||||
when(postService.draftPost(any())).thenReturn(Mono.just(TestPost.postV1()));
|
||||
|
||||
webTestClient.post()
|
||||
.uri("/posts")
|
||||
.bodyValue(postRequest(TestPost.postV1()))
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(Post.class)
|
||||
.value(post -> assertThat(post).isEqualTo(TestPost.postV1()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePost() {
|
||||
when(postService.updatePost(any())).thenReturn(Mono.just(TestPost.postV1()));
|
||||
|
||||
webTestClient.put()
|
||||
.uri("/posts/post-A")
|
||||
.bodyValue(postRequest(TestPost.postV1()))
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(Post.class)
|
||||
.value(post -> assertThat(post).isEqualTo(TestPost.postV1()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishPost() {
|
||||
when(postService.publishPost(any())).thenReturn(Mono.just(TestPost.postV1()));
|
||||
|
||||
webTestClient.put()
|
||||
.uri("/posts/post-A/publish")
|
||||
.bodyValue(postRequest(TestPost.postV1()))
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(Post.class)
|
||||
.value(post -> assertThat(post).isEqualTo(TestPost.postV1()));
|
||||
}
|
||||
|
||||
PostRequest postRequest(Post post) {
|
||||
return new PostRequest(post, new PostRequest.Content("B", "<p>B</p>", "MARKDOWN"));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
package run.halo.app.core.extension.reconciler;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.ContentService;
|
||||
import run.halo.app.content.ContentWrapper;
|
||||
import run.halo.app.content.TestPost;
|
||||
import run.halo.app.core.extension.Post;
|
||||
import run.halo.app.core.extension.Snapshot;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
|
||||
/**
|
||||
* Tests for {@link PostReconciler}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PostReconcilerTest {
|
||||
|
||||
@Mock
|
||||
private ExtensionClient client;
|
||||
@Mock
|
||||
private ContentService contentService;
|
||||
|
||||
private PostReconciler postReconciler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
postReconciler = new PostReconciler(client, contentService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcile() {
|
||||
String name = "post-A";
|
||||
Post post = TestPost.postV1();
|
||||
post.getSpec().setHeadSnapshot("post-A-head-snapshot");
|
||||
when(client.fetch(eq(Post.class), eq(name)))
|
||||
.thenReturn(Optional.of(post));
|
||||
when(contentService.getContent(eq(post.getSpec().getHeadSnapshot())))
|
||||
.thenReturn(Mono.just(
|
||||
new ContentWrapper(post.getSpec().getHeadSnapshot(), "hello world",
|
||||
"<p>hello world</p>", "markdown")));
|
||||
|
||||
Snapshot snapshotV1 = TestPost.snapshotV1();
|
||||
Snapshot snapshotV2 = TestPost.snapshotV2();
|
||||
snapshotV1.getSpec().setContributors(Set.of("guqing"));
|
||||
snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan"));
|
||||
when(contentService.listSnapshots(any()))
|
||||
.thenReturn(Flux.just(snapshotV1, snapshotV2));
|
||||
|
||||
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
|
||||
postReconciler.reconcile(new Reconciler.Request(name));
|
||||
|
||||
verify(client, times(1)).update(captor.capture());
|
||||
|
||||
Post value = captor.getValue();
|
||||
assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world");
|
||||
assertThat(value.getStatus().getPermalink()).isEqualTo(
|
||||
PostReconciler.PERMALINK_PREFIX + name);
|
||||
assertThat(value.getStatus().getContributors()).isEqualTo(List.of("guqing", "zhangsan"));
|
||||
}
|
||||
|
||||
}
|
|
@ -19,4 +19,12 @@ class ListResultTest {
|
|||
assertEquals("FakeList", fakeListClass.getSimpleName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateGenericClassForClassParam() {
|
||||
var fakeListClass = ListResult.generateGenericClass(FakeExtension.class);
|
||||
assertTrue(ListResult.class.isAssignableFrom(fakeListClass));
|
||||
assertSame(FakeExtension.class, ((ParameterizedType) fakeListClass.getGenericSuperclass())
|
||||
.getActualTypeArguments()[0]);
|
||||
assertEquals("FakeExtensionList", fakeListClass.getSimpleName());
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue