feat: provides the route of the post archive page for theme-side (#2598)

#### What type of PR is this?
/kind feature
/area core
/milestone 2.0

#### What this PR does / why we need it:
提供文章归档页

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

Fixes #2548

#### Special notes for your reviewer:

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

```release-note
为主题提供文章归档页
```
pull/2626/head
guqing 2022-10-25 15:46:12 +08:00 committed by GitHub
parent 95f0809042
commit 160dd909cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 292 additions and 32 deletions

View File

@ -35,6 +35,10 @@ public class Post extends AbstractExtension {
public static final String VISIBLE_LABEL = "content.halo.run/visible";
public static final String PHASE_LABEL = "content.halo.run/phase";
public static final String ARCHIVE_YEAR_LABEL = "content.halo.run/archive-year";
public static final String ARCHIVE_MONTH_LABEL = "content.halo.run/archive-month";
@Schema(required = true)
private PostSpec spec;

View File

@ -21,6 +21,7 @@ import run.halo.app.extension.Ref;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.infra.utils.HaloUtils;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
@ -90,6 +91,11 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
labels.put(Post.VISIBLE_LABEL,
Objects.requireNonNullElse(spec.getVisible(), Post.VisibleEnum.PUBLIC).name());
labels.put(Post.OWNER_LABEL, spec.getOwner());
Instant publishTime = post.getSpec().getPublishTime();
if (publishTime != null) {
labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime));
labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime));
}
if (!oldPost.equals(post)) {
client.update(post);

View File

@ -3,10 +3,13 @@ package run.halo.app.infra.utils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneId;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpHeaders;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
@ -50,4 +53,15 @@ public class HaloUtils {
}
return StringUtils.defaultString(userAgent, "unknown");
}
public static String getMonthText(Instant instant) {
Assert.notNull(instant, "Instant must not be null");
int monthValue = instant.atZone(ZoneId.systemDefault()).getMonthValue();
return StringUtils.leftPad(String.valueOf(monthValue), 2, '0');
}
public static String getYearText(Instant instant) {
Assert.notNull(instant, "Instant must not be null");
return String.valueOf(instant.atZone(ZoneId.systemDefault()).getYear());
}
}

View File

@ -4,6 +4,7 @@ import org.springframework.lang.Nullable;
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.PostArchiveVo;
import run.halo.app.theme.finders.vo.PostVo;
/**
@ -24,4 +25,10 @@ public interface PostFinder {
String categoryName);
ListResult<PostVo> listByTag(@Nullable Integer page, @Nullable Integer size, String tag);
ListResult<PostArchiveVo> archives(Integer page, Integer size);
ListResult<PostArchiveVo> archives(Integer page, Integer size, String year);
ListResult<PostArchiveVo> archives(Integer page, Integer size, String year, String month);
}

View File

@ -3,17 +3,21 @@ package run.halo.app.theme.finders.impl;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.NonNull;
import org.springframework.util.comparator.Comparators;
import run.halo.app.content.ContentService;
import run.halo.app.core.extension.Counter;
import run.halo.app.core.extension.Post;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.HaloUtils;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
import run.halo.app.theme.finders.CategoryFinder;
@ -24,6 +28,8 @@ 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.Contributor;
import run.halo.app.theme.finders.vo.PostArchiveVo;
import run.halo.app.theme.finders.vo.PostArchiveYearMonthVo;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.finders.vo.StatsVo;
import run.halo.app.theme.finders.vo.TagVo;
@ -90,19 +96,72 @@ public class PostFinderImpl implements PostFinder {
@Override
public ListResult<PostVo> list(Integer page, Integer size) {
return listPost(page, size, null);
return listPost(page, size, null, defaultComparator());
}
@Override
public ListResult<PostVo> listByCategory(Integer page, Integer size, String categoryName) {
return listPost(page, size,
post -> contains(post.getSpec().getCategories(), categoryName));
post -> contains(post.getSpec().getCategories(), categoryName), defaultComparator());
}
@Override
public ListResult<PostVo> listByTag(Integer page, Integer size, String tag) {
return listPost(page, size,
post -> contains(post.getSpec().getTags(), tag));
post -> contains(post.getSpec().getTags(), tag), defaultComparator());
}
@Override
public ListResult<PostArchiveVo> archives(Integer page, Integer size) {
return archives(page, size, null, null);
}
@Override
public ListResult<PostArchiveVo> archives(Integer page, Integer size, String year) {
return archives(page, size, year, null);
}
@Override
public ListResult<PostArchiveVo> archives(Integer page, Integer size, String year,
String month) {
ListResult<PostVo> list = listPost(page, size, post -> {
Map<String, String> labels = post.getMetadata().getLabels();
if (labels == null) {
return false;
}
boolean yearMatch = StringUtils.isBlank(year)
|| year.equals(labels.get(Post.ARCHIVE_YEAR_LABEL));
boolean monthMatch = StringUtils.isBlank(month)
|| month.equals(labels.get(Post.ARCHIVE_MONTH_LABEL));
return yearMatch && monthMatch;
}, archiveComparator());
Map<String, List<PostVo>> yearPosts = list.get()
.collect(Collectors.groupingBy(
post -> HaloUtils.getYearText(post.getSpec().getPublishTime())));
List<PostArchiveVo> postArchives =
yearPosts.entrySet().stream().map(entry -> {
String key = entry.getKey();
// archives by month
Map<String, List<PostVo>> monthPosts = entry.getValue().stream()
.collect(Collectors.groupingBy(
post -> HaloUtils.getMonthText(post.getSpec().getPublishTime())));
// convert to archive year month value objects
List<PostArchiveYearMonthVo> monthArchives = monthPosts.entrySet()
.stream()
.sorted(Map.Entry.comparingByKey())
.map(monthEntry -> PostArchiveYearMonthVo.builder()
.posts(monthEntry.getValue())
.month(monthEntry.getKey())
.build()
)
.toList();
return PostArchiveVo.builder()
.year(String.valueOf(key))
.months(monthArchives)
.build();
}).toList();
return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), postArchives);
}
private boolean contains(List<String> c, String key) {
@ -112,14 +171,15 @@ public class PostFinderImpl implements PostFinder {
return c.contains(key);
}
private ListResult<PostVo> listPost(Integer page, Integer size, Predicate<Post> postPredicate) {
private ListResult<PostVo> listPost(Integer page, Integer size, Predicate<Post> postPredicate,
Comparator<Post> comparator) {
Predicate<Post> predicate = FIXED_PREDICATE
.and(postPredicate == null ? post -> true : postPredicate);
ListResult<Post> list = client.list(Post.class, predicate,
defaultComparator(), pageNullSafe(page), sizeNullSafe(size))
comparator, pageNullSafe(page), sizeNullSafe(size))
.block();
if (list == null) {
return new ListResult<>(0, 0, 0, List.of());
return new ListResult<>(List.of());
}
List<PostVo> postVos = list.get()
.map(this::getPostVo)
@ -168,6 +228,15 @@ public class PostFinderImpl implements PostFinder {
.reversed();
}
static Comparator<Post> archiveComparator() {
Function<Post, Instant> publishTime =
post -> post.getSpec().getPublishTime();
Function<Post, String> name = post -> post.getMetadata().getName();
return Comparator.comparing(publishTime, Comparators.nullsLow())
.thenComparing(name)
.reversed();
}
int pageNullSafe(Integer page) {
return ObjectUtils.defaultIfNull(page, 1);
}

View File

@ -0,0 +1,20 @@
package run.halo.app.theme.finders.vo;
import java.util.List;
import lombok.Builder;
import lombok.Value;
/**
* Post archives by year and month.
*
* @author guqing
* @since 2.0.0
*/
@Value
@Builder
public class PostArchiveVo {
String year;
List<PostArchiveYearMonthVo> months;
}

View File

@ -0,0 +1,20 @@
package run.halo.app.theme.finders.vo;
import java.util.List;
import lombok.Builder;
import lombok.Value;
/**
* Post archives by month.
*
* @author guqing
* @since 2.0.0
*/
@Value
@Builder
public class PostArchiveYearMonthVo {
String month;
List<PostVo> posts;
}

View File

@ -14,7 +14,6 @@ import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
@ -53,22 +52,7 @@ public class PermalinkHttpGetRouter implements InitializingBean {
* @return a handler function if matched, otherwise null
*/
public HandlerFunction<ServerResponse> route(ServerRequest request) {
MultiValueMap<String, String> queryParams = request.queryParams();
String requestPath = request.path();
// 文章的 permalink 规则需要对 p 参数规则特殊处理
if (requestPath.equals("/") && queryParams.containsKey("p")) {
// post special route path
String postSlug = queryParams.getFirst("p");
requestPath = requestPath + "?p=" + postSlug;
}
// /categories/{slug}/page/{page} 和 /tags/{slug}/page/{page} 需要去掉 page 部分
if (PageUrlUtils.isPageUrl(requestPath)) {
int i = requestPath.lastIndexOf("/page/");
if (i != -1) {
requestPath = requestPath.substring(0, i);
}
}
return routeTree.match(requestPath);
return routeTree.match(request);
}
public void insert(String key, HandlerFunction<ServerResponse> handlerFunction) {

View File

@ -2,11 +2,18 @@ package run.halo.app.theme.router;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.server.PathContainer;
import org.springframework.lang.Nullable;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.util.UriUtils;
import org.springframework.web.util.pattern.PathPattern;
@ -66,11 +73,11 @@ public class RadixRouterTree extends RadixTree<HandlerFunction<ServerResponse>>
* TODO Optimize matching algorithm to improve efficiency and try your best to get results
* through one search
*
* @param requestPath request path
* @param request server request
* @return a handler function if matched, otherwise null
*/
public HandlerFunction<ServerResponse> match(String requestPath) {
String path = processRequestPath(requestPath);
public HandlerFunction<ServerResponse> match(ServerRequest request) {
String path = pathToFind(request);
HandlerFunction<ServerResponse> result = find(path);
if (result != null) {
return result;
@ -106,10 +113,70 @@ public class RadixRouterTree extends RadixTree<HandlerFunction<ServerResponse>>
+ secondBestMatch + "}");
}
}
PathPattern.PathMatchInfo info =
bestMatch.matchAndExtract(request.requestPath().pathWithinApplication());
if (info != null) {
mergeAttributes(request, info.getUriVariables(), bestMatch);
}
return find(bestMatch.getPatternString());
}
/**
* TODO Optimize parameter route matching query.
* Router URL , /?p=post-name URL query URL
*/
private String pathToFind(ServerRequest request) {
String requestPath = processRequestPath(request.path());
MultiValueMap<String, String> queryParams = request.queryParams();
// 文章的 permalink 规则需要对 p 参数规则特殊处理
if (requestPath.equals("/") && queryParams.containsKey("p")) {
// post special route path
String postSlug = queryParams.getFirst("p");
requestPath = requestPath + "?p=" + postSlug;
}
// /categories/{slug}/page/{page} 和 /tags/{slug}/page/{page} 需要去掉 page 部分
if (PageUrlUtils.isPageUrl(requestPath)) {
int i = requestPath.lastIndexOf("/page/");
if (i != -1) {
requestPath = requestPath.substring(0, i);
}
}
return StringUtils.prependIfMissing(requestPath, "/");
}
private static void mergeAttributes(ServerRequest request, Map<String, String> variables,
PathPattern pattern) {
Map<String, String> pathVariables = mergePathVariables(request.pathVariables(), variables);
request.attributes().put(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE,
Collections.unmodifiableMap(pathVariables));
pattern = mergePatterns(
(PathPattern) request.attributes().get(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE),
pattern);
request.attributes().put(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE, pattern);
}
private static PathPattern mergePatterns(@Nullable PathPattern oldPattern,
PathPattern newPattern) {
if (oldPattern != null) {
return oldPattern.combine(newPattern);
} else {
return newPattern;
}
}
private static Map<String, String> mergePathVariables(Map<String, String> oldVariables,
Map<String, String> newVariables) {
if (!newVariables.isEmpty()) {
Map<String, String> mergedVariables = new LinkedHashMap<>(oldVariables);
mergedVariables.putAll(newVariables);
return mergedVariables;
} else {
return oldVariables;
}
}
private String processRequestPath(String requestPath) {
String path = StringUtils.prependIfMissing(requestPath, "/");
return UriUtils.decode(path, StandardCharsets.UTF_8);

View File

@ -14,7 +14,7 @@ import reactor.core.scheduler.Schedulers;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.finders.vo.PostArchiveVo;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
@ -33,22 +33,32 @@ public class ArchivesRouteStrategy implements ListPageRouteHandlerStrategy {
this.postFinder = postFinder;
}
private Mono<UrlContextListResult<PostVo>> postList(ServerRequest request) {
private Mono<UrlContextListResult<PostArchiveVo>> postList(ServerRequest request) {
String year = pathVariable(request, "year");
String month = pathVariable(request, "month");
String path = request.path();
return Mono.defer(() -> Mono.just(postFinder.list(pageNum(request), 10)))
return Mono.defer(() -> Mono.just(postFinder.archives(pageNum(request), 10, year, month)))
.publishOn(Schedulers.boundedElastic())
.map(list -> new UrlContextListResult.Builder<PostVo>()
.map(list -> new UrlContextListResult.Builder<PostArchiveVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build());
}
private String pathVariable(ServerRequest request, String name) {
Map<String, String> pathVariables = request.pathVariables();
if (pathVariables.containsKey(name)) {
return pathVariables.get(name);
}
return null;
}
@Override
public HandlerFunction<ServerResponse> getHandler() {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.ARCHIVES.getValue(),
Map.of("posts", postList(request)));
Map.of("archives", postList(request)));
}
@Override
@ -56,6 +66,8 @@ public class ArchivesRouteStrategy implements ListPageRouteHandlerStrategy {
return List.of(
prefix,
PathUtils.combinePath(prefix, "/page/{page:\\d+}"),
PathUtils.combinePath(prefix, "/{year:\\d{4}}"),
PathUtils.combinePath(prefix, "/{year:\\d{4}}/page/{page:\\d+}"),
PathUtils.combinePath(prefix, "/{year:\\d{4}}/{month:\\d{2}}"),
PathUtils.combinePath(prefix,
"/{year:\\d{4}}/{month:\\d{2}}/page/{page:\\d+}")

View File

@ -1,6 +1,8 @@
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;
@ -15,13 +17,17 @@ 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.Counter;
import run.halo.app.core.extension.Post;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.metrics.CounterService;
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;
import run.halo.app.theme.finders.vo.PostArchiveVo;
/**
* Tests for {@link PostFinderImpl}.
@ -38,6 +44,9 @@ class PostFinderImplTest {
@Mock
private ContentService contentService;
@Mock
private CounterService counterService;
@Mock
private CategoryFinder categoryFinder;
@ -81,6 +90,48 @@ class PostFinderImplTest {
assertThat(strings).isEqualTo(List.of("post-1", "post-2", "post-6"));
}
@Test
void archives() {
Counter counter = new Counter();
counter.setMetadata(new Metadata());
when(counterService.getByName(any())).thenReturn(counter);
ListResult<Post> listResult = new ListResult<>(1, 10, 3, postsForArchives());
when(client.list(eq(Post.class), any(), any(), anyInt(), anyInt()))
.thenReturn(Mono.just(listResult));
ListResult<PostArchiveVo> archives = postFinder.archives(1, 10);
List<PostArchiveVo> items = archives.getItems();
assertThat(items.size()).isEqualTo(2);
assertThat(items.get(0).getYear()).isEqualTo("2022");
assertThat(items.get(0).getMonths().size()).isEqualTo(2);
assertThat(items.get(0).getMonths().get(0).getMonth()).isEqualTo("10");
assertThat(items.get(0).getMonths().get(1).getMonth()).isEqualTo("12");
assertThat(items.get(0).getMonths().get(1).getPosts().size()).isEqualTo(1);
assertThat(items.get(0).getMonths().get(1).getPosts().size()).isEqualTo(1);
assertThat(items.get(1).getYear()).isEqualTo("2021");
assertThat(items.get(1).getMonths()).hasSize(1);
assertThat(items.get(1).getMonths().get(0).getMonth()).isEqualTo("01");
}
List<Post> postsForArchives() {
Post post1 = post(1);
post1.getSpec().setPublished(true);
post1.getSpec().setPublishTime(Instant.parse("2021-01-01T00:00:00Z"));
post1.getMetadata().setCreationTimestamp(Instant.now());
Post post2 = post(2);
post2.getSpec().setPublished(true);
post2.getSpec().setPublishTime(Instant.parse("2022-12-01T00:00:00Z"));
post2.getMetadata().setCreationTimestamp(Instant.now());
Post post3 = post(3);
post2.getSpec().setPublished(true);
post2.getSpec().setPublishTime(Instant.parse("2022-12-03T00:00:00Z"));
post3.getMetadata().setCreationTimestamp(Instant.now());
return List.of(post1, post2, post3);
}
List<Post> posts() {
// 置顶的排前面按 priority 排序
// 再根据创建时间排序

View File

@ -55,6 +55,12 @@ class IndexRouteStrategyTest extends RouterStrategyTestSuite {
.expectStatus()
.isOk();
client.get()
.uri("/page/1")
.exchange()
.expectStatus()
.isOk();
client.get()
.uri("/nothing")
.exchange()