feat: support obtaining the previous post and next post according to post name (#2636)

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

#### What this PR does / why we need it:
支持通过文章名称获取上一篇和下一篇文章数据

本 PR 通过实现一个固定大小的滑动窗口数据结构来通过传入的文章名称获取上一篇和下一篇文章
例如当具有一系列文章名为 [a, b, c, d, e, f, g]
查询文章名为 d 的文章的上一篇和下一篇则使用一个固定大小为 3 的滑动窗口, 示意图如下
<img width="526" alt="image" src="https://user-images.githubusercontent.com/38999863/198243133-20f77431-1107-4526-9f4f-6a11c68204e7.png">
通过窗口右移来调整当窗口圈住所需元素 D 时且 元素 D 的位置不是窗口的最后一个元素时得到一个想要的窗口
此时:
1. 如果 D 位于 window 中的第一个元素则 D 没有上一篇
2. 如果 D 位于 window 中间则 window 的第一个元素为 D 的上一篇,最后一个元素为下一篇
3. 如果 D 位于 window 的最后一个位置,则 D 没有下一篇
#### Which issue(s) this PR fixes:

Fixes #2635

#### Special notes for your reviewer:
how to test it?
1. 创建几篇文章然后发布
4. 能通过 `${postFinder.cursor(your-post-name)}` 来获取到带有上一页下一页的文章数据
5. 当文章处于第一条时没有上一页,当文章处于最后一页时没有下一页(可以通过`${postCursor.hasPrevious()}` 和 `${postCursor.hasNext()}` 判断按钮是否展示)

/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
支持通过文章名称获取上一篇和下一篇文章数据
```
pull/2646/head
guqing 2022-10-31 15:44:16 +08:00 committed by GitHub
parent 8b038c1e0a
commit b63131c36e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 194 additions and 0 deletions

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.NavigationPostVo;
import run.halo.app.theme.finders.vo.PostArchiveVo;
import run.halo.app.theme.finders.vo.PostVo;
@ -19,6 +20,8 @@ public interface PostFinder {
ContentVo content(String postName);
NavigationPostVo cursor(String current);
ListResult<PostVo> list(@Nullable Integer page, @Nullable Integer size);
ListResult<PostVo> listByCategory(@Nullable Integer page, @Nullable Integer size,

View File

@ -1,7 +1,10 @@
package run.halo.app.theme.finders.impl;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -10,8 +13,10 @@ import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.lang.NonNull;
import org.springframework.util.comparator.Comparators;
import reactor.util.function.Tuple2;
import run.halo.app.content.ContentService;
import run.halo.app.core.extension.Counter;
import run.halo.app.core.extension.Post;
@ -28,6 +33,7 @@ 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.NavigationPostVo;
import run.halo.app.theme.finders.vo.PostArchiveVo;
import run.halo.app.theme.finders.vo.PostArchiveYearMonthVo;
import run.halo.app.theme.finders.vo.PostVo;
@ -94,6 +100,107 @@ public class PostFinderImpl implements PostFinder {
.block();
}
@Override
public NavigationPostVo cursor(String currentName) {
// TODO Optimize the post names query here
List<String> postNames = client.list(Post.class, FIXED_PREDICATE, defaultComparator())
.map(post -> post.getMetadata().getName())
.collectList()
.block();
if (postNames == null) {
return NavigationPostVo.empty();
}
NavigationPostVo.NavigationPostVoBuilder builder = NavigationPostVo.builder()
.current(getByName(currentName));
Pair<String, String> previousNextPair = postPreviousNextPair(postNames, currentName);
String previousPostName = previousNextPair.getLeft();
String nextPostName = previousNextPair.getRight();
if (previousPostName != null) {
builder.previous(getByName(previousPostName));
}
if (nextPostName != null) {
builder.next(getByName(nextPostName));
}
return builder.build();
}
static Pair<String, String> postPreviousNextPair(List<String> postNames,
String currentName) {
FixedSizeSlidingWindow<String> window = new FixedSizeSlidingWindow<>(3);
for (String postName : postNames) {
window.add(postName);
if (!window.isFull()) {
continue;
}
int index = window.indexOf(currentName);
if (index == -1) {
continue;
}
// got expected window
if (index < 2) {
break;
}
}
List<String> elements = window.elements();
Tuple2<String, String> previousNext;
// current post index
int index = elements.indexOf(currentName);
String previousPostName = null;
if (index != 0) {
previousPostName = elements.get(index - 1);
}
String nextPostName = null;
if (elements.size() - 1 > index) {
nextPostName = elements.get(index + 1);
}
return Pair.of(previousPostName, nextPostName);
}
static class FixedSizeSlidingWindow<T> {
Deque<T> queue;
int size;
public FixedSizeSlidingWindow(int size) {
this.size = size;
// FIFO
queue = new ArrayDeque<>(size);
}
/**
* Add element to the window.
* The element added first will be deleted when the element in the collection exceeds
* {@code size}.
*/
public void add(T t) {
if (queue.size() == size) {
// remove first
queue.poll();
}
// add to last
queue.add(t);
}
public int indexOf(T o) {
List<T> elements = elements();
return elements.indexOf(o);
}
public List<T> elements() {
return new ArrayList<>(queue);
}
public boolean isFull() {
return queue.size() == size;
}
}
@Override
public ListResult<PostVo> list(Integer page, Integer size) {
return listPost(page, size, null, defaultComparator());

View File

@ -0,0 +1,33 @@
package run.halo.app.theme.finders.vo;
import lombok.Builder;
import lombok.Value;
/**
* Post navigation vo to hold previous and next item.
*
* @author guqing
* @since 2.0.0
*/
@Value
@Builder
public class NavigationPostVo {
PostVo previous;
PostVo current;
PostVo next;
public boolean hasNext() {
return next != null;
}
public boolean hasPrevious() {
return previous != null;
}
public static NavigationPostVo empty() {
return NavigationPostVo.builder().build();
}
}

View File

@ -7,8 +7,11 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.logging.log4j.util.Strings;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -114,6 +117,54 @@ class PostFinderImplTest {
assertThat(items.get(1).getMonths().get(0).getMonth()).isEqualTo("01");
}
@Test
void fixedSizeSlidingWindow() {
PostFinderImpl.FixedSizeSlidingWindow<Integer>
window = new PostFinderImpl.FixedSizeSlidingWindow<>(3);
List<String> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
window.add(i);
list.add(Strings.join(window.elements(), ','));
}
assertThat(list).isEqualTo(
List.of("0", "0,1", "0,1,2", "1,2,3", "2,3,4", "3,4,5", "4,5,6", "5,6,7", "6,7,8",
"7,8,9")
);
}
@Test
void postPreviousNextPair() {
List<String> postNames = new ArrayList<>();
for (int i = 0; i < 10; i++) {
postNames.add("post-" + i);
}
// post-0, post-1, post-2
Pair<String, String> previousNextPair =
PostFinderImpl.postPreviousNextPair(postNames, "post-0");
assertThat(previousNextPair.getLeft()).isNull();
assertThat(previousNextPair.getRight()).isEqualTo("post-1");
previousNextPair = PostFinderImpl.postPreviousNextPair(postNames, "post-1");
assertThat(previousNextPair.getLeft()).isEqualTo("post-0");
assertThat(previousNextPair.getRight()).isEqualTo("post-2");
// post-1, post-2, post-3
previousNextPair = PostFinderImpl.postPreviousNextPair(postNames, "post-2");
assertThat(previousNextPair.getLeft()).isEqualTo("post-1");
assertThat(previousNextPair.getRight()).isEqualTo("post-3");
// post-7, post-8, post-9
previousNextPair = PostFinderImpl.postPreviousNextPair(postNames, "post-8");
assertThat(previousNextPair.getLeft()).isEqualTo("post-7");
assertThat(previousNextPair.getRight()).isEqualTo("post-9");
previousNextPair = PostFinderImpl.postPreviousNextPair(postNames, "post-9");
assertThat(previousNextPair.getLeft()).isEqualTo("post-8");
assertThat(previousNextPair.getRight()).isNull();
}
List<Post> postsForArchives() {
Post post1 = post(1);
post1.getSpec().setPublished(true);