diff --git a/src/main/java/run/halo/app/core/extension/Category.java b/src/main/java/run/halo/app/core/extension/Category.java index cfc3c410a..fc3a7a895 100644 --- a/src/main/java/run/halo/app/core/extension/Category.java +++ b/src/main/java/run/halo/app/core/extension/Category.java @@ -1,5 +1,6 @@ package run.halo.app.core.extension; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Data; @@ -47,6 +48,14 @@ public class Category extends AbstractExtension { private List children; } + @JsonIgnore + public CategoryStatus getStatusOrDefault() { + if (this.status == null) { + this.status = new CategoryStatus(); + } + return this.status; + } + @Data public static class CategoryStatus { diff --git a/src/main/java/run/halo/app/core/extension/Tag.java b/src/main/java/run/halo/app/core/extension/Tag.java index 8ab5a635b..c435f4312 100644 --- a/src/main/java/run/halo/app/core/extension/Tag.java +++ b/src/main/java/run/halo/app/core/extension/Tag.java @@ -1,5 +1,6 @@ package run.halo.app.core.extension; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Data; @@ -55,6 +56,15 @@ public class Tag extends AbstractExtension { private String cover; } + @JsonIgnore + public TagStatus getStatusOrDefault() { + if (this.status == null) { + this.status = new TagStatus(); + } + return this.status; + } + + @Data public static class TagStatus { diff --git a/src/main/java/run/halo/app/theme/HaloViewResolver.java b/src/main/java/run/halo/app/theme/HaloViewResolver.java index 2c0543ce5..bc7157ad6 100644 --- a/src/main/java/run/halo/app/theme/HaloViewResolver.java +++ b/src/main/java/run/halo/app/theme/HaloViewResolver.java @@ -1,19 +1,37 @@ package run.halo.app.theme; +import java.util.Locale; import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.web.reactive.result.view.View; import org.springframework.web.server.ServerWebExchange; import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView; import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import run.halo.app.theme.finders.FinderRegistry; @Component("thymeleafReactiveViewResolver") public class HaloViewResolver extends ThymeleafReactiveViewResolver { - public HaloViewResolver() { + private final FinderRegistry finderRegistry; + + public HaloViewResolver(FinderRegistry finderRegistry) { setViewClass(HaloView.class); + this.finderRegistry = finderRegistry; + } + + @Override + protected Mono loadView(String viewName, Locale locale) { + return super.loadView(viewName, locale) + .cast(HaloView.class) + .map(view -> { + // populate finders to view static variables + finderRegistry.getFinders().forEach(view::addStaticVariable); + return view; + }); } public static class HaloView extends ThymeleafReactiveView { @@ -30,7 +48,8 @@ public class HaloViewResolver extends ThymeleafReactiveViewResolver { return themeResolver.getTheme(exchange.getRequest()).flatMap(theme -> { // calculate the engine before rendering setTemplateEngine(engineManager.getTemplateEngine(theme)); - return super.render(model, contentType, exchange); + return super.render(model, contentType, exchange) + .subscribeOn(Schedulers.boundedElastic()); }); } } diff --git a/src/main/java/run/halo/app/theme/engine/SpringWebFluxTemplateEngine.java b/src/main/java/run/halo/app/theme/engine/SpringWebFluxTemplateEngine.java index 6a6328a98..6e44b118d 100644 --- a/src/main/java/run/halo/app/theme/engine/SpringWebFluxTemplateEngine.java +++ b/src/main/java/run/halo/app/theme/engine/SpringWebFluxTemplateEngine.java @@ -37,6 +37,7 @@ import org.thymeleaf.util.LoggingUtils; import org.thymeleaf.web.IWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; /** @@ -47,6 +48,10 @@ import reactor.core.publisher.Mono; * Code from * thymeleaf SpringWebFluxTemplateEngine * + *

Note that: We need to subscribe on a new thread to support blocking operations + * for theme finders in {@link #createDataDrivenStream} and {@link #createFullStream} and + * {@link #createChunkedStream}.

+ * * @author Daniel Fernández * @see ISpringWebFluxTemplateEngine * @see org.thymeleaf.spring6.SpringWebFluxTemplateEngine @@ -125,7 +130,9 @@ public class SpringWebFluxTemplateEngine extends SpringTemplateEngine // We should be executing in data-driven mode return createDataDrivenStream( template, markupSelectors, context, dataDriverVariableName, bufferFactory, - charset, chunkSizeBytes, sse); + charset, chunkSizeBytes, sse) + // We need to subscribe on a new thread to support blocking operations + .subscribeOn(Schedulers.boundedElastic()); } } catch (final Throwable t) { return Flux.error(t); @@ -151,14 +158,18 @@ public class SpringWebFluxTemplateEngine extends SpringTemplateEngine */ if (chunkSizeBytes == Integer.MAX_VALUE) { // No limit on buffer size, so there is no reason to throttle: using FULL mode instead. - return createFullStream(template, markupSelectors, context, bufferFactory, charset); + return createFullStream(template, markupSelectors, context, bufferFactory, charset) + // We need to subscribe on a new thread to support blocking operations + .subscribeOn(Schedulers.boundedElastic()); } /* * CREATE A CHUNKED STREAM */ return createChunkedStream( - template, markupSelectors, context, bufferFactory, charset, responseMaxChunkSizeBytes); + template, markupSelectors, context, bufferFactory, charset, responseMaxChunkSizeBytes) + // We need to subscribe on a new thread to support blocking operations + .subscribeOn(Schedulers.boundedElastic()); } diff --git a/src/main/java/run/halo/app/theme/finders/CategoryFinder.java b/src/main/java/run/halo/app/theme/finders/CategoryFinder.java new file mode 100644 index 000000000..4d3691bf9 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/CategoryFinder.java @@ -0,0 +1,26 @@ +package run.halo.app.theme.finders; + +import java.util.List; +import run.halo.app.core.extension.Category; +import run.halo.app.extension.ListResult; +import run.halo.app.theme.finders.vo.CategoryTreeVo; +import run.halo.app.theme.finders.vo.CategoryVo; + +/** + * A finder for {@link Category}. + * + * @author guqing + * @since 2.0.0 + */ +public interface CategoryFinder { + + CategoryVo getByName(String name); + + List getByNames(List names); + + ListResult list(int page, int size); + + List listAll(); + + List listAsTree(); +} diff --git a/src/main/java/run/halo/app/theme/finders/CommentFinder.java b/src/main/java/run/halo/app/theme/finders/CommentFinder.java new file mode 100644 index 000000000..1f382e26b --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/CommentFinder.java @@ -0,0 +1,21 @@ +package run.halo.app.theme.finders; + +import run.halo.app.core.extension.Comment; +import run.halo.app.extension.ListResult; +import run.halo.app.theme.finders.vo.CommentVo; +import run.halo.app.theme.finders.vo.ReplyVo; + +/** + * A finder for finding {@link Comment comments} in template. + * + * @author guqing + * @since 2.0.0 + */ +public interface CommentFinder { + + CommentVo getByName(String name); + + ListResult list(Comment.CommentSubjectRef ref, int page, int size); + + ListResult listReply(String commentName, int page, int size); +} diff --git a/src/main/java/run/halo/app/theme/finders/Contributor.java b/src/main/java/run/halo/app/theme/finders/Contributor.java new file mode 100644 index 000000000..4cb50bdfa --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/Contributor.java @@ -0,0 +1,37 @@ +package run.halo.app.theme.finders; + +import lombok.Builder; +import lombok.Value; +import run.halo.app.core.extension.User; + +/** + * A value object for {@link run.halo.app.core.extension.User}. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@Builder +public class Contributor { + String name; + + String displayName; + + String avatar; + + String bio; + + /** + * Convert {@link User} to {@link Contributor}. + * + * @param user user extension + * @return contributor value object + */ + public static Contributor from(User user) { + return builder().name(user.getMetadata().getName()) + .displayName(user.getSpec().getDisplayName()) + .avatar(user.getSpec().getAvatar()) + .bio(user.getSpec().getBio()) + .build(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/ContributorFinder.java b/src/main/java/run/halo/app/theme/finders/ContributorFinder.java new file mode 100644 index 000000000..4ddfd1488 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/ContributorFinder.java @@ -0,0 +1,14 @@ +package run.halo.app.theme.finders; + +import java.util.List; +import run.halo.app.core.extension.User; + +/** + * A finder for {@link User}. + */ +public interface ContributorFinder { + + Contributor getContributor(String name); + + List getContributors(List names); +} diff --git a/src/main/java/run/halo/app/theme/finders/Finder.java b/src/main/java/run/halo/app/theme/finders/Finder.java new file mode 100644 index 000000000..6a24311a6 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/Finder.java @@ -0,0 +1,26 @@ +package run.halo.app.theme.finders; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.stereotype.Service; + +/** + * Template model data finder for theme. + * + * @author guqing + * @since 2.0.0 + */ +@Service +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Finder { + + /** + * The name of the theme model variable. + * + * @return variable name, class simple name if not specified + */ + String value() default ""; +} \ No newline at end of file diff --git a/src/main/java/run/halo/app/theme/finders/FinderRegistry.java b/src/main/java/run/halo/app/theme/finders/FinderRegistry.java new file mode 100644 index 000000000..7c0b9c350 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/FinderRegistry.java @@ -0,0 +1,133 @@ +package run.halo.app.theme.finders; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import run.halo.app.plugin.ExtensionContextRegistry; +import run.halo.app.plugin.PluginApplicationContext; +import run.halo.app.plugin.event.HaloPluginStartedEvent; +import run.halo.app.plugin.event.HaloPluginStoppedEvent; + +/** + * Finder registry for class annotated with {@link Finder}. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class FinderRegistry implements InitializingBean { + private final Map> pluginFindersLookup = new ConcurrentHashMap<>(); + private final Map finders = new ConcurrentHashMap<>(64); + + private final ApplicationContext applicationContext; + + public FinderRegistry(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + Object get(String name) { + return finders.get(name); + } + + /** + * Given a name, register a Finder for it. + * + * @param name the canonical name + * @param finder the finder to be registered + * @throws IllegalStateException if the name is already existing + */ + public void registerFinder(String name, Object finder) { + if (finders.containsKey(name)) { + throw new IllegalStateException( + "Finder with name '" + name + "' is already registered"); + } + finders.put(name, finder); + } + + /** + * Register a finder. + * + * @param finder register a finder that annotated with {@link Finder} + * @return the name of the finder + */ + public String registerFinder(Object finder) { + Finder annotation = finder.getClass().getAnnotation(Finder.class); + if (annotation == null) { + throw new IllegalStateException("Finder must be annotated with @Finder"); + } + String name = annotation.value(); + if (name == null) { + name = finder.getClass().getSimpleName(); + } + this.registerFinder(name, finder); + return name; + } + + public void removeFinder(String name) { + finders.remove(name); + } + + public Map getFinders() { + return Map.copyOf(finders); + } + + @Override + public void afterPropertiesSet() throws Exception { + // initialize finders from application context + applicationContext.getBeansWithAnnotation(Finder.class) + .forEach((k, v) -> { + registerFinder(v); + }); + } + + /** + * Register finders for a plugin. + * + * @param event plugin started event + */ + @EventListener(HaloPluginStartedEvent.class) + public void onPluginStarted(HaloPluginStartedEvent event) { + String pluginId = event.getPlugin().getPluginId(); + PluginApplicationContext pluginApplicationContext = ExtensionContextRegistry.getInstance() + .getByPluginId(pluginId); + pluginApplicationContext.getBeansWithAnnotation(Finder.class) + .forEach((beanName, finderObject) -> { + // register finder + String finderName = registerFinder(finderObject); + // add to plugin finder lookup + pluginFindersLookup.computeIfAbsent(pluginId, k -> new ArrayList<>()) + .add(finderName); + }); + } + + /** + * Remove finders registered by the plugin. + * + * @param event plugin stopped event + */ + @EventListener(HaloPluginStoppedEvent.class) + public void onPluginStopped(HaloPluginStoppedEvent event) { + String pluginId = event.getPlugin().getPluginId(); + boolean containsKey = pluginFindersLookup.containsKey(pluginId); + if (!containsKey) { + return; + } + pluginFindersLookup.get(pluginId).forEach(this::removeFinder); + } + + /** + * Only for test. + * + * @param pluginId plugin id + * @param finderName finder name + */ + void addPluginFinder(String pluginId, String finderName) { + pluginFindersLookup.computeIfAbsent(pluginId, k -> new ArrayList<>()) + .add(finderName); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/MenuFinder.java b/src/main/java/run/halo/app/theme/finders/MenuFinder.java new file mode 100644 index 000000000..63973a1dc --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/MenuFinder.java @@ -0,0 +1,16 @@ +package run.halo.app.theme.finders; + +import run.halo.app.theme.finders.vo.MenuVo; + +/** + * A finder for {@link run.halo.app.core.extension.Menu}. + * + * @author guqing + * @since 2.0.0 + */ +public interface MenuFinder { + + MenuVo getByName(String name); + + MenuVo getDefault(); +} diff --git a/src/main/java/run/halo/app/theme/finders/PostFinder.java b/src/main/java/run/halo/app/theme/finders/PostFinder.java new file mode 100644 index 000000000..b62d5943e --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/PostFinder.java @@ -0,0 +1,25 @@ +package run.halo.app.theme.finders; + +import run.halo.app.core.extension.Post; +import run.halo.app.extension.ListResult; +import run.halo.app.theme.finders.vo.ContentVo; +import run.halo.app.theme.finders.vo.PostVo; + +/** + * A finder for {@link Post}. + * + * @author guqing + * @since 2.0.0 + */ +public interface PostFinder { + + PostVo getByName(String postName); + + ContentVo content(String postName); + + ListResult list(int page, int size); + + ListResult listByCategory(int page, int size, String categoryName); + + ListResult listByTag(int page, int size, String tag); +} diff --git a/src/main/java/run/halo/app/theme/finders/TagFinder.java b/src/main/java/run/halo/app/theme/finders/TagFinder.java new file mode 100644 index 000000000..73f4f16af --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/TagFinder.java @@ -0,0 +1,23 @@ +package run.halo.app.theme.finders; + +import java.util.List; +import run.halo.app.core.extension.Tag; +import run.halo.app.extension.ListResult; +import run.halo.app.theme.finders.vo.TagVo; + +/** + * A finder for {@link Tag}. + * + * @author guqing + * @since 2.0.0 + */ +public interface TagFinder { + + TagVo getByName(String name); + + List getByNames(List names); + + ListResult list(int page, int size); + + List listAll(); +} diff --git a/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java new file mode 100644 index 000000000..714d2c6fc --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java @@ -0,0 +1,122 @@ +package run.halo.app.theme.finders.impl; + +import java.time.Instant; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.util.CollectionUtils; +import run.halo.app.core.extension.Category; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.theme.finders.CategoryFinder; +import run.halo.app.theme.finders.Finder; +import run.halo.app.theme.finders.vo.CategoryTreeVo; +import run.halo.app.theme.finders.vo.CategoryVo; + +/** + * A default implementation of {@link CategoryFinder}. + * + * @author guqing + * @since 2.0.0 + */ +@Finder("categoryFinder") +public class CategoryFinderImpl implements CategoryFinder { + private final ReactiveExtensionClient client; + + public CategoryFinderImpl(ReactiveExtensionClient client) { + this.client = client; + } + + @Override + public CategoryVo getByName(String name) { + return client.fetch(Category.class, name) + .map(CategoryVo::from) + .block(); + } + + @Override + public List getByNames(List names) { + if (names == null) { + return List.of(); + } + return names.stream().map(this::getByName) + .filter(Objects::nonNull) + .toList(); + } + + @Override + public ListResult list(int page, int size) { + return client.list(Category.class, null, + defaultComparator(), page, size) + .map(list -> { + List categoryVos = list.stream() + .map(CategoryVo::from) + .collect(Collectors.toList()); + return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), + categoryVos); + }) + .block(); + } + + @Override + public List listAll() { + return client.list(Category.class, null, defaultComparator()) + .map(CategoryVo::from) + .collectList() + .block(); + } + + @Override + public List listAsTree() { + List categoryVos = listAll(); + Map nameIdentityMap = categoryVos.stream() + .collect(Collectors.toMap(CategoryVo::getName, Function.identity())); + + Map treeVoMap = new HashMap<>(); + // populate parentName + categoryVos.forEach(categoryVo -> { + final String parentName = categoryVo.getName(); + treeVoMap.putIfAbsent(parentName, CategoryTreeVo.from(categoryVo)); + List children = categoryVo.getChildren(); + if (CollectionUtils.isEmpty(children)) { + return; + } + children.forEach(childrenName -> { + CategoryVo childrenVo = nameIdentityMap.get(childrenName); + CategoryTreeVo treeVo = CategoryTreeVo.from(childrenVo); + treeVo.setParentName(parentName); + treeVoMap.putIfAbsent(treeVo.getName(), treeVo); + }); + }); + nameIdentityMap.clear(); + return listToTree(treeVoMap.values()); + } + + static List listToTree(Collection list) { + Map> nameIdentityMap = list.stream() + .filter(item -> item.getParentName() != null) + .collect(Collectors.groupingBy(CategoryTreeVo::getParentName)); + list.forEach(node -> node.setChildren(nameIdentityMap.get(node.getName()))); + return list.stream() + .filter(v -> v.getParentName() == null) + .collect(Collectors.toList()); + } + + static Comparator defaultComparator() { + Function priority = + category -> Objects.requireNonNullElse(category.getSpec().getPriority(), 0); + Function creationTimestamp = + category -> category.getMetadata().getCreationTimestamp(); + Function name = + category -> category.getMetadata().getName(); + return Comparator.comparing(priority) + .thenComparing(creationTimestamp) + .thenComparing(name) + .reversed(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java new file mode 100644 index 000000000..d48a91ed4 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java @@ -0,0 +1,93 @@ +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.springframework.util.Assert; +import run.halo.app.core.extension.Comment; +import run.halo.app.core.extension.Reply; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.theme.finders.CommentFinder; +import run.halo.app.theme.finders.Finder; +import run.halo.app.theme.finders.vo.CommentVo; +import run.halo.app.theme.finders.vo.ReplyVo; + +/** + * A default implementation of {@link CommentFinder}. + * + * @author guqing + * @since 2.0.0 + */ +@Finder("commentFinder") +public class CommentFinderImpl implements CommentFinder { + + private final ReactiveExtensionClient client; + + public CommentFinderImpl(ReactiveExtensionClient client) { + this.client = client; + } + + @Override + public CommentVo getByName(String name) { + return client.fetch(Comment.class, name) + .map(CommentVo::from) + .block(); + } + + @Override + public ListResult list(Comment.CommentSubjectRef ref, int page, int size) { + return client.list(Comment.class, fixedPredicate(ref), + defaultComparator(), + page, size) + .map(list -> { + List commentVos = list.get().map(CommentVo::from).toList(); + return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), + commentVos); + }) + .block(); + } + + @Override + public ListResult listReply(String commentName, int page, int size) { + Comparator comparator = + Comparator.comparing(reply -> reply.getMetadata().getCreationTimestamp()); + return client.list(Reply.class, + reply -> reply.getSpec().getCommentName().equals(commentName) + && Objects.equals(false, reply.getSpec().getHidden()) + && Objects.equals(true, reply.getSpec().getApproved()), + comparator.reversed(), page, size) + .map(list -> { + List replyVos = list.get().map(ReplyVo::from).toList(); + return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), + replyVos); + }) + .block(); + } + + private Predicate fixedPredicate(Comment.CommentSubjectRef ref) { + Assert.notNull(ref, "Comment subject reference must not be null"); + return comment -> ref.equals(comment.getSpec().getSubjectRef()) + && Objects.equals(false, comment.getSpec().getHidden()) + && Objects.equals(true, comment.getSpec().getApproved()); + } + + static Comparator defaultComparator() { + Function top = + comment -> Objects.requireNonNullElse(comment.getSpec().getTop(), false); + Function priority = + comment -> Objects.requireNonNullElse(comment.getSpec().getPriority(), 0); + Function creationTimestamp = + comment -> comment.getMetadata().getCreationTimestamp(); + Function name = + comment -> comment.getMetadata().getName(); + return Comparator.comparing(top) + .thenComparing(priority) + .thenComparing(creationTimestamp) + .thenComparing(name) + .reversed(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java new file mode 100644 index 000000000..b25ba6da4 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java @@ -0,0 +1,43 @@ +package run.halo.app.theme.finders.impl; + +import java.util.List; +import java.util.Objects; +import run.halo.app.core.extension.User; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.theme.finders.Contributor; +import run.halo.app.theme.finders.ContributorFinder; +import run.halo.app.theme.finders.Finder; + +/** + * A default implementation of {@link ContributorFinder}. + * + * @author guqing + * @since 2.0.0 + */ +@Finder("contributorFinder") +public class ContributorFinderImpl implements ContributorFinder { + + private final ReactiveExtensionClient client; + + public ContributorFinderImpl(ReactiveExtensionClient client) { + this.client = client; + } + + @Override + public Contributor getContributor(String name) { + return client.fetch(User.class, name) + .map(Contributor::from) + .block(); + } + + @Override + public List getContributors(List names) { + if (names == null) { + return List.of(); + } + return names.stream() + .map(this::getContributor) + .filter(Objects::nonNull) + .toList(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java new file mode 100644 index 000000000..612726302 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java @@ -0,0 +1,128 @@ +package run.halo.app.theme.finders.impl; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.util.CollectionUtils; +import run.halo.app.core.extension.Menu; +import run.halo.app.core.extension.MenuItem; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.theme.finders.Finder; +import run.halo.app.theme.finders.MenuFinder; +import run.halo.app.theme.finders.vo.MenuItemVo; +import run.halo.app.theme.finders.vo.MenuVo; + +/** + * A default implementation for {@link MenuFinder}. + * + * @author guqing + * @since 2.0.0 + */ +@Finder("menuFinder") +public class MenuFinderImpl implements MenuFinder { + + private final ReactiveExtensionClient client; + + public MenuFinderImpl(ReactiveExtensionClient client) { + this.client = client; + } + + @Override + public MenuVo getByName(String name) { + return listAsTree().stream() + .filter(menu -> menu.getName().equals(name)) + .findAny() + .orElse(null); + } + + @Override + public MenuVo getDefault() { + List menuVos = listAsTree(); + if (CollectionUtils.isEmpty(menuVos)) { + return null; + } + // TODO If there are multiple groups of menus, + // return the first as the default, and consider optimizing it later + return menuVos.get(0); + } + + + List listAll() { + return client.list(Menu.class, null, null) + .map(MenuVo::from) + .collectList() + .block(); + } + + List listAsTree() { + List menuItemVos = populateParentName(listAllMenuItem()); + List treeList = listToTree(menuItemVos); + Map nameItemRootNodeMap = treeList.stream() + .collect(Collectors.toMap(MenuItemVo::getName, Function.identity())); + return listAll().stream() + .map(menuVo -> { + List menuItems = menuVo.getMenuItemNames().stream() + .map(nameItemRootNodeMap::get) + .filter(Objects::nonNull) + .toList(); + return menuVo.withMenuItems(menuItems); + }) + .toList(); + } + + static List listToTree(List list) { + Map> nameIdentityMap = list.stream() + .filter(item -> item.getParentName() != null) + .collect(Collectors.groupingBy(MenuItemVo::getParentName)); + list.forEach(node -> node.setChildren(nameIdentityMap.get(node.getName()))); + // clear map to release memory + nameIdentityMap.clear(); + return list.stream() + .filter(v -> v.getParentName() == null) + .collect(Collectors.toList()); + } + + List listAllMenuItem() { + Function priority = menuItem -> menuItem.getSpec().getPriority(); + Function name = menuItem -> menuItem.getMetadata().getName(); + + return client.list(MenuItem.class, null, + Comparator.comparing(priority).thenComparing(name).reversed() + ) + .map(MenuItemVo::from) + .collectList() + .block(); + } + + static List populateParentName(List menuItemVos) { + Map nameIdentityMap = menuItemVos.stream() + .collect(Collectors.toMap(MenuItemVo::getName, Function.identity())); + + Map treeVoMap = new HashMap<>(); + // populate parentName + menuItemVos.forEach(menuItemVo -> { + final String parentName = menuItemVo.getName(); + treeVoMap.putIfAbsent(parentName, menuItemVo); + LinkedHashSet children = menuItemVo.getChildrenNames(); + if (CollectionUtils.isEmpty(children)) { + return; + } + children.forEach(childrenName -> { + MenuItemVo childrenVo = nameIdentityMap.get(childrenName); + childrenVo.setParentName(parentName); + treeVoMap.putIfAbsent(childrenVo.getName(), childrenVo); + }); + }); + // clear map to release memory + nameIdentityMap.clear(); + return treeVoMap.values() + .stream() + .sorted(Comparator.comparing(MenuItemVo::getPriority)) + .toList(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java new file mode 100644 index 000000000..eb0481423 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java @@ -0,0 +1,146 @@ +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.StringUtils; +import org.springframework.lang.NonNull; +import run.halo.app.content.ContentService; +import run.halo.app.core.extension.Post; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.theme.finders.CategoryFinder; +import run.halo.app.theme.finders.Contributor; +import run.halo.app.theme.finders.ContributorFinder; +import run.halo.app.theme.finders.Finder; +import run.halo.app.theme.finders.PostFinder; +import run.halo.app.theme.finders.TagFinder; +import run.halo.app.theme.finders.vo.CategoryVo; +import run.halo.app.theme.finders.vo.ContentVo; +import run.halo.app.theme.finders.vo.PostVo; +import run.halo.app.theme.finders.vo.TagVo; + +/** + * A finder for {@link Post}. + * + * @author guqing + * @since 2.0.0 + */ +@Finder("postFinder") +public class PostFinderImpl implements PostFinder { + + public static final Predicate FIXED_PREDICATE = post -> + Objects.equals(false, post.getSpec().getDeleted()) + && Objects.equals(true, post.getSpec().getPublished()) + && Post.VisibleEnum.PUBLIC.equals(post.getSpec().getVisible()); + private final ReactiveExtensionClient client; + + private final ContentService contentService; + + private final TagFinder tagFinder; + + private final CategoryFinder categoryFinder; + + private final ContributorFinder contributorFinder; + + public PostFinderImpl(ReactiveExtensionClient client, + ContentService contentService, + TagFinder tagFinder, + CategoryFinder categoryFinder, + ContributorFinder contributorFinder) { + this.client = client; + this.contentService = contentService; + this.tagFinder = tagFinder; + this.categoryFinder = categoryFinder; + this.contributorFinder = contributorFinder; + } + + @Override + public PostVo getByName(String postName) { + Post post = client.fetch(Post.class, postName) + .block(); + if (post == null) { + return null; + } + return getPostVo(post); + } + + @Override + public ContentVo content(String postName) { + return client.fetch(Post.class, postName) + .map(post -> post.getSpec().getReleaseSnapshot()) + .flatMap(contentService::getContent) + .map(wrapper -> ContentVo.builder().content(wrapper.content()) + .raw(wrapper.raw()).build()) + .block(); + } + + @Override + public ListResult list(int page, int size) { + return listPost(page, size, null); + } + + @Override + public ListResult listByCategory(int page, int size, String categoryName) { + return listPost(page, size, + post -> contains(post.getSpec().getCategories(), categoryName)); + } + + @Override + public ListResult listByTag(int page, int size, String tag) { + return listPost(page, size, + post -> contains(post.getSpec().getTags(), tag)); + } + + private boolean contains(List c, String key) { + if (StringUtils.isBlank(key) || c == null) { + return false; + } + return c.contains(key); + } + + private ListResult listPost(int page, int size, Predicate postPredicate) { + Predicate predicate = FIXED_PREDICATE + .and(postPredicate == null ? post -> true : postPredicate); + ListResult list = client.list(Post.class, predicate, + defaultComparator(), page, size) + .block(); + if (list == null) { + return new ListResult<>(0, 0, 0, List.of()); + } + List postVos = list.get() + .map(this::getPostVo) + .toList(); + return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), postVos); + } + + private PostVo getPostVo(@NonNull Post post) { + List tags = tagFinder.getByNames(post.getSpec().getTags()); + List categoryVos = categoryFinder.getByNames(post.getSpec().getCategories()); + List contributors = + contributorFinder.getContributors(post.getStatus().getContributors()); + PostVo postVo = PostVo.from(post); + postVo.setCategories(categoryVos); + postVo.setTags(tags); + postVo.setContributors(contributors); + return postVo; + } + + static Comparator defaultComparator() { + Function pinned = + post -> Objects.requireNonNullElse(post.getSpec().getPinned(), false); + Function priority = + post -> Objects.requireNonNullElse(post.getSpec().getPriority(), 0); + Function creationTimestamp = + post -> post.getMetadata().getCreationTimestamp(); + Function name = post -> post.getMetadata().getName(); + return Comparator.comparing(pinned) + .thenComparing(priority) + .thenComparing(creationTimestamp) + .thenComparing(name) + .reversed(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java new file mode 100644 index 000000000..ba1b75707 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java @@ -0,0 +1,67 @@ +package run.halo.app.theme.finders.impl; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import run.halo.app.core.extension.Tag; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.theme.finders.Finder; +import run.halo.app.theme.finders.TagFinder; +import run.halo.app.theme.finders.vo.TagVo; + +/** + * A default implementation of {@link TagFinder}. + * + * @author guqing + * @since 2.0.0 + */ +@Finder("tagFinder") +public class TagFinderImpl implements TagFinder { + + public static final Comparator DEFAULT_COMPARATOR = + Comparator.comparing(tag -> tag.getMetadata().getCreationTimestamp()); + + private final ReactiveExtensionClient client; + + public TagFinderImpl(ReactiveExtensionClient client) { + this.client = client; + } + + @Override + public TagVo getByName(String name) { + return client.fetch(Tag.class, name) + .map(TagVo::from) + .block(); + } + + @Override + public List getByNames(List names) { + return names.stream().map(this::getByName) + .filter(Objects::nonNull) + .toList(); + } + + @Override + public ListResult list(int page, int size) { + return client.list(Tag.class, null, + DEFAULT_COMPARATOR.reversed(), page, size) + .map(list -> { + List tagVos = list.stream() + .map(TagVo::from) + .collect(Collectors.toList()); + return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), tagVos); + }) + .block(); + } + + @Override + public List listAll() { + return client.list(Tag.class, null, + DEFAULT_COMPARATOR.reversed()) + .map(TagVo::from) + .collectList() + .block(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/BaseCategoryVo.java b/src/main/java/run/halo/app/theme/finders/vo/BaseCategoryVo.java new file mode 100644 index 000000000..cf0295600 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/BaseCategoryVo.java @@ -0,0 +1,37 @@ +package run.halo.app.theme.finders.vo; + +import java.util.List; +import lombok.Getter; +import lombok.experimental.SuperBuilder; + +/** + * Base class for category value object. + * + * @author guqing + * @since 2.0.0 + */ +@Getter +@SuperBuilder +public class BaseCategoryVo { + String name; + + String displayName; + + String slug; + + String description; + + String cover; + + String template; + + Integer priority; + + String permalink; + + List posts; + + public int getPostCount() { + return posts == null ? 0 : posts.size(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/BaseCommentVo.java b/src/main/java/run/halo/app/theme/finders/vo/BaseCommentVo.java new file mode 100644 index 000000000..d54de3120 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/BaseCommentVo.java @@ -0,0 +1,26 @@ +package run.halo.app.theme.finders.vo; + +import java.util.Map; +import lombok.experimental.SuperBuilder; +import run.halo.app.core.extension.Comment; + +@SuperBuilder +public class BaseCommentVo { + String name; + + String raw; + + String content; + + Comment.CommentOwner owner; + + String userAgent; + + Integer priority; + + Boolean top; + + Boolean allowNotification; + + Map annotations; +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java b/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java new file mode 100644 index 000000000..2421c48e0 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java @@ -0,0 +1,44 @@ +package run.halo.app.theme.finders.vo; + +import java.util.List; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; +import org.springframework.util.Assert; +import run.halo.app.core.extension.Category; + +/** + * A tree vo for {@link Category}. + * + * @author guqing + * @since 2.0.0 + */ +@Data +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class CategoryTreeVo extends BaseCategoryVo { + + private List children; + + private String parentName; + + /** + * Convert {@link CategoryVo} to {@link CategoryTreeVo}. + * + * @param category category value object + * @return category tree value object + */ + public static CategoryTreeVo from(CategoryVo category) { + Assert.notNull(category, "The category must not be null"); + return CategoryTreeVo.builder() + .name(category.getName()) + .displayName(category.getDisplayName()) + .slug(category.getSlug()) + .description(category.getDescription()) + .cover(category.getCover()) + .template(category.getTemplate()) + .priority(category.getPriority()) + .permalink(category.getPermalink()) + .build(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/CategoryVo.java b/src/main/java/run/halo/app/theme/finders/vo/CategoryVo.java new file mode 100644 index 000000000..407032e8b --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/CategoryVo.java @@ -0,0 +1,42 @@ +package run.halo.app.theme.finders.vo; + +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.Value; +import lombok.experimental.SuperBuilder; +import run.halo.app.core.extension.Category; + +/** + * A value object for {@link Category}. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class CategoryVo extends BaseCategoryVo { + + List children; + + /** + * Convert {@link Category} to {@link CategoryVo}. + * + * @param category category extension + * @return category value object + */ + public static CategoryVo from(Category category) { + Category.CategorySpec spec = category.getSpec(); + return CategoryVo.builder() + .name(category.getMetadata().getName()) + .displayName(spec.getDisplayName()) + .slug(spec.getSlug()) + .description(spec.getDescription()) + .cover(spec.getCover()) + .template(spec.getTemplate()) + .priority(spec.getPriority()) + .children(spec.getChildren()) + .permalink(category.getStatusOrDefault().getPermalink()) + .build(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java b/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java new file mode 100644 index 000000000..d629a4df3 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java @@ -0,0 +1,42 @@ +package run.halo.app.theme.finders.vo; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import lombok.experimental.SuperBuilder; +import run.halo.app.core.extension.Comment; + +/** + * A value object for {@link Comment}. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class CommentVo extends BaseCommentVo { + + Comment.CommentSubjectRef subjectRef; + + /** + * Convert {@link Comment} to {@link CommentVo}. + * + * @param comment comment extension + * @return a value object for {@link Comment} + */ + public static CommentVo from(Comment comment) { + Comment.CommentSpec spec = comment.getSpec(); + return CommentVo.builder() + .name(comment.getMetadata().getName()) + .subjectRef(spec.getSubjectRef()) + .raw(spec.getRaw()) + .content(spec.getContent()) + .owner(spec.getOwner()) + .userAgent(spec.getUserAgent()) + .priority(spec.getPriority()) + .top(spec.getTop()) + .allowNotification(spec.getAllowNotification()) + .annotations(comment.getMetadata().getAnnotations()) + .build(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/ContentVo.java b/src/main/java/run/halo/app/theme/finders/vo/ContentVo.java new file mode 100644 index 000000000..e69916e14 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/ContentVo.java @@ -0,0 +1,20 @@ +package run.halo.app.theme.finders.vo; + +import lombok.Builder; +import lombok.Value; +import run.halo.app.core.extension.Snapshot; + +/** + * A value object for Content from {@link Snapshot}. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@Builder +public class ContentVo { + + String raw; + + String content; +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/MenuItemVo.java b/src/main/java/run/halo/app/theme/finders/vo/MenuItemVo.java new file mode 100644 index 000000000..dba53231a --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/MenuItemVo.java @@ -0,0 +1,49 @@ +package run.halo.app.theme.finders.vo; + +import java.util.LinkedHashSet; +import java.util.List; +import lombok.Builder; +import lombok.Data; +import run.halo.app.core.extension.MenuItem; + +/** + * A value object for {@link MenuItem}. + * + * @author guqing + * @since 2.0.0 + */ +@Data +@Builder +public class MenuItemVo { + + String name; + + String parentName; + + String displayName; + + String href; + + Integer priority; + + LinkedHashSet childrenNames; + + List children; + + /** + * Convert {@link MenuItem} to {@link MenuItemVo}. + * + * @param menuItem menu item extension + * @return menu item value object + */ + public static MenuItemVo from(MenuItem menuItem) { + MenuItem.MenuItemStatus status = menuItem.getStatus(); + return MenuItemVo.builder() + .name(menuItem.getMetadata().getName()) + .priority(menuItem.getSpec().getPriority()) + .childrenNames(menuItem.getSpec().getChildren()) + .displayName(status.getDisplayName()) + .href(status.getHref()) + .build(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/MenuVo.java b/src/main/java/run/halo/app/theme/finders/vo/MenuVo.java new file mode 100644 index 000000000..e7bd9bf78 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/MenuVo.java @@ -0,0 +1,44 @@ +package run.halo.app.theme.finders.vo; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.LinkedHashSet; +import java.util.List; +import lombok.Builder; +import lombok.Value; +import lombok.With; +import run.halo.app.core.extension.Menu; + +/** + * A value object for {@link Menu}. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@Builder +public class MenuVo { + + String name; + + String displayName; + + @JsonIgnore + LinkedHashSet menuItemNames; + + @With + List menuItems; + + /** + * Convert {@link Menu} to {@link MenuVo}. + * + * @param menu menu extension + * @return menu value object + */ + public static MenuVo from(Menu menu) { + return builder() + .name(menu.getMetadata().getName()) + .displayName(menu.getSpec().getDisplayName()) + .menuItemNames(menu.getSpec().getMenuItems()) + .build(); + } +} 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 new file mode 100644 index 000000000..3a4b8aebc --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/PostVo.java @@ -0,0 +1,100 @@ +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 org.springframework.util.Assert; +import run.halo.app.core.extension.Post; +import run.halo.app.theme.finders.Contributor; + +/** + * A value object for {@link Post}. + * + * @author guqing + * @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; + + List categories; + + List tags; + + List> htmlMetas; + + String permalink; + + List contributors; + + Map annotations; + + /** + * Convert {@link Post} to {@link PostVo}. + * + * @param post post extension + * @return post value object + */ + public static PostVo from(Post post) { + Assert.notNull(post, "The post must not be null."); + Post.PostSpec spec = post.getSpec(); + Post.PostStatus postStatus = post.getStatusOrDefault(); + return PostVo.builder() + .name(post.getMetadata().getName()) + .annotations(post.getMetadata().getAnnotations()) + .title(spec.getTitle()) + .cover(spec.getCover()) + .allowComment(spec.getAllowComment()) + .categories(List.of()) + .tags(List.of()) + .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(postStatus.getPermalink()) + .excerpt(postStatus.getExcerpt()) + .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/ReplyVo.java b/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java new file mode 100644 index 000000000..a509045b6 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java @@ -0,0 +1,45 @@ +package run.halo.app.theme.finders.vo; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import lombok.experimental.SuperBuilder; +import run.halo.app.core.extension.Reply; + +/** + * A value object for {@link Reply}. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class ReplyVo extends BaseCommentVo { + + String commentName; + + String quoteReply; + + /** + * Convert {@link Reply} to {@link ReplyVo}. + * + * @param reply reply extension + * @return a value object for {@link Reply} + */ + public static ReplyVo from(Reply reply) { + Reply.ReplySpec spec = reply.getSpec(); + return ReplyVo.builder() + .name(reply.getMetadata().getName()) + .commentName(spec.getCommentName()) + .quoteReply(spec.getQuoteReply()) + .raw(spec.getRaw()) + .content(spec.getContent()) + .owner(spec.getOwner()) + .userAgent(spec.getUserAgent()) + .priority(spec.getPriority()) + .top(spec.getTop()) + .allowNotification(spec.getAllowNotification()) + .annotations(reply.getMetadata().getAnnotations()) + .build(); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/TagVo.java b/src/main/java/run/halo/app/theme/finders/vo/TagVo.java new file mode 100644 index 000000000..5b45765b1 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/TagVo.java @@ -0,0 +1,52 @@ +package run.halo.app.theme.finders.vo; + +import java.util.List; +import java.util.Map; +import lombok.Builder; +import lombok.Value; +import run.halo.app.core.extension.Tag; + +/** + * A value object for {@link Tag}. + */ +@Value +@Builder +public class TagVo { + + String name; + + String displayName; + + String slug; + + String color; + + String cover; + + String permalink; + + List posts; + + Map annotations; + + /** + * Convert {@link Tag} to {@link TagVo}. + * + * @param tag tag extension + * @return tag value object + */ + public static TagVo from(Tag tag) { + Tag.TagSpec spec = tag.getSpec(); + Tag.TagStatus status = tag.getStatusOrDefault(); + return TagVo.builder() + .name(tag.getMetadata().getName()) + .displayName(spec.getDisplayName()) + .slug(spec.getSlug()) + .color(spec.getColor()) + .cover(spec.getCover()) + .permalink(status.getPermalink()) + .posts(status.getPosts()) + .annotations(tag.getMetadata().getAnnotations()) + .build(); + } +} diff --git a/src/test/java/run/halo/app/theme/finders/FinderRegistryTest.java b/src/test/java/run/halo/app/theme/finders/FinderRegistryTest.java new file mode 100644 index 000000000..bcaf9866f --- /dev/null +++ b/src/test/java/run/halo/app/theme/finders/FinderRegistryTest.java @@ -0,0 +1,86 @@ +package run.halo.app.theme.finders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.util.Map; +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.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.pf4j.PluginWrapper; +import org.springframework.context.ApplicationContext; +import run.halo.app.plugin.event.HaloPluginStoppedEvent; + +/** + * Tests for {@link FinderRegistry}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class FinderRegistryTest { + + private FinderRegistry finderRegistry; + @Mock + private ApplicationContext applicationContext; + + @BeforeEach + void setUp() { + finderRegistry = new FinderRegistry(applicationContext); + } + + @Test + void registerFinder() { + assertThatThrownBy(() -> { + finderRegistry.registerFinder(new Object()); + }).isInstanceOf(IllegalStateException.class) + .hasMessage("Finder must be annotated with @Finder"); + + String s = finderRegistry.registerFinder(new FakeFinder()); + assertThat(s).isEqualTo("test"); + } + + @Test + void removeFinder() { + String s = finderRegistry.registerFinder(new FakeFinder()); + assertThat(s).isEqualTo("test"); + Object test = finderRegistry.get("test"); + assertThat(test).isNotNull(); + finderRegistry.removeFinder(s); + + test = finderRegistry.get("test"); + assertThat(test).isNull(); + } + + @Test + void getFinders() { + assertThat(finderRegistry.getFinders()).hasSize(0); + + finderRegistry.registerFinder(new FakeFinder()); + Map finders = finderRegistry.getFinders(); + assertThat(finders).hasSize(1); + } + + @Test + void onPluginStopped() { + finderRegistry.registerFinder("a", new Object()); + finderRegistry.addPluginFinder("fake", "a"); + + HaloPluginStoppedEvent event = Mockito.mock(HaloPluginStoppedEvent.class); + PluginWrapper pluginWrapper = Mockito.mock(PluginWrapper.class); + when(event.getPlugin()).thenReturn(pluginWrapper); + when(pluginWrapper.getPluginId()).thenReturn("fake"); + + finderRegistry.onPluginStopped(event); + assertThat(finderRegistry.get("a")).isNull(); + } + + @Finder("test") + static class FakeFinder { + + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java b/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java new file mode 100644 index 000000000..3fb494a42 --- /dev/null +++ b/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java @@ -0,0 +1,172 @@ +package run.halo.app.theme.finders.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.json.JSONException; +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.skyscreamer.jsonassert.JSONAssert; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Category; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.theme.finders.vo.CategoryTreeVo; +import run.halo.app.theme.finders.vo.CategoryVo; + +/** + * Tests for {@link CategoryFinderImpl}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class CategoryFinderImplTest { + + @Mock + private ReactiveExtensionClient client; + + private CategoryFinderImpl categoryFinder; + + @BeforeEach + void setUp() { + categoryFinder = new CategoryFinderImpl(client); + } + + @Test + void getByName() throws JSONException { + when(client.fetch(eq(Category.class), eq("hello"))) + .thenReturn(Mono.just(category())); + CategoryVo categoryVo = categoryFinder.getByName("hello"); + JSONAssert.assertEquals(""" + { + "name": "hello", + "displayName": "displayName-1", + "slug": "slug-1", + "description": "description-1", + "cover": "cover-1", + "template": "template-1", + "priority": 0, + "children": [ + "C1", + "C2" + ], + "postCount": 0 + } + """, + JsonUtils.objectToJson(categoryVo), + true); + } + + @Test + void list() { + ListResult categories = new ListResult<>(1, 10, 3, + categories().stream() + .sorted(CategoryFinderImpl.defaultComparator()) + .toList()); + when(client.list(eq(Category.class), eq(null), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(categories)); + ListResult list = categoryFinder.list(1, 10); + assertThat(list.getItems()).hasSize(3); + assertThat(list.get().map(CategoryVo::getName).toList()) + .isEqualTo(List.of("c3", "c2", "hello")); + } + + @Test + void listAsTree() { + when(client.list(eq(Category.class), eq(null), any())) + .thenReturn(Flux.fromIterable(categoriesForTree())); + List treeVos = categoryFinder.listAsTree(); + assertThat(treeVos).hasSize(1); + } + + private List categoriesForTree() { + /* + * D + * ├── E + * │ ├── A + * │ │ └── B + * │ └── C + * └── G + * ├── F + * └── H + */ + Category d = category(); + d.getMetadata().setName("D"); + d.getSpec().setChildren(List.of("E", "G", "F")); + + Category e = category(); + e.getMetadata().setName("E"); + e.getSpec().setChildren(List.of("A", "C")); + + Category a = category(); + a.getMetadata().setName("A"); + a.getSpec().setChildren(List.of("B")); + + Category b = category(); + b.getMetadata().setName("B"); + b.getSpec().setChildren(null); + + Category c = category(); + c.getMetadata().setName("C"); + c.getSpec().setChildren(null); + + Category g = category(); + g.getMetadata().setName("G"); + g.getSpec().setChildren(null); + + Category f = category(); + f.getMetadata().setName("F"); + f.getSpec().setChildren(List.of("H")); + + Category h = category(); + h.getMetadata().setName("H"); + h.getSpec().setChildren(null); + return List.of(d, e, a, b, c, g, f, h); + } + + private List categories() { + Category category2 = JsonUtils.deepCopy(category()); + category2.getMetadata().setName("c2"); + category2.getSpec().setPriority(2); + + Category category3 = JsonUtils.deepCopy(category()); + category3.getMetadata().setName("c3"); + category3.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(20)); + category3.getSpec().setPriority(2); + return List.of(category2, category(), category3); + } + + private Category category() { + final Category category = new Category(); + + Metadata metadata = new Metadata(); + metadata.setName("hello"); + metadata.setAnnotations(Map.of("K1", "V1")); + metadata.setCreationTimestamp(Instant.now()); + category.setMetadata(metadata); + + Category.CategorySpec categorySpec = new Category.CategorySpec(); + categorySpec.setSlug("slug-1"); + categorySpec.setDisplayName("displayName-1"); + categorySpec.setCover("cover-1"); + categorySpec.setDescription("description-1"); + categorySpec.setTemplate("template-1"); + categorySpec.setPriority(0); + categorySpec.setChildren(List.of("C1", "C2")); + category.setSpec(categorySpec); + return category; + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/finders/impl/MenuFinderImplTest.java b/src/test/java/run/halo/app/theme/finders/impl/MenuFinderImplTest.java new file mode 100644 index 000000000..57d314192 --- /dev/null +++ b/src/test/java/run/halo/app/theme/finders/impl/MenuFinderImplTest.java @@ -0,0 +1,190 @@ +package run.halo.app.theme.finders.impl; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import org.json.JSONException; +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.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.skyscreamer.jsonassert.JSONAssert; +import reactor.core.publisher.Flux; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; +import run.halo.app.core.extension.Menu; +import run.halo.app.core.extension.MenuItem; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.theme.finders.vo.MenuVo; + +/** + * Tests for {@link MenuFinderImpl}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class MenuFinderImplTest { + + @Mock + private ReactiveExtensionClient client; + + private MenuFinderImpl menuFinder; + + @BeforeEach + void setUp() { + menuFinder = new MenuFinderImpl(client); + } + + @Test + void listAsTree() throws JSONException { + Tuple2, List> tuple = testTree(); + Mockito.when(client.list(eq(Menu.class), eq(null), eq(null))) + .thenReturn(Flux.fromIterable(tuple.getT1())); + Mockito.when(client.list(eq(MenuItem.class), eq(null), any())) + .thenReturn(Flux.fromIterable(tuple.getT2())); + + List menuVos = menuFinder.listAsTree(); + JSONAssert.assertEquals(""" + [ + { + "name": "D", + "displayName": "D", + "menuItems": [ + { + "name": "E", + "priority": 0, + "childrenNames": [ + "A", + "C" + ], + "children": [ + { + "name": "A", + "parentName": "E", + "priority": 0, + "childrenNames": [ + "B" + ], + "children": [ + { + "name": "B", + "parentName": "A", + "priority": 0 + } + ] + }, + { + "name": "C", + "parentName": "E", + "priority": 0 + } + ] + } + ] + }, + { + "name": "X", + "displayName": "X", + "menuItems": [ + { + "name": "G", + "priority": 0 + } + ] + }, + { + "name": "Y", + "displayName": "Y", + "menuItems": [ + { + "name": "F", + "priority": 0, + "childrenNames": [ + "H" + ], + "children": [ + { + "name": "H", + "parentName": "F", + "priority": 0 + } + ] + } + ] + } + ] + """, + JsonUtils.objectToJson(menuVos), + true); + } + + Tuple2, List> testTree() { + /* + * D + * ├── E + * │ ├── A + * │ │ └── B + * │ └── C + * X── G + * Y── F + * └── H + */ + Menu menuD = menu("D", of("E")); + Menu menuX = menu("X", of("G")); + Menu menuY = menu("Y", of("F")); + + MenuItem itemE = menuItem("E", of("A", "C")); + MenuItem itemG = menuItem("G", null); + MenuItem itemF = menuItem("F", of("H")); + MenuItem itemA = menuItem("A", of("B")); + MenuItem itemB = menuItem("B", null); + MenuItem itemC = menuItem("C", null); + MenuItem itemH = menuItem("H", null); + return Tuples.of(List.of(menuD, menuX, menuY), + List.of(itemE, itemG, itemF, itemA, itemB, itemC, itemH)); + } + + LinkedHashSet of(String... names) { + LinkedHashSet list = new LinkedHashSet<>(); + Collections.addAll(list, names); + return list; + } + + Menu menu(String name, LinkedHashSet menuItemNames) { + Menu menu = new Menu(); + Metadata metadata = new Metadata(); + metadata.setName(name); + menu.setMetadata(metadata); + + Menu.Spec spec = new Menu.Spec(); + spec.setDisplayName(name); + spec.setMenuItems(menuItemNames); + menu.setSpec(spec); + return menu; + } + + MenuItem menuItem(String name, LinkedHashSet childrenNames) { + MenuItem menuItem = new MenuItem(); + Metadata metadata = new Metadata(); + metadata.setName(name); + menuItem.setMetadata(metadata); + + MenuItem.MenuItemSpec spec = new MenuItem.MenuItemSpec(); + spec.setPriority(0); + spec.setDisplayName(name); + spec.setChildren(childrenNames); + menuItem.setSpec(spec); + + MenuItem.MenuItemStatus status = new MenuItem.MenuItemStatus(); + menuItem.setStatus(status); + return menuItem; + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java b/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java new file mode 100644 index 000000000..3ea473f15 --- /dev/null +++ b/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java @@ -0,0 +1,150 @@ +package run.halo.app.theme.finders.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +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 reactor.core.publisher.Mono; +import run.halo.app.content.ContentService; +import run.halo.app.content.ContentWrapper; +import run.halo.app.core.extension.Post; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.theme.finders.CategoryFinder; +import run.halo.app.theme.finders.ContributorFinder; +import run.halo.app.theme.finders.TagFinder; +import run.halo.app.theme.finders.vo.ContentVo; + +/** + * Tests for {@link PostFinderImpl}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class PostFinderImplTest { + + @Mock + private ReactiveExtensionClient client; + + @Mock + private ContentService contentService; + + @Mock + private CategoryFinder categoryFinder; + + @Mock + private TagFinder tagFinder; + + @Mock + private ContributorFinder contributorFinder; + + private PostFinderImpl postFinder; + + @BeforeEach + void setUp() { + postFinder = new PostFinderImpl(client, contentService, tagFinder, categoryFinder, + contributorFinder); + } + + @Test + void content() { + Post post = post(1); + post.getSpec().setReleaseSnapshot("release-snapshot"); + ContentWrapper contentWrapper = new ContentWrapper("snapshot", "raw", "content", "rawType"); + when(client.fetch(eq(Post.class), eq("post-1"))) + .thenReturn(Mono.just(post)); + when(contentService.getContent(post.getSpec().getReleaseSnapshot())) + .thenReturn(Mono.just(contentWrapper)); + ContentVo content = postFinder.content("post-1"); + assertThat(content.getContent()).isEqualTo(contentWrapper.content()); + assertThat(content.getRaw()).isEqualTo(contentWrapper.raw()); + } + + @Test + void compare() { + List strings = posts().stream().sorted(PostFinderImpl.defaultComparator()) + .map(post -> post.getMetadata().getName()) + .toList(); + assertThat(strings).isEqualTo( + List.of("post-6", "post-2", "post-1", "post-5", "post-4", "post-3")); + } + + @Test + void predicate() { + List strings = posts().stream().filter(PostFinderImpl.FIXED_PREDICATE) + .map(post -> post.getMetadata().getName()) + .toList(); + assertThat(strings).isEqualTo(List.of("post-1", "post-2", "post-6")); + } + + List posts() { + // 置顶的排前面按 priority 排序 + // 再根据创建时间排序 + // 相同再根据名称排序 + // 6, 2, 1, 5, 4, 3 + Post post1 = post(1); + post1.getSpec().setPinned(false); + post1.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(20)); + + Post post2 = post(2); + post2.getSpec().setPinned(true); + post2.getSpec().setPriority(2); + post2.getMetadata().setCreationTimestamp(Instant.now()); + + Post post3 = post(3); + post3.getSpec().setDeleted(true); + post3.getMetadata().setCreationTimestamp(Instant.now()); + + Post post4 = post(4); + post4.getSpec().setVisible(Post.VisibleEnum.PRIVATE); + post4.getMetadata().setCreationTimestamp(Instant.now()); + + Post post5 = post(5); + post5.getSpec().setPublished(false); + post5.getMetadata().setCreationTimestamp(Instant.now()); + + Post post6 = post(6); + post6.getSpec().setPinned(true); + post6.getSpec().setPriority(3); + post6.getMetadata().setCreationTimestamp(Instant.now()); + + return List.of(post1, post2, post3, post4, post5, post6); + } + + Post post(int i) { + final Post post = new Post(); + Metadata metadata = new Metadata(); + metadata.setName("post-" + i); + metadata.setCreationTimestamp(Instant.now()); + metadata.setAnnotations(Map.of("K1", "V1")); + post.setMetadata(metadata); + + Post.PostSpec postSpec = new Post.PostSpec(); + postSpec.setDeleted(false); + postSpec.setAllowComment(true); + postSpec.setPublishTime(Instant.now()); + postSpec.setPinned(false); + postSpec.setPriority(0); + postSpec.setPublished(true); + postSpec.setVisible(Post.VisibleEnum.PUBLIC); + postSpec.setTitle("title-" + i); + postSpec.setSlug("slug-" + i); + post.setSpec(postSpec); + + Post.PostStatus postStatus = new Post.PostStatus(); + postStatus.setPermalink("/post-" + i); + postStatus.setContributors(List.of("contributor-1", "contributor-2")); + postStatus.setExcerpt("hello world!"); + post.setStatus(postStatus); + return post; + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/finders/impl/TagFinderImplTest.java b/src/test/java/run/halo/app/theme/finders/impl/TagFinderImplTest.java new file mode 100644 index 000000000..e1150b0a4 --- /dev/null +++ b/src/test/java/run/halo/app/theme/finders/impl/TagFinderImplTest.java @@ -0,0 +1,118 @@ +package run.halo.app.theme.finders.impl; + +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.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.json.JSONException; +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.skyscreamer.jsonassert.JSONAssert; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Tag; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.theme.finders.vo.TagVo; + +/** + * Tests for {@link TagFinderImpl}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class TagFinderImplTest { + + @Mock + private ReactiveExtensionClient client; + + private TagFinderImpl tagFinder; + + @BeforeEach + void setUp() { + tagFinder = new TagFinderImpl(client); + } + + @Test + void getByName() throws JSONException { + when(client.fetch(eq(Tag.class), eq("t1"))) + .thenReturn(Mono.just(tag(1))); + TagVo tagVo = tagFinder.getByName("t1"); + JSONAssert.assertEquals(""" + { + "name": "t1", + "displayName": "displayName-1", + "slug": "slug-1", + "color": "color-1", + "cover": "cover-1", + "permalink": "permalink-1", + "posts": [ + "p1", + "p2" + ], + "annotations": { + "K1": "V1" + } + } + """, + JsonUtils.objectToJson(tagVo), + true); + } + + @Test + void listAll() { + when(client.list(eq(Tag.class), eq(null), any())) + .thenReturn(Flux.fromIterable( + tags().stream().sorted(TagFinderImpl.DEFAULT_COMPARATOR.reversed()).toList() + ) + ); + List tags = tagFinder.listAll(); + assertThat(tags).hasSize(3); + assertThat(tags.stream().map(TagVo::getName).collect(Collectors.toList())) + .isEqualTo(List.of("t3", "t2", "t1")); + } + + List tags() { + Tag tag1 = tag(1); + + Tag tag2 = tag(2); + tag2.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(1)); + + Tag tag3 = tag(3); + tag3.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(2)); + // sorted: 3, 2, 1 + return List.of(tag2, tag1, tag3); + } + + Tag tag(int i) { + final Tag tag = new Tag(); + Metadata metadata = new Metadata(); + metadata.setName("t" + i); + metadata.setAnnotations(Map.of("K1", "V1")); + metadata.setCreationTimestamp(Instant.now()); + tag.setMetadata(metadata); + + Tag.TagSpec tagSpec = new Tag.TagSpec(); + tagSpec.setDisplayName("displayName-" + i); + tagSpec.setSlug("slug-" + i); + tagSpec.setColor("color-" + i); + tagSpec.setCover("cover-" + i); + tag.setSpec(tagSpec); + + Tag.TagStatus tagStatus = new Tag.TagStatus(); + tagStatus.setPermalink("permalink-" + i); + tagStatus.setPosts(List.of("p1", "p2")); + tag.setStatus(tagStatus); + return tag; + } +} \ No newline at end of file