diff --git a/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/src/main/java/run/halo/app/config/ExtensionConfiguration.java index 3cd837b45..ca738ffa2 100644 --- a/src/main/java/run/halo/app/config/ExtensionConfiguration.java +++ b/src/main/java/run/halo/app/config/ExtensionConfiguration.java @@ -18,6 +18,7 @@ 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.SinglePage; import run.halo.app.core.extension.Tag; import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.User; @@ -29,6 +30,7 @@ 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.SinglePageReconciler; import run.halo.app.core.extension.reconciler.SystemSettingReconciler; import run.halo.app.core.extension.reconciler.TagReconciler; import run.halo.app.core.extension.reconciler.ThemeReconciler; @@ -50,6 +52,7 @@ import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.plugin.HaloPluginManager; import run.halo.app.plugin.resources.JsBundleRuleProvider; +import run.halo.app.theme.router.TemplateRouteManager; @Configuration(proxyBeanMethods = false) public class ExtensionConfiguration { @@ -181,6 +184,17 @@ public class ExtensionConfiguration { .extension(new Attachment()) .build(); } + + @Bean + Controller singlePageController(ExtensionClient client, ContentService contentService, + ApplicationContext applicationContext, TemplateRouteManager templateRouteManager) { + return new ControllerBuilder("single-page-controller", client) + .reconciler(new SinglePageReconciler(client, contentService, + applicationContext, templateRouteManager) + ) + .extension(new SinglePage()) + .build(); + } } } diff --git a/src/main/java/run/halo/app/content/ListedSinglePage.java b/src/main/java/run/halo/app/content/ListedSinglePage.java new file mode 100644 index 000000000..c80fb631e --- /dev/null +++ b/src/main/java/run/halo/app/content/ListedSinglePage.java @@ -0,0 +1,23 @@ +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.SinglePage; + + +/** + * An aggregate object of {@link SinglePage} and {@link Contributor} single page list. + * + * @author guqing + * @since 2.0.0 + */ +@Data +public class ListedSinglePage { + + @Schema(required = true) + private SinglePage page; + + @Schema(required = true) + private List contributors; +} diff --git a/src/main/java/run/halo/app/content/PostQuery.java b/src/main/java/run/halo/app/content/PostQuery.java index b44aec60a..f80bf8050 100644 --- a/src/main/java/run/halo/app/content/PostQuery.java +++ b/src/main/java/run/halo/app/content/PostQuery.java @@ -1,10 +1,11 @@ package run.halo.app.content; +import java.util.List; import java.util.Set; -import lombok.Data; -import lombok.EqualsAndHashCode; +import org.springframework.lang.Nullable; +import org.springframework.util.MultiValueMap; import run.halo.app.core.extension.Post; -import run.halo.app.extension.router.ListRequest; +import run.halo.app.extension.router.IListRequest; /** * A query object for {@link Post} list. @@ -12,14 +13,29 @@ import run.halo.app.extension.router.ListRequest; * @author guqing * @since 2.0.0 */ -@Data -@EqualsAndHashCode(callSuper = true) -public class PostQuery extends ListRequest { +public class PostQuery extends IListRequest.QueryListRequest { - private Set contributors; + public PostQuery(MultiValueMap queryParams) { + super(queryParams); + } - private Set categories; + @Nullable + public Set getContributors() { + return listToSet(queryParams.get("contributor")); + } - private Set tags; - // TODO add more query fields + @Nullable + public Set getCategories() { + return listToSet(queryParams.get("category")); + } + + @Nullable + public Set getTags() { + return listToSet(queryParams.get("tag")); + } + + @Nullable + Set listToSet(List param) { + return param == null ? null : Set.copyOf(param); + } } diff --git a/src/main/java/run/halo/app/content/SinglePageQuery.java b/src/main/java/run/halo/app/content/SinglePageQuery.java new file mode 100644 index 000000000..419968fd0 --- /dev/null +++ b/src/main/java/run/halo/app/content/SinglePageQuery.java @@ -0,0 +1,27 @@ +package run.halo.app.content; + +import java.util.List; +import java.util.Set; +import org.springframework.lang.Nullable; +import org.springframework.util.MultiValueMap; +import run.halo.app.core.extension.SinglePage; +import run.halo.app.extension.router.IListRequest; + +/** + * Query parameter for {@link SinglePage} list. + * + * @author guqing + * @since 2.0.0 + */ +public class SinglePageQuery extends IListRequest.QueryListRequest { + + public SinglePageQuery(MultiValueMap queryParams) { + super(queryParams); + } + + @Nullable + public Set getContributors() { + List contributorList = queryParams.get("contributor"); + return contributorList == null ? null : Set.copyOf(contributorList); + } +} diff --git a/src/main/java/run/halo/app/content/SinglePageRequest.java b/src/main/java/run/halo/app/content/SinglePageRequest.java new file mode 100644 index 000000000..ea1314d82 --- /dev/null +++ b/src/main/java/run/halo/app/content/SinglePageRequest.java @@ -0,0 +1,25 @@ +package run.halo.app.content; + +import io.swagger.v3.oas.annotations.media.Schema; +import run.halo.app.core.extension.SinglePage; +import run.halo.app.core.extension.Snapshot; + +/** + * A request parameter for {@link SinglePage}. + * + * @author guqing + * @since 2.0.0 + */ +public record SinglePageRequest(@Schema(required = true) SinglePage page, + @Schema(required = true) Content content) { + + public ContentRequest contentRequest() { + Snapshot.SubjectRef subjectRef = + Snapshot.SubjectRef.of(SinglePage.KIND, page.getMetadata().getName()); + return new ContentRequest(subjectRef, page.getSpec().getHeadSnapshot(), content.raw, + content.content, content.rawType); + } + + public record Content(String raw, String content, String rawType) { + } +} diff --git a/src/main/java/run/halo/app/content/SinglePageService.java b/src/main/java/run/halo/app/content/SinglePageService.java new file mode 100644 index 000000000..29aeda2eb --- /dev/null +++ b/src/main/java/run/halo/app/content/SinglePageService.java @@ -0,0 +1,22 @@ +package run.halo.app.content; + +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.SinglePage; +import run.halo.app.extension.ListResult; + +/** + * Single page service. + * + * @author guqing + * @since 2.0.0 + */ +public interface SinglePageService { + + Mono> list(SinglePageQuery listRequest); + + Mono draft(SinglePageRequest pageRequest); + + Mono update(SinglePageRequest pageRequest); + + Mono publish(String name); +} diff --git a/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java b/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java new file mode 100644 index 000000000..81e2a352d --- /dev/null +++ b/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java @@ -0,0 +1,200 @@ +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.Service; +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.ListedSinglePage; +import run.halo.app.content.SinglePageQuery; +import run.halo.app.content.SinglePageRequest; +import run.halo.app.content.SinglePageService; +import run.halo.app.core.extension.Post; +import run.halo.app.core.extension.SinglePage; +import run.halo.app.core.extension.Snapshot; +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; + +/** + * Single page service implementation. + * + * @author guqing + * @since 2.0.0 + */ +@Service +public class SinglePageServiceImpl implements SinglePageService { + private static final Comparator DEFAULT_PAGE_COMPARATOR = + Comparator.comparing(page -> page.getMetadata().getCreationTimestamp()); + + private final ContentService contentService; + + private final ReactiveExtensionClient client; + + public SinglePageServiceImpl(ContentService contentService, ReactiveExtensionClient client) { + this.contentService = contentService; + this.client = client; + } + + @Override + public Mono> list(SinglePageQuery query) { + return client.list(SinglePage.class, pageListPredicate(query), + DEFAULT_PAGE_COMPARATOR.reversed(), query.getPage(), query.getSize()) + .flatMap(listResult -> Flux.fromStream( + listResult.get().map(this::getListedSinglePage) + ) + .flatMap(Function.identity()) + .collectList() + .map(listedSinglePages -> new ListResult<>(listResult.getPage(), + listResult.getSize(), + listResult.getTotal(), listedSinglePages) + ) + ); + } + + @Override + public Mono draft(SinglePageRequest pageRequest) { + return contentService.draftContent(pageRequest.contentRequest()) + .flatMap(contentWrapper -> getContextUsername() + .flatMap(username -> { + SinglePage page = pageRequest.page(); + page.getSpec().setBaseSnapshot(contentWrapper.snapshotName()); + page.getSpec().setHeadSnapshot(contentWrapper.snapshotName()); + page.getSpec().setOwner(username); + appendPublishedCondition(page, Post.PostPhase.DRAFT); + return client.create(page) + .then(Mono.defer(() -> + client.fetch(SinglePage.class, + pageRequest.page().getMetadata().getName()))); + })); + } + + @Override + public Mono update(SinglePageRequest pageRequest) { + SinglePage page = pageRequest.page(); + return contentService.updateContent(pageRequest.contentRequest()) + .flatMap(contentWrapper -> { + page.getSpec().setHeadSnapshot(contentWrapper.snapshotName()); + return client.update(page); + }) + .then(Mono.defer(() -> client.fetch(SinglePage.class, page.getMetadata().getName()))); + } + + @Override + public Mono publish(String name) { + return client.fetch(SinglePage.class, name) + .flatMap(page -> { + SinglePage.SinglePageSpec spec = page.getSpec(); + if (Objects.equals(true, spec.getPublished())) { + // has been published before + spec.setVersion(spec.getVersion() + 1); + } else { + spec.setPublished(true); + } + + if (spec.getPublishTime() == null) { + spec.setPublishTime(Instant.now()); + } + + Snapshot.SubjectRef subjectRef = + Snapshot.SubjectRef.of(SinglePage.KIND, page.getMetadata().getName()); + return contentService.publish(spec.getHeadSnapshot(), subjectRef) + .flatMap(contentWrapper -> { + // update release snapshot name and condition + appendPublishedCondition(page, Post.PostPhase.PUBLISHED); + page.getSpec().setReleaseSnapshot(contentWrapper.snapshotName()); + return client.update(page); + }) + .then(Mono.defer(() -> client.fetch(SinglePage.class, name))); + }); + } + + private Mono getContextUsername() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Principal::getName); + } + + Predicate pageListPredicate(SinglePageQuery query) { + Predicate paramPredicate = singlePage -> contains(query.getContributors(), + singlePage.getStatusOrDefault().getContributors()); + Predicate predicate = labelAndFieldSelectorToPredicate(query.getLabelSelector(), + query.getFieldSelector()); + return predicate.and(paramPredicate); + } + + private Mono getListedSinglePage(SinglePage singlePage) { + Assert.notNull(singlePage, "The singlePage must not be null."); + ListedSinglePage listedSinglePage = new ListedSinglePage(); + listedSinglePage.setPage(singlePage); + return Mono.just(listedSinglePage) + .flatMap(page -> listContributors(singlePage.getStatusOrDefault().getContributors()) + .map(contributors -> { + page.setContributors(contributors); + return page; + })); + } + + private Mono> listContributors(List 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(); + } + + boolean contains(Collection left, List 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); + } + + void appendPublishedCondition(SinglePage page, Post.PostPhase phase) { + Assert.notNull(page, "The singlePage must not be null."); + SinglePage.SinglePageStatus status = page.getStatusOrDefault(); + status.setPhase(phase.name()); + List 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()); + } +} diff --git a/src/main/java/run/halo/app/core/extension/SinglePage.java b/src/main/java/run/halo/app/core/extension/SinglePage.java new file mode 100644 index 000000000..bf94489a2 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/SinglePage.java @@ -0,0 +1,103 @@ +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.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; + +/** + *

Single page extension.

+ * + * @author guqing + * @since 2.0.0 + */ +@Data +@ToString(callSuper = true) +@GVK(group = "content.halo.run", version = "v1alpha1", kind = SinglePage.KIND, + plural = "singlepages", singular = "singlepage") +@EqualsAndHashCode(callSuper = true) +public class SinglePage extends AbstractExtension { + public static final String KIND = "SinglePage"; + 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 SinglePageSpec spec; + + @Schema + private SinglePageStatus status; + + @JsonIgnore + public SinglePageStatus getStatusOrDefault() { + if (this.status == null) { + this.status = new SinglePageStatus(); + } + return this.status; + } + + @Data + public static class SinglePageSpec { + @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 Post.VisibleEnum visible; + + @Schema(required = true, defaultValue = "1") + private Integer version; + + @Schema(required = true, defaultValue = "0") + private Integer priority; + + @Schema(required = true) + private Post.Excerpt excerpt; + + private List> htmlMetas; + } + + @Data + @EqualsAndHashCode(callSuper = true) + public static class SinglePageStatus extends Post.PostStatus { + + } +} diff --git a/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java index 9a8ecfbe7..dcb468a54 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java @@ -8,7 +8,6 @@ import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuil 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; @@ -117,23 +116,7 @@ public class PostEndpoint implements CustomEndpoint { } Mono 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); + PostQuery postQuery = new PostQuery(request.queryParams()); return postService.listPost(postQuery) .flatMap(listedPosts -> ServerResponse.ok().bodyValue(listedPosts)); } diff --git a/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java new file mode 100644 index 000000000..34cd41973 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java @@ -0,0 +1,123 @@ +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.ListedSinglePage; +import run.halo.app.content.SinglePageQuery; +import run.halo.app.content.SinglePageRequest; +import run.halo.app.content.SinglePageService; +import run.halo.app.core.extension.SinglePage; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.router.QueryParamBuildUtil; + +/** + * Endpoint for managing {@link SinglePage}. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class SinglePageEndpoint implements CustomEndpoint { + + private final SinglePageService singlePageService; + + public SinglePageEndpoint(SinglePageService singlePageService) { + this.singlePageService = singlePageService; + } + + @Override + public RouterFunction endpoint() { + final var tag = "api.halo.run/v1alpha1/SinglePage"; + return SpringdocRouteBuilder.route() + .GET("singlepages", this::listSinglePage, builder -> { + builder.operationId("ListSinglePages") + .description("List single pages.") + .tag(tag) + .response(responseBuilder() + .implementation(ListResult.generateGenericClass(ListedSinglePage.class)) + ); + QueryParamBuildUtil.buildParametersFromType(builder, SinglePageQuery.class); + } + ) + .POST("singlepages", this::draftSinglePage, + builder -> builder.operationId("DraftSinglePage") + .description("Draft a single page.") + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(Builder.schemaBuilder() + .implementation(SinglePageRequest.class)) + )) + .response(responseBuilder() + .implementation(SinglePage.class)) + ) + .PUT("singlepages/{name}", this::updateSinglePage, + builder -> builder.operationId("UpdateDraftSinglePage") + .description("Update a single page.") + .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(SinglePageRequest.class)) + )) + .response(responseBuilder() + .implementation(SinglePage.class)) + ) + .PUT("singlepages/{name}/publish", this::publishSinglePage, + builder -> builder.operationId("PublishSinglePage") + .description("Publish a single page.") + .tag(tag) + .parameter(parameterBuilder().name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class)) + .response(responseBuilder() + .implementation(SinglePage.class)) + ) + .build(); + } + + Mono draftSinglePage(ServerRequest request) { + return request.bodyToMono(SinglePageRequest.class) + .flatMap(singlePageService::draft) + .flatMap(singlePage -> ServerResponse.ok().bodyValue(singlePage)); + } + + Mono updateSinglePage(ServerRequest request) { + return request.bodyToMono(SinglePageRequest.class) + .flatMap(singlePageService::update) + .flatMap(page -> ServerResponse.ok().bodyValue(page)); + } + + Mono publishSinglePage(ServerRequest request) { + String name = request.pathVariable("name"); + return singlePageService.publish(name) + .flatMap(singlePage -> ServerResponse.ok().bodyValue(singlePage)); + } + + Mono listSinglePage(ServerRequest request) { + var listRequest = new SinglePageQuery(request.queryParams()); + return singlePageService.list(listRequest) + .flatMap(listedPages -> ServerResponse.ok().bodyValue(listedPages)); + } +} diff --git a/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java index b669e73c7..c20ba4ddb 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java @@ -7,6 +7,8 @@ import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.utils.JsonUtils; /** + * Reconciler for {@link Category}. + * * @author guqing * @since 2.0.0 */ @@ -36,10 +38,8 @@ public class CategoryReconciler implements Reconciler { } private void reconcilePermalink(Category category) { - if (category.getStatusOrDefault().getPermalink() == null) { - category.getStatusOrDefault() - .setPermalink(categoryPermalinkPolicy.permalink(category)); - } + category.getStatusOrDefault() + .setPermalink(categoryPermalinkPolicy.permalink(category)); if (category.getMetadata().getDeletionTimestamp() != null) { categoryPermalinkPolicy.onPermalinkDelete(category); diff --git a/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java index 63bcefc11..c82e44488 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java @@ -61,10 +61,8 @@ public class PostReconciler implements Reconciler { } private void permalinkReconcile(Post post) { - if (post.getStatusOrDefault().getPermalink() == null) { - post.getStatusOrDefault() - .setPermalink(postPermalinkPolicy.permalink(post)); - } + post.getStatusOrDefault() + .setPermalink(postPermalinkPolicy.permalink(post)); if (Objects.equals(true, post.getSpec().getDeleted()) || post.getMetadata().getDeletionTimestamp() != null @@ -143,7 +141,7 @@ public class PostReconciler implements Reconciler { // handle logic delete Map labels = getLabelsOrDefault(post); - if (Objects.equals(spec.getDeleted(), true)) { + if (isDeleted(post)) { labels.put(Post.DELETED_LABEL, Boolean.TRUE.toString()); // TODO do more about logic delete such as remove router } else { @@ -176,4 +174,9 @@ public class PostReconciler implements Reconciler { private boolean isPublished(Snapshot snapshot) { return snapshot.getSpec().getPublishTime() != null; } + + private boolean isDeleted(Post post) { + return Objects.equals(true, post.getSpec().getDeleted()) + || post.getMetadata().getDeletionTimestamp() != null; + } } diff --git a/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java new file mode 100644 index 000000000..e1a23d376 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java @@ -0,0 +1,259 @@ +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.context.ApplicationContext; +import org.springframework.util.Assert; +import run.halo.app.content.ContentService; +import run.halo.app.content.permalinks.ExtensionLocator; +import run.halo.app.core.extension.Post; +import run.halo.app.core.extension.SinglePage; +import run.halo.app.core.extension.Snapshot; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.GroupVersionKind; +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; +import run.halo.app.infra.utils.PathUtils; +import run.halo.app.theme.DefaultTemplateEnum; +import run.halo.app.theme.router.PermalinkIndexAddCommand; +import run.halo.app.theme.router.PermalinkIndexDeleteCommand; +import run.halo.app.theme.router.TemplateRouteManager; + +/** + *

Reconciler for {@link SinglePage}.

+ * + *

things to do:

+ *
    + * 1. generate permalink + * 2. generate excerpt if auto generate is enabled + *
+ * + * @author guqing + * @since 2.0.0 + */ +public class SinglePageReconciler implements Reconciler { + private static final String FINALIZER_NAME = "single-page-protection"; + private static final GroupVersionKind GVK = GroupVersionKind.fromExtension(SinglePage.class); + private final ExtensionClient client; + private final ContentService contentService; + private final ApplicationContext applicationContext; + private final TemplateRouteManager templateRouteManager; + + public SinglePageReconciler(ExtensionClient client, ContentService contentService, + ApplicationContext applicationContext, TemplateRouteManager templateRouteManager) { + this.client = client; + this.contentService = contentService; + this.applicationContext = applicationContext; + this.templateRouteManager = templateRouteManager; + } + + @Override + public Result reconcile(Request request) { + client.fetch(SinglePage.class, request.name()) + .ifPresent(singlePage -> { + SinglePage oldPage = JsonUtils.deepCopy(singlePage); + if (isDeleted(oldPage)) { + cleanUpResourcesAndRemoveFinalizer(request.name()); + return; + } + addFinalizerIfNecessary(oldPage); + + reconcileStatus(request.name()); + reconcileMetadata(request.name()); + }); + return new Result(false, null); + } + + private void addFinalizerIfNecessary(SinglePage oldSinglePage) { + Set finalizers = oldSinglePage.getMetadata().getFinalizers(); + if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { + return; + } + client.fetch(SinglePage.class, oldSinglePage.getMetadata().getName()) + .ifPresent(singlePage -> { + Set newFinalizers = singlePage.getMetadata().getFinalizers(); + if (newFinalizers == null) { + newFinalizers = new HashSet<>(); + singlePage.getMetadata().setFinalizers(newFinalizers); + } + newFinalizers.add(FINALIZER_NAME); + client.update(singlePage); + }); + } + + private void cleanUpResources(SinglePage singlePage) { + // remove permalink from permalink indexer + permalinkOnDelete(singlePage); + } + + private void cleanUpResourcesAndRemoveFinalizer(String pageName) { + client.fetch(SinglePage.class, pageName).ifPresent(singlePage -> { + cleanUpResources(singlePage); + if (singlePage.getMetadata().getFinalizers() != null) { + singlePage.getMetadata().getFinalizers().remove(FINALIZER_NAME); + } + client.update(singlePage); + }); + } + + private void reconcileMetadata(String name) { + client.fetch(SinglePage.class, name).ifPresent(singlePage -> { + final SinglePage oldPage = JsonUtils.deepCopy(singlePage); + + SinglePage.SinglePageSpec spec = singlePage.getSpec(); + // handle logic delete + Map labels = getLabelsOrDefault(singlePage); + if (isDeleted(singlePage)) { + labels.put(SinglePage.DELETED_LABEL, Boolean.TRUE.toString()); + } else { + labels.put(SinglePage.DELETED_LABEL, Boolean.FALSE.toString()); + } + // synchronize some fields to labels to query + labels.put(SinglePage.PHASE_LABEL, singlePage.getStatusOrDefault().getPhase()); + labels.put(SinglePage.VISIBLE_LABEL, + Objects.requireNonNullElse(spec.getVisible(), Post.VisibleEnum.PUBLIC).name()); + labels.put(SinglePage.OWNER_LABEL, spec.getOwner()); + if (!oldPage.equals(singlePage)) { + client.update(singlePage); + } + }); + } + + private void permalinkOnDelete(SinglePage singlePage) { + singlePage.getStatusOrDefault() + .setPermalink(PathUtils.combinePath(singlePage.getSpec().getSlug())); + ExtensionLocator locator = new ExtensionLocator(GVK, singlePage.getMetadata().getName(), + singlePage.getSpec().getSlug()); + applicationContext.publishEvent(new PermalinkIndexDeleteCommand(this, locator, + singlePage.getStatusOrDefault().getPermalink())); + templateRouteManager.changeTemplatePattern(DefaultTemplateEnum.SINGLE_PAGE.getValue()); + } + + private void permalinkOnAdd(SinglePage singlePage) { + ExtensionLocator locator = new ExtensionLocator(GVK, singlePage.getMetadata().getName(), + singlePage.getSpec().getSlug()); + applicationContext.publishEvent(new PermalinkIndexAddCommand(this, locator, + singlePage.getStatusOrDefault().getPermalink())); + templateRouteManager.changeTemplatePattern(DefaultTemplateEnum.SINGLE_PAGE.getValue()); + } + + private void reconcileStatus(String name) { + client.fetch(SinglePage.class, name).ifPresent(singlePage -> { + final SinglePage oldPage = JsonUtils.deepCopy(singlePage); + permalinkOnDelete(oldPage); + + singlePage.getStatusOrDefault() + .setPermalink(PathUtils.combinePath(singlePage.getSpec().getSlug())); + if (isPublished(singlePage)) { + permalinkOnAdd(singlePage); + } + + SinglePage.SinglePageSpec spec = singlePage.getSpec(); + SinglePage.SinglePageStatus status = singlePage.getStatusOrDefault(); + if (status.getPhase() == null) { + status.setPhase(Post.PostPhase.DRAFT.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 + String headSnapshot = singlePage.getSpec().getHeadSnapshot(); + contentService.listSnapshots(Snapshot.SubjectRef.of(SinglePage.KIND, name)) + .collectList() + .subscribe(snapshots -> { + List contributors = snapshots.stream() + .map(snapshot -> { + Set usernames = snapshot.getSpec().getContributors(); + return Objects.requireNonNullElseGet(usernames, + () -> new HashSet()); + }) + .flatMap(Set::stream) + .distinct() + .sorted() + .toList(); + status.setContributors(contributors); + + // update in progress status + snapshots.stream() + .filter(snapshot -> snapshot.getMetadata().getName().equals(headSnapshot)) + .findAny() + .ifPresent(snapshot -> { + status.setInProgress(!isPublished(snapshot)); + }); + }); + + // 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()); + } + + if (!oldPage.equals(singlePage)) { + client.update(singlePage); + } + }); + } + + private Map getLabelsOrDefault(SinglePage singlePage) { + Assert.notNull(singlePage, "The singlePage must not be null."); + Map labels = singlePage.getMetadata().getLabels(); + if (labels == null) { + labels = new LinkedHashMap<>(); + singlePage.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); + } + + private boolean isPublished(Snapshot snapshot) { + return snapshot.getSpec().getPublishTime() != null; + } + + private boolean isPublished(SinglePage singlePage) { + return Objects.equals(true, singlePage.getSpec().getPublished()); + } + + private boolean isDeleted(SinglePage singlePage) { + return Objects.equals(true, singlePage.getSpec().getDeleted()) + || singlePage.getMetadata().getDeletionTimestamp() != null; + } +} diff --git a/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java index 972925406..135bbf50a 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java @@ -7,6 +7,8 @@ import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.utils.JsonUtils; /** + * Reconciler for {@link Tag}. + * * @author guqing * @since 2.0.0 */ @@ -35,10 +37,8 @@ public class TagReconciler implements Reconciler { } private void reconcilePermalink(Tag tag) { - if (tag.getStatusOrDefault().getPermalink() == null) { - tag.getStatusOrDefault() - .setPermalink(tagPermalinkPolicy.permalink(tag)); - } + tag.getStatusOrDefault() + .setPermalink(tagPermalinkPolicy.permalink(tag)); if (tag.getMetadata().getDeletionTimestamp() != null) { tagPermalinkPolicy.onPermalinkDelete(tag); diff --git a/src/main/java/run/halo/app/infra/SchemeInitializer.java b/src/main/java/run/halo/app/infra/SchemeInitializer.java index a75d64ef8..bbf72bf8e 100644 --- a/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -15,6 +15,7 @@ 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.SinglePage; import run.halo.app.core.extension.Snapshot; import run.halo.app.core.extension.Tag; import run.halo.app.core.extension.Theme; @@ -55,6 +56,7 @@ public class SchemeInitializer implements ApplicationListener list(@Nullable Integer page, @Nullable Integer size); +} diff --git a/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java new file mode 100644 index 000000000..35b4631ca --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java @@ -0,0 +1,115 @@ +package run.halo.app.theme.finders.impl; + +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; +import org.apache.commons.lang3.ObjectUtils; +import run.halo.app.content.ContentService; +import run.halo.app.core.extension.Post; +import run.halo.app.core.extension.SinglePage; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.theme.finders.ContributorFinder; +import run.halo.app.theme.finders.Finder; +import run.halo.app.theme.finders.SinglePageFinder; +import run.halo.app.theme.finders.vo.ContentVo; +import run.halo.app.theme.finders.vo.Contributor; +import run.halo.app.theme.finders.vo.SinglePageVo; + +/** + * A default implementation of {@link SinglePage}. + * + * @author guqing + * @since 2.0.0 + */ +@Finder("singlePageFinder") +public class SinglePageFinderImpl implements SinglePageFinder { + + public static final Predicate FIXED_PREDICATE = page -> + Objects.equals(false, page.getSpec().getDeleted()) + && Objects.equals(true, page.getSpec().getPublished()) + && Post.VisibleEnum.PUBLIC.equals(page.getSpec().getVisible()); + + private final ReactiveExtensionClient client; + + private final ContentService contentService; + + private final ContributorFinder contributorFinder; + + public SinglePageFinderImpl(ReactiveExtensionClient client, ContentService contentService, + ContributorFinder contributorFinder) { + this.client = client; + this.contentService = contentService; + this.contributorFinder = contributorFinder; + } + + @Override + public SinglePageVo getByName(String pageName) { + SinglePage page = client.fetch(SinglePage.class, pageName) + .block(); + if (page == null) { + return null; + } + List contributors = + contributorFinder.getContributors(page.getStatus().getContributors()); + SinglePageVo pageVo = SinglePageVo.from(page); + pageVo.setContributors(contributors); + return pageVo; + } + + @Override + public ContentVo content(String pageName) { + return client.fetch(SinglePage.class, pageName) + .map(page -> page.getSpec().getReleaseSnapshot()) + .flatMap(contentService::getContent) + .map(wrapper -> ContentVo.builder().content(wrapper.content()) + .raw(wrapper.raw()).build()) + .block(); + } + + @Override + public ListResult list(Integer page, Integer size) { + ListResult list = client.list(SinglePage.class, FIXED_PREDICATE, + defaultComparator(), pageNullSafe(page), sizeNullSafe(size)) + .block(); + if (list == null) { + return new ListResult<>(0, 0, 0, List.of()); + } + List postVos = list.get() + .map(sp -> { + List contributors = + contributorFinder.getContributors(sp.getStatus().getContributors()); + SinglePageVo pageVo = SinglePageVo.from(sp); + pageVo.setContributors(contributors); + return pageVo; + }) + .toList(); + return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), postVos); + } + + static Comparator defaultComparator() { + Function pinned = + page -> Objects.requireNonNullElse(page.getSpec().getPinned(), false); + Function priority = + page -> Objects.requireNonNullElse(page.getSpec().getPriority(), 0); + Function creationTimestamp = + page -> page.getMetadata().getCreationTimestamp(); + Function name = page -> page.getMetadata().getName(); + return Comparator.comparing(pinned) + .thenComparing(priority) + .thenComparing(creationTimestamp) + .thenComparing(name) + .reversed(); + } + + int pageNullSafe(Integer page) { + return ObjectUtils.defaultIfNull(page, 1); + } + + int sizeNullSafe(Integer size) { + return ObjectUtils.defaultIfNull(size, 10); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/BasePostVo.java b/src/main/java/run/halo/app/theme/finders/vo/BasePostVo.java new file mode 100644 index 000000000..caedd0867 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/BasePostVo.java @@ -0,0 +1,61 @@ +package run.halo.app.theme.finders.vo; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.Data; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import run.halo.app.core.extension.Post; + +/** + * a base entity for post. + * + * @author guqing + * @since 2.0.0 + */ +@Data +@ToString +@SuperBuilder +public abstract class BasePostVo { + String name; + + String title; + + String slug; + + String owner; + + String template; + + String cover; + + Boolean published; + + Instant publishTime; + + Boolean pinned; + + Boolean allowComment; + + Post.VisibleEnum visible; + + Integer version; + + Integer priority; + + String excerpt; + + List> htmlMetas; + + String permalink; + + List contributors; + + Map annotations; + + static List nullSafe(List t) { + return Objects.requireNonNullElse(t, List.of()); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/PostVo.java b/src/main/java/run/halo/app/theme/finders/vo/PostVo.java index 2868d693f..7b3f1f5ee 100644 --- a/src/main/java/run/halo/app/theme/finders/vo/PostVo.java +++ b/src/main/java/run/halo/app/theme/finders/vo/PostVo.java @@ -1,11 +1,9 @@ package run.halo.app.theme.finders.vo; -import java.time.Instant; import java.util.List; -import java.util.Map; -import java.util.Objects; -import lombok.Builder; import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; import org.springframework.util.Assert; import run.halo.app.core.extension.Post; @@ -16,49 +14,14 @@ import run.halo.app.core.extension.Post; * @since 2.0.0 */ @Data -@Builder -public class PostVo { - - String name; - - String title; - - String slug; - - String owner; - - String template; - - String cover; - - Boolean published; - - Instant publishTime; - - Boolean pinned; - - Boolean allowComment; - - Post.VisibleEnum visible; - - Integer version; - - Integer priority; - - String excerpt; +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class PostVo extends BasePostVo { List categories; List tags; - List> htmlMetas; - - String permalink; - - List contributors; - - Map annotations; - /** * Convert {@link Post} to {@link PostVo}. * @@ -92,8 +55,4 @@ public class PostVo { .contributors(List.of()) .build(); } - - static List nullSafe(List t) { - return Objects.requireNonNullElse(t, List.of()); - } } diff --git a/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java b/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java new file mode 100644 index 000000000..8db7a5860 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java @@ -0,0 +1,54 @@ +package run.halo.app.theme.finders.vo; + +import java.util.List; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.springframework.util.Assert; +import run.halo.app.core.extension.SinglePage; + +/** + * A value object for {@link SinglePage}. + * + * @author guqing + * @since 2.0.0 + */ +@Data +@SuperBuilder +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +public class SinglePageVo extends BasePostVo { + + /** + * Convert {@link SinglePage} to {@link SinglePageVo}. + * + * @param singlePage single page extension + * @return special page value object + */ + public static SinglePageVo from(SinglePage singlePage) { + Assert.notNull(singlePage, "The singlePage must not be null."); + SinglePage.SinglePageSpec spec = singlePage.getSpec(); + SinglePage.SinglePageStatus pageStatus = singlePage.getStatus(); + return SinglePageVo.builder() + .name(singlePage.getMetadata().getName()) + .annotations(singlePage.getMetadata().getAnnotations()) + .title(spec.getTitle()) + .cover(spec.getCover()) + .allowComment(spec.getAllowComment()) + .owner(spec.getOwner()) + .pinned(spec.getPinned()) + .slug(spec.getSlug()) + .htmlMetas(nullSafe(spec.getHtmlMetas())) + .published(spec.getPublished()) + .publishTime(spec.getPublishTime()) + .priority(spec.getPriority()) + .version(spec.getVersion()) + .visible(spec.getVisible()) + .template(spec.getTemplate()) + .permalink(pageStatus.getPermalink()) + .excerpt(pageStatus.getExcerpt()) + .contributors(List.of()) + .build(); + } +} diff --git a/src/main/java/run/halo/app/theme/router/PermalinkPatternProvider.java b/src/main/java/run/halo/app/theme/router/PermalinkPatternProvider.java index 122ee7a17..d4307129c 100644 --- a/src/main/java/run/halo/app/theme/router/PermalinkPatternProvider.java +++ b/src/main/java/run/halo/app/theme/router/PermalinkPatternProvider.java @@ -51,7 +51,7 @@ public class PermalinkPatternProvider { public String getPattern(DefaultTemplateEnum defaultTemplateEnum) { SystemSetting.ThemeRouteRules permalinkRules = getPermalinkRules(); return switch (defaultTemplateEnum) { - case INDEX -> null; + case INDEX, SINGLE_PAGE -> null; case POST -> permalinkRules.getPost(); case ARCHIVES -> permalinkRules.getArchives(); case CATEGORY, CATEGORIES -> permalinkRules.getCategories(); diff --git a/src/main/java/run/halo/app/theme/router/TemplateRouteManager.java b/src/main/java/run/halo/app/theme/router/TemplateRouteManager.java index 2f7d1cebd..0cf1eb969 100644 --- a/src/main/java/run/halo/app/theme/router/TemplateRouteManager.java +++ b/src/main/java/run/halo/app/theme/router/TemplateRouteManager.java @@ -16,6 +16,7 @@ import run.halo.app.theme.router.strategy.CategoriesRouteStrategy; import run.halo.app.theme.router.strategy.CategoryRouteStrategy; import run.halo.app.theme.router.strategy.IndexRouteStrategy; import run.halo.app.theme.router.strategy.PostRouteStrategy; +import run.halo.app.theme.router.strategy.SinglePageRouteStrategy; import run.halo.app.theme.router.strategy.TagRouteStrategy; import run.halo.app.theme.router.strategy.TagsRouteStrategy; @@ -113,6 +114,7 @@ public class TemplateRouteManager implements ApplicationListener new TagRouteStrategy(permalinkIndexer); case CATEGORIES -> new CategoriesRouteStrategy(); case CATEGORY -> new CategoryRouteStrategy(permalinkIndexer); + case SINGLE_PAGE -> new SinglePageRouteStrategy(permalinkIndexer); }; } diff --git a/src/main/java/run/halo/app/theme/router/strategy/SinglePageRouteStrategy.java b/src/main/java/run/halo/app/theme/router/strategy/SinglePageRouteStrategy.java new file mode 100644 index 000000000..ca574cba6 --- /dev/null +++ b/src/main/java/run/halo/app/theme/router/strategy/SinglePageRouteStrategy.java @@ -0,0 +1,59 @@ +package run.halo.app.theme.router.strategy; + +import static org.springframework.web.reactive.function.server.RequestPredicates.accept; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.server.RequestPredicate; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.core.extension.SinglePage; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.theme.DefaultTemplateEnum; +import run.halo.app.theme.router.PermalinkIndexer; +import run.halo.app.theme.router.TemplateRouterStrategy; + +/** + * The {@link SinglePageRouteStrategy} for generate {@link RouterFunction} specific to the template + * page.html. + * + * @author guqing + * @since 2.0.0 + */ +public class SinglePageRouteStrategy implements TemplateRouterStrategy { + + private final PermalinkIndexer permalinkIndexer; + + public SinglePageRouteStrategy(PermalinkIndexer permalinkIndexer) { + this.permalinkIndexer = permalinkIndexer; + } + + @Override + public RouterFunction getRouteFunction(String template, String pattern) { + GroupVersionKind gvk = GroupVersionKind.fromExtension(SinglePage.class); + + RequestPredicate requestPredicate = request -> false; + + List permalinks = + Objects.requireNonNullElse(permalinkIndexer.getPermalinks(gvk), List.of()); + for (String permalink : permalinks) { + requestPredicate = requestPredicate.or(RequestPredicates.GET(permalink)); + } + + return RouterFunctions + .route(requestPredicate.and(accept(MediaType.TEXT_HTML)), request -> { + String slug = StringUtils.removeStart(request.path(), "/"); + String name = permalinkIndexer.getNameBySlug(gvk, slug); + if (name == null) { + return ServerResponse.notFound().build(); + } + return ServerResponse.ok() + .render(DefaultTemplateEnum.SINGLE_PAGE.getValue(), Map.of("name", name)); + }); + } +} diff --git a/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java b/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java index d906f444e..a3ce6b42f 100644 --- a/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java +++ b/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java @@ -5,12 +5,13 @@ 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 org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import run.halo.app.content.ContentService; import run.halo.app.content.PostQuery; import run.halo.app.content.TestPost; @@ -40,8 +41,9 @@ class PostServiceImplTest { @Test void listPredicate() { - PostQuery postQuery = new PostQuery(); - postQuery.setCategories(Set.of("category1", "category2")); + MultiValueMap multiValueMap = new LinkedMultiValueMap<>(); + multiValueMap.put("category", List.of("category1", "category2")); + PostQuery postQuery = new PostQuery(multiValueMap); Post post = TestPost.postV1(); post.getSpec().setTags(null); @@ -55,21 +57,23 @@ class PostServiceImplTest { assertThat(test).isTrue(); // Do not include tags - postQuery.setTags(Set.of("tag2")); + multiValueMap.put("tag", List.of("tag2")); post.getSpec().setTags(List.of("tag1")); + post.getSpec().setCategories(null); test = postService.postListPredicate(postQuery).test(post); assertThat(test).isFalse(); - postQuery.setTags(Set.of()); + multiValueMap.put("tag", List.of()); + multiValueMap.remove("category"); + postQuery = new PostQuery(multiValueMap); post.getSpec().setTags(List.of()); test = postService.postListPredicate(postQuery).test(post); assertThat(test).isTrue(); - postQuery.setLabelSelector(List.of("hello")); + multiValueMap.put("labelSelector", 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(); diff --git a/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java new file mode 100644 index 000000000..fbe78fcff --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java @@ -0,0 +1,122 @@ +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.ArgumentMatchers.isA; +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 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 org.springframework.context.ApplicationContext; +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.SinglePage; +import run.halo.app.core.extension.Snapshot; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.theme.DefaultTemplateEnum; +import run.halo.app.theme.router.PermalinkIndexAddCommand; +import run.halo.app.theme.router.PermalinkIndexDeleteCommand; +import run.halo.app.theme.router.PermalinkIndexUpdateCommand; +import run.halo.app.theme.router.TemplateRouteManager; + +/** + * Tests for {@link SinglePageReconciler}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class SinglePageReconcilerTest { + @Mock + private ExtensionClient client; + @Mock + private ContentService contentService; + + @Mock + private ApplicationContext applicationContext; + + @Mock + private TemplateRouteManager templateRouteManager; + + private SinglePageReconciler singlePageReconciler; + + @BeforeEach + void setUp() { + singlePageReconciler = new SinglePageReconciler(client, contentService, applicationContext, + templateRouteManager); + } + + @Test + void reconcile() { + String name = "page-A"; + SinglePage page = pageV1(); + page.getSpec().setHeadSnapshot("page-A-head-snapshot"); + when(client.fetch(eq(SinglePage.class), eq(name))) + .thenReturn(Optional.of(page)); + when(contentService.getContent(eq(page.getSpec().getHeadSnapshot()))) + .thenReturn(Mono.just( + new ContentWrapper(page.getSpec().getHeadSnapshot(), "hello world", + "

hello world

", "markdown"))); + + Snapshot snapshotV1 = 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 captor = ArgumentCaptor.forClass(SinglePage.class); + singlePageReconciler.reconcile(new Reconciler.Request(name)); + + verify(client, times(3)).update(captor.capture()); + + SinglePage value = captor.getValue(); + assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world"); + assertThat(value.getStatus().getContributors()).isEqualTo(List.of("guqing", "zhangsan")); + + verify(applicationContext, times(0)).publishEvent(isA(PermalinkIndexAddCommand.class)); + verify(applicationContext, times(1)).publishEvent(isA(PermalinkIndexDeleteCommand.class)); + verify(applicationContext, times(0)).publishEvent(isA(PermalinkIndexUpdateCommand.class)); + verify(templateRouteManager, times(1)) + .changeTemplatePattern(eq(DefaultTemplateEnum.SINGLE_PAGE.getValue())); + } + + public static SinglePage pageV1() { + SinglePage page = new SinglePage(); + page.setKind(Post.KIND); + + page.setApiVersion("content.halo.run/v1alpha1"); + Metadata metadata = new Metadata(); + metadata.setName("page-A"); + page.setMetadata(metadata); + + SinglePage.SinglePageSpec spec = new SinglePage.SinglePageSpec(); + page.setSpec(spec); + + spec.setTitle("page-A"); + spec.setSlug("page-slug"); + spec.setVersion(1); + spec.setBaseSnapshot(snapshotV1().getMetadata().getName()); + spec.setHeadSnapshot("base-snapshot"); + spec.setReleaseSnapshot(null); + + return page; + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/router/strategy/SinglePageRouteStrategyTest.java b/src/test/java/run/halo/app/theme/router/strategy/SinglePageRouteStrategyTest.java new file mode 100644 index 000000000..8f578e988 --- /dev/null +++ b/src/test/java/run/halo/app/theme/router/strategy/SinglePageRouteStrategyTest.java @@ -0,0 +1,76 @@ +package run.halo.app.theme.router.strategy; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.List; +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.http.HttpStatus; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.server.HandlerStrategies; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.reactive.result.view.ViewResolver; +import reactor.core.publisher.Mono; +import run.halo.app.theme.DefaultTemplateEnum; +import run.halo.app.theme.router.PermalinkIndexer; + +/** + * Tests for {@link SinglePageRouteStrategy}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class SinglePageRouteStrategyTest { + + @Mock + private PermalinkIndexer permalinkIndexer; + + @Mock + private ViewResolver viewResolver; + + private SinglePageRouteStrategy strategy; + + @BeforeEach + void setUp() { + strategy = new SinglePageRouteStrategy(permalinkIndexer); + when(permalinkIndexer.getPermalinks(any())) + .thenReturn(List.of("/fake-slug")); + when(permalinkIndexer.getNameBySlug(any(), eq("fake-slug"))) + .thenReturn("fake-name"); + } + + @Test + void getRouteFunction() { + RouterFunction routeFunction = + strategy.getRouteFunction(DefaultTemplateEnum.SINGLE_PAGE.getValue(), + null); + + WebTestClient client = WebTestClient.bindToRouterFunction(routeFunction) + .handlerStrategies(HandlerStrategies.builder() + .viewResolver(viewResolver) + .build()) + .build(); + + when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.SINGLE_PAGE.getValue()), any())) + .thenReturn(Mono.just(new EmptyView())); + + client.get() + .uri("/fake-slug") + .exchange() + .expectStatus() + .isOk(); + + client.get() + .uri("/nothing") + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.NOT_FOUND); + } +} \ No newline at end of file