diff --git a/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java b/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java index f8023fb6c0..80bd684bf8 100644 --- a/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java +++ b/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java @@ -30,4 +30,6 @@ public interface CategoryFinder { Flux listAsTree(String name); Mono getParentByName(String name); + + Flux getBreadcrumbs(String name); } diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java index af479c1169..11c592424f 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java @@ -3,6 +3,7 @@ import static run.halo.app.extension.index.query.QueryFactory.notEqual; import java.time.Instant; +import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.List; @@ -16,6 +17,7 @@ import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Sort; +import org.springframework.util.Assert; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.CategoryService; @@ -101,8 +103,17 @@ public Flux listAll() { .map(CategoryVo::from); } + Flux listAllFor(String parentName) { + return categoryService.isCategoryHidden(parentName) + .flatMapMany( + isHidden -> client.listAll(Category.class, new ListOptions(), defaultSort()) + .filter(category -> isHidden || !category.getSpec().isHideFromList()) + .map(CategoryVo::from) + ); + } + Flux toCategoryTreeVoFlux(String name) { - return listAll() + return listAllFor(name) .collectList() .flatMapIterable(categoryVos -> { Map nameIdentityMap = categoryVos.stream() @@ -153,6 +164,7 @@ private CategoryTreeVo dummyVirtualRoot(List treeNodes) { Category.CategorySpec categorySpec = new Category.CategorySpec(); categorySpec.setSlug("/"); return CategoryTreeVo.builder() + .metadata(new Metadata()) .spec(categorySpec) .postCount(0) .children(treeNodes) @@ -214,6 +226,48 @@ public Mono getParentByName(String name) { .map(CategoryVo::from); } + @Override + public Flux getBreadcrumbs(String name) { + return listAsTree() + .collectList() + .flatMapMany(treeNodes -> { + var rootNode = dummyVirtualRoot(treeNodes); + var paths = new ArrayList(); + findPathHelper(rootNode, name, paths); + return Flux.fromIterable(paths); + }); + } + + private static boolean findPathHelper(CategoryTreeVo node, String targetName, + List path) { + Assert.notNull(targetName, "Target name must not be null"); + if (node == null) { + return false; + } + + // null name is just a virtual root + if (node.getMetadata().getName() != null) { + path.add(CategoryTreeVo.toCategoryVo(node)); + } + + // node maybe a virtual root node so it may have null name + if (targetName.equals(node.getMetadata().getName())) { + return true; + } + + for (CategoryTreeVo child : node.getChildren()) { + if (findPathHelper(child, targetName, path)) { + return true; + } + } + + // if the target node is not in the current subtree, remove the current node to roll back + if (!path.isEmpty()) { + path.remove(path.size() - 1); + } + return false; + } + int pageNullSafe(Integer page) { return ObjectUtils.defaultIfNull(page, 1); } diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java index d6780a3e5a..b429af6af7 100644 --- a/application/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java +++ b/application/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java @@ -51,6 +51,19 @@ public static CategoryTreeVo from(CategoryVo category) { .build(); } + /** + * Convert {@link CategoryTreeVo} to {@link CategoryVo}. + */ + public static CategoryVo toCategoryVo(CategoryTreeVo categoryTreeVo) { + Assert.notNull(categoryTreeVo, "The category tree vo must not be null"); + return CategoryVo.builder() + .metadata(categoryTreeVo.getMetadata()) + .spec(categoryTreeVo.getSpec()) + .status(categoryTreeVo.getStatus()) + .postCount(categoryTreeVo.getPostCount()) + .build(); + } + @Override public String nodeText() { return String.format("%s (%s)%s", getSpec().getDisplayName(), getPostCount(), diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java index 2156c20dff..1e03078714 100644 --- a/application/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java +++ b/application/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java @@ -3,6 +3,7 @@ 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.lenient; import static org.mockito.Mockito.when; import com.fasterxml.jackson.core.type.TypeReference; @@ -55,6 +56,7 @@ class CategoryFinderImplTest { @BeforeEach void setUp() { categoryFinder = new CategoryFinderImpl(client, categoryService); + lenient().when(categoryService.isCategoryHidden(any())).thenReturn(Mono.just(false)); } @Test @@ -227,6 +229,44 @@ void computePostCountFromTree() { └── IndependentChild4 (3) """); } + + @Test + void getBreadcrumbsTest() { + // first level + var breadcrumbs = categoryFinder.getBreadcrumbs("全部").collectList().block(); + assertThat(toNames(breadcrumbs)).containsSequence("全部"); + + // second level + breadcrumbs = categoryFinder.getBreadcrumbs("AnotherRootChild").collectList().block(); + assertThat(toNames(breadcrumbs)).containsSequence("全部", "AnotherRootChild"); + + // more levels + breadcrumbs = categoryFinder.getBreadcrumbs("DeepNode5").collectList().block(); + assertThat(toNames(breadcrumbs)).containsSequence("全部", "AnotherRootChild", "Child1", + "SubChild2", "DeepNode3", "DeepNode5"); + + breadcrumbs = categoryFinder.getBreadcrumbs("IndependentChild4").collectList().block(); + assertThat(toNames(breadcrumbs)).containsSequence("全部", "FIT2CLOUD", + "IndependentNode", + "IndependentChild4"); + + breadcrumbs = categoryFinder.getBreadcrumbs("SubNode4").collectList().block(); + assertThat(toNames(breadcrumbs)).containsSequence("全部", "AnotherRootChild", "Child2", + "IndependentSubNode", "SubNode4"); + + // not exist + breadcrumbs = categoryFinder.getBreadcrumbs("not-exist").collectList().block(); + assertThat(toNames(breadcrumbs)).isEmpty(); + } + + static List toNames(List categories) { + if (categories == null) { + return List.of(); + } + return categories.stream() + .map(category -> category.getMetadata().getName()) + .toList(); + } } private List categoriesForTree() {