rules = new ArrayList<>();
+ rules.add(rule);
+ return new RssCacheClearRequested(source, rules, false);
+ }
+}
diff --git a/api/src/main/java/run/halo/feed/RssRouteItem.java b/api/src/main/java/run/halo/feed/RssRouteItem.java
new file mode 100644
index 0000000..9d079e3
--- /dev/null
+++ b/api/src/main/java/run/halo/feed/RssRouteItem.java
@@ -0,0 +1,40 @@
+package run.halo.feed;
+
+import org.pf4j.ExtensionPoint;
+import org.springframework.lang.NonNull;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import reactor.core.publisher.Mono;
+
+public interface RssRouteItem extends ExtensionPoint {
+
+ /**
+ * Path pattern of this route.
+ * If return {@link Mono#empty()}, the route will be ignored.
+ * Otherwise, the route will be registered with the returned path pattern by rule: {@code
+ * /feed/[namespace]/{pathPattern}}.
+ */
+ Mono pathPattern();
+
+ @NonNull
+ String displayName();
+
+ default String description() {
+ return "";
+ }
+
+ /**
+ * An example URI for this route.
+ */
+ default String example() {
+ return "";
+ }
+
+ /**
+ * The namespace of this route to avoid conflicts.
+ */
+ default String namespace() {
+ return "";
+ }
+
+ Mono handler(ServerRequest request);
+}
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..6ec86ca
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,34 @@
+plugins {
+ id 'java'
+ id "io.freefair.lombok" version "8.0.0-rc2"
+ id "run.halo.plugin.devtools" version "0.4.1"
+}
+
+group = 'run.halo.feed'
+version = rootProject.version
+
+jar {
+ dependsOn(":api:jar")
+}
+
+dependencies {
+ implementation project(':api')
+ compileOnly 'run.halo.app:api'
+ implementation 'org.dom4j:dom4j:2.1.3'
+ implementation('org.apache.commons:commons-text:1.10.0') {
+ // Because the transitive dependency already exists in halo.
+ exclude group: 'org.apache.commons', module: 'commons-lang3'
+ }
+
+ testImplementation 'run.halo.app:api'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+}
+
+test {
+ useJUnitPlatform()
+}
+
+halo {
+ version = '2.20.11'
+ debug = true
+}
\ No newline at end of file
diff --git a/app/src/main/java/run/halo/feed/BasicProp.java b/app/src/main/java/run/halo/feed/BasicProp.java
new file mode 100644
index 0000000..928d27a
--- /dev/null
+++ b/app/src/main/java/run/halo/feed/BasicProp.java
@@ -0,0 +1,38 @@
+package run.halo.feed;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+import reactor.core.publisher.Mono;
+import run.halo.app.plugin.ReactiveSettingFetcher;
+
+@Data
+@Accessors(chain = true)
+public class BasicProp {
+ private boolean enableCategories = Boolean.TRUE;
+ private boolean enableAuthors = Boolean.TRUE;
+ private String descriptionType;
+ private Integer outputNum;
+ private String rssExtraTags;
+
+ public boolean isExcerptDescriptionType() {
+ return DescriptionType.EXCERPT.name().equalsIgnoreCase(descriptionType);
+ }
+
+ public enum DescriptionType {
+ EXCERPT,
+ CONTENT
+ }
+
+ public static Mono getBasicProp(ReactiveSettingFetcher settingFetcher) {
+ return settingFetcher.fetch("basic", BasicProp.class)
+ .defaultIfEmpty(new BasicProp())
+ .doOnNext(prop -> {
+ if (prop.getOutputNum() == null) {
+ prop.setOutputNum(20);
+ }
+ if (prop.getDescriptionType() == null) {
+ prop.setDescriptionType(DescriptionType.EXCERPT.name());
+ }
+ });
+ }
+}
diff --git a/src/main/java/run/halo/feed/FeedPlugin.java b/app/src/main/java/run/halo/feed/FeedPlugin.java
similarity index 100%
rename from src/main/java/run/halo/feed/FeedPlugin.java
rename to app/src/main/java/run/halo/feed/FeedPlugin.java
diff --git a/app/src/main/java/run/halo/feed/FeedPluginEndpoint.java b/app/src/main/java/run/halo/feed/FeedPluginEndpoint.java
new file mode 100644
index 0000000..ce1ada9
--- /dev/null
+++ b/app/src/main/java/run/halo/feed/FeedPluginEndpoint.java
@@ -0,0 +1,112 @@
+package run.halo.feed;
+
+import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
+import static org.springframework.web.reactive.function.server.RequestPredicates.path;
+
+import lombok.AllArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.context.annotation.Bean;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.lang.NonNull;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.HandlerFunction;
+import org.springframework.web.reactive.function.server.RequestPredicate;
+import org.springframework.web.reactive.function.server.RouterFunction;
+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 reactor.core.publisher.Mono;
+import run.halo.app.plugin.extensionpoint.ExtensionGetter;
+import run.halo.feed.provider.PostRssProvider;
+
+@Component
+@AllArgsConstructor
+public class FeedPluginEndpoint {
+ static final RequestPredicate ACCEPT_PREDICATE = accept(
+ MediaType.TEXT_XML,
+ MediaType.APPLICATION_RSS_XML
+ );
+
+ private final PostRssProvider postRssProvider;
+ private final ExtensionGetter extensionGetter;
+ private final RssCacheManager rssCacheManager;
+
+ @Bean
+ RouterFunction rssRouterFunction() {
+ return RouterFunctions.route()
+ .GET(path("/feed.xml").or(path("/rss.xml")).and(ACCEPT_PREDICATE),
+ request -> rssCacheManager.get("/rss.xml", postRssProvider.handler(request))
+ .flatMap(this::buildResponse)
+ )
+ .build();
+ }
+
+ @Bean
+ RouterFunction additionalRssRouter() {
+ var pathMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/feed/**");
+ return new RouterFunction<>() {
+ @Override
+ @NonNull
+ public Mono> route(@NonNull ServerRequest request) {
+ return pathMatcher.matches(request.exchange())
+ .filter(ServerWebExchangeMatcher.MatchResult::isMatch)
+ .flatMap(matched -> handleRequest(request));
+ }
+
+ private Mono> handleRequest(ServerRequest request) {
+ return extensionGetter.getEnabledExtensions(RssRouteItem.class)
+ .concatMap(routeItem -> buildRequestPredicate(routeItem)
+ .map(requestPredicate -> new RouteItem(requestPredicate,
+ buildHandleFunction(routeItem))
+ )
+ )
+ .filter(route -> route.requestPredicate.test(request))
+ .next()
+ .map(RouteItem::handler);
+ }
+
+ record RouteItem(RequestPredicate requestPredicate,
+ HandlerFunction handler) {
+ }
+
+ private HandlerFunction buildHandleFunction(RssRouteItem routeItem) {
+ return request -> rssCacheManager.get(request.path(), routeItem.handler(request))
+ .flatMap(item -> buildResponse(item));
+ }
+
+ private Mono buildRequestPredicate(RssRouteItem routeItem) {
+ return routeItem.pathPattern()
+ .map(pathPattern -> path(
+ buildPathPattern(pathPattern, routeItem.namespace()))
+ .and(ACCEPT_PREDICATE)
+ );
+ }
+
+ private String buildPathPattern(String pathPattern, String namespace) {
+ var sb = new StringBuilder("/feed/");
+
+ if (StringUtils.isNotBlank(namespace)) {
+ sb.append(namespace);
+ if (!namespace.endsWith("/")) {
+ sb.append("/");
+ }
+ }
+
+ if (pathPattern.startsWith("/")) {
+ pathPattern = pathPattern.substring(1);
+ }
+ sb.append(pathPattern);
+
+ return sb.toString();
+ }
+ };
+ }
+
+ private Mono buildResponse(String xml) {
+ return ServerResponse.ok().contentType(MediaType.TEXT_XML)
+ .bodyValue(xml);
+ }
+}
diff --git a/app/src/main/java/run/halo/feed/RssCacheManager.java b/app/src/main/java/run/halo/feed/RssCacheManager.java
new file mode 100644
index 0000000..0705502
--- /dev/null
+++ b/app/src/main/java/run/halo/feed/RssCacheManager.java
@@ -0,0 +1,93 @@
+package run.halo.feed;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import java.time.Duration;
+import java.util.function.Predicate;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+import run.halo.app.event.post.PostUpdatedEvent;
+import run.halo.app.infra.SystemInfoGetter;
+import run.halo.app.plugin.PluginConfigUpdatedEvent;
+import run.halo.app.plugin.ReactiveSettingFetcher;
+
+@Component
+@RequiredArgsConstructor
+public class RssCacheManager {
+ private final Duration expireMinutes = Duration.ofMinutes(60);
+ private final Cache cache = CacheBuilder.newBuilder()
+ .expireAfterWrite(expireMinutes)
+ .build();
+
+ private final SystemInfoGetter systemInfoGetter;
+ private final ReactiveSettingFetcher settingFetcher;
+
+ public Mono get(String key, Mono loader) {
+ return Mono.fromCallable(() -> cache.get(key, () -> generateRssXml(loader)
+ .doOnNext(xml -> cache.put(key, xml))
+ .block()
+ ))
+ .cache()
+ .subscribeOn(Schedulers.boundedElastic());
+ }
+
+ private Mono generateRssXml(Mono loader) {
+ var builder = new RssXmlBuilder();
+ var rssMono = loader.doOnNext(builder::withRss2);
+ var generatorMono = getRssGenerator()
+ .doOnNext(builder::withGenerator);
+ var extractTagsMono = BasicProp.getBasicProp(settingFetcher)
+ .doOnNext(prop -> builder.withExtractRssTags(prop.getRssExtraTags()));
+ return Mono.when(rssMono, generatorMono, extractTagsMono)
+ .then(Mono.fromSupplier(builder::toXmlString));
+ }
+
+ private Mono getRssGenerator() {
+ return systemInfoGetter.get()
+ .map(info -> "Halo v" + info.getVersion().toStableVersion().toString())
+ .defaultIfEmpty("Halo v2.0");
+ }
+
+ @EventListener(PluginConfigUpdatedEvent.class)
+ public void onPluginConfigUpdated() {
+ cache.invalidateAll();
+ }
+
+ @EventListener(RssCacheClearRequested.class)
+ public void onCacheClearRequested(RssCacheClearRequested event) {
+ if (event.isApplyToAll()) {
+ cache.invalidateAll();
+ return;
+ }
+ event.getRules().forEach(rule -> {
+ switch (rule.type()) {
+ case PREFIX:
+ invalidateCache(key -> key.startsWith(rule.value()));
+ break;
+ case EXACT:
+ invalidateCache(key -> key.equals(rule.value()));
+ break;
+ case CONTAINS:
+ invalidateCache(key -> key.contains(rule.value()));
+ break;
+ default:
+ break;
+ }
+ });
+ }
+
+ @EventListener(PostUpdatedEvent.class)
+ public void onPostUpdated() {
+ invalidateCache(key -> key.equals("/rss.xml")
+ || key.startsWith("/feed/authors/")
+ || key.startsWith("/feed/categories/")
+ );
+ }
+
+ private void invalidateCache(Predicate predicate) {
+ cache.asMap().keySet().removeIf(predicate);
+ }
+}
diff --git a/app/src/main/java/run/halo/feed/RssXmlBuilder.java b/app/src/main/java/run/halo/feed/RssXmlBuilder.java
new file mode 100644
index 0000000..a76bc39
--- /dev/null
+++ b/app/src/main/java/run/halo/feed/RssXmlBuilder.java
@@ -0,0 +1,212 @@
+package run.halo.feed;
+
+import com.google.common.base.Throwables;
+import java.io.StringReader;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.dom4j.Document;
+import org.dom4j.DocumentException;
+import org.dom4j.DocumentHelper;
+import org.dom4j.Element;
+import org.dom4j.io.SAXReader;
+import org.springframework.util.CollectionUtils;
+
+@Slf4j
+public class RssXmlBuilder {
+ private RSS2 rss2;
+ private String generator = "Halo v2.0";
+ private String extractRssTags;
+ private Instant lastBuildDate = Instant.now();
+
+ public RssXmlBuilder withRss2(RSS2 rss2) {
+ this.rss2 = rss2;
+ return this;
+ }
+
+ /**
+ * For test.
+ */
+ RssXmlBuilder withGenerator(String generator) {
+ this.generator = generator;
+ return this;
+ }
+
+ public RssXmlBuilder withExtractRssTags(String extractRssTags) {
+ this.extractRssTags = extractRssTags;
+ return this;
+ }
+
+ /**
+ * For test.
+ */
+ RssXmlBuilder withLastBuildDate(Instant lastBuildDate) {
+ this.lastBuildDate = lastBuildDate;
+ return this;
+ }
+
+ public String toXmlString() {
+ Document document = DocumentHelper.createDocument();
+
+ Element root = DocumentHelper.createElement("rss");
+ root.addAttribute("version", "2.0");
+ root.addNamespace("media", "http://search.yahoo.com/mrss/");
+ document.setRootElement(root);
+
+ Element channel = root.addElement("channel");
+ channel.addElement("title").addText(rss2.getTitle());
+ channel.addElement("link").addText(rss2.getLink());
+
+ var description = StringUtils.defaultIfBlank(rss2.getDescription(), rss2.getTitle());
+ var secureDescription = XmlCharUtils.removeInvalidXmlChar(description);
+ channel.addElement("description").addText(secureDescription);
+ channel.addElement("generator").addText(generator);
+
+ channel.addElement("language")
+ .addText(StringUtils.defaultIfBlank(rss2.getLanguage(), "zh-cn"));
+
+ if (StringUtils.isNotBlank(rss2.getImage())) {
+ Element imageElement = channel.addElement("image");
+ imageElement.addElement("url").addText(rss2.getImage());
+ imageElement.addElement("title").addText(rss2.getTitle());
+ imageElement.addElement("link").addText(rss2.getLink());
+ }
+ channel.addElement("lastBuildDate")
+ .addText(instantToString(lastBuildDate));
+
+ if (StringUtils.isNotBlank(extractRssTags)) {
+ try {
+ var extractRssTagsElement = parseXmlString(extractRssTags);
+ for (Element element : extractRssTagsElement.elements()) {
+ Element newElement = channel.addElement(element.getName());
+ copyAttributesAndChildren(newElement, element);
+ }
+ } catch (Throwable e) {
+ // ignore
+ log.error("无法注入自定义的 RSS 标签, 确保是正确的 XML 格式",
+ Throwables.getRootCause(e));
+ }
+ }
+
+ var items = rss2.getItems();
+ createItemElementsToChannel(channel, items);
+
+ return document.asXML();
+ }
+
+ private void copyAttributesAndChildren(Element target, Element source) {
+ for (var attribute : source.attributes()) {
+ target.addAttribute(attribute.getName(), attribute.getValue());
+ }
+ if (source.getTextTrim() != null) {
+ target.setText(source.getTextTrim());
+ }
+ for (Element child : source.elements()) {
+ Element newChild = target.addElement(child.getName());
+ copyAttributesAndChildren(newChild, child);
+ }
+ }
+
+ private Element parseXmlString(String xml) throws DocumentException {
+ // for SAXReader to load class from current classloader
+ var originalContextClassloader = Thread.currentThread().getContextClassLoader();
+ Thread.currentThread().setContextClassLoader(this.getClass().getClassLoader());
+ try {
+ SAXReader reader = new SAXReader();
+ Document document = reader.read(new StringReader("""
+
+ %s
+
+ """.formatted(xml)));
+ return document.getRootElement();
+ } finally {
+ Thread.currentThread().setContextClassLoader(originalContextClassloader);
+ }
+ }
+
+ private static void createItemElementsToChannel(Element channel, List items) {
+ if (CollectionUtils.isEmpty(items)) {
+ return;
+ }
+ items.forEach(item -> createItemElementToChannel(channel, item));
+ }
+
+ private static void createItemElementToChannel(Element channel, RSS2.Item item) {
+ Element itemElement = channel.addElement("item");
+ itemElement.addElement("title").addCDATA(item.getTitle());
+ itemElement.addElement("link").addText(item.getLink());
+ itemElement.addElement("description").addCDATA(item.getDescription());
+ itemElement.addElement("guid")
+ .addAttribute("isPermaLink", "false")
+ .addText(item.getGuid());
+
+ if (StringUtils.isNotBlank(item.getAuthor())) {
+ itemElement.addElement("author").addText(item.getAuthor());
+ }
+
+ if (StringUtils.isNotBlank(item.getEnclosureUrl())) {
+ itemElement.addElement("enclosure")
+ .addAttribute("url", item.getEnclosureUrl())
+ .addAttribute("length", item.getEnclosureLength())
+ .addAttribute("type", item.getEnclosureType());
+ }
+
+ nullSafeList(item.getCategories())
+ .forEach(category -> itemElement.addElement("category").addText(category));
+
+ itemElement.addElement("pubDate")
+ .addText(instantToString(item.getPubDate()));
+
+ // support for media:content
+ nullSafeList(item.getMediaContents()).forEach(mediaContent -> {
+ Element mediaElement = itemElement.addElement("media:content")
+ .addAttribute("url", mediaContent.getUrl())
+ .addAttribute("type", mediaContent.getType());
+
+ if (mediaContent.getMediaType() != null) {
+ mediaElement.addAttribute("medium",
+ mediaContent.getMediaType().name().toLowerCase());
+ }
+
+ if (StringUtils.isNotBlank(mediaContent.getFileSize())) {
+ mediaElement.addAttribute("fileSize", mediaContent.getFileSize());
+ }
+ if (StringUtils.isNotBlank(mediaContent.getDuration())) {
+ mediaElement.addAttribute("duration", mediaContent.getDuration());
+ }
+ if (StringUtils.isNotBlank(mediaContent.getHeight())) {
+ mediaElement.addAttribute("height", mediaContent.getHeight());
+ }
+ if (StringUtils.isNotBlank(mediaContent.getWidth())) {
+ mediaElement.addAttribute("width", mediaContent.getWidth());
+ }
+
+ // add nested media:thumbnail
+ var thumbnail = mediaContent.getThumbnail();
+ if (thumbnail != null) {
+ Element thumbnailElement = mediaElement.addElement("media:thumbnail")
+ .addAttribute("url", thumbnail.getUrl());
+
+ if (StringUtils.isNotBlank(thumbnail.getHeight())) {
+ thumbnailElement.addAttribute("height", thumbnail.getHeight());
+ }
+
+ if (StringUtils.isNotBlank(thumbnail.getWidth())) {
+ thumbnailElement.addAttribute("width", thumbnail.getWidth());
+ }
+ }
+ });
+ }
+
+ static List nullSafeList(List list) {
+ return list == null ? List.of() : list;
+ }
+
+ static String instantToString(Instant instant) {
+ return instant.atOffset(ZoneOffset.UTC)
+ .format(DateTimeFormatter.RFC_1123_DATE_TIME);
+ }
+}
diff --git a/src/main/java/run/halo/feed/XmlCharUtils.java b/app/src/main/java/run/halo/feed/XmlCharUtils.java
similarity index 100%
rename from src/main/java/run/halo/feed/XmlCharUtils.java
rename to app/src/main/java/run/halo/feed/XmlCharUtils.java
diff --git a/app/src/main/java/run/halo/feed/provider/AbstractPostRssProvider.java b/app/src/main/java/run/halo/feed/provider/AbstractPostRssProvider.java
new file mode 100644
index 0000000..cb9a5f8
--- /dev/null
+++ b/app/src/main/java/run/halo/feed/provider/AbstractPostRssProvider.java
@@ -0,0 +1,151 @@
+package run.halo.feed.provider;
+
+import java.util.List;
+import java.util.function.Function;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Accessors;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.text.StringEscapeUtils;
+import org.springframework.util.CollectionUtils;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.server.ServerWebInputException;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+import run.halo.app.content.ContentWrapper;
+import run.halo.app.content.PostContentService;
+import run.halo.app.core.attachment.ThumbnailSize;
+import run.halo.app.core.extension.User;
+import run.halo.app.core.extension.content.Category;
+import run.halo.app.core.extension.content.Post;
+import run.halo.app.extension.ExtensionUtil;
+import run.halo.app.extension.ListOptions;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.extension.index.query.QueryFactory;
+import run.halo.app.infra.ExternalLinkProcessor;
+import run.halo.app.infra.ExternalUrlSupplier;
+import run.halo.app.infra.SystemInfoGetter;
+import run.halo.app.plugin.ReactiveSettingFetcher;
+import run.halo.feed.BasicProp;
+import run.halo.feed.RSS2;
+import run.halo.feed.service.PostService;
+
+@RequiredArgsConstructor
+public abstract class AbstractPostRssProvider {
+ protected final PostService postService;
+ protected final ReactiveExtensionClient client;
+ protected final ExternalLinkProcessor externalLinkProcessor;
+ protected final ReactiveSettingFetcher settingFetcher;
+ protected final PostContentService postContentService;
+ protected final ExternalUrlSupplier externalUrlSupplier;
+ protected final SystemInfoGetter systemInfoGetter;
+
+ public Mono handler(ServerRequest request) {
+ var builder = RSS2.builder();
+ var rssMono = systemInfoGetter.get()
+ .doOnNext(systemInfo -> builder
+ .title(systemInfo.getTitle())
+ .image(externalLinkProcessor.processLink(systemInfo.getLogo()))
+ .description(
+ StringUtils.defaultIfBlank(systemInfo.getSubtitle(), systemInfo.getTitle()))
+ .link(externalUrlSupplier.getURL(request.exchange().getRequest()).toString())
+ )
+ .subscribeOn(Schedulers.boundedElastic());
+
+ var itemsMono = listPosts(request)
+ .concatMap(postWithContent -> {
+ var post = postWithContent.getPost();
+ var permalink = post.getStatusOrDefault().getPermalink();
+ var itemBuilder = RSS2.Item.builder()
+ .title(post.getSpec().getTitle())
+ .link(externalLinkProcessor.processLink(permalink))
+ .pubDate(post.getSpec().getPublishTime())
+ .guid(post.getStatusOrDefault().getPermalink())
+ .description(postWithContent.getContent());
+
+ if (StringUtils.isNotBlank(post.getSpec().getCover())) {
+ itemBuilder.enclosureUrl(genThumbUrl(post.getSpec().getCover()))
+ .enclosureType("image/jpeg");
+ }
+
+ var ownerName = post.getSpec().getOwner();
+ var userMono = fetchUser(ownerName)
+ .map(user -> user.getSpec().getDisplayName())
+ .doOnNext(itemBuilder::author);
+
+ var categoryMono = fetchCategoryDisplayName(post.getSpec().getCategories())
+ .collectList()
+ .doOnNext(itemBuilder::categories);
+
+ return Mono.when(userMono, categoryMono)
+ .then(Mono.fromSupplier(itemBuilder::build));
+ })
+ .collectList()
+ .doOnNext(builder::items)
+ .subscribeOn(Schedulers.boundedElastic());
+
+ return Mono.when(rssMono, itemsMono)
+ .then(Mono.fromSupplier(builder::build));
+ }
+
+ private String genThumbUrl(String url) {
+ return externalLinkProcessor.processLink(
+ "/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=" + url + "&size="
+ + ThumbnailSize.M.name().toLowerCase()
+ );
+ }
+
+ protected Flux listPosts(ServerRequest request) {
+ return listPostsByFunc(basicProp -> postService.listPosts(basicProp.getOutputNum())
+ .flatMapIterable(ListResult::getItems));
+ }
+
+ protected Flux listPostsByFunc(Function> postListFunc) {
+ return BasicProp.getBasicProp(settingFetcher)
+ .flatMapMany(basicProp -> postListFunc.apply(basicProp)
+ .concatMap(post -> {
+ var postWithContent = new PostWithContent()
+ .setPost(post);
+ if (basicProp.isExcerptDescriptionType()) {
+ // Prevent parsing excerpt as html
+ var escapedContent =
+ StringEscapeUtils.escapeXml10(post.getStatusOrDefault().getExcerpt());
+ postWithContent.setContent(escapedContent);
+ return Mono.just(postWithContent);
+ }
+ return postContentService.getReleaseContent(post.getMetadata().getName())
+ .map(ContentWrapper::getContent)
+ .doOnNext(content -> postWithContent.setContent(
+ StringUtils.defaultString(content))
+ )
+ .thenReturn(postWithContent);
+ })
+ );
+ }
+
+ protected Mono fetchUser(String username) {
+ return client.fetch(User.class, username)
+ .switchIfEmpty(Mono.error(new ServerWebInputException("User not found")))
+ .subscribeOn(Schedulers.boundedElastic());
+ }
+
+ private Flux fetchCategoryDisplayName(List categoryNames) {
+ if (CollectionUtils.isEmpty(categoryNames)) {
+ return Flux.empty();
+ }
+ return client.listAll(Category.class, ListOptions.builder()
+ .fieldQuery(QueryFactory.in("metadata.name", categoryNames))
+ .build(), ExtensionUtil.defaultSort())
+ .map(category -> category.getSpec().getDisplayName())
+ .subscribeOn(Schedulers.boundedElastic());
+ }
+
+ @Data
+ @Accessors(chain = true)
+ protected static class PostWithContent {
+ private Post post;
+ private String content;
+ }
+}
diff --git a/app/src/main/java/run/halo/feed/provider/AuthorPostRssProvider.java b/app/src/main/java/run/halo/feed/provider/AuthorPostRssProvider.java
new file mode 100644
index 0000000..681b7f2
--- /dev/null
+++ b/app/src/main/java/run/halo/feed/provider/AuthorPostRssProvider.java
@@ -0,0 +1,80 @@
+package run.halo.feed.provider;
+
+import org.springframework.lang.NonNull;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.PostContentService;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.infra.ExternalLinkProcessor;
+import run.halo.app.infra.ExternalUrlSupplier;
+import run.halo.app.infra.SystemInfoGetter;
+import run.halo.app.plugin.ReactiveSettingFetcher;
+import run.halo.feed.BasicProp;
+import run.halo.feed.RSS2;
+import run.halo.feed.RssRouteItem;
+import run.halo.feed.service.PostService;
+
+@Component
+public class AuthorPostRssProvider extends AbstractPostRssProvider implements RssRouteItem {
+
+ public AuthorPostRssProvider(PostService postService, ReactiveExtensionClient client,
+ ExternalLinkProcessor externalLinkProcessor, ReactiveSettingFetcher settingFetcher,
+ PostContentService postContentService, ExternalUrlSupplier externalUrlSupplier,
+ SystemInfoGetter systemInfoGetter) {
+ super(postService, client, externalLinkProcessor, settingFetcher, postContentService,
+ externalUrlSupplier, systemInfoGetter);
+ }
+
+ @Override
+ public Mono pathPattern() {
+ return BasicProp.getBasicProp(settingFetcher)
+ .filter(BasicProp::isEnableAuthors)
+ .map(prop -> "/authors/{author}.xml");
+ }
+
+ @Override
+ @NonNull
+ public String displayName() {
+ return "作者文章订阅";
+ }
+
+ @Override
+ public String description() {
+ return "按作者订阅站点文章";
+ }
+
+ @Override
+ public String example() {
+ return "https://exmaple.com/feed/authors/fake.xml";
+ }
+
+ @Override
+ public Mono handler(ServerRequest request) {
+ var authorName = request.pathVariable("author");
+ return super.handler(request)
+ .flatMap(rss2 -> fetchUser(authorName)
+ .doOnNext(author -> {
+ var displayName = author.getSpec().getDisplayName();
+ rss2.setTitle("作者:" + displayName + " - " + rss2.getTitle());
+
+ if (author.getStatus() != null) {
+ rss2.setLink(
+ externalLinkProcessor.processLink(author.getStatus().getPermalink()));
+ }
+ })
+ .thenReturn(rss2)
+ );
+ }
+
+ @Override
+ protected Flux listPosts(ServerRequest request) {
+ return listPostsByFunc(basicProp -> {
+ var author = request.pathVariable("author");
+ return postService.listPostByAuthor(basicProp.getOutputNum(), author)
+ .flatMapIterable(ListResult::getItems);
+ });
+ }
+}
diff --git a/app/src/main/java/run/halo/feed/provider/CategoryPostRssProvider.java b/app/src/main/java/run/halo/feed/provider/CategoryPostRssProvider.java
new file mode 100644
index 0000000..17f74f5
--- /dev/null
+++ b/app/src/main/java/run/halo/feed/provider/CategoryPostRssProvider.java
@@ -0,0 +1,93 @@
+package run.halo.feed.provider;
+
+import org.springframework.lang.NonNull;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.server.ServerWebInputException;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.PostContentService;
+import run.halo.app.core.extension.content.Category;
+import run.halo.app.extension.ListOptions;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.PageRequestImpl;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.extension.index.query.QueryFactory;
+import run.halo.app.infra.ExternalLinkProcessor;
+import run.halo.app.infra.ExternalUrlSupplier;
+import run.halo.app.infra.SystemInfoGetter;
+import run.halo.app.plugin.ReactiveSettingFetcher;
+import run.halo.feed.BasicProp;
+import run.halo.feed.RSS2;
+import run.halo.feed.RssRouteItem;
+import run.halo.feed.service.PostService;
+
+@Component
+public class CategoryPostRssProvider extends AbstractPostRssProvider implements RssRouteItem {
+
+ public CategoryPostRssProvider(PostService postService, ReactiveExtensionClient client,
+ ExternalLinkProcessor externalLinkProcessor, ReactiveSettingFetcher settingFetcher,
+ PostContentService postContentService, ExternalUrlSupplier externalUrlSupplier,
+ SystemInfoGetter systemInfoGetter) {
+ super(postService, client, externalLinkProcessor, settingFetcher, postContentService,
+ externalUrlSupplier, systemInfoGetter);
+ }
+
+ @Override
+ public Mono pathPattern() {
+ return BasicProp.getBasicProp(settingFetcher)
+ .filter(BasicProp::isEnableCategories)
+ .map(prop -> "/categories/{slug}.xml");
+ }
+
+ @Override
+ @NonNull
+ public String displayName() {
+ return "分类文章订阅";
+ }
+
+ @Override
+ public String description() {
+ return "按分类订阅站点文章";
+ }
+
+ @Override
+ public String example() {
+ return "https://exmaple.com/feed/categories/fake.xml";
+ }
+
+ @Override
+ public Mono handler(ServerRequest request) {
+ var slug = request.pathVariable("slug");
+ return super.handler(request)
+ .flatMap(rss2 -> getCategoryDisplayName(slug)
+ .doOnNext(category -> {
+ var displayName = category.getSpec().getDisplayName();
+ var permalink = category.getStatusOrDefault().getPermalink();
+ rss2.setTitle("分类:" + displayName + " - " + rss2.getTitle());
+ rss2.setLink(externalLinkProcessor.processLink(permalink));
+ })
+ .thenReturn(rss2)
+ );
+ }
+
+ private Mono getCategoryDisplayName(String categorySlug) {
+ return client.listBy(Category.class, ListOptions.builder()
+ .fieldQuery(QueryFactory.equal("spec.slug", categorySlug))
+ .build(),
+ PageRequestImpl.ofSize(1)
+ )
+ .flatMapIterable(ListResult::getItems)
+ .next()
+ .switchIfEmpty(Mono.error(new ServerWebInputException("Category not found")));
+ }
+
+ @Override
+ protected Flux listPosts(ServerRequest request) {
+ var categorySlug = request.pathVariable("slug");
+ return listPostsByFunc(basicProp ->
+ postService.listPostByCategorySlug(basicProp.getOutputNum(), categorySlug)
+ .flatMapIterable(ListResult::getItems)
+ );
+ }
+}
diff --git a/app/src/main/java/run/halo/feed/provider/PostRssProvider.java b/app/src/main/java/run/halo/feed/provider/PostRssProvider.java
new file mode 100644
index 0000000..b0712aa
--- /dev/null
+++ b/app/src/main/java/run/halo/feed/provider/PostRssProvider.java
@@ -0,0 +1,22 @@
+package run.halo.feed.provider;
+
+import org.springframework.stereotype.Component;
+import run.halo.app.content.PostContentService;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.infra.ExternalLinkProcessor;
+import run.halo.app.infra.ExternalUrlSupplier;
+import run.halo.app.infra.SystemInfoGetter;
+import run.halo.app.plugin.ReactiveSettingFetcher;
+import run.halo.feed.service.PostService;
+
+@Component
+public class PostRssProvider extends AbstractPostRssProvider {
+
+ public PostRssProvider(PostService postService, ReactiveExtensionClient client,
+ ExternalLinkProcessor externalLinkProcessor, ReactiveSettingFetcher settingFetcher,
+ PostContentService postContentService, ExternalUrlSupplier externalUrlSupplier,
+ SystemInfoGetter systemInfoGetter) {
+ super(postService, client, externalLinkProcessor, settingFetcher, postContentService,
+ externalUrlSupplier, systemInfoGetter);
+ }
+}
diff --git a/app/src/main/java/run/halo/feed/service/PostService.java b/app/src/main/java/run/halo/feed/service/PostService.java
new file mode 100644
index 0000000..befc542
--- /dev/null
+++ b/app/src/main/java/run/halo/feed/service/PostService.java
@@ -0,0 +1,16 @@
+package run.halo.feed.service;
+
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.content.Category;
+import run.halo.app.core.extension.content.Post;
+import run.halo.app.extension.ListResult;
+
+public interface PostService {
+ Mono> listPosts(int size);
+
+ Mono> listPostByCategorySlug(int size, String categorySlug);
+
+ Mono> listPostByAuthor(int size, String author);
+
+ Mono getCategoryBySlug(String categorySlug);
+}
diff --git a/app/src/main/java/run/halo/feed/service/PostServiceImpl.java b/app/src/main/java/run/halo/feed/service/PostServiceImpl.java
new file mode 100644
index 0000000..599cf1c
--- /dev/null
+++ b/app/src/main/java/run/halo/feed/service/PostServiceImpl.java
@@ -0,0 +1,84 @@
+package run.halo.feed.service;
+
+import static run.halo.app.extension.index.query.QueryFactory.and;
+import static run.halo.app.extension.index.query.QueryFactory.equal;
+import static run.halo.app.extension.index.query.QueryFactory.in;
+import static run.halo.app.extension.index.query.QueryFactory.isNull;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.content.Category;
+import run.halo.app.core.extension.content.Post;
+import run.halo.app.extension.ListOptions;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.PageRequestImpl;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.extension.index.query.Query;
+
+@Component
+@RequiredArgsConstructor
+public class PostServiceImpl implements PostService {
+ private final ReactiveExtensionClient client;
+
+ @Override
+ public Mono> listPosts(int size) {
+ return client.listBy(Post.class, buildPostListOptions(),
+ PageRequestImpl.ofSize(size).withSort(defaultSort()));
+ }
+
+ @Override
+ public Mono> listPostByCategorySlug(int size, String categorySlug) {
+ return getCategoryBySlug(categorySlug)
+ .flatMap(category -> {
+ var categoryName = category.getMetadata().getName();
+ return client.listBy(Post.class,
+ buildPostListOptions(in("spec.categories", categoryName)),
+ PageRequestImpl.ofSize(size).withSort(defaultSort()));
+ });
+ }
+
+ @Override
+ public Mono> listPostByAuthor(int size, String author) {
+ return client.listBy(Post.class, buildPostListOptions(in("spec.owner", author)),
+ PageRequestImpl.ofSize(size).withSort(defaultSort()));
+ }
+
+ @Override
+ public Mono getCategoryBySlug(String categorySlug) {
+ return client.listBy(Category.class, ListOptions.builder()
+ .fieldQuery(equal("spec.slug", categorySlug))
+ .build(),
+ PageRequestImpl.ofSize(1)
+ )
+ .flatMap(listResult -> Mono.justOrEmpty(ListResult.first(listResult)));
+ }
+
+ ListOptions buildPostListOptions() {
+ return buildPostListOptions(null);
+ }
+
+ ListOptions buildPostListOptions(Query query) {
+ var builder = ListOptions.builder()
+ .labelSelector()
+ .eq(Post.PUBLISHED_LABEL, "true")
+ .end()
+ .fieldQuery(and(
+ isNull("metadata.deletionTimestamp"),
+ equal("spec.deleted", "false"),
+ equal("spec.visible", Post.VisibleEnum.PUBLIC.name())
+ ));
+ if (query != null) {
+ builder.fieldQuery(query);
+ }
+ return builder.build();
+ }
+
+ static Sort defaultSort() {
+ return Sort.by(
+ Sort.Order.desc("spec.publishTime"),
+ Sort.Order.asc("metadata.name")
+ );
+ }
+}
diff --git a/app/src/main/resources/extensions/ext-definition.yaml b/app/src/main/resources/extensions/ext-definition.yaml
new file mode 100644
index 0000000..4e704de
--- /dev/null
+++ b/app/src/main/resources/extensions/ext-definition.yaml
@@ -0,0 +1,30 @@
+apiVersion: plugin.halo.run/v1alpha1
+kind: ExtensionPointDefinition
+metadata:
+ name: feed-rss-route-item
+spec:
+ className: run.halo.feed.RssRouteItem
+ displayName: "RSS 订阅源"
+ description: "用于扩展 RSS 订阅源的生成"
+ type: MULTI_INSTANCE
+ icon: "/plugins/PluginFeed/assets/logo.svg"
+---
+apiVersion: plugin.halo.run/v1alpha1
+kind: ExtensionDefinition
+metadata:
+ name: feed-category-post-rss-item
+spec:
+ className: run.halo.feed.provider.CategoryPostRssProvider
+ extensionPointName: feed-rss-route-item
+ displayName: "分类文章订阅"
+ description: "用于生成按照分类订阅文章的 RSS 订阅源"
+---
+apiVersion: plugin.halo.run/v1alpha1
+kind: ExtensionDefinition
+metadata:
+ name: feed-author-post-rss-item
+spec:
+ className: run.halo.feed.provider.AuthorPostRssProvider
+ extensionPointName: feed-rss-route-item
+ displayName: "作者文章订阅"
+ description: "用于生成按照作者订阅文章的 RSS 订阅源"
diff --git a/src/main/resources/extensions/settings.yaml b/app/src/main/resources/extensions/settings.yaml
similarity index 84%
rename from src/main/resources/extensions/settings.yaml
rename to app/src/main/resources/extensions/settings.yaml
index 93728b8..6ee5bdb 100644
--- a/src/main/resources/extensions/settings.yaml
+++ b/app/src/main/resources/extensions/settings.yaml
@@ -32,3 +32,9 @@ spec:
label: 内容输出条数
value: 20
validation: required|number|min:1
+ - $formkit: code
+ name: rssExtraTags
+ label: 注入额外的 RSS 标签
+ value: ""
+ height: 150px
+ language: html
diff --git a/src/main/resources/logo.svg b/app/src/main/resources/logo.svg
similarity index 100%
rename from src/main/resources/logo.svg
rename to app/src/main/resources/logo.svg
diff --git a/src/main/resources/plugin.yaml b/app/src/main/resources/plugin.yaml
similarity index 94%
rename from src/main/resources/plugin.yaml
rename to app/src/main/resources/plugin.yaml
index b9cfcdc..fa5c6be 100644
--- a/src/main/resources/plugin.yaml
+++ b/app/src/main/resources/plugin.yaml
@@ -8,8 +8,7 @@ metadata:
"store.halo.run/app-id": "app-KhIVw"
spec:
enabled: true
- version: 1.1.1
- requires: ">=2.10.0"
+ requires: ">=2.20.11"
author:
name: Halo
website: https://github.com/halo-dev
diff --git a/app/src/test/java/run/halo/feed/RSS2Test.java b/app/src/test/java/run/halo/feed/RSS2Test.java
new file mode 100644
index 0000000..7804de4
--- /dev/null
+++ b/app/src/test/java/run/halo/feed/RSS2Test.java
@@ -0,0 +1,132 @@
+package run.halo.feed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Instant;
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+
+class RSS2Test {
+
+ @Test
+ void toXmlString() {
+ var rss = RSS2.builder()
+ .title("title")
+ .description("description")
+ .link("link")
+ .items(Arrays.asList(
+ RSS2.Item.builder()
+ .title("title1")
+ .description("description1")
+ .link("link1")
+ .pubDate(Instant.EPOCH)
+ .guid("guid1")
+ .build(),
+ RSS2.Item.builder()
+ .title("title2")
+ .description("description2")
+ .link("link2")
+ .pubDate(Instant.ofEpochSecond(2208988800L))
+ .guid("guid2")
+ .build()
+ ))
+ .build();
+
+ var instant = Instant.now();
+ var rssXml = new RssXmlBuilder()
+ .withRss2(rss)
+ .withGenerator("Halo")
+ .withLastBuildDate(instant)
+ .toXmlString();
+
+ var lastBuildDate = RssXmlBuilder.instantToString(instant);
+
+ // language=xml
+ var expected = """
+
+
+
+ title
+ link
+ description
+ Halo
+ zh-cn
+ %s
+ -
+
+
+
+ link1
+
+
+
+ guid1
+ Thu, 1 Jan 1970 00:00:00 GMT
+
+ -
+
+
+
+ link2
+
+
+
+ guid2
+ Sun, 1 Jan 2040 00:00:00 GMT
+
+
+
+ """.formatted(lastBuildDate);
+ assertThat(rssXml).isEqualToIgnoringWhitespace(expected);
+ }
+
+ @Test
+ void extractRssTagsTest() {
+ var instant = Instant.now();
+ var rss = RSS2.builder()
+ .title("title")
+ .description("description")
+ .link("link")
+ .build();
+
+ var lastBuildDate = RssXmlBuilder.instantToString(instant);
+ var rssXml = new RssXmlBuilder()
+ .withRss2(rss)
+ .withGenerator("Halo")
+ .withExtractRssTags("""
+
+ John
+ 20
+
+
+ Tom
+ 30
+
+ """)
+ .toXmlString();
+ // language=xml
+ var expected = """
+
+
+
+ title
+ link
+ description
+ Halo
+ zh-cn
+ %s
+
+ John
+ 20
+
+
+ Tom
+ 30
+
+
+
+ """.formatted(lastBuildDate);
+ assertThat(rssXml).isEqualToIgnoringWhitespace(expected);
+ }
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 3bfc5ce..cf01d1c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,34 +1,12 @@
plugins {
- id "io.freefair.lombok" version "8.0.0-rc2"
- id "run.halo.plugin.devtools" version "0.0.9"
id 'java'
}
-group 'run.halo.feed'
-sourceCompatibility = JavaVersion.VERSION_17
-
-repositories {
- mavenCentral()
- maven { url 'https://s01.oss.sonatype.org/content/repositories/releases' }
- maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' }
- maven { url 'https://repo.spring.io/milestone' }
-}
-
-dependencies {
- implementation 'org.dom4j:dom4j:2.1.3'
- implementation('org.apache.commons:commons-text:1.10.0') {
- // Because the transitive dependency already exists in halo.
- exclude group: 'org.apache.commons', module: 'commons-lang3'
+allprojects {
+ repositories {
+ mavenCentral()
+ maven { url 'https://s01.oss.sonatype.org/content/repositories/releases' }
+ maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' }
+ maven { url 'https://repo.spring.io/milestone' }
}
-
- implementation platform('run.halo.tools.platform:plugin:2.10.0-SNAPSHOT')
- compileOnly 'run.halo.app:api'
-
- testImplementation 'run.halo.app:api'
- testImplementation 'org.springframework.boot:spring-boot-starter-test'
-
-}
-
-test {
- useJUnitPlatform()
}
diff --git a/settings.gradle b/settings.gradle
index b70863a..043ed59 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,11 +1,10 @@
pluginManagement {
repositories {
- maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' }
- maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
- maven { url 'https://maven.aliyun.com/repository/spring-plugin' }
- maven { url 'https://repo.spring.io/milestone' }
+ mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = 'plugin-feed'
+include 'api'
+include 'app'
diff --git a/src/main/java/run/halo/feed/BasicSetting.java b/src/main/java/run/halo/feed/BasicSetting.java
deleted file mode 100644
index 7cc269f..0000000
--- a/src/main/java/run/halo/feed/BasicSetting.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package run.halo.feed;
-
-
-import lombok.Data;
-
-@Data
-public class BasicSetting {
- public static final String CONFIG_MAP_NAME = "plugin-feed-config";
- public static final String GROUP = "basic";
-
- private Boolean enableCategories = Boolean.TRUE;
-
- private Boolean enableAuthors = Boolean.TRUE;
-
- private DescriptionType descriptionType = DescriptionType.excerpt;
-
- private Integer outputNum = 20;
-
-
- enum DescriptionType {
- /**
- * 全文
- */
- content,
-
- /**
- * 摘要
- */
- excerpt
-
- }
-}
diff --git a/src/main/java/run/halo/feed/ContentWrapper.java b/src/main/java/run/halo/feed/ContentWrapper.java
deleted file mode 100644
index 0a30ec7..0000000
--- a/src/main/java/run/halo/feed/ContentWrapper.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package run.halo.feed;
-
-import lombok.Builder;
-import lombok.Data;
-import org.apache.commons.lang3.StringUtils;
-import org.springframework.util.Assert;
-import run.halo.app.core.extension.content.Snapshot;
-
-/**
- * Copy from here .
- */
-@Data
-@Builder
-public class ContentWrapper {
-
- private String snapshotName;
- private String raw;
- private String content;
- private String rawType;
-
- public static ContentWrapper patchSnapshot(Snapshot patchSnapshot, Snapshot baseSnapshot) {
- Assert.notNull(baseSnapshot, "The baseSnapshot must not be null.");
- String baseSnapshotName = baseSnapshot.getMetadata().getName();
- if (StringUtils.equals(patchSnapshot.getMetadata().getName(), baseSnapshotName)) {
- return ContentWrapper.builder()
- .snapshotName(patchSnapshot.getMetadata().getName())
- .raw(patchSnapshot.getSpec().getRawPatch())
- .content(patchSnapshot.getSpec().getContentPatch())
- .rawType(patchSnapshot.getSpec().getRawType())
- .build();
- }
- String patchedContent = PatchUtils.applyPatch(baseSnapshot.getSpec().getContentPatch(),
- patchSnapshot.getSpec().getContentPatch());
- String patchedRaw = PatchUtils.applyPatch(baseSnapshot.getSpec().getRawPatch(),
- patchSnapshot.getSpec().getRawPatch());
- return ContentWrapper.builder()
- .snapshotName(patchSnapshot.getMetadata().getName())
- .raw(patchedRaw)
- .content(patchedContent)
- .rawType(patchSnapshot.getSpec().getRawType())
- .build();
- }
-}
diff --git a/src/main/java/run/halo/feed/FeedPluginEndpoint.java b/src/main/java/run/halo/feed/FeedPluginEndpoint.java
deleted file mode 100644
index 46a242d..0000000
--- a/src/main/java/run/halo/feed/FeedPluginEndpoint.java
+++ /dev/null
@@ -1,40 +0,0 @@
-package run.halo.feed;
-
-import lombok.AllArgsConstructor;
-import org.springframework.context.annotation.Bean;
-import org.springframework.http.MediaType;
-import org.springframework.stereotype.Component;
-import org.springframework.web.reactive.function.server.RequestPredicate;
-import org.springframework.web.reactive.function.server.RouterFunction;
-import org.springframework.web.reactive.function.server.RouterFunctions;
-import org.springframework.web.reactive.function.server.ServerResponse;
-
-import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
-import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
-
-/**
- * Feed plugin endpoint.
- * Router function configuration.
- */
-@Component
-@AllArgsConstructor
-public class FeedPluginEndpoint {
- private final FeedService feedService;
-
-
- @Bean
- RouterFunction sitemapRouterFunction() {
- RequestPredicate requestPredicate = accept(
- MediaType.TEXT_XML,
- MediaType.APPLICATION_RSS_XML
- );
- return RouterFunctions.route(GET("/feed.xml").and(requestPredicate),
- feedService::allFeed)
- .andRoute(GET("/rss.xml").and(requestPredicate),
- feedService::allFeed)
- .andRoute(GET("/feed/categories/{category}.xml").and(requestPredicate),
- request -> feedService.categoryFeed(request, request.pathVariable("category")))
- .andRoute(GET("/feed/authors/{author}.xml").and(requestPredicate),
- request -> feedService.authorFeed(request, request.pathVariable("author")));
- }
-}
\ No newline at end of file
diff --git a/src/main/java/run/halo/feed/FeedService.java b/src/main/java/run/halo/feed/FeedService.java
deleted file mode 100644
index f7eed88..0000000
--- a/src/main/java/run/halo/feed/FeedService.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package run.halo.feed;
-
-import org.springframework.web.reactive.function.server.ServerRequest;
-import org.springframework.web.reactive.function.server.ServerResponse;
-import reactor.core.publisher.Mono;
-
-public interface FeedService {
- /**
- * Get all posts feed response
- *
- * @return feed response
- */
- Mono allFeed(ServerRequest request);
-
- /**
- * Get category posts feed response
- *
- * @param request
- * @param category category name
- * @return feed response
- */
- Mono categoryFeed(ServerRequest request, String category);
-
- /**
- * Get author posts feed response
- *
- * @param request
- * @param author author metadata name
- * @return feed response
- */
- Mono authorFeed(ServerRequest request, String author);
-}
diff --git a/src/main/java/run/halo/feed/FeedServiceImpl.java b/src/main/java/run/halo/feed/FeedServiceImpl.java
deleted file mode 100644
index b0c0966..0000000
--- a/src/main/java/run/halo/feed/FeedServiceImpl.java
+++ /dev/null
@@ -1,218 +0,0 @@
-package run.halo.feed;
-
-
-import lombok.AllArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.BooleanUtils;
-import org.apache.commons.text.StringEscapeUtils;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.stereotype.Service;
-import org.springframework.util.Assert;
-import org.springframework.util.StringUtils;
-import org.springframework.web.reactive.function.server.ServerRequest;
-import org.springframework.web.reactive.function.server.ServerResponse;
-import reactor.core.publisher.Mono;
-import run.halo.app.core.extension.content.Post;
-import run.halo.app.extension.ListResult;
-import run.halo.app.infra.ExternalUrlSupplier;
-import run.halo.app.infra.SystemSetting;
-
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.net.URL;
-import java.util.Objects;
-
-@Service
-@AllArgsConstructor
-@Slf4j
-public class FeedServiceImpl implements FeedService {
- private final ReactiveSettingFetcher settingFetcher;
-
- private final ExternalUrlSupplier externalUrlSupplier;
-
- private final FeedSourceFinder feedSourceFinder;
-
- private static final int FIRST_PAGE = 1;
-
- @Override
- public Mono allFeed(ServerRequest request) {
- return getFeedContext(request)
- .flatMap(feedContext -> {
- var rss2 = buildBaseRss(feedContext);
-
- return postListResultToXmlServerResponse(
- feedSourceFinder.listPosts(FIRST_PAGE,
- feedContext.basicPluginSetting.getOutputNum()),
- feedContext, rss2);
- });
- }
-
- @Override
- public Mono categoryFeed(ServerRequest request, String category) {
- return getFeedContext(request)
- .filter(feedContext -> BooleanUtils.isTrue(
- feedContext.basicPluginSetting.getEnableCategories()))
- .flatMap(feedContext -> {
- var rss2 = buildBaseRss(feedContext);
- // Get category metadata name by category slug
- return feedSourceFinder.getCategoriesContentBySlug(category)
- .next()
- .map(categoryContent -> {
- // Set category info
- var permalink = categoryContent.getStatusOrDefault().getPermalink();
- if (permalink != null) {
- var permalinkUri = URI.create(permalink);
- if (!permalinkUri.isAbsolute()) {
- permalinkUri =
- request.exchange().getRequest().getURI().resolve(permalinkUri);
- }
- permalink = permalinkUri.toString();
- }
- rss2.setTitle("分类:" + categoryContent.getSpec().getDisplayName() +
- " - " + rss2.getTitle());
- rss2.setLink(permalink);
- if (StringUtils.hasText(categoryContent.getSpec().getDescription())) {
- rss2.setDescription(categoryContent.getSpec().getDescription());
- }
- return categoryContent.getMetadata().getName();
- })
- .flatMap(categoryMetadataName -> {
- // Get posts by category metadata name
- var listResultMono = feedSourceFinder.listPostsByCategory(
- FIRST_PAGE,
- feedContext.basicPluginSetting.getOutputNum(),
- categoryMetadataName);
- return postListResultToXmlServerResponse(listResultMono, feedContext, rss2);
- });
- })
- .onErrorResume(error -> {
- log.error("Failed to get category feed", error);
- return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
- })
- .switchIfEmpty(ServerResponse.notFound().build());
- }
-
- @Override
- public Mono authorFeed(ServerRequest request, String author) {
- return getFeedContext(request)
- .filter(feedContext -> BooleanUtils.isTrue(
- feedContext.basicPluginSetting.getEnableAuthors()))
- .flatMap(feedContext -> {
- var rss2 = buildBaseRss(feedContext);
- // Get author display name by author metadata name
- return feedSourceFinder.getUserByName(author)
- .flatMap(user -> {
- rss2.setTitle(
- "作者:" + user.getSpec().getDisplayName() + " - " + rss2.getTitle());
- // TODO author link need upgrade halo dependency version
-
- return postListResultToXmlServerResponse(
- feedSourceFinder.listPostsByAuthor(FIRST_PAGE,
- feedContext.basicPluginSetting.getOutputNum(), author),
- feedContext, rss2);
- });
- })
- .onErrorResume(error -> {
- log.error("Failed to get author feed", error);
- return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
- })
- .switchIfEmpty(ServerResponse.notFound().build());
- }
-
- private Mono getFeedContext(ServerRequest request) {
- return Mono
- .zip(settingFetcher.fetch(BasicSetting.CONFIG_MAP_NAME, BasicSetting.GROUP,
- BasicSetting.class)
- .switchIfEmpty(Mono.just(new BasicSetting())),
- settingFetcher.fetch(SystemSetting.SYSTEM_CONFIG, SystemSetting.Basic.GROUP,
- SystemSetting.Basic.class))
- .map(tuple -> {
- var basicPluginSetting = tuple.getT1();
- var systemBasicSetting = tuple.getT2();
- // Assert basic plugin setting
- Assert.notNull(basicPluginSetting.getOutputNum(), "outputNum cannot be null");
- Assert.isTrue(basicPluginSetting.getOutputNum() > 0,
- "OutputNum must be greater than zero");
- Assert.notNull(basicPluginSetting.getDescriptionType(),
- "descriptionType cannot be null");
-
- var externalUrl = externalUrlSupplier.getRaw();
- if (externalUrl == null) {
- externalUrl = externalUrlSupplier.getURL(request.exchange().getRequest());
- }
- // Build feed context
- return new FeedContext(basicPluginSetting, systemBasicSetting, externalUrl);
- });
- }
-
- private Mono postListResultToXmlServerResponse(
- Mono> postListResult,
- FeedContext feedContext, RSS2 rss2) {
- return postListResult
- .flatMapIterable(ListResult::getItems)
- .concatMap(post -> {
- // Create item
- var permalink = post.getStatusOrDefault().getPermalink();
- if (permalink != null) {
- var permalinkUri = URI.create(permalink);
- if (!permalinkUri.isAbsolute()) {
- try {
- permalinkUri = feedContext.externalUrl.toURI().resolve(permalinkUri);
- } catch (URISyntaxException e) {
- throw new RuntimeException(e);
- }
- }
- permalink = permalinkUri.toString();
- }
- var itemBuilder = RSS2.Item.builder()
- .title(post.getSpec().getTitle())
- .link(permalink)
- .pubDate(post.getSpec().getPublishTime())
- .guid(post.getStatusOrDefault().getPermalink());
-
- // TODO lastBuildDate need upgrade halo dependency version
-
- // Set description
- if (Objects.equals(feedContext.basicPluginSetting.getDescriptionType(),
- BasicSetting.DescriptionType.content)) {
- // Set releaseSnapshot as description
- var releaseSnapshot = post.getSpec().getReleaseSnapshot();
- var baseSnapshot = post.getSpec().getBaseSnapshot();
- return feedSourceFinder.getPostContent(releaseSnapshot, baseSnapshot)
- .map(contentWrapper -> itemBuilder
- .description(
- XmlCharUtils.removeInvalidXmlChar(contentWrapper.getContent()))
- .build());
- } else {
- // Set excerpt as description
- return Mono.just(itemBuilder
- // Prevent parsing excerpt as html
- // escapeXml10 already remove invalid characters
- .description(
- StringEscapeUtils.escapeXml10(post.getStatusOrDefault().getExcerpt()))
- .build());
- }
- })
- .collectList()
- .map(items -> {
- rss2.setItems(items);
- return rss2.toXmlString();
- })
- .flatMap(xml -> ServerResponse.ok().contentType(MediaType.TEXT_XML).bodyValue(xml));
- }
-
- private RSS2 buildBaseRss(FeedContext feedContext) {
- return RSS2.builder()
- .title(feedContext.systemBasicSetting.getTitle())
- .description(StringUtils.hasText(feedContext.systemBasicSetting.getSubtitle()) ?
- feedContext.systemBasicSetting.getSubtitle() :
- feedContext.systemBasicSetting.getTitle())
- .link(feedContext.externalUrl.toString())
- .build();
- }
-
- record FeedContext(BasicSetting basicPluginSetting, SystemSetting.Basic systemBasicSetting,
- URL externalUrl) {
- }
-}
diff --git a/src/main/java/run/halo/feed/FeedSourceFinder.java b/src/main/java/run/halo/feed/FeedSourceFinder.java
deleted file mode 100644
index 78a29bb..0000000
--- a/src/main/java/run/halo/feed/FeedSourceFinder.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package run.halo.feed;
-
-import reactor.core.publisher.Flux;
-import reactor.core.publisher.Mono;
-import run.halo.app.core.extension.User;
-import run.halo.app.core.extension.content.Category;
-import run.halo.app.core.extension.content.Post;
-import run.halo.app.extension.ListResult;
-
-public interface FeedSourceFinder {
-
- /**
- * List all posts.
- *
- * @param page page number
- * @param size page size
- * @return post list result
- */
- Mono> listPosts(Integer page, Integer size);
-
- /**
- * List posts by category.
- *
- * @param page page number
- * @param size page size
- * @param category category metadata name
- * @return post list result
- */
- Mono> listPostsByCategory(Integer page, Integer size, String category);
-
- /**
- * List posts by author.
- *
- * @param page page number
- * @param size page size
- * @param author author slug name
- * @return post list result
- */
- Mono> listPostsByAuthor(Integer page, Integer size, String author);
-
- /**
- * Get post snapshot post content.
- *
- * @param snapshotName snapshot name
- * @param baseSnapshotName base snapshot name
- * @return post content
- */
- Mono getPostContent(String snapshotName, String baseSnapshotName);
-
- /**
- * Get categories by category slug.
- *
- * @param slug category slug
- * @return category
- */
- Flux getCategoriesContentBySlug(String slug);
-
- /**
- * Get user by user metadata name.
- *
- * @param name user metadata name
- * @return user
- */
- Mono getUserByName(String name);
-}
diff --git a/src/main/java/run/halo/feed/FeedSourceFinderImpl.java b/src/main/java/run/halo/feed/FeedSourceFinderImpl.java
deleted file mode 100644
index 6df427f..0000000
--- a/src/main/java/run/halo/feed/FeedSourceFinderImpl.java
+++ /dev/null
@@ -1,116 +0,0 @@
-package run.halo.feed;
-
-import java.time.Instant;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Objects;
-import java.util.function.Function;
-import java.util.function.Predicate;
-import lombok.AllArgsConstructor;
-import org.apache.commons.lang3.StringUtils;
-import org.springframework.stereotype.Component;
-import org.springframework.util.Assert;
-import org.springframework.util.comparator.Comparators;
-import reactor.core.publisher.Flux;
-import reactor.core.publisher.Mono;
-import run.halo.app.core.extension.User;
-import run.halo.app.core.extension.content.Category;
-import run.halo.app.core.extension.content.Post;
-import run.halo.app.core.extension.content.Snapshot;
-import run.halo.app.extension.ListResult;
-import run.halo.app.extension.MetadataUtil;
-import run.halo.app.extension.ReactiveExtensionClient;
-
-@Component
-@AllArgsConstructor
-public class FeedSourceFinderImpl implements FeedSourceFinder {
- private final ReactiveExtensionClient client;
-
- public static final Predicate FIXED_PREDICATE = post -> post.isPublished()
- && Objects.equals(false,
- post.getSpec().getDeleted())
- && Post.VisibleEnum.PUBLIC.equals(
- post.getSpec().getVisible())
- && post.getMetadata()
- .getDeletionTimestamp() ==
- null;
-
- static Comparator defaultComparator() {
- Function publishTime =
- post -> post.getSpec().getPublishTime();
- Function name = post -> post.getMetadata().getName();
- return Comparator.comparing(publishTime, Comparators.nullsLow())
- .thenComparing(name)
- .reversed();
- }
-
- @Override
- public Mono> listPosts(Integer page, Integer size) {
- return listPost(page, size, null, defaultComparator());
- }
-
- @Override
- public Mono> listPostsByCategory(Integer page, Integer size, String category) {
- return listPost(page, size, post -> contains(post.getSpec().getCategories(), category),
- defaultComparator());
- }
-
- @Override
- public Mono> listPostsByAuthor(Integer page, Integer size, String author) {
- return listPost(page, size, post -> post.getSpec().getOwner().equals(author),
- defaultComparator());
- }
-
- private boolean contains(List c, String key) {
- if (StringUtils.isBlank(key) || c == null) {
- return false;
- }
- return c.contains(key);
- }
-
- private Mono> listPost(Integer page, Integer size,
- Predicate postPredicate,
- Comparator comparator) {
- Predicate predicate = FIXED_PREDICATE
- .and(postPredicate == null ? post -> true : postPredicate);
- return client.list(Post.class, predicate, comparator, page, size);
- }
-
- @Override
- public Mono getPostContent(String snapshotName, String baseSnapshotName) {
- return client.fetch(Snapshot.class, baseSnapshotName)
- .doOnNext(this::checkBaseSnapshot)
- .flatMap(baseSnapshot -> {
- if (StringUtils.equals(snapshotName, baseSnapshotName)) {
- var wrapper = ContentWrapper.patchSnapshot(baseSnapshot, baseSnapshot);
- return Mono.just(wrapper);
- }
- return client.fetch(Snapshot.class, snapshotName)
- .map(snapshot -> ContentWrapper.patchSnapshot(snapshot, baseSnapshot));
- });
- }
-
- private void checkBaseSnapshot(Snapshot snapshot) {
- Assert.notNull(snapshot, "The snapshot must not be null.");
- String keepRawAnno =
- MetadataUtil.nullSafeAnnotations(snapshot).get(Snapshot.KEEP_RAW_ANNO);
- if (!StringUtils.equals(Boolean.TRUE.toString(), keepRawAnno)) {
- throw new IllegalArgumentException(
- String.format("The snapshot [%s] is not a base snapshot.",
- snapshot.getMetadata().getName()));
- }
- }
-
- @Override
- public Flux getCategoriesContentBySlug(String slug) {
- return client.list(Category.class,
- category -> StringUtils.equals(category.getSpec().getSlug(), slug),
- Comparator.naturalOrder());
- }
-
- @Override
- public Mono getUserByName(String name) {
- return client.fetch(User.class, name);
- }
-
-}
diff --git a/src/main/java/run/halo/feed/PatchUtils.java b/src/main/java/run/halo/feed/PatchUtils.java
deleted file mode 100644
index 82c4ccf..0000000
--- a/src/main/java/run/halo/feed/PatchUtils.java
+++ /dev/null
@@ -1,87 +0,0 @@
-package run.halo.feed;
-
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.github.difflib.DiffUtils;
-import com.github.difflib.patch.AbstractDelta;
-import com.github.difflib.patch.ChangeDelta;
-import com.github.difflib.patch.Chunk;
-import com.github.difflib.patch.DeleteDelta;
-import com.github.difflib.patch.DeltaType;
-import com.github.difflib.patch.InsertDelta;
-import com.github.difflib.patch.Patch;
-import com.github.difflib.patch.PatchFailedException;
-import com.google.common.base.Splitter;
-import java.util.Collections;
-import java.util.List;
-import lombok.Data;
-import org.apache.commons.lang3.StringUtils;
-import run.halo.app.infra.utils.JsonUtils;
-
-/**
- * Copy from here .
- */
-public class PatchUtils {
- private static final String DELIMITER = "\n";
- private static final Splitter lineSplitter = Splitter.on(DELIMITER);
-
- public static Patch create(String deltasJson) {
- List deltas = JsonUtils.jsonToObject(deltasJson, new TypeReference<>() {
- });
- Patch patch = new Patch<>();
- for (Delta delta : deltas) {
- StringChunk sourceChunk = delta.getSource();
- StringChunk targetChunk = delta.getTarget();
- Chunk orgChunk = new Chunk<>(sourceChunk.getPosition(), sourceChunk.getLines(),
- sourceChunk.getChangePosition());
- Chunk revChunk = new Chunk<>(targetChunk.getPosition(), targetChunk.getLines(),
- targetChunk.getChangePosition());
- switch (delta.getType()) {
- case DELETE -> patch.addDelta(new DeleteDelta<>(orgChunk, revChunk));
- case INSERT -> patch.addDelta(new InsertDelta<>(orgChunk, revChunk));
- case CHANGE -> patch.addDelta(new ChangeDelta<>(orgChunk, revChunk));
- default -> throw new IllegalArgumentException("Unsupported delta type.");
- }
- }
- return patch;
- }
-
- public static String patchToJson(Patch patch) {
- List> deltas = patch.getDeltas();
- return JsonUtils.objectToJson(deltas);
- }
-
- public static String applyPatch(String original, String patchJson) {
- Patch patch = PatchUtils.create(patchJson);
- try {
- return String.join(DELIMITER, patch.applyTo(breakLine(original)));
- } catch (PatchFailedException e) {
- throw new RuntimeException(e);
- }
- }
-
- public static String diffToJsonPatch(String original, String revised) {
- Patch patch = DiffUtils.diff(breakLine(original), breakLine(revised));
- return PatchUtils.patchToJson(patch);
- }
-
- public static List breakLine(String content) {
- if (StringUtils.isBlank(content)) {
- return Collections.emptyList();
- }
- return lineSplitter.splitToList(content);
- }
-
- @Data
- public static class Delta {
- private StringChunk source;
- private StringChunk target;
- private DeltaType type;
- }
-
- @Data
- public static class StringChunk {
- private int position;
- private List lines;
- private List changePosition;
- }
-}
diff --git a/src/main/java/run/halo/feed/RSS2.java b/src/main/java/run/halo/feed/RSS2.java
deleted file mode 100644
index df694c0..0000000
--- a/src/main/java/run/halo/feed/RSS2.java
+++ /dev/null
@@ -1,67 +0,0 @@
-package run.halo.feed;
-
-import lombok.Builder;
-import lombok.Data;
-import org.dom4j.Document;
-import org.dom4j.DocumentHelper;
-import org.dom4j.Element;
-
-import java.time.Instant;
-import java.time.ZoneOffset;
-import java.time.format.DateTimeFormatter;
-import java.util.List;
-
-@Data
-@Builder
-public class RSS2 {
- private String title;
-
- private String link;
-
- private String description;
-
- private List- items;
-
- @Data
- @Builder
- public static class Item {
- private String title;
-
- private String link;
-
- private String description;
-
- private Instant pubDate;
-
- private String guid;
- }
-
- public String toXmlString() {
- Document document = DocumentHelper.createDocument();
-
- Element root = DocumentHelper.createElement("rss");
- root.addAttribute("version", "2.0");
- document.setRootElement(root);
-
- Element channel = root.addElement("channel");
- channel.addElement("title").addText(title);
- channel.addElement("link").addText(link);
- channel.addElement("description").addText(description);
-
- // TODO lastBuildDate need upgrade halo dependency version
-
- if (items != null) {
- items.forEach(item -> {
- Element itemElement = channel.addElement("item");
- itemElement.addElement("title").addCDATA(item.getTitle());
- itemElement.addElement("link").addText(item.getLink());
- itemElement.addElement("description").addCDATA(item.getDescription());
- itemElement.addElement("guid").addText(item.getGuid());
- itemElement.addElement("pubDate")
- .addText(item.pubDate.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.RFC_1123_DATE_TIME));
- });
- }
-
- return document.asXML();
- }
-}
diff --git a/src/main/java/run/halo/feed/ReactiveSettingFetcher.java b/src/main/java/run/halo/feed/ReactiveSettingFetcher.java
deleted file mode 100644
index 8ca0128..0000000
--- a/src/main/java/run/halo/feed/ReactiveSettingFetcher.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package run.halo.feed;
-
-import lombok.AllArgsConstructor;
-import org.springframework.lang.NonNull;
-import org.springframework.stereotype.Component;
-import reactor.core.publisher.Mono;
-import run.halo.app.extension.ConfigMap;
-import run.halo.app.extension.ReactiveExtensionClient;
-import run.halo.app.infra.utils.JsonUtils;
-
-import java.util.Map;
-
-@Component
-@AllArgsConstructor
-public class ReactiveSettingFetcher {
- private final ReactiveExtensionClient extensionClient;
-
- public
Mono fetch(String configMapName, String group, Class clazz) {
- return getValuesInternal(configMapName)
- .filter(map -> map.containsKey(group))
- .map(map -> map.get(group))
- .mapNotNull(stringValue -> JsonUtils.jsonToObject(stringValue, clazz));
- }
-
-
- @NonNull
- private Mono> getValuesInternal(String configMapName) {
- return getConfigMap(configMapName)
- .filter(configMap -> configMap.getData() != null)
- .map(ConfigMap::getData)
- .defaultIfEmpty(Map.of());
- }
-
- private Mono getConfigMap(String configMapName) {
- return extensionClient.fetch(ConfigMap.class, configMapName);
- }
-}
diff --git a/src/test/java/run/halo/feed/RSS2Test.java b/src/test/java/run/halo/feed/RSS2Test.java
deleted file mode 100644
index 5990cbb..0000000
--- a/src/test/java/run/halo/feed/RSS2Test.java
+++ /dev/null
@@ -1,54 +0,0 @@
-package run.halo.feed;
-
-import org.junit.jupiter.api.Test;
-
-import java.time.Instant;
-import java.util.Arrays;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-class RSS2Test {
-
- @Test
- void toXmlString() {
- String result = RSS2.builder()
- .title("title")
- .description("description")
- .link("link")
- .items(Arrays.asList(
- RSS2.Item.builder()
- .title("title1")
- .description("description1")
- .link("link1")
- .pubDate(Instant.EPOCH)
- .guid("guid1")
- .build(),
- RSS2.Item.builder()
- .title("title2")
- .description("description2")
- .link("link2")
- .pubDate(Instant.ofEpochSecond(2208988800L))
- .guid("guid2")
- .build()
- ))
- .build()
- .toXmlString();
-
- String standard = "\n" +
- "" +
- "" +
- "title " +
- " link" +
- "description " +
- " link1" +
- "guid1 " +
- "Thu, 1 Jan 1970 00:00:00 GMT " +
- " link2" +
- "guid2 " +
- "Sun, 1 Jan 2040 00:00:00 GMT " +
- " " +
- " ";
-
- assertEquals(standard, result);
- }
-}
\ No newline at end of file