feat: add method to find path of a specified node in a category tree (#6135)

#### What type of PR is this?
/kind feature
/area core
/milestone 2.17.x

#### What this PR does / why we need it:
为分类 Finder 提供获取指定节点的面包屑路径方法

#### Which issue(s) this PR fixes:
Fixes #3374

#### Does this PR introduce a user-facing change?
```release-note
为分类 Finder 提供获取指定节点的面包屑路径方法
```
pull/6185/head
guqing 2024-06-27 18:13:05 +08:00 committed by GitHub
parent bb0a3bc467
commit 0cdd043d1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 110 additions and 1 deletions

View File

@ -30,4 +30,6 @@ public interface CategoryFinder {
Flux<CategoryTreeVo> listAsTree(String name);
Mono<CategoryVo> getParentByName(String name);
Flux<CategoryVo> getBreadcrumbs(String name);
}

View File

@ -3,6 +3,7 @@ package run.halo.app.theme.finders.impl;
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.BooleanUtils;
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 class CategoryFinderImpl implements CategoryFinder {
.map(CategoryVo::from);
}
Flux<CategoryVo> 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<CategoryTreeVo> toCategoryTreeVoFlux(String name) {
return listAll()
return listAllFor(name)
.collectList()
.flatMapIterable(categoryVos -> {
Map<String, CategoryTreeVo> nameIdentityMap = categoryVos.stream()
@ -153,6 +164,7 @@ public class CategoryFinderImpl implements CategoryFinder {
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 class CategoryFinderImpl implements CategoryFinder {
.map(CategoryVo::from);
}
@Override
public Flux<CategoryVo> getBreadcrumbs(String name) {
return listAsTree()
.collectList()
.flatMapMany(treeNodes -> {
var rootNode = dummyVirtualRoot(treeNodes);
var paths = new ArrayList<CategoryVo>();
findPathHelper(rootNode, name, paths);
return Flux.fromIterable(paths);
});
}
private static boolean findPathHelper(CategoryTreeVo node, String targetName,
List<CategoryVo> 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);
}

View File

@ -51,6 +51,19 @@ public class CategoryTreeVo implements VisualizableTreeNode<CategoryTreeVo>, Ext
.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(),

View File

@ -3,6 +3,7 @@ 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.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 @@ class CategoryFinderImplTest {
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<String> toNames(List<CategoryVo> categories) {
if (categories == null) {
return List.of();
}
return categories.stream()
.map(category -> category.getMetadata().getName())
.toList();
}
}
private List<Category> categoriesForTree() {