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 index cff2db2bd..54bb219cc 100644 --- a/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java +++ b/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java @@ -3,14 +3,13 @@ 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 lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ObjectUtils; -import org.springframework.util.CollectionUtils; import run.halo.app.core.extension.Category; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; @@ -25,6 +24,7 @@ import run.halo.app.theme.finders.vo.CategoryVo; * @author guqing * @since 2.0.0 */ +@Slf4j @Finder("categoryFinder") public class CategoryFinderImpl implements CategoryFinder { private final ReactiveExtensionClient client; @@ -75,40 +75,55 @@ public class CategoryFinderImpl implements CategoryFinder { @Override public List listAsTree() { List categoryVos = listAll(); - Map nameIdentityMap = categoryVos.stream() + Map nameIdentityMap = categoryVos.stream() + .map(CategoryTreeVo::from) .collect(Collectors.toMap(categoryVo -> categoryVo.getMetadata().getName(), Function.identity())); - Map treeVoMap = new HashMap<>(); - // populate parentName - categoryVos.forEach(categoryVo -> { - final String parentName = categoryVo.getMetadata().getName(); - treeVoMap.putIfAbsent(parentName, CategoryTreeVo.from(categoryVo)); - List children = categoryVo.getSpec().getChildren(); - if (CollectionUtils.isEmpty(children)) { - return; + nameIdentityMap.forEach((name, value) -> { + List children = value.getSpec().getChildren(); + if (children != null) { + for (String child : children) { + CategoryTreeVo childNode = nameIdentityMap.get(child); + childNode.setParentName(name); + } } - children.forEach(childrenName -> { - CategoryVo childrenVo = nameIdentityMap.get(childrenName); - CategoryTreeVo treeVo = CategoryTreeVo.from(childrenVo); - treeVo.setParentName(parentName); - treeVoMap.putIfAbsent(treeVo.getMetadata().getName(), treeVo); - }); }); - nameIdentityMap.clear(); - return listToTree(treeVoMap.values()); + return listToTree(nameIdentityMap.values()); } static List listToTree(Collection list) { - Map> nameIdentityMap = list.stream() - .filter(item -> item.getParentName() != null) + Map> parentNameIdentityMap = list.stream() + .filter(categoryTreeVo -> categoryTreeVo.getParentName() != null) .collect(Collectors.groupingBy(CategoryTreeVo::getParentName)); - list.forEach(node -> node.setChildren(nameIdentityMap.get(node.getMetadata().getName()))); + + list.forEach(node -> { + // sort children + List children = + parentNameIdentityMap.getOrDefault(node.getMetadata().getName(), List.of()) + .stream() + .sorted(defaultTreeNodeComparator()) + .toList(); + node.setChildren(children); + }); return list.stream() .filter(v -> v.getParentName() == null) + .sorted(defaultTreeNodeComparator()) .collect(Collectors.toList()); } + static Comparator defaultTreeNodeComparator() { + 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); + } + static Comparator defaultComparator() { Function priority = category -> Objects.requireNonNullElse(category.getSpec().getPriority(), 0); 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 index ffc8296df..a760f21e2 100644 --- a/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java +++ b/src/main/java/run/halo/app/theme/finders/vo/CategoryTreeVo.java @@ -1,6 +1,7 @@ package run.halo.app.theme.finders.vo; import java.util.List; +import java.util.Objects; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; @@ -19,7 +20,7 @@ import run.halo.app.extension.MetadataOperator; @Builder @ToString @EqualsAndHashCode -public class CategoryTreeVo { +public class CategoryTreeVo implements VisualizableTreeNode { private MetadataOperator metadata; @@ -46,7 +47,12 @@ public class CategoryTreeVo { .spec(category.getSpec()) .status(category.getStatus()) .children(List.of()) - .postCount(category.getPostCount()) + .postCount(Objects.requireNonNullElse(category.getPostCount(), 0)) .build(); } + + @Override + public String nodeText() { + return String.format("%s (%s)", getSpec().getDisplayName(), getPostCount()); + } } 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 index 3e915e896..4f862f289 100644 --- a/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java +++ b/src/test/java/run/halo/app/theme/finders/impl/CategoryFinderImplTest.java @@ -6,9 +6,11 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.core.type.TypeReference; import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.Objects; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -99,6 +101,28 @@ class CategoryFinderImplTest { assertThat(treeVos).hasSize(1); } + /** + * Test for {@link CategoryFinderImpl#listAsTree()}. + * + * @see Fix #2532 + */ + @Test + void listAsTreeMore() { + when(client.list(eq(Category.class), eq(null), any())) + .thenReturn(Flux.fromIterable(moreCategories())); + List treeVos = categoryFinder.listAsTree(); + String s = visualizeTree(treeVos); + assertThat(s).isEqualTo(""" + 全部 (5) + ├── FIT2CLOUD (2) + │ ├── DataEase (0) + │ ├── Halo (2) + │ ├── MeterSphere (0) + │ └── JumpServer (0) + └── 默认分类 (3) + """); + } + private List categoriesForTree() { /* * D @@ -144,6 +168,30 @@ class CategoryFinderImplTest { return List.of(d, e, a, b, c, g, f, h); } + /** + * Visualize a tree. + */ + String visualizeTree(List categoryTreeVos) { + Category.CategorySpec categorySpec = new Category.CategorySpec(); + categorySpec.setSlug("/"); + categorySpec.setDisplayName("全部"); + Integer postCount = categoryTreeVos.stream() + .map(CategoryTreeVo::getPostCount) + .filter(Objects::nonNull) + .reduce(Integer::sum) + .orElse(0); + CategoryTreeVo root = CategoryTreeVo.builder() + .spec(categorySpec) + .postCount(postCount) + .children(categoryTreeVos) + .metadata(new Metadata()) + .build(); + StringBuilder stringBuilder = new StringBuilder(); + root.print(stringBuilder, "", ""); + return stringBuilder.toString(); + } + + private List categories() { Category category2 = JsonUtils.deepCopy(category()); category2.getMetadata().setName("c2"); @@ -176,4 +224,176 @@ class CategoryFinderImplTest { category.setSpec(categorySpec); return category; } + + private List moreCategories() { + String s = """ + [ + { + "spec":{ + "displayName":"默认分类", + "slug":"default", + "description":"这是你的默认分类,如不需要,删除即可。", + "cover":"", + "template":"", + "priority":1, + "children":[ + ] + }, + "status":{ + "permalink":"/categories/default", + "postCount":3, + "visiblePostCount":3 + }, + "apiVersion":"content.halo.run/v1alpha1", + "kind":"Category", + "metadata":{ + "name":"76514a40-6ef1-4ed9-b58a-e26945bde3ca", + "version":16, + "creationTimestamp":"2022-10-08T06:17:47.589181Z" + } + }, + { + "spec":{ + "displayName":"MeterSphere", + "slug":"metersphere", + "description":"", + "cover":"", + "template":"", + "priority":2, + "children":[ + ] + }, + "status":{ + "permalink":"/categories/metersphere", + "postCount":0, + "visiblePostCount":0 + }, + "apiVersion":"content.halo.run/v1alpha1", + "kind":"Category", + "metadata":{ + "finalizers":[ + "category-protection" + ], + "name":"acf09686-d5a7-4227-ba8c-3aeff063f12f", + "version":13, + "creationTimestamp":"2022-10-08T06:32:36.650974Z" + } + }, + { + "spec":{ + "displayName":"DataEase", + "slug":"dataease", + "description":"", + "cover":"", + "template":"", + "priority":0, + "children":[ + ] + }, + "status":{ + "permalink":"/categories/dataease", + "postCount":0, + "visiblePostCount":0 + }, + "apiVersion":"content.halo.run/v1alpha1", + "kind":"Category", + "metadata":{ + "finalizers":[ + "category-protection" + ], + "name":"bd95f914-22fc-4de5-afcc-a9ffba2f6401", + "version":13, + "creationTimestamp":"2022-10-08T06:32:53.353838Z" + } + }, + { + "spec":{ + "displayName":"FIT2CLOUD", + "slug":"fit2cloud", + "description":"", + "cover":"", + "template":"", + "priority":0, + "children":[ + "bd95f914-22fc-4de5-afcc-a9ffba2f6401", + "e1150fd9-4512-453c-9186-f8de9c156c3d", + "acf09686-d5a7-4227-ba8c-3aeff063f12f", + "ed064d5e-2b6f-4123-8114-78d0c6f2c4e2" + ] + }, + "status":{ + "permalink":"/categories/fit2cloud", + "postCount":2, + "visiblePostCount":2 + }, + "apiVersion":"content.halo.run/v1alpha1", + "kind":"Category", + "metadata":{ + "finalizers":[ + "category-protection" + ], + "name":"c25c17ae-4a7b-43c5-a424-76950b9622cd", + "version":14, + "creationTimestamp":"2022-10-08T06:32:27.802025Z" + } + }, + { + "spec":{ + "displayName":"Halo", + "slug":"halo", + "description":"", + "cover":"", + "template":"", + "priority":1, + "children":[ + ] + }, + "status":{ + "permalink":"/categories/halo", + "postCount":2, + "visiblePostCount":2 + }, + "apiVersion":"content.halo.run/v1alpha1", + "kind":"Category", + "metadata":{ + "finalizers":[ + "category-protection" + ], + "name":"e1150fd9-4512-453c-9186-f8de9c156c3d", + "version":15, + "creationTimestamp":"2022-10-08T06:32:42.991788Z" + } + }, + { + "spec":{ + "displayName":"JumpServer", + "slug":"jumpserver", + "description":"", + "cover":"", + "template":"", + "priority":3, + "children":[ + ] + }, + "status":{ + "permalink":"/categories/jumpserver", + "postCount":0, + "visiblePostCount":0 + }, + "apiVersion":"content.halo.run/v1alpha1", + "kind":"Category", + "metadata":{ + "finalizers":[ + "category-protection" + ], + "name":"ed064d5e-2b6f-4123-8114-78d0c6f2c4e2", + "version":13, + "creationTimestamp":"2022-10-08T06:33:00.557435Z" + } + } + ] + """; + return JsonUtils.jsonToObject(s, new TypeReference<>() { + }); + } } \ No newline at end of file