From cff7c7e1ebb3c1c91fd8c97e66ab204f94f14971 Mon Sep 17 00:00:00 2001
From: guqing <38999863+guqing@users.noreply.github.com>
Date: Thu, 5 Dec 2024 15:59:14 +0800
Subject: [PATCH] feat: restructure RSS generation to support extension by
other plugins (#39)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
### What this PR does?
重写 RSS 生成并支持被其他插件扩展
```release-note
重写 RSS 生成并支持被其他插件扩展
```
---
.github/workflows/cd.yaml | 1 +
.github/workflows/ci.yaml | 1 +
.gitignore | 1 +
README.md | 104 ++++++++-
api/build.gradle | 76 ++++++
.../java/run/halo/feed/CacheClearRule.java | 40 ++++
api/src/main/java/run/halo/feed/RSS2.java | 186 +++++++++++++++
.../run/halo/feed/RssCacheClearRequested.java | 68 ++++++
.../main/java/run/halo/feed/RssRouteItem.java | 40 ++++
app/build.gradle | 34 +++
.../main/java/run/halo/feed/BasicProp.java | 38 +++
.../main/java/run/halo/feed/FeedPlugin.java | 0
.../run/halo/feed/FeedPluginEndpoint.java | 112 +++++++++
.../java/run/halo/feed/RssCacheManager.java | 93 ++++++++
.../java/run/halo/feed/RssXmlBuilder.java | 212 +++++++++++++++++
.../main/java/run/halo/feed/XmlCharUtils.java | 0
.../provider/AbstractPostRssProvider.java | 151 ++++++++++++
.../feed/provider/AuthorPostRssProvider.java | 80 +++++++
.../provider/CategoryPostRssProvider.java | 93 ++++++++
.../halo/feed/provider/PostRssProvider.java | 22 ++
.../run/halo/feed/service/PostService.java | 16 ++
.../halo/feed/service/PostServiceImpl.java | 84 +++++++
.../resources/extensions/ext-definition.yaml | 30 +++
.../main/resources/extensions/settings.yaml | 6 +
{src => app/src}/main/resources/logo.svg | 0
{src => app/src}/main/resources/plugin.yaml | 3 +-
app/src/test/java/run/halo/feed/RSS2Test.java | 132 +++++++++++
build.gradle | 34 +--
settings.gradle | 7 +-
src/main/java/run/halo/feed/BasicSetting.java | 32 ---
.../java/run/halo/feed/ContentWrapper.java | 43 ----
.../run/halo/feed/FeedPluginEndpoint.java | 40 ----
src/main/java/run/halo/feed/FeedService.java | 32 ---
.../java/run/halo/feed/FeedServiceImpl.java | 218 ------------------
.../java/run/halo/feed/FeedSourceFinder.java | 65 ------
.../run/halo/feed/FeedSourceFinderImpl.java | 116 ----------
src/main/java/run/halo/feed/PatchUtils.java | 87 -------
src/main/java/run/halo/feed/RSS2.java | 67 ------
.../run/halo/feed/ReactiveSettingFetcher.java | 37 ---
src/test/java/run/halo/feed/RSS2Test.java | 54 -----
40 files changed, 1621 insertions(+), 834 deletions(-)
create mode 100644 api/build.gradle
create mode 100644 api/src/main/java/run/halo/feed/CacheClearRule.java
create mode 100644 api/src/main/java/run/halo/feed/RSS2.java
create mode 100644 api/src/main/java/run/halo/feed/RssCacheClearRequested.java
create mode 100644 api/src/main/java/run/halo/feed/RssRouteItem.java
create mode 100644 app/build.gradle
create mode 100644 app/src/main/java/run/halo/feed/BasicProp.java
rename {src => app/src}/main/java/run/halo/feed/FeedPlugin.java (100%)
create mode 100644 app/src/main/java/run/halo/feed/FeedPluginEndpoint.java
create mode 100644 app/src/main/java/run/halo/feed/RssCacheManager.java
create mode 100644 app/src/main/java/run/halo/feed/RssXmlBuilder.java
rename {src => app/src}/main/java/run/halo/feed/XmlCharUtils.java (100%)
create mode 100644 app/src/main/java/run/halo/feed/provider/AbstractPostRssProvider.java
create mode 100644 app/src/main/java/run/halo/feed/provider/AuthorPostRssProvider.java
create mode 100644 app/src/main/java/run/halo/feed/provider/CategoryPostRssProvider.java
create mode 100644 app/src/main/java/run/halo/feed/provider/PostRssProvider.java
create mode 100644 app/src/main/java/run/halo/feed/service/PostService.java
create mode 100644 app/src/main/java/run/halo/feed/service/PostServiceImpl.java
create mode 100644 app/src/main/resources/extensions/ext-definition.yaml
rename {src => app/src}/main/resources/extensions/settings.yaml (84%)
rename {src => app/src}/main/resources/logo.svg (100%)
rename {src => app/src}/main/resources/plugin.yaml (94%)
create mode 100644 app/src/test/java/run/halo/feed/RSS2Test.java
delete mode 100644 src/main/java/run/halo/feed/BasicSetting.java
delete mode 100644 src/main/java/run/halo/feed/ContentWrapper.java
delete mode 100644 src/main/java/run/halo/feed/FeedPluginEndpoint.java
delete mode 100644 src/main/java/run/halo/feed/FeedService.java
delete mode 100644 src/main/java/run/halo/feed/FeedServiceImpl.java
delete mode 100644 src/main/java/run/halo/feed/FeedSourceFinder.java
delete mode 100644 src/main/java/run/halo/feed/FeedSourceFinderImpl.java
delete mode 100644 src/main/java/run/halo/feed/PatchUtils.java
delete mode 100644 src/main/java/run/halo/feed/RSS2.java
delete mode 100644 src/main/java/run/halo/feed/ReactiveSettingFetcher.java
delete mode 100644 src/test/java/run/halo/feed/RSS2Test.java
diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml
index 929d953..e83fa03 100644
--- a/.github/workflows/cd.yaml
+++ b/.github/workflows/cd.yaml
@@ -16,3 +16,4 @@ jobs:
with:
app-id: app-KhIVw
skip-node-setup: true
+ artifacts-path: 'app/build/libs'
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 6efa4de..fec1287 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -13,3 +13,4 @@ jobs:
uses: halo-sigs/reusable-workflows/.github/workflows/plugin-ci.yaml@v1
with:
skip-node-setup: true
+ artifacts-path: 'app/build/libs'
diff --git a/.gitignore b/.gitignore
index c97ac56..1e7a540 100755
--- a/.gitignore
+++ b/.gitignore
@@ -72,3 +72,4 @@ application-local.properties
/admin-frontend/node_modules/
/workplace/
+*/workplace/
\ No newline at end of file
diff --git a/README.md b/README.md
index 95843a2..24bab3e 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,92 @@
Halo 2.0 的 RSS 订阅链接生成插件
+## 如何扩展 RSS 源
+
+> 从 feed 插件 v1.4.0 版本开始,支持扩展 RSS 功能。
+
+`feed` 插件提供了扩展点,允许其他插件扩展 RSS 源。
+
+### 步骤 1:在插件中引入 feed 依赖
+
+在你的插件项目中添加 `feed` 插件的依赖:
+
+```groovy
+dependencies {
+ // ...
+ compileOnly "run.halo.feed:api:{version}"
+}
+```
+
+将 `{version}` 替换为实际的 `feed` 插件版本号。
+
+### 步骤 2:实现 `RssRouteItem` 扩展点接口
+
+创建一个类实现 `run.halo.feed.RssRouteItem` 接口,提供自定义的 RSS 数据源。例如:
+
+```java
+public class MomentRssProvider implements RssRouteItem {
+ // 实现具体的 RSS 提供逻辑
+}
+```
+
+你可以参考 [PostRssProvider](./app/src/main/java/run/halo/feed/provider/PostRssProvider.java) 示例。
+
+### 步骤 3:声明扩展点
+
+在 `src/main/resources/extensions`
+目录下,声明你的扩展。你可以参考 [ext-definition.yaml](app/src/main/resources/extensions/ext-definition.yaml) 文件来完成此步骤。
+
+### 步骤 4:定义配置类并清理 RSS 缓存
+
+在插件中定义一个配置类,使用 `@ConditionalOnClass` 注解确保只有在 `run.halo.feed.RssRouteItem` 类存在时才会创建对应的
+Bean。同时,定义事件监听器来清理缓存。
+
+`@ConditionalOnClass` 注解只能使用 name 属性来指定类全限定名,不支持使用 value 属性。
+
+示例代码:
+
+```java
+
+@Configuration
+@ConditionalOnClass(name = "run.halo.feed.RssRouteItem")
+@RequiredArgsConstructor
+public class RssAutoConfiguration {
+ private final ApplicationEventPublisher eventPublisher;
+
+ @Bean
+ public MomentRssProvider momentRssProvider() {
+ return new MomentRssProvider();
+ }
+
+ @Async
+ @EventListener({MomentUpdatedEvent.class, MomentDeletedEvent.class, ContextClosedEvent.class})
+ public void onMomentUpdatedOrDeleted() {
+ var rule = CacheClearRule.forExact("/feed/moments/rss.xml");
+ var event = RssCacheClearRequested.forRule(this, rule);
+ eventPublisher.publishEvent(event);
+ }
+}
+```
+
+此配置确保了当 `RssRouteItem` 接口存在时,插件才会自动创建 `MomentRssProvider` 并监听相关事件来清理缓存。
+
+### 步骤 5:声明插件依赖
+
+在 `plugin.yaml` 文件中声明 `feed` 插件为可选依赖,确保当 `feed` 插件存在并启用时,插件能够自动注册 RSS 源。
+
+```yaml
+apiVersion: plugin.halo.run/v1alpha1
+kind: Plugin
+metadata:
+ name: moment
+spec:
+ pluginDependencies:
+ "PluginFeed?": ">=1.4.0"
+```
+
+这样,当 `feed` 插件可用时,插件会自动注册自定义的 RSS 源。
+
## 开发环境
```bash
@@ -28,15 +114,15 @@ cd path/to/plugin-feed
```yaml
halo:
- plugin:
- runtime-mode: development
- classes-directories:
- - "build/classes"
- - "build/resources"
- lib-directories:
- - "libs"
- fixedPluginPath:
- - "/path/to/plugin-feed"
+ plugin:
+ runtime-mode: development
+ classes-directories:
+ - "build/classes"
+ - "build/resources"
+ lib-directories:
+ - "libs"
+ fixedPluginPath:
+ - "/path/to/plugin-feed"
```
## 使用方式
diff --git a/api/build.gradle b/api/build.gradle
new file mode 100644
index 0000000..4a8918d
--- /dev/null
+++ b/api/build.gradle
@@ -0,0 +1,76 @@
+plugins {
+ id 'java-library'
+ id 'maven-publish'
+ id "io.freefair.lombok" version "8.0.0-rc2"
+}
+
+group = 'run.halo.feed'
+version = rootProject.version
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ withSourcesJar()
+}
+
+compileJava.options.encoding = "UTF-8"
+compileTestJava.options.encoding = "UTF-8"
+javadoc.options.encoding = "UTF-8"
+
+dependencies {
+ api platform('run.halo.tools.platform:plugin:2.20.11')
+ compileOnly 'run.halo.app:api'
+}
+
+test {
+ useJUnitPlatform()
+}
+
+publishing {
+ publications {
+ mavenJava(MavenPublication) {
+ from components.java
+ artifact tasks.sourcesJar
+
+ artifactId = 'api'
+ version = project.hasProperty('version') ? project.property('version') : 'unspecified'
+
+ pom {
+ name = 'RSS'
+ description = '为站点生成 RSS 订阅链接'
+ url = 'https://www.halo.run/store/apps/app-KhIVw'
+
+ licenses {
+ license {
+ name = 'GPL-3.0'
+ url = 'https://github.com/halo-dev/plugin-feed/blob/main/LICENSE'
+ }
+ }
+
+ developers {
+ developer {
+ id = 'guqing'
+ name = 'guqing'
+ email = 'i@guqing.email'
+ }
+ }
+
+ scm {
+ connection = 'scm:git:git@github.com:halo-dev/plugin-feed.git'
+ developerConnection = 'scm:git:git@github.com:halo-dev/plugin-feed.git'
+ url = 'https://github.com/halo-dev/plugin-feed'
+ }
+ }
+ }
+ }
+ repositories {
+ maven {
+ url = version.endsWith('-SNAPSHOT') ? 'https://s01.oss.sonatype.org/content/repositories/snapshots/' :
+ 'https://s01.oss.sonatype.org/content/repositories/releases/'
+ credentials {
+ username = project.findProperty("ossr.user") ?: System.getenv("OSSR_USERNAME")
+ password = project.findProperty("ossr.password") ?: System.getenv("OSSR_PASSWORD")
+ }
+ }
+ }
+}
diff --git a/api/src/main/java/run/halo/feed/CacheClearRule.java b/api/src/main/java/run/halo/feed/CacheClearRule.java
new file mode 100644
index 0000000..14695ae
--- /dev/null
+++ b/api/src/main/java/run/halo/feed/CacheClearRule.java
@@ -0,0 +1,40 @@
+package run.halo.feed;
+
+import org.springframework.util.Assert;
+
+public record CacheClearRule(Type type, String value) {
+ public CacheClearRule {
+ Assert.notNull(type, "Type cannot be null");
+ Assert.notNull(value, "Value cannot be null");
+ if (type == Type.EXACT && !value.startsWith("/")) {
+ throw new IllegalArgumentException("Exact value must start with /");
+ }
+ }
+
+ public static CacheClearRule forPrefix(String prefix) {
+ return new CacheClearRule(Type.PREFIX, prefix);
+ }
+
+ public static CacheClearRule forExact(String exact) {
+ return new CacheClearRule(Type.EXACT, exact);
+ }
+
+ public static CacheClearRule forContains(String contains) {
+ return new CacheClearRule(Type.CONTAINS, contains);
+ }
+
+ @Override
+ public String toString() {
+ return "CacheClearRule{" +
+ "type=" + type +
+ ", value='" + value + '\'' +
+ '}';
+ }
+
+ public enum Type {
+ PREFIX,
+ EXACT,
+ CONTAINS
+ }
+}
+
diff --git a/api/src/main/java/run/halo/feed/RSS2.java b/api/src/main/java/run/halo/feed/RSS2.java
new file mode 100644
index 0000000..d335eea
--- /dev/null
+++ b/api/src/main/java/run/halo/feed/RSS2.java
@@ -0,0 +1,186 @@
+package run.halo.feed;
+
+import jakarta.validation.constraints.NotBlank;
+import java.time.Instant;
+import java.util.List;
+import lombok.Builder;
+import lombok.Data;
+import lombok.Singular;
+
+@Data
+@Builder
+public class RSS2 {
+ /**
+ * (Recommended) The name of the feed, which should be plain text only
+ */
+ @NotBlank
+ private String title;
+
+ /**
+ * (Recommended) The URL of the website associated with the feed, which should link to a
+ * human-readable website
+ */
+ @NotBlank
+ private String link;
+
+ /**
+ * (Optional) The summary of the feed, which should be plain text only
+ */
+ private String description;
+
+ /**
+ * The primary language of the feed, which should be a value from
+ * RSS Language Codes or ISO
+ * 639 language codes
+ */
+ private String language;
+
+ /**
+ * (Recommended) The URL of the image that represents the channel, which should be relatively
+ * large and square
+ */
+ private String image;
+
+ @Singular
+ private List items;
+
+ @Data
+ @Builder
+ public static class Item {
+ /**
+ * (Required) The title of the item, which should be plain text only
+ */
+ @NotBlank
+ private String title;
+
+ /**
+ * (Recommended) The URL of the item, which should link to a human-readable website
+ */
+ @NotBlank
+ private String link;
+
+ /**
+ *
(Recommended) The content of the item. For an Atom feed, it's the atom:content
+ * element.
+ *
For a JSON feed, it's the content_html field.
+ */
+ @NotBlank
+ private String description;
+
+ /**
+ * (Optional) The author of the item
+ */
+ private String author;
+
+ /**
+ * (Optional) The category of the item. You can use a plain string or an array of strings
+ */
+ @Singular
+ private List categories;
+
+ /**
+ * (Recommended) The publication
+ * date of the item, which should be a Date object
+ * following the standard
+ */
+ private Instant pubDate;
+
+ /**
+ * (Optional) The unique identifier of the item
+ */
+ private String guid;
+
+ /**
+ * (Optional) The URL of an enclosure associated with the item
+ */
+ private String enclosureUrl;
+
+ /**
+ * (Optional) The size of the enclosure file in byte, which should be a number
+ */
+ private String enclosureLength;
+
+ /**
+ * (Optional) The MIME type of the enclosure file, which should be a string
+ */
+ private String enclosureType;
+
+ /**
+ * (Optional) Media content, represented by the element.
+ */
+ @Singular
+ private List mediaContents;
+ }
+
+ @Data
+ @Builder
+ public static class MediaContent {
+ /**
+ * URL of the media object.
+ */
+ private String url;
+
+ /**
+ * Type of the media, such as "image/jpeg", "audio/mpeg", "video/mp4".
+ */
+ private String type;
+
+ /**
+ * The general type of media: image, audio, video.
+ */
+ private MediaType mediaType;
+
+ /**
+ * File size of the media object in bytes.
+ */
+ private String fileSize;
+
+ /**
+ * Duration of the media object in seconds (for audio and video).
+ */
+ private String duration;
+
+ /**
+ * Height of the media object in pixels (for image and video).
+ */
+ private String height;
+
+ /**
+ * Width of the media object in pixels (for image and video).
+ */
+ private String width;
+
+ /**
+ * Bitrate of the media (for audio and video).
+ */
+ private String bitrate;
+
+ /**
+ * Thumbnail associated with this media content.
+ */
+ private MediaThumbnail thumbnail;
+
+ public enum MediaType {
+ IMAGE, AUDIO, VIDEO, DOCUMENT
+ }
+ }
+
+ @Data
+ @Builder
+ public static class MediaThumbnail {
+ /**
+ * URL of the thumbnail.
+ */
+ private String url;
+
+ /**
+ * Height of the thumbnail in pixels.
+ */
+ private String height;
+
+ /**
+ * Width of the thumbnail in pixels.
+ */
+ private String width;
+ }
+}
diff --git a/api/src/main/java/run/halo/feed/RssCacheClearRequested.java b/api/src/main/java/run/halo/feed/RssCacheClearRequested.java
new file mode 100644
index 0000000..cae3b4b
--- /dev/null
+++ b/api/src/main/java/run/halo/feed/RssCacheClearRequested.java
@@ -0,0 +1,68 @@
+package run.halo.feed;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import org.springframework.context.ApplicationEvent;
+import run.halo.app.plugin.SharedEvent;
+
+/**
+ * Represents an event to request the clearing of RSS cache with flexible rules.
+ * This event allows specifying multiple strategies for cache invalidation, including
+ * prefix matching, exact route matching, and keyword containment.
+ *
+ *
Attributes:
+ *
+ *
rules (required, List):
+ * A list of rules defining the cache clearing strategy. Each rule includes:
+ *
+ *
type (required, String): The type of matching rule. Supported values are:
+ *
+ *
prefix: Matches cache entries with keys that start with the specified prefix.
+ *
exact: Matches cache entries with keys that exactly match the specified value.
+ *
contains: Matches cache entries with keys that contain the specified substring
+ * .
+ *
+ *
+ *
value (required, String): The matching value for the rule.
+ *
+ *
For type "prefix", the value is the prefix path (e.g., "/feed/").
+ *
For type "exact", the value is the exact route (e.g., "/feed/moments/rss.xml")
+ * .
+ *
For type "contains", the value is a substring to search for (e.g., "moments").
+ *
+ *
+ *
+ *
+ *
applyToAll (optional, boolean, default: false):
+ * Indicates whether to clear all cache entries. If true, all rules in the "rules" list are
+ * ignored,
+ * and the entire cache is cleared.
+ *
+ */
+@SharedEvent
+@Getter
+public class RssCacheClearRequested extends ApplicationEvent {
+ private final List rules;
+ private final boolean applyToAll;
+
+ public RssCacheClearRequested(Object source, List rules, boolean applyToAll) {
+ super(source);
+ this.rules = (rules == null ? List.of() : new ArrayList<>(rules));
+ this.applyToAll = applyToAll;
+ }
+
+ public static RssCacheClearRequested forAll(Object source) {
+ return new RssCacheClearRequested(source, null, true);
+ }
+
+ public static RssCacheClearRequested forRules(Object source, List rules) {
+ return new RssCacheClearRequested(source, rules, false);
+ }
+
+ public static RssCacheClearRequested forRule(Object source, CacheClearRule rule) {
+ List 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}}.