feat: add theme-side business data query model (#2347)

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?
/kind feature
/milestone 2.0
/area core
<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind improvement

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:
新增主题端数据查询模型
#### Which issue(s) this PR fixes:

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #2346

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
None
```
pull/2365/head
guqing 2022-08-29 10:42:19 +08:00 committed by GitHub
parent 7682318ae6
commit d124c01a84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2191 additions and 5 deletions

View File

@ -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<String> children;
}
@JsonIgnore
public CategoryStatus getStatusOrDefault() {
if (this.status == null) {
this.status = new CategoryStatus();
}
return this.status;
}
@Data
public static class CategoryStatus {

View File

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

View File

@ -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<View> 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());
});
}
}

View File

@ -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
* <a href="https://github.com/thymeleaf/thymeleaf/blob/3.1-master/lib/thymeleaf-spring6/src/main/java/org/thymeleaf/spring6/SpringWebFluxTemplateEngine.java">thymeleaf SpringWebFluxTemplateEngine</a>
*
* <p>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}.</p>
*
* @author Daniel Fern&aacute;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());
}

View File

@ -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<CategoryVo> getByNames(List<String> names);
ListResult<CategoryVo> list(int page, int size);
List<CategoryVo> listAll();
List<CategoryTreeVo> listAsTree();
}

View File

@ -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<CommentVo> list(Comment.CommentSubjectRef ref, int page, int size);
ListResult<ReplyVo> listReply(String commentName, int page, int size);
}

View File

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

View File

@ -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<Contributor> getContributors(List<String> names);
}

View File

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

View File

@ -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<String, List<String>> pluginFindersLookup = new ConcurrentHashMap<>();
private final Map<String, Object> 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<String, Object> 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);
}
}

View File

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

View File

@ -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<PostVo> list(int page, int size);
ListResult<PostVo> listByCategory(int page, int size, String categoryName);
ListResult<PostVo> listByTag(int page, int size, String tag);
}

View File

@ -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<TagVo> getByNames(List<String> names);
ListResult<TagVo> list(int page, int size);
List<TagVo> listAll();
}

View File

@ -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<CategoryVo> getByNames(List<String> names) {
if (names == null) {
return List.of();
}
return names.stream().map(this::getByName)
.filter(Objects::nonNull)
.toList();
}
@Override
public ListResult<CategoryVo> list(int page, int size) {
return client.list(Category.class, null,
defaultComparator(), page, size)
.map(list -> {
List<CategoryVo> categoryVos = list.stream()
.map(CategoryVo::from)
.collect(Collectors.toList());
return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
categoryVos);
})
.block();
}
@Override
public List<CategoryVo> listAll() {
return client.list(Category.class, null, defaultComparator())
.map(CategoryVo::from)
.collectList()
.block();
}
@Override
public List<CategoryTreeVo> listAsTree() {
List<CategoryVo> categoryVos = listAll();
Map<String, CategoryVo> nameIdentityMap = categoryVos.stream()
.collect(Collectors.toMap(CategoryVo::getName, Function.identity()));
Map<String, CategoryTreeVo> treeVoMap = new HashMap<>();
// populate parentName
categoryVos.forEach(categoryVo -> {
final String parentName = categoryVo.getName();
treeVoMap.putIfAbsent(parentName, CategoryTreeVo.from(categoryVo));
List<String> 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<CategoryTreeVo> listToTree(Collection<CategoryTreeVo> list) {
Map<String, List<CategoryTreeVo>> 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<Category> defaultComparator() {
Function<Category, Integer> priority =
category -> Objects.requireNonNullElse(category.getSpec().getPriority(), 0);
Function<Category, Instant> creationTimestamp =
category -> category.getMetadata().getCreationTimestamp();
Function<Category, String> name =
category -> category.getMetadata().getName();
return Comparator.comparing(priority)
.thenComparing(creationTimestamp)
.thenComparing(name)
.reversed();
}
}

View File

@ -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<CommentVo> list(Comment.CommentSubjectRef ref, int page, int size) {
return client.list(Comment.class, fixedPredicate(ref),
defaultComparator(),
page, size)
.map(list -> {
List<CommentVo> commentVos = list.get().map(CommentVo::from).toList();
return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
commentVos);
})
.block();
}
@Override
public ListResult<ReplyVo> listReply(String commentName, int page, int size) {
Comparator<Reply> 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<ReplyVo> replyVos = list.get().map(ReplyVo::from).toList();
return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
replyVos);
})
.block();
}
private Predicate<Comment> 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<Comment> defaultComparator() {
Function<Comment, Boolean> top =
comment -> Objects.requireNonNullElse(comment.getSpec().getTop(), false);
Function<Comment, Integer> priority =
comment -> Objects.requireNonNullElse(comment.getSpec().getPriority(), 0);
Function<Comment, Instant> creationTimestamp =
comment -> comment.getMetadata().getCreationTimestamp();
Function<Comment, String> name =
comment -> comment.getMetadata().getName();
return Comparator.comparing(top)
.thenComparing(priority)
.thenComparing(creationTimestamp)
.thenComparing(name)
.reversed();
}
}

View File

@ -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<Contributor> getContributors(List<String> names) {
if (names == null) {
return List.of();
}
return names.stream()
.map(this::getContributor)
.filter(Objects::nonNull)
.toList();
}
}

View File

@ -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<MenuVo> 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<MenuVo> listAll() {
return client.list(Menu.class, null, null)
.map(MenuVo::from)
.collectList()
.block();
}
List<MenuVo> listAsTree() {
List<MenuItemVo> menuItemVos = populateParentName(listAllMenuItem());
List<MenuItemVo> treeList = listToTree(menuItemVos);
Map<String, MenuItemVo> nameItemRootNodeMap = treeList.stream()
.collect(Collectors.toMap(MenuItemVo::getName, Function.identity()));
return listAll().stream()
.map(menuVo -> {
List<MenuItemVo> menuItems = menuVo.getMenuItemNames().stream()
.map(nameItemRootNodeMap::get)
.filter(Objects::nonNull)
.toList();
return menuVo.withMenuItems(menuItems);
})
.toList();
}
static List<MenuItemVo> listToTree(List<MenuItemVo> list) {
Map<String, List<MenuItemVo>> 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<MenuItemVo> listAllMenuItem() {
Function<MenuItem, Integer> priority = menuItem -> menuItem.getSpec().getPriority();
Function<MenuItem, String> name = menuItem -> menuItem.getMetadata().getName();
return client.list(MenuItem.class, null,
Comparator.comparing(priority).thenComparing(name).reversed()
)
.map(MenuItemVo::from)
.collectList()
.block();
}
static List<MenuItemVo> populateParentName(List<MenuItemVo> menuItemVos) {
Map<String, MenuItemVo> nameIdentityMap = menuItemVos.stream()
.collect(Collectors.toMap(MenuItemVo::getName, Function.identity()));
Map<String, MenuItemVo> treeVoMap = new HashMap<>();
// populate parentName
menuItemVos.forEach(menuItemVo -> {
final String parentName = menuItemVo.getName();
treeVoMap.putIfAbsent(parentName, menuItemVo);
LinkedHashSet<String> 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();
}
}

View File

@ -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<Post> 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<PostVo> list(int page, int size) {
return listPost(page, size, null);
}
@Override
public ListResult<PostVo> listByCategory(int page, int size, String categoryName) {
return listPost(page, size,
post -> contains(post.getSpec().getCategories(), categoryName));
}
@Override
public ListResult<PostVo> listByTag(int page, int size, String tag) {
return listPost(page, size,
post -> contains(post.getSpec().getTags(), tag));
}
private boolean contains(List<String> c, String key) {
if (StringUtils.isBlank(key) || c == null) {
return false;
}
return c.contains(key);
}
private ListResult<PostVo> listPost(int page, int size, Predicate<Post> postPredicate) {
Predicate<Post> predicate = FIXED_PREDICATE
.and(postPredicate == null ? post -> true : postPredicate);
ListResult<Post> list = client.list(Post.class, predicate,
defaultComparator(), page, size)
.block();
if (list == null) {
return new ListResult<>(0, 0, 0, List.of());
}
List<PostVo> postVos = list.get()
.map(this::getPostVo)
.toList();
return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), postVos);
}
private PostVo getPostVo(@NonNull Post post) {
List<TagVo> tags = tagFinder.getByNames(post.getSpec().getTags());
List<CategoryVo> categoryVos = categoryFinder.getByNames(post.getSpec().getCategories());
List<Contributor> contributors =
contributorFinder.getContributors(post.getStatus().getContributors());
PostVo postVo = PostVo.from(post);
postVo.setCategories(categoryVos);
postVo.setTags(tags);
postVo.setContributors(contributors);
return postVo;
}
static Comparator<Post> defaultComparator() {
Function<Post, Boolean> pinned =
post -> Objects.requireNonNullElse(post.getSpec().getPinned(), false);
Function<Post, Integer> priority =
post -> Objects.requireNonNullElse(post.getSpec().getPriority(), 0);
Function<Post, Instant> creationTimestamp =
post -> post.getMetadata().getCreationTimestamp();
Function<Post, String> name = post -> post.getMetadata().getName();
return Comparator.comparing(pinned)
.thenComparing(priority)
.thenComparing(creationTimestamp)
.thenComparing(name)
.reversed();
}
}

View File

@ -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<Tag> 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<TagVo> getByNames(List<String> names) {
return names.stream().map(this::getByName)
.filter(Objects::nonNull)
.toList();
}
@Override
public ListResult<TagVo> list(int page, int size) {
return client.list(Tag.class, null,
DEFAULT_COMPARATOR.reversed(), page, size)
.map(list -> {
List<TagVo> tagVos = list.stream()
.map(TagVo::from)
.collect(Collectors.toList());
return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), tagVos);
})
.block();
}
@Override
public List<TagVo> listAll() {
return client.list(Tag.class, null,
DEFAULT_COMPARATOR.reversed())
.map(TagVo::from)
.collectList()
.block();
}
}

View File

@ -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<String> posts;
public int getPostCount() {
return posts == null ? 0 : posts.size();
}
}

View File

@ -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<String, String> annotations;
}

View File

@ -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<CategoryTreeVo> 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();
}
}

View File

@ -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<String> 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();
}
}

View File

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

View File

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

View File

@ -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<String> childrenNames;
List<MenuItemVo> 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();
}
}

View File

@ -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<String> menuItemNames;
@With
List<MenuItemVo> 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();
}
}

View File

@ -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<CategoryVo> categories;
List<TagVo> tags;
List<Map<String, String>> htmlMetas;
String permalink;
List<Contributor> contributors;
Map<String, String> 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 <T> List<T> nullSafe(List<T> t) {
return Objects.requireNonNullElse(t, List.of());
}
}

View File

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

View File

@ -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<String> posts;
Map<String, String> 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();
}
}

View File

@ -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<String, Object> 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 {
}
}

View File

@ -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<Category> 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<CategoryVo> 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<CategoryTreeVo> treeVos = categoryFinder.listAsTree();
assertThat(treeVos).hasSize(1);
}
private List<Category> 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<Category> 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;
}
}

View File

@ -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<Menu>, List<MenuItem>> 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<MenuVo> 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<Menu>, List<MenuItem>> 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<String> of(String... names) {
LinkedHashSet<String> list = new LinkedHashSet<>();
Collections.addAll(list, names);
return list;
}
Menu menu(String name, LinkedHashSet<String> 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<String> 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;
}
}

View File

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

View File

@ -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<TagVo> tags = tagFinder.listAll();
assertThat(tags).hasSize(3);
assertThat(tags.stream().map(TagVo::getName).collect(Collectors.toList()))
.isEqualTo(List.of("t3", "t2", "t1"));
}
List<Tag> 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;
}
}