diff --git a/application/src/main/java/run/halo/app/infra/utils/SortUtils.java b/application/src/main/java/run/halo/app/infra/utils/SortUtils.java
new file mode 100644
index 000000000..e600f7805
--- /dev/null
+++ b/application/src/main/java/run/halo/app/infra/utils/SortUtils.java
@@ -0,0 +1,43 @@
+package run.halo.app.infra.utils;
+
+import java.util.List;
+import lombok.experimental.UtilityClass;
+import org.springframework.data.domain.Sort;
+import org.springframework.lang.NonNull;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+@UtilityClass
+public class SortUtils {
+ static final String delimiter = ",";
+
+ /**
+ *
Resolve from direction params, e.g. "name,asc" or "name"
+ *
+ * @param directionParams direction params
+ * @return sort object
+ */
+ public static Sort resolve(List directionParams) {
+ if (CollectionUtils.isEmpty(directionParams)) {
+ return Sort.unsorted();
+ }
+ Sort.Order[] orders = new Sort.Order[directionParams.size()];
+ for (int i = 0; i < directionParams.size(); i++) {
+ String[] parts = directionParams.get(i).split(delimiter);
+ if (parts.length == 1) {
+ orders[i] = new Sort.Order(Sort.Direction.ASC, parts[0]);
+ } else {
+ orders[i] = new Sort.Order(toDirection(parts[1]), parts[0]);
+ }
+ }
+ return Sort.by(orders);
+ }
+
+ private static Sort.Direction toDirection(@NonNull String direction) {
+ Assert.notNull(direction, "Direction must not be null");
+ if (direction.contains(" ")) {
+ throw new IllegalArgumentException("Direction must not contain whitespace");
+ }
+ return Sort.Direction.fromString(direction);
+ }
+}
diff --git a/application/src/main/java/run/halo/app/theme/finders/PostFinder.java b/application/src/main/java/run/halo/app/theme/finders/PostFinder.java
index 834797610..1a469715d 100644
--- a/application/src/main/java/run/halo/app/theme/finders/PostFinder.java
+++ b/application/src/main/java/run/halo/app/theme/finders/PostFinder.java
@@ -1,10 +1,12 @@
package run.halo.app.theme.finders;
+import java.util.Map;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
+import run.halo.app.theme.finders.impl.PostFinderImpl.PostQuery;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.finders.vo.NavigationPostVo;
@@ -20,8 +22,7 @@ import run.halo.app.theme.finders.vo.PostVo;
public interface PostFinder {
/**
- * Gets post detail by name.
- *
+ *
Gets post detail by name.
* We ensure the post is public, non-deleted and published.
*
* @param postName is post name
@@ -35,6 +36,13 @@ public interface PostFinder {
Flux listAll();
+ /**
+ * Lists posts by query params.
+ *
+ * @param params query params see {@link PostQuery}
+ */
+ Mono> list(Map params);
+
Mono> list(@Nullable Integer page, @Nullable Integer size);
Mono> listByCategory(@Nullable Integer page, @Nullable Integer size,
diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java
index e53b5e02e..0af92012c 100644
--- a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java
+++ b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java
@@ -8,8 +8,10 @@ import static run.halo.app.extension.index.query.QueryFactory.notEqual;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
+import lombok.Data;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
@@ -23,6 +25,7 @@ import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
+import run.halo.app.extension.PageRequest;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
@@ -31,6 +34,8 @@ import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.extension.router.selector.LabelSelector;
import run.halo.app.infra.utils.HaloUtils;
+import run.halo.app.infra.utils.JsonUtils;
+import run.halo.app.infra.utils.SortUtils;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.PostPublicQueryService;
@@ -146,6 +151,25 @@ public class PostFinderImpl implements PostFinder {
return notEqual("status.hideFromList", BooleanUtils.TRUE);
}
+ @Override
+ public Mono> list(Map params) {
+ var query = Optional.ofNullable(params)
+ .map(map -> JsonUtils.mapToObject(map, PostQuery.class))
+ .orElseGet(PostQuery::new);
+ if (StringUtils.isNotBlank(query.getCategoryName())) {
+ return listChildrenCategories(query.getCategoryName())
+ .map(category -> category.getMetadata().getName())
+ .collectList()
+ .map(categoryNames -> ListOptions.builder(query.toListOptions())
+ .andQuery(in("spec.categories", categoryNames))
+ .build()
+ )
+ .flatMap(
+ listOptions -> postPublicQueryService.list(listOptions, query.toPageRequest()));
+ }
+ return postPublicQueryService.list(query.toListOptions(), query.toPageRequest());
+ }
+
@Override
public Mono> list(Integer page, Integer size) {
var listOptions = ListOptions.builder()
@@ -270,14 +294,54 @@ public class PostFinderImpl implements PostFinder {
.concatMap(postPublicQueryService::convertToListedVo);
}
- int pageNullSafe(Integer page) {
+ static int pageNullSafe(Integer page) {
return ObjectUtils.defaultIfNull(page, 1);
}
- int sizeNullSafe(Integer size) {
+ static int sizeNullSafe(Integer size) {
return ObjectUtils.defaultIfNull(size, 10);
}
record LinkNavigation(String prev, String current, String next) {
}
+
+ @Data
+ public static class PostQuery {
+ private Integer page;
+ private Integer size;
+ private String categoryName;
+ private String tagName;
+ private String owner;
+ private List sort;
+
+ public ListOptions toListOptions() {
+ var builder = ListOptions.builder();
+ var hasQuery = false;
+ if (StringUtils.isNotBlank(owner)) {
+ builder.andQuery(equal("spec.owner", owner));
+ hasQuery = true;
+ }
+ if (StringUtils.isNotBlank(tagName)) {
+ builder.andQuery(equal("spec.tags", tagName));
+ hasQuery = true;
+ }
+ if (StringUtils.isNotBlank(categoryName)) {
+ builder.andQuery(in("spec.categories", categoryName));
+ hasQuery = true;
+ }
+ // Exclude hidden posts when no query
+ if (!hasQuery) {
+ builder.fieldQuery(notHiddenPostQuery());
+ }
+ return builder.build();
+ }
+
+ public PageRequest toPageRequest() {
+ var resolvedSort = Optional.of(SortUtils.resolve(sort))
+ .filter(Sort::isUnsorted)
+ .orElse(defaultSort());
+ return PageRequestImpl.of(pageNullSafe(getPage()),
+ sizeNullSafe(getSize()), resolvedSort);
+ }
+ }
}
diff --git a/application/src/test/java/run/halo/app/infra/utils/SortUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/SortUtilsTest.java
new file mode 100644
index 000000000..d2960d35a
--- /dev/null
+++ b/application/src/test/java/run/halo/app/infra/utils/SortUtilsTest.java
@@ -0,0 +1,37 @@
+package run.halo.app.infra.utils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link SortUtils}.
+ *
+ * @author guqing
+ * @since 2.19.0
+ */
+class SortUtilsTest {
+
+ @Test
+ void resolve() {
+ // null case
+ assertThat(SortUtils.resolve(null).isUnsorted()).isTrue();
+
+ // multiple sort and directions
+ var str = List.of("name,asc", "age,desc");
+ var sort = SortUtils.resolve(str);
+ assertThat(sort.toString()).isEqualTo("name: ASC,age: DESC");
+
+ // missing direction
+ str = List.of("name");
+ sort = SortUtils.resolve(str);
+ assertThat(sort.toString()).isEqualTo("name: ASC");
+
+ // whitespace in direction
+ assertThatThrownBy(() -> SortUtils.resolve(List.of("name, desc")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Direction must not contain whitespace");
+ }
+}
\ No newline at end of file
diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplIntegrationTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplIntegrationTest.java
new file mode 100644
index 000000000..1f95772d2
--- /dev/null
+++ b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplIntegrationTest.java
@@ -0,0 +1,108 @@
+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.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thymeleaf.IEngineConfiguration;
+import org.thymeleaf.TemplateEngine;
+import org.thymeleaf.context.Context;
+import org.thymeleaf.spring6.SpringTemplateEngine;
+import org.thymeleaf.spring6.dialect.SpringStandardDialect;
+import org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator;
+import org.thymeleaf.templateresolver.StringTemplateResolver;
+import org.thymeleaf.templateresource.ITemplateResource;
+import org.thymeleaf.templateresource.StringTemplateResource;
+import reactor.core.publisher.Mono;
+import run.halo.app.extension.ListResult;
+import run.halo.app.theme.ReactiveSpelVariableExpressionEvaluator;
+import run.halo.app.theme.finders.PostPublicQueryService;
+
+/**
+ * Tests for {@link PostFinderImpl}.
+ *
+ * @author guqing
+ * @since 2.19.0
+ */
+@ExtendWith(MockitoExtension.class)
+class PostFinderImplIntegrationTest {
+
+ private TemplateEngine templateEngine;
+
+ @Mock
+ private PostPublicQueryService postPublicQueryService;
+
+ @InjectMocks
+ private PostFinderImpl postFinder;
+
+ @Mock
+ private TemplateResourceComputer templateResourceComputer;
+
+ @BeforeEach
+ void setUp() {
+ templateEngine = new SpringTemplateEngine();
+ templateEngine.setDialect(new SpringStandardDialect() {
+ @Override
+ public IStandardVariableExpressionEvaluator getVariableExpressionEvaluator() {
+ return ReactiveSpelVariableExpressionEvaluator.INSTANCE;
+ }
+ });
+ templateEngine.addTemplateResolver(new TestTemplateResolver(templateResourceComputer));
+ }
+
+ @Test
+ void listTest() {
+ var context = new Context();
+ context.setVariable("postFinder", postFinder);
+
+ // empty param
+ when(templateResourceComputer.compute(eq("post"))).thenReturn(new StringTemplateResource("""
+
+ """));
+
+ when(postPublicQueryService.list(any(), any()))
+ .thenReturn(Mono.just(ListResult.emptyResult()));
+
+ var result = templateEngine.process("post", context);
+ assertThat(result).isEqualToIgnoringWhitespace(
+ "ListResult(page=0, size=0, total=0, items=[])");
+
+ when(templateResourceComputer.compute(eq("post"))).thenReturn(new StringTemplateResource("""
+
+
+ """));
+ result = templateEngine.process("post", context);
+ assertThat(result).isEqualToIgnoringWhitespace("");
+ }
+
+ static class TestTemplateResolver extends StringTemplateResolver {
+ private final TemplateResourceComputer templateResourceComputer;
+
+ TestTemplateResolver(TemplateResourceComputer templateResourceComputer) {
+ this.templateResourceComputer = templateResourceComputer;
+ }
+
+ @Override
+ protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration,
+ String ownerTemplate, String template,
+ Map templateResolutionAttributes) {
+ return templateResourceComputer.compute(template);
+ }
+ }
+
+ interface TemplateResourceComputer {
+ ITemplateResource compute(String template);
+ }
+}