Compare commits

...

21 Commits

Author SHA1 Message Date
Takagi 808f16edfb
Merge b48476448b into fe809c10a1 2024-05-08 04:03:12 +00:00
LIlGG b48476448b fix: incorrect column width calculation after merging cells in the table 2024-05-08 12:03:02 +08:00
John Niang fe809c10a1
Fix the problem that some plugins could not be used after upgrading dependent plugin (#5855)
#### What type of PR is this?

/kind bug
/area plugin
/area core

#### What this PR does / why we need it:

This PR resolves the problem that some plugins could not be used after upgrading dependent plugin.

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

Fixes https://github.com/halo-dev/halo/issues/5615

#### Special notes for your reviewer:

1. Install plugin [app-store](https://www.halo.run/store/apps/app-VYJbF)
2. Install plugin [backup](https://www.halo.run/store/apps/app-dHakX) and activate it
3. Disable plugin app-store
4. Check the features of plugin backup
5. Enable plugin app-store
6. Check the features of plugin backup
7. Upgrade plugin app-store with the any versions
8. Check the features of plugin backup

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

```release-note
修复因升级应用市场插件导致部分插件意外停止的问题
```
2024-05-07 03:25:58 +00:00
guqing 9b3f00dab0
fix: extension resources weren't deleted as expected on initialization (#5859)
#### What type of PR is this?
/kind improvement
/area core
/milestone 2.16.x

#### What this PR does / why we need it:
修复初始化时未按预期删除自定义资源

#### Does this PR introduce a user-facing change?
```release-note
修复初始化时未按预期删除自定义资源
```
2024-05-06 08:21:34 +00:00
John Niang fa286f74ee
Support excluding node_modules for idea IDE (#5857)
#### What type of PR is this?

/kind cleanup
/area core

#### What this PR does / why we need it:

There are too many unrelated results from node_modules folders when we try to search in files using IDEA IDE. This PR adapts the IDEA IDE using Gradle plugin `idea` to reduce search results.

Before:

<img width="700" alt="image" src="https://github.com/halo-dev/halo/assets/16865714/0e730896-6327-445c-b30e-0b0773f1743d">

After:

<img width="686" alt="image" src="https://github.com/halo-dev/halo/assets/16865714/2fc080cd-939c-4658-9309-e066e143bc30">

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

```release-note
None
```
2024-05-05 06:51:34 +00:00
John Niang 7ea414dd6d
Prepare for developing 2.16.0 (#5856)
#### What type of PR is this?

/kind cleanup
/area core

#### What this PR does / why we need it:

This is a regular action after releasing a minor version.

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

```release-note
None
```
2024-05-05 01:37:33 +00:00
Ryan Wang 2178bd8b80
fix: show plugin error message (#5838)
#### What type of PR is this?

/area ui
/kind bug
/milestone 2.15.0

#### What this PR does / why we need it:

修复插件异常启动时,指示器显示不正确的问题。

Fixes https://github.com/halo-dev/halo/pull/5520

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

```release-note
None
```
2024-04-30 06:40:24 +00:00
Ryan Wang 1f71532327
chore: bump preset plugins version (#5839)
#### What type of PR is this?

/area core
/kind improvement
/milestone 2.15.0

#### What this PR does / why we need it:

升级所有预设插件的版本。

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

```release-note
None
```
2024-04-30 06:38:17 +00:00
croatialu 2bcab942c1
chore: specify expose port in dockerfile (#5820)
#### What type of PR is this?
/kind improvement

#### What this PR does / why we need it:

When traefik is used, the host cannot be accessed to 8090 by default after being configured. Therefore, you need to configure loadbalance. The process is complicated, so specify the expose port

当使用 traefik 时, 配置完 host 后无法直接的访问到对应的服务, 需要额外的配置service, loadbalance.port 才能, 所以建议增加一个 expose port, 指定下默认的服务端口
#### Which issue(s) this PR fixes:


#### Special notes for your reviewer:

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

```release-note
None
```
2024-04-29 10:37:26 +00:00
guqing 5770ad4c55
fix: update post with retry (#5823)
#### What type of PR is this?
/kind improvement
/area core
/milestone 2.15.x

#### What this PR does / why we need it:
修复重试更新文章的错误写法

#### Does this PR introduce a user-facing change?
```release-note
None
```
2024-04-29 09:48:35 +00:00
Ryan Wang 966558d1ce
feat: add cleanup feature for post snapshots (#5822)
#### What type of PR is this?

/area ui
/kind feature
/milestone 2.15.x

#### What this PR does / why we need it:

支持清理所有无用的文章历史快照,仅保留已发布、基础版本、正在编辑的版本。

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

Fixes https://github.com/halo-dev/halo/issues/5817

#### Special notes for your reviewer:

需要在历史版本页面测试清理功能是否符合预期。

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

```release-note
None
```
2024-04-29 08:41:08 +00:00
Ryan Wang d1d4705705
refactor: improve editor ui (#5828)
#### What type of PR is this?

/area ui
/kind improvement
/milestone 2.15.x

#### What this PR does / why we need it:

优化编辑器的部分 UI。

1. 优化顶部工具栏的样式。
2. 统一格式刷和清除格式的图标。

before:

<img width="1001" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/4d3f2e9a-a79d-429e-aaa4-70313f61da6c">

after:

<img width="1021" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/676ce83b-da87-4c5b-bc63-1405106827f8">

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

```release-note
优化编辑器的部分 UI。
```
2024-04-29 07:59:34 +00:00
Cedric 546d63740b
Fix potential github action smells (#5809) 2024-04-29 15:45:22 +08:00
guqing d86ddf4a04
fix: permissions for post content versions (#5819)
#### What type of PR is this?
/kind bug
/area core
/milestone 2.15.x

#### What this PR does / why we need it:
修复拥有文章管理权限的用户无法正常使用版本历史的问题

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

#### Does this PR introduce a user-facing change?
```release-note
修复拥有文章管理权限的用户无法正常使用版本历史的问题
```
2024-04-28 05:50:11 +00:00
Takagi 3916d5b8e5
fix: the post admin does not have tag list permissions (#5818)
#### What type of PR is this?

/kind bug
/area ui

#### What this PR does / why we need it:

补充 #5593 中缺少的 `api.console.halo.run` 权限。用于解决具有文章列表权限的用户,访问标签列表时提示无权限的问题

#### How to test it?

创建一个具有文章列表查看权限的用户,是否能够访问到标签列表。

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

Fixes #5814

#### Does this PR introduce a user-facing change?
```release-note
None
```
2024-04-28 03:22:11 +00:00
Takagi 8abae05be7
fix: excel tables copied into editor become images (#5793)
#### What type of PR is this?

/kind bug
/area ui
/area editor

#### What this PR does / why we need it:

在进行图片上传的前置处理中,将剪切板属性中的 `text/plain` 类型与 `text/html` 类型进行过滤,用于解决将 Excel 表格粘贴至默认编辑器时,会导致其变为了图片而不是表格。

#### How to test it?

从 Excel 中复制一个表格,在默认编辑器中使用粘贴,查看其是否成功粘贴为表格而不是图片。

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

Fixes #5761 

#### Does this PR introduce a user-facing change?
```release-note
解决 Excel 表格粘贴至默认编辑器后会变为图片的问题。
```
2024-04-26 10:32:40 +00:00
guqing 0e17d53ede
feat: subscription support for expression-based subscribing (#5705)
#### What type of PR is this?
/kind feature
/area core
/milestone 2.15.x

#### What this PR does / why we need it:
通知订阅支持基于表达式订阅

see #5632 for more details

how to test it?
1. 测试系统通知功能的文章、页面有新评论通知和评论有新回复通知的功能是否正常
2. 测试 2.14 创建的文章、评论和回复升级到此版本后是否能继续收到相应通知,如文章有新评论

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

#### Does this PR introduce a user-facing change?
```release-note
通知订阅支持基于表达式订阅避免订阅随数据量增长同时自动优化之前的订阅数据
```
2024-04-26 10:26:41 +00:00
Ryan Wang 58f82d2cc2
feat: add features for view post history snapshots (#5787)
* Add snapshots related api

* feat: add features for view post history snapshots

---------

Co-authored-by: guqing <i@guqing.email>
2024-04-26 18:10:06 +08:00
guqing 1ade8493da
feat: require password verification for email updates (#5780)
#### What type of PR is this?
/kind feature
/milestone 2.15.x
/area core

#### What this PR does / why we need it:

增加了在用户尝试更新邮箱地址时进行密码验证的步骤。此举提高了安全性,确保邮箱修改操作由经过身份验证的用户执行。

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

#### Does this PR introduce a user-facing change?
```release-note
更新邮箱地址时需进行密码验证
```
2024-04-26 10:06:32 +00:00
Takagi cb6836aa8c
feat: add tab shortcut function to the default editor table (#5784)
#### What type of PR is this?

/kind feature
/area editor

#### What this PR does / why we need it:

为默认编辑器表格增加 `Tab` 与 `Shift-Tab` 切换至上一个单元格或下一个单元格。具体功能如下:

1. 使用 Tab 快捷键从左向右切换至下一个单元格,当光标在最后一个单元格时,使用 Tab 键新建一行并跳转至新一行的第一个单元格。
2. 使用 Shift + Tab 快捷键从右向左来切换至上一个单元格。

#### How to test it?

测试在默认编辑器中 `Tab` 快捷键切换单元格是否生效。
测试在合并单元格等各种表格操作下,切换单元格是否生效。

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

Fixes #5771 

#### Does this PR introduce a user-facing change?
```release-note
为默认编辑器表格增加 Tab 快捷键切换单元格的功能
```
2024-04-26 10:00:10 +00:00
guqing c0de807b9e
refactor: optimize comment and reply deletion (#5777)
#### What type of PR is this?
/kind improvement
/area core
/milestone 2.15.x

#### What this PR does / why we need it:
优化评论和回复删除,只有删除第一页后才会再次查询避免数据堆积

#### Does this PR introduce a user-facing change?
```release-note
None
```
2024-04-26 09:49:26 +00:00
122 changed files with 5477 additions and 811 deletions

View File

@ -19,6 +19,10 @@ on:
types:
- published
concurrency:
group: ${{github.workflow}} - ${{github.ref}}
cancel-in-progress: true
jobs:
test:
if: github.event_name == 'pull_request' || github.event_name == 'push'
@ -30,6 +34,7 @@ jobs:
- name: Check Halo
run: ./gradlew check
- name: Upload coverage reports to Codecov
if: github.repository == 'halo-dev/halo'
uses: codecov/codecov-action@v4
build:
@ -51,6 +56,7 @@ jobs:
- name: Build Halo
run: ./gradlew clean && ./gradlew downloadPluginPresets && ./gradlew build -x check
- name: Upload Artifacts
if: github.repository == 'halo-dev/halo'
uses: actions/upload-artifact@v4
with:
name: halo-artifacts
@ -69,6 +75,7 @@ jobs:
name: halo-artifacts
path: application/build/libs
- name: Upload Artifacts
if: github.repository == 'halo-dev/halo'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload ${{ github.event.release.tag_name }} application/build/libs/*

View File

@ -23,4 +23,6 @@ ENV JVM_OPTS="-Xmx256m -Xms256m" \
RUN ln -sf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone
Expose 8090
ENTRYPOINT ["sh", "-c", "java ${JVM_OPTS} org.springframework.boot.loader.launch.JarLauncher ${0} ${@}"]

View File

@ -26,7 +26,7 @@
## 快速开始
```bash
docker run -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.14
docker run -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.15
```
以上仅作为体验使用,详细部署文档请查阅:<https://docs.halo.run/getting-started/install/docker-compose>

View File

@ -2933,6 +2933,80 @@
}
},
"/apis/api.console.halo.run/v1alpha1/posts/{name}/content": {
"delete": {
"description": "Delete a content for post.",
"operationId": "deletePostContent",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "snapshotName",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ContentWrapper"
}
}
},
"description": "default response"
}
},
"tags": [
"api.console.halo.run/v1alpha1/Post"
]
},
"get": {
"description": "Fetch content of post.",
"operationId": "fetchPostContent",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "snapshotName",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ContentWrapper"
}
}
},
"description": "default response"
}
},
"tags": [
"api.console.halo.run/v1alpha1/Post"
]
},
"put": {
"description": "Update a post\u0027s content.",
"operationId": "UpdatePostContent",
@ -3094,6 +3168,81 @@
]
}
},
"/apis/api.console.halo.run/v1alpha1/posts/{name}/revert-content": {
"put": {
"description": "Revert to specified snapshot for post content.",
"operationId": "revertToSpecifiedSnapshotForPost",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RevertSnapshotForPostParam"
}
}
},
"required": true
},
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Post"
}
}
},
"description": "default response"
}
},
"tags": [
"api.console.halo.run/v1alpha1/Post"
]
}
},
"/apis/api.console.halo.run/v1alpha1/posts/{name}/snapshot": {
"get": {
"description": "List all snapshots for post content.",
"operationId": "listPostSnapshots",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ListedSnapshotDto"
}
}
}
},
"description": "default response"
}
},
"tags": [
"api.console.halo.run/v1alpha1/Post"
]
}
},
"/apis/api.console.halo.run/v1alpha1/posts/{name}/unpublish": {
"put": {
"description": "Publish a post.",
@ -3399,6 +3548,80 @@
}
},
"/apis/api.console.halo.run/v1alpha1/singlepages/{name}/content": {
"delete": {
"description": "Delete a content for post.",
"operationId": "deleteSinglePageContent",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "snapshotName",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ContentWrapper"
}
}
},
"description": "default response"
}
},
"tags": [
"api.console.halo.run/v1alpha1/SinglePage"
]
},
"get": {
"description": "Fetch content of single page.",
"operationId": "fetchSinglePageContent",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "snapshotName",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ContentWrapper"
}
}
},
"description": "default response"
}
},
"tags": [
"api.console.halo.run/v1alpha1/SinglePage"
]
},
"put": {
"description": "Update a single page\u0027s content.",
"operationId": "UpdateSinglePageContent",
@ -3532,6 +3755,81 @@
]
}
},
"/apis/api.console.halo.run/v1alpha1/singlepages/{name}/revert-content": {
"put": {
"description": "Revert to specified snapshot for single page content.",
"operationId": "revertToSpecifiedSnapshotForSinglePage",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RevertSnapshotForSingleParam"
}
}
},
"required": true
},
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Post"
}
}
},
"description": "default response"
}
},
"tags": [
"api.console.halo.run/v1alpha1/SinglePage"
]
}
},
"/apis/api.console.halo.run/v1alpha1/singlepages/{name}/snapshot": {
"get": {
"description": "List all snapshots for single page content.",
"operationId": "listSinglePageSnapshots",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ListedSnapshotDto"
}
}
}
},
"description": "default response"
}
},
"tags": [
"api.console.halo.run/v1alpha1/SinglePage"
]
}
},
"/apis/api.console.halo.run/v1alpha1/stats": {
"get": {
"description": "Get stats.",
@ -13890,6 +14188,24 @@
}
}
},
"ContentUpdateParam": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"raw": {
"type": "string"
},
"rawType": {
"type": "string"
},
"version": {
"type": "integer",
"format": "int64"
}
}
},
"ContentVo": {
"type": "object",
"properties": {
@ -14687,6 +15003,10 @@
],
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The expression to be interested in"
},
"reasonType": {
"type": "string",
"description": "The name of the reason definition to be interested in"
@ -15373,6 +15693,36 @@
}
}
},
"ListedSnapshotDto": {
"required": [
"metadata",
"spec"
],
"type": "object",
"properties": {
"metadata": {
"$ref": "#/components/schemas/Metadata"
},
"spec": {
"$ref": "#/components/schemas/ListedSnapshotSpec"
}
}
},
"ListedSnapshotSpec": {
"required": [
"owner"
],
"type": "object",
"properties": {
"modifyTime": {
"type": "string",
"format": "date-time"
},
"owner": {
"type": "string"
}
}
},
"ListedUser": {
"required": [
"roles",
@ -16952,7 +17302,7 @@
"type": "object",
"properties": {
"content": {
"$ref": "#/components/schemas/Content"
"$ref": "#/components/schemas/ContentUpdateParam"
},
"post": {
"$ref": "#/components/schemas/Post"
@ -17954,6 +18304,30 @@
}
}
},
"RevertSnapshotForPostParam": {
"required": [
"snapshotName"
],
"type": "object",
"properties": {
"snapshotName": {
"minLength": 1,
"type": "string"
}
}
},
"RevertSnapshotForSingleParam": {
"required": [
"snapshotName"
],
"type": "object",
"properties": {
"snapshotName": {
"minLength": 1,
"type": "string"
}
}
},
"Role": {
"required": [
"apiVersion",
@ -18595,7 +18969,7 @@
"type": "object",
"properties": {
"content": {
"$ref": "#/components/schemas/Content"
"$ref": "#/components/schemas/ContentUpdateParam"
},
"page": {
"$ref": "#/components/schemas/SinglePage"
@ -19975,13 +20349,17 @@
},
"VerifyCodeRequest": {
"required": [
"code"
"code",
"password"
],
"type": "object",
"properties": {
"code": {
"minLength": 1,
"type": "string"
},
"password": {
"type": "string"
}
}
},

View File

@ -11,7 +11,6 @@ import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
@ -59,6 +58,44 @@ public class Subscription extends AbstractExtension {
@Schema(requiredMode = REQUIRED, description = "The subject name of reason type to be"
+ " interested in")
private ReasonSubject subject;
@Schema(requiredMode = NOT_REQUIRED, description = "The expression to be interested in")
private String expression;
/**
* <p>Since 2.15.0, we have added a new field <code>expression</code> to the
* <code>InterestReason</code> object, so <code>subject</code> can be null.</p>
* <p>In this particular scenario, when the <code>subject</code> is null, we assign it a
* default <code>ReasonSubject</code> object. The properties of this object are set to
* specific values that do not occur in actual applications, thus we can consider this as
* <code>nonexistent data</code>.
* The purpose of this approach is to maintain backward compatibility, even if the
* <code>subject</code> can be null in the new version of the code.</p>
*/
public static void ensureSubjectHasValue(InterestReason interestReason) {
if (interestReason.getSubject() == null) {
interestReason.setSubject(createFallbackSubject());
}
}
/**
* Check if the given reason subject is a fallback subject.
*/
public static boolean isFallbackSubject(ReasonSubject reasonSubject) {
if (reasonSubject == null) {
return true;
}
var fallback = createFallbackSubject();
return fallback.getKind().equals(reasonSubject.getKind())
&& fallback.getApiVersion().equals(reasonSubject.getApiVersion());
}
static ReasonSubject createFallbackSubject() {
return ReasonSubject.builder()
.apiVersion("notification.halo.run/v1alpha1")
.kind("NonexistentKind")
.build();
}
}
@Data
@ -85,10 +122,14 @@ public class Subscription extends AbstractExtension {
}
@Data
@ToString
@Schema(name = "SubscriptionSubscriber")
public static class Subscriber {
private String name;
@Override
public String toString() {
return name;
}
}
/**

View File

@ -107,15 +107,15 @@ tasks.named('jacocoTestReport', JacocoReport) {
}
ext.presetPluginUrls = [
'https://github.com/halo-dev/plugin-comment-widget/releases/download/v1.9.0/plugin-comment-widget-1.9.0.jar' : 'plugin-comment-widget.jar',
'https://github.com/halo-dev/plugin-search-widget/releases/download/v1.3.1/plugin-search-widget-1.3.1.jar' : 'plugin-search-widget.jar',
'https://github.com/halo-dev/plugin-comment-widget/releases/download/v2.1.0/plugin-comment-widget-2.1.0.jar' : 'plugin-comment-widget.jar',
'https://github.com/halo-dev/plugin-search-widget/releases/download/v1.4.0/plugin-search-widget-1.4.0.jar' : 'plugin-search-widget.jar',
'https://github.com/halo-dev/plugin-sitemap/releases/download/v1.1.1/plugin-sitemap-1.1.1.jar' : 'plugin-sitemap.jar',
'https://github.com/halo-dev/plugin-feed/releases/download/v1.2.1/plugin-feed-1.2.1.jar' : 'plugin-feed.jar',
'https://github.com/halo-dev/plugin-feed/releases/download/v1.2.2/plugin-feed-1.2.2.jar' : 'plugin-feed.jar',
// Currently, plugin-app-store is not open source, so we need to download it from the official website.
// Please see https://github.com/halo-dev/plugin-app-store/issues/55
// https://www.halo.run/store/apps/app-VYJbF
'https://www.halo.run/store/apps/app-VYJbF/releases/download/app-release-cWbLS/assets/app-release-cWbLS-fZYSx': 'appstore.jar',
'https://www.halo.run/store/apps/app-VYJbF/releases/download/app-release-yfjXe/assets/app-release-yfjXe-kOzZZ': 'appstore.jar',
]
tasks.register('downloadPluginPresets', Download) {

View File

@ -1,5 +1,9 @@
package run.halo.app.content;
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.isNull;
import java.security.Principal;
import java.time.Duration;
import java.time.Instant;
@ -8,15 +12,20 @@ import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
import run.halo.app.extension.router.selector.FieldSelector;
/**
* Abstract Service for {@link Snapshot}.
@ -62,14 +71,27 @@ public abstract class AbstractContentService {
}
protected Mono<ContentWrapper> draftContent(@Nullable String baseSnapshotName,
ContentRequest contentRequest,
@Nullable String parentSnapshotName) {
return create(baseSnapshotName, contentRequest, parentSnapshotName)
.flatMap(head -> {
String baseSnapshotNameToUse =
StringUtils.defaultIfBlank(baseSnapshotName, head.getMetadata().getName());
return restoredContent(baseSnapshotNameToUse, head);
});
}
protected Mono<ContentWrapper> draftContent(String baseSnapshotName, ContentRequest content) {
return this.draftContent(baseSnapshotName, content, content.headSnapshotName());
}
private Mono<Snapshot> create(@Nullable String baseSnapshotName,
ContentRequest contentRequest,
@Nullable String parentSnapshotName) {
Snapshot snapshot = contentRequest.toSnapshot();
snapshot.getMetadata().setName(UUID.randomUUID().toString());
snapshot.getSpec().setParentSnapshotName(parentSnapshotName);
final String baseSnapshotNameToUse =
StringUtils.defaultIfBlank(baseSnapshotName, snapshot.getMetadata().getName());
return client.fetch(Snapshot.class, baseSnapshotName)
.doOnNext(this::checkBaseSnapshot)
.defaultIfEmpty(snapshot)
@ -77,19 +99,13 @@ public abstract class AbstractContentService {
contentRequest)
)
.flatMap(source -> getContextUsername()
.map(username -> {
.doOnNext(username -> {
Snapshot.addContributor(source, username);
source.getSpec().setOwner(username);
return source;
})
.defaultIfEmpty(source)
.thenReturn(source)
)
.flatMap(snapshotToCreate -> client.create(snapshotToCreate)
.flatMap(head -> restoredContent(baseSnapshotNameToUse, head)));
}
protected Mono<ContentWrapper> draftContent(String baseSnapshotName, ContentRequest content) {
return this.draftContent(baseSnapshotName, content, content.headSnapshotName());
.flatMap(client::create);
}
protected Mono<ContentWrapper> updateContent(String baseSnapshotName,
@ -98,17 +114,23 @@ public abstract class AbstractContentService {
Assert.notNull(baseSnapshotName, "The baseSnapshotName must not be null");
Assert.notNull(contentRequest.headSnapshotName(), "The headSnapshotName must not be null");
return Mono.defer(() -> client.fetch(Snapshot.class, contentRequest.headSnapshotName())
.flatMap(headSnapshot -> {
var oldVersion = contentRequest.version();
var version = headSnapshot.getMetadata().getVersion();
if (hasConflict(oldVersion, version)) {
// draft a new snapshot as the head snapshot
return create(baseSnapshotName, contentRequest,
contentRequest.headSnapshotName());
}
return Mono.just(headSnapshot);
})
.flatMap(headSnapshot -> client.fetch(Snapshot.class, baseSnapshotName)
.map(baseSnapshot -> determineRawAndContentPatch(headSnapshot, baseSnapshot,
contentRequest)
)
.map(baseSnapshot -> determineRawAndContentPatch(headSnapshot,
baseSnapshot, contentRequest))
)
.flatMap(headSnapshot -> getContextUsername()
.map(username -> {
Snapshot.addContributor(headSnapshot, username);
return headSnapshot;
})
.defaultIfEmpty(headSnapshot)
.doOnNext(username -> Snapshot.addContributor(headSnapshot, username))
.thenReturn(headSnapshot)
)
.flatMap(client::update)
)
@ -117,6 +139,19 @@ public abstract class AbstractContentService {
.flatMap(head -> restoredContent(baseSnapshotName, head));
}
protected Flux<Snapshot> listSnapshotsBy(Ref ref) {
var snapshotListOptions = new ListOptions();
var query = and(isNull("metadata.deletionTimestamp"),
equal("spec.subjectRef", Snapshot.toSubjectRefKey(ref)));
snapshotListOptions.setFieldSelector(FieldSelector.of(query));
var sort = Sort.by("metadata.creationTimestamp", "metadata.name").descending();
return client.listAll(Snapshot.class, snapshotListOptions, sort);
}
boolean hasConflict(Long oldVersion, Long newVersion) {
return oldVersion != null && !newVersion.equals(oldVersion);
}
protected Mono<ContentWrapper> restoredContent(String baseSnapshotName, Snapshot headSnapshot) {
return client.fetch(Snapshot.class, baseSnapshotName)
.doOnNext(this::checkBaseSnapshot)

View File

@ -1,9 +1,11 @@
package run.halo.app.content;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.HashMap;
import lombok.Builder;
import org.apache.commons.lang3.StringUtils;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.extension.Metadata;
@ -13,8 +15,10 @@ import run.halo.app.extension.Ref;
* @author guqing
* @since 2.0.0
*/
@Builder
public record ContentRequest(@Schema(requiredMode = REQUIRED) Ref subjectRef,
String headSnapshotName,
@Schema(requiredMode = NOT_REQUIRED) Long version,
@Schema(requiredMode = REQUIRED) String raw,
@Schema(requiredMode = REQUIRED) String content,
@Schema(requiredMode = REQUIRED) String rawType) {

View File

@ -0,0 +1,9 @@
package run.halo.app.content;
public record ContentUpdateParam(Long version, String raw, String content, String rawType) {
public static ContentUpdateParam from(Content content) {
return new ContentUpdateParam(null, content.raw(), content.content(),
content.rawType());
}
}

View File

@ -0,0 +1,42 @@
package run.halo.app.content;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import lombok.Data;
import lombok.experimental.Accessors;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.extension.MetadataOperator;
@Data
@Accessors(chain = true)
public class ListedSnapshotDto {
@Schema(requiredMode = REQUIRED)
private MetadataOperator metadata;
@Schema(requiredMode = REQUIRED)
private Spec spec;
@Data
@Accessors(chain = true)
@Schema(name = "ListedSnapshotSpec")
public static class Spec {
@Schema(requiredMode = REQUIRED)
private String owner;
private Instant modifyTime;
}
/**
* Creates from snapshot.
*/
public static ListedSnapshotDto from(Snapshot snapshot) {
return new ListedSnapshotDto()
.setMetadata(snapshot.getMetadata())
.setSpec(new Spec()
.setOwner(snapshot.getSpec().getOwner())
.setModifyTime(snapshot.getSpec().getLastModifyTime())
);
}
}

View File

@ -14,12 +14,12 @@ import run.halo.app.extension.Ref;
* @since 2.0.0
*/
public record PostRequest(@Schema(requiredMode = REQUIRED) @NonNull Post post,
Content content) {
ContentUpdateParam content) {
public ContentRequest contentRequest() {
Ref subjectRef = Ref.of(post);
return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.raw(),
content.content(), content.rawType());
return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.version(),
content.raw(), content.content(), content.rawType());
}
}

View File

@ -1,6 +1,7 @@
package run.halo.app.content;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
@ -31,6 +32,8 @@ public interface PostService {
Mono<ContentWrapper> getContent(String snapshotName, String baseSnapshotName);
Flux<ListedSnapshotDto> listSnapshots(String name);
Mono<Post> publish(Post post);
Mono<Post> unpublish(Post post);
@ -43,4 +46,8 @@ public interface PostService {
* @return full post data or empty.
*/
Mono<Post> getByUsername(String postName, String username);
Mono<Post> revertToSpecifiedSnapshot(String postName, String snapshotName);
Mono<ContentWrapper> deleteContent(String postName, String snapshotName);
}

View File

@ -13,12 +13,12 @@ import run.halo.app.extension.Ref;
* @since 2.0.0
*/
public record SinglePageRequest(@Schema(requiredMode = REQUIRED) SinglePage page,
@Schema(requiredMode = REQUIRED) Content content) {
@Schema(requiredMode = REQUIRED) ContentUpdateParam content) {
public ContentRequest contentRequest() {
Ref subjectRef = Ref.of(page);
return new ContentRequest(subjectRef, page.getSpec().getHeadSnapshot(), content.raw(),
content.content(), content.rawType());
return new ContentRequest(subjectRef, page.getSpec().getHeadSnapshot(), content.version(),
content.raw(), content.content(), content.rawType());
}
}

View File

@ -1,5 +1,6 @@
package run.halo.app.content;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.ListResult;
@ -18,9 +19,15 @@ public interface SinglePageService {
Mono<ContentWrapper> getContent(String snapshotName, String baseSnapshotName);
Flux<ListedSnapshotDto> listSnapshots(String pageName);
Mono<ListResult<ListedSinglePage>> list(SinglePageQuery listRequest);
Mono<SinglePage> draft(SinglePageRequest pageRequest);
Mono<SinglePage> update(SinglePageRequest pageRequest);
Mono<SinglePage> revertToSpecifiedSnapshot(String pageName, String snapshotName);
Mono<ContentWrapper> deleteContent(String postName, String snapshotName);
}

View File

@ -1,6 +1,7 @@
package run.halo.app.content.comment;
import static org.apache.commons.lang3.StringUtils.defaultIfBlank;
import static run.halo.app.content.comment.ReplyNotificationSubscriptionHelper.identityFrom;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.Map;
@ -29,7 +30,6 @@ import run.halo.app.extension.Ref;
import run.halo.app.infra.ExternalLinkProcessor;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.notification.NotificationReasonEmitter;
import run.halo.app.notification.UserIdentity;
import run.halo.app.plugin.ExtensionComponentsFinder;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
@ -114,6 +114,7 @@ public class CommentNotificationReasonPublisher {
builder -> {
var attributes = CommentOnPostReasonData.builder()
.postName(subjectRef.getName())
.postOwner(post.getSpec().getOwner())
.postTitle(post.getSpec().getTitle())
.postUrl(postUrl)
.commenter(owner.getDisplayName())
@ -144,8 +145,9 @@ public class CommentNotificationReasonPublisher {
}
@Builder
record CommentOnPostReasonData(String postName, String postTitle, String postUrl,
String commenter, String content, String commentName) {
record CommentOnPostReasonData(String postName, String postOwner, String postTitle,
String postUrl, String commenter, String content,
String commentName) {
}
}
@ -180,6 +182,7 @@ public class CommentNotificationReasonPublisher {
builder -> {
var attributes = CommentOnPageReasonData.builder()
.pageName(subjectRef.getName())
.pageOwner(singlePage.getSpec().getOwner())
.pageTitle(singlePage.getSpec().getTitle())
.pageUrl(pageUrl)
.commenter(defaultIfBlank(owner.getDisplayName(), owner.getName()))
@ -210,8 +213,9 @@ public class CommentNotificationReasonPublisher {
}
@Builder
record CommentOnPageReasonData(String pageName, String pageTitle, String pageUrl,
String commenter, String content, String commentName) {
record CommentOnPageReasonData(String pageName, String pageOwner, String pageTitle,
String pageUrl, String commenter, String content,
String commentName) {
}
}
@ -224,13 +228,6 @@ public class CommentNotificationReasonPublisher {
}
}
static UserIdentity identityFrom(Comment.CommentOwner owner) {
if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
return UserIdentity.anonymousWithEmail(owner.getName());
}
return UserIdentity.of(owner.getName());
}
@Component
@RequiredArgsConstructor
static class NewReplyReasonPublisher {
@ -272,6 +269,10 @@ public class CommentNotificationReasonPublisher {
.orElse(null);
var replyOwner = reply.getSpec().getOwner();
var repliedOwner = quoteReplyOptional
.map(quoteReply -> quoteReply.getSpec().getOwner())
.orElseGet(() -> comment.getSpec().getOwner());
var reasonAttributesBuilder = NewReplyReasonData.builder()
.commentContent(comment.getSpec().getContent())
.isQuoteReply(isQuoteReply)
@ -279,7 +280,9 @@ public class CommentNotificationReasonPublisher {
.commentName(comment.getMetadata().getName())
.replier(defaultIfBlank(replyOwner.getDisplayName(), replyOwner.getName()))
.content(reply.getSpec().getContent())
.replyName(reply.getMetadata().getName());
.replyName(reply.getMetadata().getName())
.replyOwner(identityFrom(replyOwner).name())
.repliedOwner(identityFrom(repliedOwner).name());
getCommentSubjectDisplay(comment.getSpec().getSubjectRef())
.ifPresent(subject -> {
@ -337,7 +340,7 @@ public class CommentNotificationReasonPublisher {
String commentSubjectUrl, boolean isQuoteReply,
String quoteContent,
String commentName, String replier, String content,
String replyName) {
String replyName, String replyOwner, String repliedOwner) {
}
}
}

View File

@ -4,10 +4,12 @@ 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.isNull;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.function.Function;
import org.apache.commons.lang3.BooleanUtils;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import org.springframework.lang.NonNull;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
@ -15,6 +17,7 @@ import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.service.RoleService;
@ -147,17 +150,33 @@ public class CommentServiceImpl implements CommentService {
@Override
public Mono<Void> removeBySubject(@NonNull Ref subjectRef) {
Assert.notNull(subjectRef, "The subjectRef must not be null.");
return cleanupComments(subjectRef, 200);
}
private Mono<Void> cleanupComments(Ref subjectRef, int batchSize) {
// ascending order by creation time and name
var pageRequest = PageRequestImpl.of(1, 200,
final var pageRequest = PageRequestImpl.of(1, batchSize,
Sort.by("metadata.creationTimestamp", "metadata.name"));
return Flux.defer(() -> listCommentsByRef(subjectRef, pageRequest))
.expand(page -> page.hasNext()
? listCommentsByRef(subjectRef, pageRequest.next())
: Mono.empty()
// forever loop first page until no more to delete
return listCommentsByRef(subjectRef, pageRequest)
.flatMap(page -> Flux.fromIterable(page.getItems())
.flatMap(this::deleteWithRetry)
.then(page.hasNext() ? cleanupComments(subjectRef, batchSize) : Mono.empty())
);
}
private Mono<Comment> deleteWithRetry(Comment item) {
return client.delete(item)
.onErrorResume(OptimisticLockingFailureException.class,
e -> attemptToDelete(item.getMetadata().getName()));
}
private Mono<Comment> attemptToDelete(String name) {
return Mono.defer(() -> client.fetch(Comment.class, name)
.flatMap(client::delete)
)
.flatMap(page -> Flux.fromIterable(page.getItems()))
.flatMap(client::delete)
.then();
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance));
}
Mono<ListResult<Comment>> listCommentsByRef(Ref subjectRef, PageRequest pageRequest) {

View File

@ -9,7 +9,7 @@ import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Reply;
import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.notification.NotificationCenter;
import run.halo.app.notification.SubscriberEmailResolver;
import run.halo.app.notification.UserIdentity;
/**
* Reply notification subscription helper.
@ -22,7 +22,6 @@ import run.halo.app.notification.SubscriberEmailResolver;
public class ReplyNotificationSubscriptionHelper {
private final NotificationCenter notificationCenter;
private final SubscriberEmailResolver subscriberEmailResolver;
/**
* Subscribe new reply reason for comment.
@ -30,13 +29,7 @@ public class ReplyNotificationSubscriptionHelper {
* @param comment comment
*/
public void subscribeNewReplyReasonForComment(Comment comment) {
var reasonSubject = Subscription.ReasonSubject.builder()
.apiVersion(comment.getApiVersion())
.kind(comment.getKind())
.name(comment.getMetadata().getName())
.build();
subscribeReply(reasonSubject,
Identity.fromCommentOwner(comment.getSpec().getOwner()));
subscribeReply(identityFrom(comment.getSpec().getOwner()));
}
/**
@ -45,50 +38,36 @@ public class ReplyNotificationSubscriptionHelper {
* @param reply reply
*/
public void subscribeNewReplyReasonForReply(Reply reply) {
var reasonSubject = Subscription.ReasonSubject.builder()
.apiVersion(reply.getApiVersion())
.kind(reply.getKind())
.name(reply.getMetadata().getName())
.build();
var subjectOwner = reply.getSpec().getOwner();
subscribeReply(reasonSubject,
Identity.fromCommentOwner(subjectOwner));
subscribeReply(identityFrom(subjectOwner));
}
void subscribeReply(Subscription.ReasonSubject reasonSubject,
Identity identity) {
void subscribeReply(UserIdentity identity) {
var subscriber = createSubscriber(identity);
if (subscriber == null) {
return;
}
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType(NotificationReasonConst.SOMEONE_REPLIED_TO_YOU);
interestReason.setSubject(reasonSubject);
interestReason.setExpression("props.repliedOwner == '%s'".formatted(identity.name()));
notificationCenter.subscribe(subscriber, interestReason).block();
}
@Nullable
private Subscription.Subscriber createSubscriber(Identity author) {
private Subscription.Subscriber createSubscriber(UserIdentity author) {
if (StringUtils.isBlank(author.name())) {
return null;
}
Subscription.Subscriber subscriber;
if (author.isEmail()) {
subscriber = subscriberEmailResolver.ofEmail(author.name());
} else {
subscriber = new Subscription.Subscriber();
subscriber.setName(author.name());
}
Subscription.Subscriber subscriber = new Subscription.Subscriber();
subscriber.setName(author.name());
return subscriber;
}
record Identity(String name, boolean isEmail) {
public static Identity fromCommentOwner(Comment.CommentOwner commentOwner) {
if (Comment.CommentOwner.KIND_EMAIL.equals(commentOwner.getKind())) {
return new Identity(commentOwner.getName(), true);
}
return new Identity(commentOwner.getName(), false);
public static UserIdentity identityFrom(Comment.CommentOwner owner) {
if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
return UserIdentity.anonymousWithEmail(owner.getName());
}
return UserIdentity.of(owner.getName());
}
}

View File

@ -5,18 +5,21 @@ import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Reply;
@ -118,17 +121,33 @@ public class ReplyServiceImpl implements ReplyService {
@Override
public Mono<Void> removeAllByComment(String commentName) {
Assert.notNull(commentName, "The commentName must not be null.");
return cleanupComments(commentName, 200);
}
private Mono<Void> cleanupComments(String commentName, int batchSize) {
// ascending order by creation time and name
var pageRequest = PageRequestImpl.of(1, 200,
final var pageRequest = PageRequestImpl.of(1, batchSize,
Sort.by("metadata.creationTimestamp", "metadata.name"));
return Flux.defer(() -> listRepliesByComment(commentName, pageRequest))
.expand(page -> page.hasNext()
? listRepliesByComment(commentName, pageRequest.next())
: Mono.empty()
// forever loop first page until no more to delete
return listRepliesByComment(commentName, pageRequest)
.flatMap(page -> Flux.fromIterable(page.getItems())
.flatMap(this::deleteWithRetry)
.then(page.hasNext() ? cleanupComments(commentName, batchSize) : Mono.empty())
);
}
private Mono<Reply> deleteWithRetry(Reply item) {
return client.delete(item)
.onErrorResume(OptimisticLockingFailureException.class,
e -> attemptToDelete(item.getMetadata().getName()));
}
private Mono<Reply> attemptToDelete(String name) {
return Mono.defer(() -> client.fetch(Reply.class, name)
.flatMap(client::delete)
)
.flatMap(page -> Flux.fromIterable(page.getItems()))
.flatMap(client::delete)
.then();
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance));
}
Mono<ListResult<Reply>> listRepliesByComment(String commentName, PageRequest pageRequest) {

View File

@ -7,6 +7,7 @@ import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.OptimisticLockingFailureException;
@ -14,6 +15,7 @@ import org.springframework.data.domain.Sort;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
@ -22,12 +24,14 @@ import run.halo.app.content.ContentRequest;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.Contributor;
import run.halo.app.content.ListedPost;
import run.halo.app.content.ListedSnapshotDto;
import run.halo.app.content.PostQuery;
import run.halo.app.content.PostRequest;
import run.halo.app.content.PostService;
import run.halo.app.content.Stats;
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.core.extension.content.Tag;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ListOptions;
@ -173,6 +177,7 @@ public class PostServiceImpl extends AbstractContentService implements PostServi
}
var contentRequest =
new ContentRequest(Ref.of(post), post.getSpec().getHeadSnapshot(),
null,
postRequest.content().raw(), postRequest.content().content(),
postRequest.content().rawType());
return draftContent(post.getSpec().getBaseSnapshot(), contentRequest)
@ -261,6 +266,13 @@ public class PostServiceImpl extends AbstractContentService implements PostServi
return getContent(releaseSnapshot, post.getSpec().getBaseSnapshot());
}
@Override
public Flux<ListedSnapshotDto> listSnapshots(String name) {
return client.fetch(Post.class, name)
.flatMapMany(page -> listSnapshotsBy(Ref.of(page)))
.map(ListedSnapshotDto::from);
}
@Override
public Mono<Post> publish(Post post) {
var spec = post.getSpec();
@ -284,4 +296,84 @@ public class PostServiceImpl extends AbstractContentService implements PostServi
.filter(post -> post.getSpec() != null)
.filter(post -> Objects.equals(username, post.getSpec().getOwner()));
}
@Override
public Mono<Post> revertToSpecifiedSnapshot(String postName, String snapshotName) {
return client.get(Post.class, postName)
.filter(post -> {
var head = post.getSpec().getHeadSnapshot();
return !StringUtils.equals(head, snapshotName);
})
.flatMap(post -> {
var baseSnapshot = post.getSpec().getBaseSnapshot();
return getContent(snapshotName, baseSnapshot)
.map(content -> ContentRequest.builder()
.subjectRef(Ref.of(post))
.headSnapshotName(post.getSpec().getHeadSnapshot())
.content(content.getContent())
.raw(content.getRaw())
.rawType(content.getRawType())
.build()
)
.flatMap(contentRequest -> draftContent(baseSnapshot, contentRequest))
.flatMap(content -> {
post.getSpec().setHeadSnapshot(content.getSnapshotName());
return publishPostWithRetry(post);
});
});
}
@Override
public Mono<ContentWrapper> deleteContent(String postName, String snapshotName) {
return client.get(Post.class, postName)
.flatMap(post -> {
var headSnapshotName = post.getSpec().getHeadSnapshot();
if (StringUtils.equals(headSnapshotName, snapshotName)) {
return updatePostWithRetry(post, record -> {
// update head to release
record.getSpec().setHeadSnapshot(record.getSpec().getReleaseSnapshot());
return record;
});
}
return Mono.just(post);
})
.flatMap(post -> {
var baseSnapshotName = post.getSpec().getBaseSnapshot();
var releaseSnapshotName = post.getSpec().getReleaseSnapshot();
if (StringUtils.equals(releaseSnapshotName, snapshotName)) {
return Mono.error(new ServerWebInputException(
"The snapshot to delete is the release snapshot, please"
+ " revert to another snapshot first."));
}
if (StringUtils.equals(baseSnapshotName, snapshotName)) {
return Mono.error(
new ServerWebInputException("The first snapshot cannot be deleted."));
}
return client.fetch(Snapshot.class, snapshotName)
.flatMap(client::delete)
.flatMap(deleted -> restoredContent(baseSnapshotName, deleted));
});
}
private Mono<Post> updatePostWithRetry(Post post, UnaryOperator<Post> func) {
return client.update(func.apply(post))
.onErrorResume(OptimisticLockingFailureException.class,
e -> Mono.defer(() -> client.get(Post.class, post.getMetadata().getName())
.map(func)
.flatMap(client::update)
)
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance))
);
}
Mono<Post> publishPostWithRetry(Post post) {
return publish(post)
.onErrorResume(OptimisticLockingFailureException.class,
e -> Mono.defer(() -> client.get(Post.class, post.getMetadata().getName())
.flatMap(this::publish))
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance))
);
}
}

View File

@ -5,11 +5,13 @@ import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
@ -18,12 +20,14 @@ import run.halo.app.content.ContentRequest;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.Contributor;
import run.halo.app.content.ListedSinglePage;
import run.halo.app.content.ListedSnapshotDto;
import run.halo.app.content.SinglePageQuery;
import run.halo.app.content.SinglePageRequest;
import run.halo.app.content.SinglePageService;
import run.halo.app.content.Stats;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
@ -73,6 +77,13 @@ public class SinglePageServiceImpl extends AbstractContentService implements Sin
});
}
@Override
public Flux<ListedSnapshotDto> listSnapshots(String pageName) {
return client.fetch(SinglePage.class, pageName)
.flatMapMany(page -> listSnapshotsBy(Ref.of(page)))
.map(ListedSnapshotDto::from);
}
@Override
public Mono<ListResult<ListedSinglePage>> list(SinglePageQuery query) {
return client.list(SinglePage.class, query.toPredicate(),
@ -103,6 +114,7 @@ public class SinglePageServiceImpl extends AbstractContentService implements Sin
.flatMap(page -> {
var contentRequest =
new ContentRequest(Ref.of(page), page.getSpec().getHeadSnapshot(),
null,
pageRequest.content().raw(), pageRequest.content().content(),
pageRequest.content().rawType());
return draftContent(page.getSpec().getBaseSnapshot(), contentRequest)
@ -163,6 +175,96 @@ public class SinglePageServiceImpl extends AbstractContentService implements Sin
});
}
@Override
public Mono<SinglePage> revertToSpecifiedSnapshot(String pageName, String snapshotName) {
return client.get(SinglePage.class, pageName)
.filter(page -> {
var head = page.getSpec().getHeadSnapshot();
return !StringUtils.equals(head, snapshotName);
})
.flatMap(page -> {
var baseSnapshot = page.getSpec().getBaseSnapshot();
return getContent(snapshotName, baseSnapshot)
.map(content -> ContentRequest.builder()
.subjectRef(Ref.of(page))
.headSnapshotName(page.getSpec().getHeadSnapshot())
.content(content.getContent())
.raw(content.getRaw())
.rawType(content.getRawType())
.build()
)
.flatMap(contentRequest -> draftContent(baseSnapshot, contentRequest))
.flatMap(content -> {
page.getSpec().setHeadSnapshot(content.getSnapshotName());
return publishPageWithRetry(page);
});
});
}
@Override
public Mono<ContentWrapper> deleteContent(String pageName, String snapshotName) {
return client.get(SinglePage.class, pageName)
.flatMap(page -> {
var headSnapshotName = page.getSpec().getHeadSnapshot();
if (StringUtils.equals(headSnapshotName, snapshotName)) {
return updatePageWithRetry(page, record -> {
// update head to release
page.getSpec().setHeadSnapshot(page.getSpec().getReleaseSnapshot());
return record;
});
}
return Mono.just(page);
})
.flatMap(page -> {
var baseSnapshotName = page.getSpec().getBaseSnapshot();
var releaseSnapshotName = page.getSpec().getReleaseSnapshot();
if (StringUtils.equals(releaseSnapshotName, snapshotName)) {
return Mono.error(new ServerWebInputException(
"The snapshot to delete is the release snapshot, please"
+ " revert to another snapshot first."));
}
if (StringUtils.equals(baseSnapshotName, snapshotName)) {
return Mono.error(
new ServerWebInputException("The first snapshot cannot be deleted."));
}
return client.fetch(Snapshot.class, snapshotName)
.flatMap(client::delete)
.flatMap(deleted -> restoredContent(baseSnapshotName, deleted));
});
}
private Mono<SinglePage> updatePageWithRetry(SinglePage page, UnaryOperator<SinglePage> func) {
return client.update(func.apply(page))
.onErrorResume(OptimisticLockingFailureException.class,
e -> Mono.defer(() -> client.get(SinglePage.class, page.getMetadata().getName())
.map(func)
.flatMap(client::update)
)
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance))
);
}
private Mono<SinglePage> publish(SinglePage singlePage) {
var spec = singlePage.getSpec();
spec.setPublish(true);
if (spec.getHeadSnapshot() == null) {
spec.setHeadSnapshot(spec.getBaseSnapshot());
}
spec.setReleaseSnapshot(spec.getHeadSnapshot());
return client.update(singlePage);
}
Mono<SinglePage> publishPageWithRetry(SinglePage page) {
return publish(page)
.onErrorResume(OptimisticLockingFailureException.class,
e -> Mono.defer(() -> client.get(SinglePage.class, page.getMetadata().getName())
.flatMap(this::publish))
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance))
);
}
private Mono<ListedSinglePage> getListedSinglePage(SinglePage singlePage) {
Assert.notNull(singlePage, "The singlePage must not be null.");
var listedSinglePage = new ListedSinglePage()

View File

@ -6,6 +6,7 @@ import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Duration;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
@ -20,12 +21,15 @@ import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerErrorException;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.Exceptions;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.content.Content;
import run.halo.app.content.ContentUpdateParam;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.ListedPost;
import run.halo.app.content.ListedSnapshotDto;
import run.halo.app.content.PostQuery;
import run.halo.app.content.PostRequest;
import run.halo.app.content.PostService;
@ -75,6 +79,23 @@ public class PostEndpoint implements CustomEndpoint {
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.GET("posts/{name}/content", this::fetchContent,
builder -> builder.operationId("fetchPostContent")
.description("Fetch content of post.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.parameter(parameterBuilder()
.name("snapshotName")
.in(ParameterIn.QUERY)
.required(true)
.implementation(String.class))
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.GET("posts/{name}/release-content", this::fetchReleaseContent,
builder -> builder.operationId("fetchPostReleaseContent")
.description("Fetch release content of post.")
@ -87,6 +108,17 @@ public class PostEndpoint implements CustomEndpoint {
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.GET("posts/{name}/snapshot", this::listSnapshots,
builder -> builder.operationId("listPostSnapshots")
.description("List all snapshots for post content.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class))
.response(responseBuilder()
.implementationArray(ListedSnapshotDto.class))
)
.POST("posts", this::draftPost,
builder -> builder.operationId("DraftPost")
.description("Draft a post.")
@ -137,6 +169,24 @@ public class PostEndpoint implements CustomEndpoint {
.response(responseBuilder()
.implementation(Post.class))
)
.PUT("posts/{name}/revert-content", this::revertToSpecifiedSnapshot,
builder -> builder.operationId("revertToSpecifiedSnapshotForPost")
.description("Revert to specified snapshot for post content.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class))
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(Builder.schemaBuilder()
.implementation(RevertSnapshotParam.class))
))
.response(responseBuilder()
.implementation(Post.class))
)
.PUT("posts/{name}/publish", this::publishPost,
builder -> builder.operationId("PublishPost")
.description("Publish a post.")
@ -168,9 +218,64 @@ public class PostEndpoint implements CustomEndpoint {
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)))
.DELETE("posts/{name}/content", this::deleteContent,
builder -> builder.operationId("deletePostContent")
.description("Delete a content for post.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.parameter(parameterBuilder()
.name("snapshotName")
.in(ParameterIn.QUERY)
.required(true)
.implementation(String.class))
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.build();
}
private Mono<ServerResponse> deleteContent(ServerRequest request) {
final var postName = request.pathVariable("name");
final var snapshotName = request.queryParam("snapshotName").orElseThrow();
return postService.deleteContent(postName, snapshotName)
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
private Mono<ServerResponse> revertToSpecifiedSnapshot(ServerRequest request) {
final var postName = request.pathVariable("name");
return request.bodyToMono(RevertSnapshotParam.class)
.switchIfEmpty(
Mono.error(new ServerWebInputException("Required request body is missing.")))
.flatMap(param -> postService.revertToSpecifiedSnapshot(postName, param.snapshotName))
.flatMap(post -> ServerResponse.ok().bodyValue(post));
}
@Schema(name = "RevertSnapshotForPostParam")
record RevertSnapshotParam(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, minLength = 1) String snapshotName) {
}
private Mono<ServerResponse> fetchContent(ServerRequest request) {
final var name = request.pathVariable("name");
final var snapshotName = request.queryParam("snapshotName").orElseThrow();
return client.fetch(Post.class, name)
.flatMap(post -> {
var baseSnapshot = post.getSpec().getBaseSnapshot();
return postService.getContent(snapshotName, baseSnapshot);
})
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
private Mono<ServerResponse> listSnapshots(ServerRequest request) {
String name = request.pathVariable("name");
var resultFlux = postService.listSnapshots(name);
return ServerResponse.ok().body(resultFlux, ListedSnapshotDto.class);
}
private Mono<ServerResponse> fetchReleaseContent(ServerRequest request) {
final var name = request.pathVariable("name");
return postService.getReleaseContent(name)
@ -191,7 +296,7 @@ public class PostEndpoint implements CustomEndpoint {
Mono<ServerResponse> updateContent(ServerRequest request) {
String postName = request.pathVariable("name");
return request.bodyToMono(Content.class)
return request.bodyToMono(ContentUpdateParam.class)
.flatMap(content -> Mono.defer(() -> client.fetch(Post.class, postName)
.flatMap(post -> {
PostRequest postRequest = new PostRequest(post, content);

View File

@ -6,6 +6,7 @@ import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Duration;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -18,12 +19,15 @@ import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import org.thymeleaf.util.StringUtils;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.content.Content;
import run.halo.app.content.ContentUpdateParam;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.ListedSinglePage;
import run.halo.app.content.ListedSnapshotDto;
import run.halo.app.content.SinglePageQuery;
import run.halo.app.content.SinglePageRequest;
import run.halo.app.content.SinglePageService;
@ -85,6 +89,33 @@ public class SinglePageEndpoint implements CustomEndpoint {
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.GET("singlepages/{name}/content", this::fetchContent,
builder -> builder.operationId("fetchSinglePageContent")
.description("Fetch content of single page.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.parameter(parameterBuilder().name("snapshotName")
.in(ParameterIn.QUERY)
.required(true)
.implementation(String.class))
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.GET("singlepages/{name}/snapshot", this::listSnapshots,
builder -> builder.operationId("listSinglePageSnapshots")
.description("List all snapshots for single page content.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class))
.response(responseBuilder()
.implementationArray(ListedSnapshotDto.class))
)
.POST("singlepages", this::draftSinglePage,
builder -> builder.operationId("DraftSinglePage")
.description("Draft a single page.")
@ -135,6 +166,24 @@ public class SinglePageEndpoint implements CustomEndpoint {
.response(responseBuilder()
.implementation(Post.class))
)
.PUT("singlepages/{name}/revert-content", this::revertToSpecifiedSnapshot,
builder -> builder.operationId("revertToSpecifiedSnapshotForSinglePage")
.description("Revert to specified snapshot for single page content.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class))
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(Builder.schemaBuilder()
.implementation(RevertSnapshotParam.class))
))
.response(responseBuilder()
.implementation(Post.class))
)
.PUT("singlepages/{name}/publish", this::publishSinglePage,
builder -> builder.operationId("PublishSinglePage")
.description("Publish a single page.")
@ -146,9 +195,64 @@ public class SinglePageEndpoint implements CustomEndpoint {
.response(responseBuilder()
.implementation(SinglePage.class))
)
.DELETE("singlepages/{name}/content", this::deleteContent,
builder -> builder.operationId("deleteSinglePageContent")
.description("Delete a content for post.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.parameter(parameterBuilder()
.name("snapshotName")
.in(ParameterIn.QUERY)
.required(true)
.implementation(String.class))
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.build();
}
private Mono<ServerResponse> deleteContent(ServerRequest request) {
final var postName = request.pathVariable("name");
final var snapshotName = request.queryParam("snapshotName").orElseThrow();
return singlePageService.deleteContent(postName, snapshotName)
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
private Mono<ServerResponse> revertToSpecifiedSnapshot(ServerRequest request) {
final var postName = request.pathVariable("name");
return request.bodyToMono(RevertSnapshotParam.class)
.switchIfEmpty(
Mono.error(new ServerWebInputException("Required request body is missing.")))
.flatMap(
param -> singlePageService.revertToSpecifiedSnapshot(postName, param.snapshotName))
.flatMap(page -> ServerResponse.ok().bodyValue(page));
}
@Schema(name = "RevertSnapshotForSingleParam")
record RevertSnapshotParam(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, minLength = 1) String snapshotName) {
}
private Mono<ServerResponse> fetchContent(ServerRequest request) {
final var snapshotName = request.queryParam("snapshotName").orElseThrow();
return client.fetch(SinglePage.class, request.pathVariable("name"))
.flatMap(page -> {
var baseSnapshot = page.getSpec().getBaseSnapshot();
return singlePageService.getContent(snapshotName, baseSnapshot);
})
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
private Mono<ServerResponse> listSnapshots(ServerRequest request) {
final var name = request.pathVariable("name");
var resultFlux = singlePageService.listSnapshots(name);
return ServerResponse.ok().body(resultFlux, ListedSnapshotDto.class);
}
private Mono<ServerResponse> fetchReleaseContent(ServerRequest request) {
final var name = request.pathVariable("name");
return singlePageService.getReleaseContent(name)
@ -169,7 +273,7 @@ public class SinglePageEndpoint implements CustomEndpoint {
Mono<ServerResponse> updateContent(ServerRequest request) {
String pageName = request.pathVariable("name");
return request.bodyToMono(Content.class)
return request.bodyToMono(ContentUpdateParam.class)
.flatMap(content -> Mono.defer(() -> client.fetch(SinglePage.class, pageName)
.flatMap(page -> {
SinglePageRequest pageRequest = new SinglePageRequest(page, content);

View File

@ -258,26 +258,38 @@ public class UserEndpoint implements CustomEndpoint {
.switchIfEmpty(Mono.error(
() -> new ServerWebInputException("Request body is required."))
)
.flatMap(verifyEmailRequest -> ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName)
.map(username -> Tuples.of(username, verifyEmailRequest.code()))
)
.flatMap(tuple2 -> {
var username = tuple2.getT1();
var code = tuple2.getT2();
return Mono.just(username)
.transformDeferred(verificationEmailRateLimiter(username))
.flatMap(name -> emailVerificationService.verify(username, code))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
})
.flatMap(this::doVerifyCode)
.then(ServerResponse.ok().build());
}
private Mono<Void> doVerifyCode(VerifyCodeRequest verifyCodeRequest) {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName)
.flatMap(username -> verifyPasswordAndCode(username, verifyCodeRequest));
}
private Mono<Void> verifyPasswordAndCode(String username, VerifyCodeRequest verifyCodeRequest) {
return userService.confirmPassword(username, verifyCodeRequest.password())
.filter(Boolean::booleanValue)
.switchIfEmpty(Mono.error(new UnsatisfiedAttributeValueException(
"Password is incorrect.", "problemDetail.user.password.notMatch", null)))
.flatMap(verified -> verifyEmailCode(username, verifyCodeRequest.code()));
}
private Mono<Void> verifyEmailCode(String username, String code) {
return Mono.just(username)
.transformDeferred(verificationEmailRateLimiter(username))
.flatMap(name -> emailVerificationService.verify(username, code))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
}
public record EmailVerifyRequest(@Schema(requiredMode = REQUIRED) String email) {
}
public record VerifyCodeRequest(@Schema(requiredMode = REQUIRED, minLength = 1) String code) {
public record VerifyCodeRequest(
@Schema(requiredMode = REQUIRED) String password,
@Schema(requiredMode = REQUIRED, minLength = 1) String code) {
}
private Mono<ServerResponse> sendEmailVerificationCode(ServerRequest request) {

View File

@ -67,12 +67,11 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
return;
}
if (addFinalizers(comment.getMetadata(), Set.of(FINALIZER_NAME))) {
replyNotificationSubscriptionHelper.subscribeNewReplyReasonForComment(comment);
client.update(comment);
eventPublisher.publishEvent(new CommentCreatedEvent(this, comment));
}
replyNotificationSubscriptionHelper.subscribeNewReplyReasonForComment(comment);
compatibleCreationTime(comment);
Comment.CommentStatus status = comment.getStatusOrDefault();
status.setHasNewReply(defaultIfNull(status.getUnreadReplyCount(), 0) > 0);

View File

@ -12,6 +12,7 @@ import static run.halo.app.plugin.PluginExtensionLoaderUtils.lookupExtensions;
import static run.halo.app.plugin.PluginUtils.generateFileName;
import static run.halo.app.plugin.PluginUtils.isDevelopmentMode;
import com.fasterxml.jackson.core.type.TypeReference;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.MalformedURLException;
@ -21,17 +22,24 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.pf4j.PluginManager;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginState;
import org.pf4j.PluginStateEvent;
import org.pf4j.PluginStateListener;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ResourceUtils;
import org.springframework.web.util.UriComponentsBuilder;
import run.halo.app.core.extension.Plugin;
@ -50,10 +58,13 @@ import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.extension.controller.RequeueException;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.infra.utils.JsonParseException;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
import run.halo.app.plugin.PluginConst;
import run.halo.app.plugin.PluginProperties;
import run.halo.app.plugin.SpringPluginManager;
/**
* Plugin reconciler.
@ -66,19 +77,23 @@ import run.halo.app.plugin.PluginProperties;
@Component
public class PluginReconciler implements Reconciler<Request> {
private static final String FINALIZER_NAME = "plugin-protection";
private static final String DEPENDENTS_ANNO_KEY = "plugin.halo.run/dependents-snapshot";
private final ExtensionClient client;
private final PluginManager pluginManager;
private final SpringPluginManager pluginManager;
private final PluginProperties pluginProperties;
private Clock clock;
public PluginReconciler(ExtensionClient client, PluginManager pluginManager,
public PluginReconciler(ExtensionClient client, SpringPluginManager pluginManager,
PluginProperties pluginProperties) {
this.client = client;
this.pluginManager = pluginManager;
this.pluginProperties = pluginProperties;
this.clock = Clock.systemUTC();
this.pluginManager.addPluginStateListener(new PluginStartedListener());
this.pluginManager.addPluginStateListener(new PluginStoppedListener());
}
/**
@ -95,6 +110,11 @@ public class PluginReconciler implements Reconciler<Request> {
return client.fetch(Plugin.class, request.name())
.map(plugin -> {
if (ExtensionUtil.isDeleted(plugin)) {
if (!checkDependents(plugin)) {
client.update(plugin);
// Check dependents every 10 seconds
return Result.requeue(Duration.ofSeconds(10));
}
// CleanUp resources and remove finalizer.
if (removeFinalizers(plugin.getMetadata(), Set.of(FINALIZER_NAME))) {
cleanupResources(plugin);
@ -117,10 +137,10 @@ public class PluginReconciler implements Reconciler<Request> {
if (requestToEnable(plugin)) {
// Start
startPlugin(plugin);
enablePlugin(plugin);
} else {
// stop the plugin and disable it
stopAndDisablePlugin(plugin);
disablePlugin(plugin);
}
} catch (Throwable t) {
// populate condition
@ -145,6 +165,28 @@ public class PluginReconciler implements Reconciler<Request> {
.orElseGet(Result::doNotRetry);
}
private boolean checkDependents(Plugin plugin) {
var pluginId = plugin.getMetadata().getName();
var dependents = pluginManager.getDependents(pluginId);
if (CollectionUtils.isEmpty(dependents)) {
return true;
}
var status = plugin.statusNonNull();
var condition = Condition.builder()
.type(PluginState.FAILED.toString())
.reason("DependentsExist")
.message(
"The plugin has dependents %s, please delete them first."
.formatted(dependents.stream().map(PluginWrapper::getPluginId).toList())
)
.status(ConditionStatus.FALSE)
.lastTransitionTime(clock.instant())
.build();
nullSafeConditions(status).addAndEvictFIFO(condition);
status.setPhase(Plugin.Phase.FAILED);
return false;
}
private void syncPluginState(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
var p = pluginManager.getPlugin(pluginName);
@ -191,32 +233,87 @@ public class PluginReconciler implements Reconciler<Request> {
}
}
private void startPlugin(Plugin plugin) {
private void enablePlugin(Plugin plugin) {
// start the plugin
var pluginName = plugin.getMetadata().getName();
var wrapper = pluginManager.getPlugin(pluginName);
log.info("Starting plugin {}", pluginName);
plugin.statusNonNull().setPhase(Plugin.Phase.STARTING);
if (!PluginState.STARTED.equals(wrapper.getPluginState())) {
var pluginState = pluginManager.startPlugin(pluginName);
if (!PluginState.STARTED.equals(pluginState)) {
throw new IllegalStateException("Failed to start plugin " + pluginName);
}
plugin.statusNonNull().setLastStartTime(clock.instant());
var condition = Condition.builder()
.type(PluginState.STARTED.toString())
.reason(PluginState.STARTED.toString())
.message("Started successfully")
.lastTransitionTime(clock.instant())
.status(ConditionStatus.TRUE)
.build();
nullSafeConditions(plugin.statusNonNull()).addAndEvictFIFO(condition);
var pluginState = pluginManager.startPlugin(pluginName);
if (!PluginState.STARTED.equals(pluginState)) {
throw new IllegalStateException("Failed to start plugin " + pluginName);
}
var dependents = getAndRemoveDependents(plugin);
log.info("Starting dependents {} for plugin {}", dependents, pluginName);
dependents.stream()
.sorted(Comparator.reverseOrder())
.forEach(dependent -> {
if (pluginManager.getPlugin(dependent) != null) {
pluginManager.startPlugin(dependent);
}
});
log.info("Started dependents {} for plugin {}", dependents, pluginName);
plugin.statusNonNull().setLastStartTime(clock.instant());
var condition = Condition.builder()
.type(PluginState.STARTED.toString())
.reason(PluginState.STARTED.toString())
.message("Started successfully")
.lastTransitionTime(clock.instant())
.status(ConditionStatus.TRUE)
.build();
nullSafeConditions(plugin.statusNonNull()).addAndEvictFIFO(condition);
plugin.statusNonNull().setPhase(Plugin.Phase.STARTED);
log.info("Started plugin {}", pluginName);
}
private void stopAndDisablePlugin(Plugin plugin) {
private List<String> getAndRemoveDependents(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
var annotations = plugin.getMetadata().getAnnotations();
if (annotations == null) {
return List.of();
}
var dependentsAnno = annotations.remove(DEPENDENTS_ANNO_KEY);
List<String> dependents = List.of();
if (StringUtils.isNotBlank(dependentsAnno)) {
try {
dependents = JsonUtils.jsonToObject(dependentsAnno, new TypeReference<>() {
});
} catch (JsonParseException ignored) {
log.error("Failed to parse dependents annotation {} for plugin {}",
dependentsAnno, pluginName);
// Keep going to start the plugin.
}
}
return dependents;
}
private void setDependents(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
var annotations = plugin.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
plugin.getMetadata().setAnnotations(annotations);
}
if (!annotations.containsKey(DEPENDENTS_ANNO_KEY)) {
// get dependents
var dependents = pluginManager.getDependents(pluginName)
.stream()
.filter(pluginWrapper ->
Objects.equals(PluginState.STARTED, pluginWrapper.getPluginState())
)
.map(PluginWrapper::getPluginId)
.toList();
annotations.put(DEPENDENTS_ANNO_KEY, JsonUtils.objectToJson(dependents));
}
}
private void disablePlugin(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
if (pluginManager.getPlugin(pluginName) != null) {
setDependents(plugin);
pluginManager.disablePlugin(pluginName);
}
plugin.statusNonNull().setPhase(Plugin.Phase.DISABLED);
@ -284,29 +381,29 @@ public class PluginReconciler implements Reconciler<Request> {
}
private void loadOrReload(Plugin plugin) {
// TODO Try to check dependencies before.
var pluginName = plugin.getMetadata().getName();
try {
var p = pluginManager.getPlugin(pluginName);
var requestToReload = requestToReload(plugin);
if (requestToReload) {
log.info("Unloading plugin {}", pluginName);
if (p != null) {
pluginManager.unloadPlugin(pluginName);
}
}
if (p == null || requestToReload) {
log.info("Loading plugin {}", pluginName);
var p = pluginManager.getPlugin(pluginName);
var requestToReload = requestToReload(plugin);
if (requestToReload) {
if (p != null) {
var loadLocation = plugin.getStatus().getLoadLocation();
pluginManager.loadPlugin(Paths.get(loadLocation));
log.info("Loaded plugin {}", pluginName);
setDependents(plugin);
var unloaded = pluginManager.reloadPlugin(pluginName, Paths.get(loadLocation));
if (!unloaded) {
throw new PluginRuntimeException("Failed to reload plugin " + pluginName);
}
p = pluginManager.getPlugin(pluginName);
}
} catch (Throwable t) {
// unload the plugin
if (pluginManager.getPlugin(pluginName) != null) {
pluginManager.unloadPlugin(pluginName);
}
throw t;
}
if (p != null && pluginManager.getUnresolvedPlugins().contains(p)) {
pluginManager.unloadPlugin(pluginName);
p = null;
}
if (p == null) {
var loadLocation = plugin.getStatus().getLoadLocation();
log.info("Loading plugin {} from {}", pluginName, loadLocation);
pluginManager.loadPlugin(Paths.get(loadLocation));
log.info("Loaded plugin {} from {}", pluginName, loadLocation);
}
}
@ -480,4 +577,38 @@ public class PluginReconciler implements Reconciler<Request> {
return pluginName + "-system-generated-reverse-proxy";
}
public class PluginStartedListener implements PluginStateListener {
@Override
public void pluginStateChanged(PluginStateEvent event) {
if (PluginState.STARTED.equals(event.getPluginState())) {
var pluginId = event.getPlugin().getPluginId();
client.fetch(Plugin.class, pluginId)
.ifPresent(plugin -> {
if (!Objects.equals(true, plugin.getSpec().getEnabled())) {
plugin.getSpec().setEnabled(true);
client.update(plugin);
}
});
}
}
}
public class PluginStoppedListener implements PluginStateListener {
@Override
public void pluginStateChanged(PluginStateEvent event) {
if (PluginState.STOPPED.equals(event.getPluginState())) {
var pluginId = event.getPlugin().getPluginId();
client.fetch(Plugin.class, pluginId)
.ifPresent(plugin -> {
if (!requestToReload(plugin)
&& Objects.equals(true, plugin.getSpec().getEnabled())) {
plugin.getSpec().setEnabled(false);
client.update(plugin);
}
});
}
}
}
}

View File

@ -246,11 +246,8 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_POST);
interestReason.setSubject(Subscription.ReasonSubject.builder()
.apiVersion(post.getApiVersion())
.kind(post.getKind())
.name(post.getMetadata().getName())
.build());
interestReason.setExpression(
"props.postOwner == '%s'".formatted(post.getSpec().getOwner()));
notificationCenter.subscribe(subscriber, interestReason).block();
}

View File

@ -46,6 +46,7 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
return;
}
if (addFinalizers(reply.getMetadata(), Set.of(FINALIZER_NAME))) {
replyNotificationSubscriptionHelper.subscribeNewReplyReasonForReply(reply);
client.update(reply);
eventPublisher.publishEvent(new ReplyCreatedEvent(this, reply));
}
@ -64,8 +65,6 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
client.update(reply);
replyNotificationSubscriptionHelper.subscribeNewReplyReasonForReply(reply);
eventPublisher.publishEvent(new ReplyChangedEvent(this, reply));
});
return new Result(false, null);

View File

@ -107,11 +107,8 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_PAGE);
interestReason.setSubject(Subscription.ReasonSubject.builder()
.apiVersion(page.getApiVersion())
.kind(page.getKind())
.name(page.getMetadata().getName())
.build());
interestReason.setExpression(
"props.pageOwner == '%s'".formatted(page.getSpec().getOwner()));
notificationCenter.subscribe(subscriber, interestReason).block();
}

View File

@ -19,6 +19,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.content.Content;
import run.halo.app.content.ContentUpdateParam;
import run.halo.app.content.ListedPost;
import run.halo.app.content.PostQuery;
import run.halo.app.content.PostRequest;
@ -280,7 +281,7 @@ public class UcPostEndpoint implements CustomEndpoint {
}
post.getSpec().setOwner(username);
}))
.map(post -> new PostRequest(post, getContent(post)))
.map(post -> new PostRequest(post, ContentUpdateParam.from(getContent(post))))
.flatMap(postService::draftPost);
return ServerResponse.ok().body(createdPost, Post.class);
}

View File

@ -15,6 +15,7 @@ import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
import run.halo.app.infra.properties.HaloProperties;
@ -96,7 +97,13 @@ public class ExtensionResourceInitializer implements ApplicationListener<Applica
extension.getMetadata().setVersion(existingExt.getMetadata().getVersion());
return extensionClient.update(extension);
})
.switchIfEmpty(Mono.defer(() -> extensionClient.create(extension)));
.switchIfEmpty(Mono.defer(() -> {
if (ExtensionUtil.isDeleted(extension)) {
// skip deleted extension
return Mono.empty();
}
return extensionClient.create(extension);
}));
}
private List<Resource> listResources(String location) {

View File

@ -0,0 +1,37 @@
package run.halo.app.infra;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ListOptions;
/**
* Reactive extension paginated operator to handle extensions by pagination.
*
* @author guqing
* @since 2.15.0
*/
public interface ReactiveExtensionPaginatedOperator {
/**
* <p>Deletes all data, including any new entries added during the execution of this method.</p>
* <p>This method continuously monitors and removes data that appears throughout its runtime,
* ensuring that even data created during the deletion process is also removed.</p>
*/
<E extends Extension> Mono<Void> deleteContinuously(Class<E> type,
ListOptions listOptions);
/**
* <p>Deletes only the data that existed at the start of the operation.</p>
* <p>This method takes a snapshot of the data at the beginning and deletes only that dataset;
* any data added after the method starts will not be affected or removed.</p>
*/
<E extends Extension> Flux<E> deleteInitialBatch(Class<E> type,
ListOptions listOptions);
/**
* <p>Note that: This method can not be used for <code>deletion</code> operation, because
* deletion operation will change the total records.</p>
*/
<E extends Extension> Flux<E> list(Class<E> type, ListOptions listOptions);
}

View File

@ -0,0 +1,132 @@
package run.halo.app.infra;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import java.time.Duration;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
@Component
@RequiredArgsConstructor
public class ReactiveExtensionPaginatedOperatorImpl implements ReactiveExtensionPaginatedOperator {
private static final int DEFAULT_PAGE_SIZE = 200;
private final ReactiveExtensionClient client;
@Override
public <E extends Extension> Mono<Void> deleteContinuously(Class<E> type,
ListOptions listOptions) {
var pageRequest = createPageRequest();
return cleanupContinuously(type, listOptions, pageRequest);
}
private <E extends Extension> Mono<Void> cleanupContinuously(Class<E> type,
ListOptions listOptions,
PageRequest pageRequest) {
// forever loop first page until no more to delete
return pageBy(type, listOptions, pageRequest)
.flatMap(page -> Flux.fromIterable(page.getItems())
.flatMap(client::delete)
.then(page.hasNext() ? cleanupContinuously(type, listOptions, pageRequest)
: Mono.empty())
);
}
@Override
public <E extends Extension> Flux<E> deleteInitialBatch(Class<E> type,
ListOptions listOptions) {
var pageRequest = createPageRequest();
var newFieldQuery = listOptions.getFieldSelector()
.andQuery(isNull("metadata.deletionTimestamp"));
listOptions.setFieldSelector(newFieldQuery);
final Instant now = Instant.now();
return pageBy(type, listOptions, pageRequest)
// forever loop first page until no more to delete
.expand(result -> result.hasNext()
? pageBy(type, listOptions, pageRequest) : Mono.empty())
.flatMap(result -> Flux.fromIterable(result.getItems()))
.takeWhile(item -> shouldTakeNext(item, now))
.flatMap(this::deleteWithRetry);
}
static <E extends Extension> boolean shouldTakeNext(E item, Instant now) {
var creationTimestamp = item.getMetadata().getCreationTimestamp();
return creationTimestamp.isBefore(now)
|| creationTimestamp.equals(now);
}
@SuppressWarnings("unchecked")
<E extends Extension> Mono<E> deleteWithRetry(E item) {
return client.delete(item)
.onErrorResume(OptimisticLockingFailureException.class,
e -> attemptToDelete((Class<E>) item.getClass(), item.getMetadata().getName()));
}
private <E extends Extension> Mono<E> attemptToDelete(Class<E> type, String name) {
return Mono.defer(() -> client.fetch(type, name)
.flatMap(client::delete)
)
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance));
}
@Override
public <E extends Extension> Flux<E> list(Class<E> type, ListOptions listOptions) {
var pageRequest = createPageRequest();
return list(type, listOptions, pageRequest);
}
/**
* Paginated list all items to avoid memory overflow.
* <pre>
* 1. Retrieve data multiple times until all data is consumed.
* 2. Fetch next page if current page has more data and consumed records is less than total
* records.
* 3. Take while consumed records is less than total records.
* 4. totalRecords from first page to ensure new inserted data will not be counted in during
* querying to avoid infinite loop.
* </pre>
*/
private <E extends Extension> Flux<E> list(Class<E> type, ListOptions listOptions,
PageRequest pageRequest) {
final var now = Instant.now();
return pageBy(type, listOptions, pageRequest)
.expand(result -> {
if (result.hasNext()) {
// fetch next page
var nextPage = nextPage(result, pageRequest.getSort());
return pageBy(type, listOptions, nextPage);
} else {
return Mono.empty();
}
})
.flatMap(page -> Flux.fromIterable(page.getItems()))
.takeWhile(item -> shouldTakeNext(item, now));
}
static <E extends Extension> PageRequest nextPage(ListResult<E> result, Sort sort) {
return PageRequestImpl.of(result.getPage() + 1, result.getSize(), sort);
}
private PageRequest createPageRequest() {
return PageRequestImpl.of(1, DEFAULT_PAGE_SIZE,
Sort.by("metadata.creationTimestamp", "metadata.name"));
}
private <E extends Extension> Mono<ListResult<E>> pageBy(Class<E> type, ListOptions listOptions,
PageRequest pageRequest) {
return client.listBy(type, listOptions, pageRequest);
}
}

View File

@ -438,6 +438,11 @@ public class SchemeInitializer implements ApplicationListener<ApplicationContext
.setIndexFunc(simpleAttribute(Subscription.class,
subscription -> subscription.getSpec().getReason().getSubject().toString()))
);
indexSpecs.add(new IndexSpec()
.setName("spec.reason.expression")
.setIndexFunc(simpleAttribute(Subscription.class,
subscription -> subscription.getSpec().getReason().getExpression()))
);
indexSpecs.add(new IndexSpec()
.setName("spec.subscriber")
.setIndexFunc(simpleAttribute(Subscription.class,

View File

@ -1,23 +1,14 @@
package run.halo.app.notification;
import static org.apache.commons.lang3.StringUtils.defaultString;
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.or;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Optional;
import java.util.function.BiPredicate;
import java.util.function.Function;
import lombok.Builder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@ -27,15 +18,8 @@ import run.halo.app.core.extension.notification.NotifierDescriptor;
import run.halo.app.core.extension.notification.Reason;
import run.halo.app.core.extension.notification.ReasonType;
import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.Query;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.notification.endpoint.SubscriptionRouter;
/**
@ -55,37 +39,33 @@ public class DefaultNotificationCenter implements NotificationCenter {
private final UserNotificationPreferenceService userNotificationPreferenceService;
private final NotificationTemplateRender notificationTemplateRender;
private final SubscriptionRouter subscriptionRouter;
private final RecipientResolver recipientResolver;
private final SubscriptionService subscriptionService;
@Override
public Mono<Void> notify(Reason reason) {
var reasonSubject = reason.getSpec().getSubject();
var subscriptionReasonSubject = Subscription.ReasonSubject.builder()
.apiVersion(reasonSubject.getApiVersion())
.kind(reasonSubject.getKind())
.name(reasonSubject.getName())
.build();
return listObservers(reason.getSpec().getReasonType(), subscriptionReasonSubject)
.doOnNext(subscription -> {
return recipientResolver.resolve(reason)
.doOnNext(subscriber -> {
log.debug("Dispatching notification to subscriber [{}] for reason [{}]",
subscription.getSpec().getSubscriber(), reason.getMetadata().getName());
subscriber, reason.getMetadata().getName());
})
.publishOn(Schedulers.boundedElastic())
.flatMap(subscription -> dispatchNotification(reason, subscription))
.flatMap(subscriber -> dispatchNotification(reason, subscriber))
.then();
}
@Override
public Mono<Subscription> subscribe(Subscription.Subscriber subscriber,
Subscription.InterestReason reason) {
return listSubscription(subscriber, reason)
.next()
.switchIfEmpty(Mono.defer(() -> {
return unsubscribe(subscriber, reason)
.then(Mono.defer(() -> {
var subscription = new Subscription();
subscription.setMetadata(new Metadata());
subscription.getMetadata().setGenerateName("subscription-");
subscription.setSpec(new Subscription.Spec());
subscription.getSpec().setUnsubscribeToken(Subscription.generateUnsubscribeToken());
subscription.getSpec().setSubscriber(subscriber);
Subscription.InterestReason.ensureSubjectHasValue(reason);
subscription.getSpec().setReason(reason);
return client.create(subscription);
}));
@ -93,75 +73,47 @@ public class DefaultNotificationCenter implements NotificationCenter {
@Override
public Mono<Void> unsubscribe(Subscription.Subscriber subscriber) {
// pagination query all subscriptions of the subscriber to avoid large data
var pageRequest = PageRequestImpl.of(1, 200,
Sort.by("metadata.creationTimestamp", "metadata.name"));
return Flux.defer(() -> pageSubscriptionBy(subscriber, pageRequest))
.expand(page -> page.hasNext()
? pageSubscriptionBy(subscriber, pageRequest.next())
: Mono.empty()
)
.flatMap(page -> Flux.fromIterable(page.getItems()))
.flatMap(client::delete)
.then();
return subscriptionService.remove(subscriber).then();
}
@Override
public Mono<Void> unsubscribe(Subscription.Subscriber subscriber,
Subscription.InterestReason reason) {
return listSubscription(subscriber, reason)
.flatMap(client::delete)
.then();
return subscriptionService.remove(subscriber, reason).then();
}
Mono<ListResult<Subscription>> pageSubscriptionBy(Subscription.Subscriber subscriber,
PageRequest pageRequest) {
var listOptions = new ListOptions();
var fieldQuery = equal("spec.subscriber", subscriber.getName());
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return client.listBy(Subscription.class, listOptions, pageRequest);
}
Flux<Subscription> listSubscription(Subscription.Subscriber subscriber,
Subscription.InterestReason reason) {
var listOptions = new ListOptions();
var fieldQuery = and(
getSubscriptionFieldQuery(reason.getReasonType(), reason.getSubject()),
equal("spec.subscriber", subscriber.getName())
);
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return client.listAll(Subscription.class, listOptions, defaultSort());
}
Flux<String> getNotifiersBySubscriber(Subscription.Subscriber subscriber, Reason reason) {
Flux<String> getNotifiersBySubscriber(Subscriber subscriber, Reason reason) {
var reasonType = reason.getSpec().getReasonType();
return userNotificationPreferenceService.getByUser(subscriber.getName())
return userNotificationPreferenceService.getByUser(subscriber.name())
.map(UserNotificationPreference::getReasonTypeNotifier)
.map(reasonTypeNotification -> reasonTypeNotification.getNotifiers(reasonType))
.flatMapMany(Flux::fromIterable);
}
Mono<Void> dispatchNotification(Reason reason, Subscription subscription) {
var subscriber = subscription.getSpec().getSubscriber();
Mono<Void> dispatchNotification(Reason reason, Subscriber subscriber) {
return getNotifiersBySubscriber(subscriber, reason)
.flatMap(notifierName -> client.fetch(NotifierDescriptor.class, notifierName))
.flatMap(descriptor -> prepareNotificationElement(subscription, reason, descriptor))
.flatMap(descriptor -> prepareNotificationElement(subscriber, reason, descriptor))
.flatMap(element -> {
var dispatchMono = sendNotification(element);
if (subscriber.isAnonymous()) {
return dispatchMono;
}
// create notification for user
var innerNofificationMono = createNotification(element);
return Mono.when(dispatchMono, innerNofificationMono);
})
.then();
}
Mono<NotificationElement> prepareNotificationElement(Subscription subscription, Reason reason,
Mono<NotificationElement> prepareNotificationElement(Subscriber subscriber, Reason reason,
NotifierDescriptor descriptor) {
return getLocaleFromSubscriber(subscription)
.flatMap(locale -> inferenceTemplate(reason, subscription, locale))
return getLocaleFromSubscriber(subscriber)
.flatMap(locale -> inferenceTemplate(reason, subscriber, locale))
.map(notificationContent -> NotificationElement.builder()
.descriptor(descriptor)
.reason(reason)
.subscription(subscription)
.subscriber(subscriber)
.reasonType(notificationContent.reasonType())
.notificationTitle(notificationContent.title())
.reasonAttributes(notificationContent.reasonAttributes())
@ -173,7 +125,7 @@ public class DefaultNotificationCenter implements NotificationCenter {
Mono<Void> sendNotification(NotificationElement notificationElement) {
var descriptor = notificationElement.descriptor();
var subscription = notificationElement.subscription();
var subscriber = notificationElement.subscriber();
final var notifierExtName = descriptor.getSpec().getNotifierExtName();
return notificationContextFrom(notificationElement)
.flatMap(notificationContext -> notificationSender.sendNotification(notifierExtName,
@ -181,7 +133,7 @@ public class DefaultNotificationCenter implements NotificationCenter {
.onErrorResume(throwable -> {
log.error(
"Failed to send notification to subscriber [{}] through notifier [{}]",
subscription.getSpec().getSubscriber(),
subscriber,
descriptor.getSpec().getDisplayName(),
throwable);
return Mono.empty();
@ -192,9 +144,8 @@ public class DefaultNotificationCenter implements NotificationCenter {
Mono<Notification> createNotification(NotificationElement notificationElement) {
var reason = notificationElement.reason();
var subscription = notificationElement.subscription();
var subscriber = subscription.getSpec().getSubscriber();
return client.fetch(User.class, subscriber.getName())
var subscriber = notificationElement.subscriber();
return client.fetch(User.class, subscriber.name())
.flatMap(user -> {
Notification notification = new Notification();
notification.setMetadata(new Metadata());
@ -203,7 +154,7 @@ public class DefaultNotificationCenter implements NotificationCenter {
notification.getSpec().setTitle(notificationElement.notificationTitle());
notification.getSpec().setRawContent(notificationElement.notificationRawBody());
notification.getSpec().setHtmlContent(notificationElement.notificationHtmlBody);
notification.getSpec().setRecipient(subscriber.getName());
notification.getSpec().setRecipient(subscriber.name());
notification.getSpec().setReason(reason.getMetadata().getName());
notification.getSpec().setUnread(true);
return client.create(notification);
@ -223,7 +174,7 @@ public class DefaultNotificationCenter implements NotificationCenter {
final var descriptorName = element.descriptor().getMetadata().getName();
final var reason = element.reason();
final var descriptor = element.descriptor();
final var subscription = element.subscription();
final var subscriber = element.subscriber();
var messagePayload = new NotificationContext.MessagePayload();
messagePayload.setTitle(element.notificationTitle());
@ -232,7 +183,7 @@ public class DefaultNotificationCenter implements NotificationCenter {
messagePayload.setAttributes(element.reasonAttributes());
var message = new NotificationContext.Message();
message.setRecipient(subscription.getSpec().getSubscriber().getName());
message.setRecipient(subscriber.name());
message.setPayload(messagePayload);
message.setTimestamp(reason.getMetadata().getCreationTimestamp());
var reasonSubject = reason.getSpec().getSubject();
@ -270,25 +221,25 @@ public class DefaultNotificationCenter implements NotificationCenter {
});
}
Mono<NotificationContent> inferenceTemplate(Reason reason, Subscription subscription,
Mono<NotificationContent> inferenceTemplate(Reason reason, Subscriber subscriber,
Locale locale) {
var reasonTypeName = reason.getSpec().getReasonType();
var subscriber = subscription.getSpec().getSubscriber();
return getReasonType(reasonTypeName)
.flatMap(reasonType -> notificationTemplateSelector.select(reasonTypeName, locale)
.flatMap(template -> {
final var templateContent = template.getSpec().getTemplate();
var model = toReasonAttributes(reason);
var identity = UserIdentity.of(subscriber.getName());
var subscriberInfo = new HashMap<>();
if (identity.isAnonymous()) {
subscriberInfo.put("displayName", identity.getEmail().orElse(""));
if (subscriber.isAnonymous()) {
subscriberInfo.put("displayName", subscriber.getEmail().orElseThrow());
} else {
subscriberInfo.put("displayName", "@" + identity.name());
subscriberInfo.put("displayName", "@" + subscriber.username());
}
subscriberInfo.put("id", subscriber.getName());
subscriberInfo.put("id", subscriber.name());
model.put("subscriber", subscriberInfo);
model.put("unsubscribeUrl", getUnsubscribeUrl(subscription));
var unsubscriptionMono = getUnsubscribeUrl(subscriber.subscriptionName())
.doOnNext(url -> model.put("unsubscribeUrl", url));
var builder = NotificationContent.builder()
.reasonType(reasonType)
@ -305,7 +256,7 @@ public class DefaultNotificationCenter implements NotificationCenter {
var htmlBodyMono = notificationTemplateRender
.render(templateContent.getHtmlBody(), model)
.doOnNext(builder::htmlBody);
return Mono.when(titleMono, rawBodyMono, htmlBodyMono)
return Mono.when(unsubscriptionMono, titleMono, rawBodyMono, htmlBodyMono)
.then(Mono.fromSupplier(builder::build));
})
);
@ -316,13 +267,14 @@ public class DefaultNotificationCenter implements NotificationCenter {
ReasonAttributes reasonAttributes) {
}
String getUnsubscribeUrl(Subscription subscription) {
return subscriptionRouter.getUnsubscribeUrl(subscription);
Mono<String> getUnsubscribeUrl(String subscriptionName) {
return client.get(Subscription.class, subscriptionName)
.map(subscriptionRouter::getUnsubscribeUrl);
}
@Builder
record NotificationElement(ReasonType reasonType, Reason reason,
Subscription subscription, NotifierDescriptor descriptor,
Subscriber subscriber, NotifierDescriptor descriptor,
String notificationTitle,
String notificationRawBody,
String notificationHtmlBody,
@ -333,80 +285,8 @@ public class DefaultNotificationCenter implements NotificationCenter {
return client.get(ReasonType.class, reasonTypeName);
}
Mono<Locale> getLocaleFromSubscriber(Subscription subscription) {
Mono<Locale> getLocaleFromSubscriber(Subscriber subscriber) {
// TODO get locale from subscriber
return Mono.just(Locale.getDefault());
}
Flux<Subscription> listObservers(String reasonTypeName,
Subscription.ReasonSubject reasonSubject) {
Assert.notNull(reasonTypeName, "The reasonTypeName must not be null");
Assert.notNull(reasonSubject, "The reasonSubject must not be null");
final var listOptions = new ListOptions();
var fieldQuery = getSubscriptionFieldQuery(reasonTypeName, reasonSubject);
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return distinctByKey(client.listAll(Subscription.class, listOptions, defaultSort()));
}
private static Query getSubscriptionFieldQuery(String reasonTypeName,
Subscription.ReasonSubject reasonSubject) {
var matchAllSubject = new Subscription.ReasonSubject();
matchAllSubject.setKind(reasonSubject.getKind());
matchAllSubject.setApiVersion(reasonSubject.getApiVersion());
return and(equal("spec.reason.reasonType", reasonTypeName),
or(equal("spec.reason.subject", reasonSubject.toString()),
// source reason subject name is blank present match all
equal("spec.reason.subject", matchAllSubject.toString())
)
);
}
static Flux<Subscription> distinctByKey(Flux<Subscription> source) {
final var distinctKeyPredicate = subscriptionDistinctKeyPredicate();
return source.distinct(Function.identity(), HashSet<Subscription>::new,
(set, val) -> {
for (Subscription subscription : set) {
if (distinctKeyPredicate.test(subscription, val)) {
return false;
}
}
// no duplicated return true
set.add(val);
return true;
},
HashSet::clear);
}
Sort defaultSort() {
return Sort.by(Sort.Order.asc("metadata.creationTimestamp"),
Sort.Order.asc("metadata.name"));
}
static BiPredicate<Subscription, Subscription> subscriptionDistinctKeyPredicate() {
return (a, b) -> {
if (!a.getSpec().getSubscriber().equals(b.getSpec().getSubscriber())) {
return false;
}
var reasonA = a.getSpec().getReason();
var reasonB = b.getSpec().getReason();
if (!reasonA.getReasonType().equals(reasonB.getReasonType())) {
return false;
}
var ars = reasonA.getSubject();
var brs = reasonB.getSubject();
var gvkForA =
GroupVersionKind.fromAPIVersionAndKind(ars.getApiVersion(), ars.getKind());
var gvkForB =
GroupVersionKind.fromAPIVersionAndKind(brs.getApiVersion(), brs.getKind());
if (!gvkForA.groupKind().equals(gvkForB.groupKind())) {
return false;
}
// name is blank present match all
if (StringUtils.isBlank(ars.getName()) || StringUtils.isBlank(brs.getName())) {
return true;
}
return ars.getName().equals(brs.getName());
};
}
}

View File

@ -21,13 +21,12 @@ import run.halo.app.extension.ReactiveExtensionClient;
@Component
@RequiredArgsConstructor
public class DefaultSubscriberEmailResolver implements SubscriberEmailResolver {
private static final String SEPARATOR = "#";
private final ReactiveExtensionClient client;
@Override
public Mono<String> resolve(Subscription.Subscriber subscriber) {
if (isEmailSubscriber(subscriber)) {
var identity = UserIdentity.of(subscriber.getName());
if (identity.isAnonymous()) {
return Mono.fromSupplier(() -> getEmail(subscriber));
}
return client.fetch(User.class, subscriber.getName())
@ -44,20 +43,14 @@ public class DefaultSubscriberEmailResolver implements SubscriberEmailResolver {
return subscriber;
}
static boolean isEmailSubscriber(Subscription.Subscriber subscriber) {
return UserIdentity.of(subscriber.getName()).isAnonymous();
}
@NonNull
String getEmail(Subscription.Subscriber subscriber) {
if (!isEmailSubscriber(subscriber)) {
throw new IllegalStateException("The subscriber is not an email subscriber");
var identity = UserIdentity.of(subscriber.getName());
if (!identity.isAnonymous()) {
throw new IllegalStateException("The subscriber is not an anonymous subscriber");
}
var subscriberName = subscriber.getName();
String email = subscriberName.substring(subscriberName.indexOf(SEPARATOR) + 1);
if (StringUtils.isBlank(email)) {
throw new IllegalStateException("The subscriber does not have an email");
}
return email;
return identity.getEmail()
.filter(StringUtils::isNotBlank)
.orElseThrow(() -> new IllegalStateException("The subscriber does not have an email"));
}
}

View File

@ -0,0 +1,9 @@
package run.halo.app.notification;
import reactor.core.publisher.Flux;
import run.halo.app.core.extension.notification.Reason;
public interface RecipientResolver {
Flux<Subscriber> resolve(Reason reason);
}

View File

@ -0,0 +1,116 @@
package run.halo.app.notification;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import com.google.common.base.Throwables;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.expression.MapAccessor;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.EvaluationException;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.ParseException;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.DataBindingPropertyAccessor;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
import org.springframework.integration.json.JsonPropertyAccessor;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import run.halo.app.core.extension.notification.Reason;
import run.halo.app.core.extension.notification.Subscription;
@Slf4j
@Component
@RequiredArgsConstructor
public class RecipientResolverImpl implements RecipientResolver {
private final ExpressionParser expressionParser = new SpelExpressionParser();
private final EvaluationContext evaluationContext = createEvaluationContext();
private final SubscriptionService subscriptionService;
@Override
public Flux<Subscriber> resolve(Reason reason) {
var reasonType = reason.getSpec().getReasonType();
return subscriptionService.listByPerPage(reasonType)
.filter(this::isNotDisabled)
.filter(subscription -> {
var interestReason = subscription.getSpec().getReason();
if (hasSubject(interestReason)) {
return subjectMatch(subscription, reason.getSpec().getSubject());
} else if (StringUtils.isNotBlank(interestReason.getExpression())) {
return expressionMatch(subscription.getMetadata().getName(),
interestReason.getExpression(), reason);
}
return false;
})
.map(subscription -> {
var id = UserIdentity.of(subscription.getSpec().getSubscriber().getName());
return new Subscriber(id, subscription.getMetadata().getName());
})
.distinct(Subscriber::name);
}
boolean hasSubject(Subscription.InterestReason interestReason) {
return !Subscription.InterestReason.isFallbackSubject(interestReason.getSubject());
}
boolean expressionMatch(String subscriptionName, String expressionStr, Reason reason) {
try {
Expression expression =
expressionParser.parseExpression(expressionStr);
var result = expression.getValue(evaluationContext,
exprRootObject(reason),
Boolean.class);
return BooleanUtils.isTrue(result);
} catch (ParseException | EvaluationException e) {
log.debug("Failed to parse or evaluate expression for subscription [{}], skip it.",
subscriptionName, Throwables.getRootCause(e));
return false;
}
}
Map<String, Object> exprRootObject(Reason reason) {
var map = new HashMap<String, Object>(3, 1);
map.put("props", defaultIfNull(reason.getSpec().getAttributes(), new ReasonAttributes()));
map.put("subject", reason.getSpec().getSubject());
map.put("author", reason.getSpec().getAuthor());
return Collections.unmodifiableMap(map);
}
static boolean subjectMatch(Subscription subscription, Reason.Subject reasonSubject) {
Assert.notNull(subscription, "The subscription must not be null");
Assert.notNull(reasonSubject, "The reasonSubject must not be null");
final var sourceSubject = subscription.getSpec().getReason().getSubject();
var matchSubject = new Subscription.ReasonSubject();
matchSubject.setKind(reasonSubject.getKind());
matchSubject.setApiVersion(reasonSubject.getApiVersion());
if (StringUtils.isBlank(sourceSubject.getName())) {
return sourceSubject.equals(matchSubject);
}
matchSubject.setName(reasonSubject.getName());
return sourceSubject.equals(matchSubject);
}
boolean isNotDisabled(Subscription subscription) {
return !subscription.getSpec().isDisabled();
}
EvaluationContext createEvaluationContext() {
return SimpleEvaluationContext.forPropertyAccessors(
DataBindingPropertyAccessor.forReadOnlyAccess(),
new MapAccessor(),
new JsonPropertyAccessor()
)
.withConversionService(DefaultConversionService.getSharedInstance())
.build();
}
}

View File

@ -0,0 +1,28 @@
package run.halo.app.notification;
import java.util.Optional;
import org.springframework.util.Assert;
import run.halo.app.infra.AnonymousUserConst;
public record Subscriber(UserIdentity identity, String subscriptionName) {
public Subscriber {
Assert.notNull(identity, "The subscriber must not be null");
Assert.hasText(subscriptionName, "The subscription name must not be blank");
}
public String name() {
return identity.name();
}
public String username() {
return identity.isAnonymous() ? AnonymousUserConst.PRINCIPAL : identity.name();
}
public boolean isAnonymous() {
return identity.isAnonymous();
}
public Optional<String> getEmail() {
return identity.getEmail();
}
}

View File

@ -0,0 +1,155 @@
package run.halo.app.notification;
import static run.halo.app.content.NotificationReasonConst.NEW_COMMENT_ON_PAGE;
import static run.halo.app.content.NotificationReasonConst.NEW_COMMENT_ON_POST;
import static run.halo.app.content.NotificationReasonConst.SOMEONE_REPLIED_TO_YOU;
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 static run.halo.app.extension.index.query.QueryFactory.startsWith;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.ReactiveExtensionPaginatedOperator;
import run.halo.app.infra.ReactiveExtensionPaginatedOperatorImpl;
/**
* Subscription migration to adapt to the new expression subscribe mechanism.
*
* @author guqing
* @since 2.15.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SubscriptionMigration implements ApplicationListener<ApplicationStartedEvent> {
private final NotificationCenter notificationCenter;
private final ReactiveExtensionClient client;
private final SubscriptionService subscriptionService;
private final ReactiveExtensionPaginatedOperator paginatedOperator;
@Override
@Async
public void onApplicationEvent(@NonNull ApplicationStartedEvent event) {
handleAnonymousSubscription();
cleanupUserSubscription();
}
private void cleanupUserSubscription() {
var listOptions = new ListOptions();
var query = isNull("metadata.deletionTimestamp");
listOptions.setFieldSelector(FieldSelector.of(query));
var iterator =
new ReactiveExtensionPaginatedOperatorImpl(client);
iterator.list(User.class, listOptions)
.map(user -> user.getMetadata().getName())
.flatMap(this::removeInternalSubscriptionForUser)
.then()
.doOnSuccess(unused -> log.info("Cleanup user subscription completed"))
.block();
}
private void handleAnonymousSubscription() {
log.debug("Start to collating anonymous subscription...");
Set<String> anonymousSubscribers = new HashSet<>();
deleteAnonymousSubscription(subscription -> {
var name = subscription.getSpec().getSubscriber().getName();
anonymousSubscribers.add(name);
}).block();
if (anonymousSubscribers.isEmpty()) {
return;
}
// anonymous only subscribe some-one-replied-to-you reason
for (String anonymousSubscriber : anonymousSubscribers) {
createSubscription(anonymousSubscriber,
SOMEONE_REPLIED_TO_YOU,
"props.repliedOwner == '%s'".formatted(anonymousSubscriber)).block();
}
log.info("Collating anonymous subscription completed.");
}
private Mono<Void> deleteAnonymousSubscription(Consumer<Subscription> consumer) {
var listOptions = new ListOptions();
var query = and(startsWith("spec.subscriber", AnonymousUserConst.PRINCIPAL),
isNull("spec.reason.expression"),
isNull("metadata.deletionTimestamp"),
in("spec.reason.reasonType", Set.of(NEW_COMMENT_ON_POST,
NEW_COMMENT_ON_PAGE,
SOMEONE_REPLIED_TO_YOU))
);
listOptions.setFieldSelector(FieldSelector.of(query));
return paginatedOperator.deleteInitialBatch(Subscription.class, listOptions)
.doOnNext(consumer)
.doOnNext(subscription -> log.debug("Deleted anonymous subscription: {}",
subscription.getMetadata().getName())
)
.then();
}
private Mono<Void> removeInternalSubscriptionForUser(String username) {
log.debug("Start to collating internal subscription for user: {}", username);
var subscriber = new Subscription.Subscriber();
subscriber.setName(username);
var listOptions = new ListOptions();
var fieldQuery = and(isNull("metadata.deletionTimestamp"),
equal("spec.subscriber", subscriber.toString()),
in("spec.reason.reasonType", Set.of(
NEW_COMMENT_ON_POST,
NEW_COMMENT_ON_PAGE,
SOMEONE_REPLIED_TO_YOU
))
);
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return subscriptionService.removeBy(listOptions)
.map(subscription -> {
var name = subscription.getSpec().getSubscriber().getName();
var reason = subscription.getSpec().getReason();
String expression = switch (reason.getReasonType()) {
case NEW_COMMENT_ON_POST -> "props.postOwner == '%s'".formatted(name);
case NEW_COMMENT_ON_PAGE -> "props.pageOwner == '%s'".formatted(name);
case SOMEONE_REPLIED_TO_YOU -> "props.repliedOwner == '%s'".formatted(name);
// never happen
default -> null;
};
return new SubscriptionSummary(name, reason.getReasonType(), expression);
})
.distinct()
.flatMap(summary -> createSubscription(summary.subscriber(), summary.reasonType(),
summary.expression()))
.then()
.doOnSuccess(unused ->
log.debug("Collating internal subscription for user: {} completed", username));
}
Mono<Void> createSubscription(String name, String reasonType, String expression) {
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType(reasonType);
interestReason.setExpression(expression);
var subscriber = new Subscription.Subscriber();
subscriber.setName(name);
log.debug("Create subscription for user: {} with reasonType: {}", name, reasonType);
return notificationCenter.subscribe(subscriber, interestReason).then();
}
record SubscriptionSummary(String subscriber, String reasonType, String expression) {
}
}

View File

@ -0,0 +1,26 @@
package run.halo.app.notification;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.ListOptions;
public interface SubscriptionService {
/**
* <p>List subscriptions by page one by one.only consume one page then next page will be
* loaded.</p>
* <p>Note that: result can not be used to delete the subscription, it is only used to query the
* subscription.</p>
*/
Flux<Subscription> listByPerPage(String reasonType);
Mono<Void> remove(Subscription.Subscriber subscriber,
Subscription.InterestReason interestReasons);
Mono<Void> remove(Subscription.Subscriber subscriber);
Mono<Subscription> remove(Subscription subscription);
Flux<Subscription> removeBy(ListOptions listOptions);
}

View File

@ -0,0 +1,103 @@
package run.halo.app.notification;
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.isNull;
import static run.halo.app.extension.index.query.QueryFactory.startsWith;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.Query;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.ReactiveExtensionPaginatedOperator;
@Component
@RequiredArgsConstructor
public class SubscriptionServiceImpl implements SubscriptionService {
private final ReactiveExtensionClient client;
private final ReactiveExtensionPaginatedOperator paginatedOperator;
@Override
public Mono<Void> remove(Subscription.Subscriber subscriber,
Subscription.InterestReason interestReason) {
Assert.notNull(subscriber, "The subscriber must not be null");
Assert.notNull(interestReason, "The interest reason must not be null");
var reasonType = interestReason.getReasonType();
var expression = interestReason.getExpression();
var subject = interestReason.getSubject();
var listOptions = new ListOptions();
var fieldQuery = and(isNull("metadata.deletionTimestamp"),
equal("spec.subscriber", subscriber.toString()),
equal("spec.reason.reasonType", reasonType));
if (subject != null) {
fieldQuery = and(fieldQuery, reasonSubjectMatch(subject));
}
if (StringUtils.isNotBlank(expression)) {
fieldQuery = and(fieldQuery, equal("spec.reason.expression", expression));
}
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return paginatedOperator.deleteInitialBatch(Subscription.class, listOptions).then();
}
@Override
public Mono<Void> remove(Subscription.Subscriber subscriber) {
var listOptions = new ListOptions();
var fieldQuery = and(isNull("metadata.deletionTimestamp"),
equal("spec.subscriber", subscriber.toString()));
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return paginatedOperator.deleteInitialBatch(Subscription.class, listOptions)
.then();
}
@Override
public Mono<Subscription> remove(Subscription subscription) {
return client.delete(subscription)
.onErrorResume(OptimisticLockingFailureException.class,
e -> attemptToDelete(subscription.getMetadata().getName()));
}
@Override
public Flux<Subscription> removeBy(ListOptions listOptions) {
return paginatedOperator.deleteInitialBatch(Subscription.class, listOptions);
}
@Override
public Flux<Subscription> listByPerPage(String reasonType) {
final var listOptions = new ListOptions();
var fieldQuery = and(isNull("metadata.deletionTimestamp"),
equal("spec.reason.reasonType", reasonType));
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return paginatedOperator.list(Subscription.class, listOptions);
}
private Mono<Subscription> attemptToDelete(String subscriptionName) {
return Mono.defer(() -> client.fetch(Subscription.class, subscriptionName)
.flatMap(client::delete)
)
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance));
}
Query reasonSubjectMatch(Subscription.ReasonSubject reasonSubject) {
Assert.notNull(reasonSubject, "The reasonSubject must not be null");
if (StringUtils.isNotBlank(reasonSubject.getName())) {
return equal("spec.reason.subject", reasonSubject.toString());
}
var matchAllSubject = new Subscription.ReasonSubject();
matchAllSubject.setKind(reasonSubject.getKind());
matchAllSubject.setApiVersion(reasonSubject.getApiVersion());
return startsWith("spec.reason.subject", matchAllSubject.toString());
}
}

View File

@ -2,7 +2,11 @@ package run.halo.app.plugin;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Stack;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.pf4j.CompoundPluginLoader;
@ -18,6 +22,7 @@ import org.pf4j.PluginDescriptorFinder;
import org.pf4j.PluginFactory;
import org.pf4j.PluginLoader;
import org.pf4j.PluginRepository;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginStatusProvider;
import org.pf4j.PluginWrapper;
import org.springframework.context.ApplicationContext;
@ -157,4 +162,50 @@ public class HaloPluginManager extends DefaultPluginManager implements SpringPlu
return sharedContext.get();
}
public boolean reloadPlugin(String pluginId, Path loadLocation) {
log.info("Reloading plugin {} from {}", pluginId, loadLocation);
var dependents = getDependents(pluginId);
dependents.forEach(plugin -> unloadPlugin(plugin.getPluginId(), false));
var unloaded = unloadPlugin(pluginId, false);
if (!unloaded) {
return false;
}
// load the root plugin
var loadedPluginId = loadPlugin(loadLocation);
if (!Objects.equals(pluginId, loadedPluginId)) {
throw new PluginRuntimeException("""
The plugin {} is reloaded successfully, \
but the plugin id is different from the original one.
""");
}
// load all dependents with reverse order
dependents.stream()
.map(PluginWrapper::getPluginPath)
.sorted(Comparator.reverseOrder())
.forEach(this::loadPlugin);
log.info("Reloaded plugin {} from {}", loadedPluginId, loadLocation);
return true;
}
@Override
public List<PluginWrapper> getDependents(String pluginId) {
var dependents = new ArrayList<PluginWrapper>();
var stack = new Stack<String>();
dependencyResolver.getDependents(pluginId).forEach(stack::push);
while (!stack.isEmpty()) {
var dependent = stack.pop();
var pluginWrapper = getPlugin(dependent);
if (pluginWrapper != null) {
dependents.add(pluginWrapper);
dependencyResolver.getDependents(dependent).forEach(stack::push);
}
}
return dependents;
}
}

View File

@ -1,6 +1,9 @@
package run.halo.app.plugin;
import java.nio.file.Path;
import java.util.List;
import org.pf4j.PluginManager;
import org.pf4j.PluginWrapper;
import org.springframework.context.ApplicationContext;
public interface SpringPluginManager extends PluginManager {
@ -9,4 +12,21 @@ public interface SpringPluginManager extends PluginManager {
ApplicationContext getSharedContext();
/**
* Reload the plugin and the plugins that depend on it.
*
* @param pluginId plugin id
* @param loadLocation new load location
* @return true if reload successfully, otherwise false
*/
boolean reloadPlugin(String pluginId, Path loadLocation);
/**
* Get all dependents recursively.
*
* @param pluginId plugin id
* @return a list of plugin wrapper. The order of the list is from the farthest dependent to
* the nearest dependent.
*/
List<PluginWrapper> getDependents(String pluginId);
}

View File

@ -52,6 +52,7 @@ problemDetail.user.email.verify.maxAttempts=Too many verification attempts, plea
problemDetail.user.password.unsatisfied=The password does not meet the specifications.
problemDetail.user.username.unsatisfied=The username does not meet the specifications.
problemDetail.user.oldPassword.notMatch=The old password does not match.
problemDetail.user.password.notMatch=The password does not match.
problemDetail.user.signUpFailed.disallowed=System does not allow new users to register.
problemDetail.user.duplicateName=The username {0} already exists, please rename it and retry.
problemDetail.comment.turnedOff=The comment function has been turned off.

View File

@ -29,6 +29,7 @@ problemDetail.user.email.verify.maxAttempts=尝试次数过多,请稍候再试
problemDetail.user.password.unsatisfied=密码不符合规范。
problemDetail.user.username.unsatisfied=用户名不符合规范。
problemDetail.user.oldPassword.notMatch=旧密码不匹配。
problemDetail.user.password.notMatch=密码不匹配。
problemDetail.user.signUpFailed.disallowed=系统不允许注册新用户。
problemDetail.user.duplicateName=用户名 {0} 已存在,请更换用户名后重试。
problemDetail.plugin.version.unsatisfied.requires=插件要求一个最小的系统版本为 {0}, 但当前版本为 {1}。

View File

@ -79,6 +79,9 @@ spec:
- name: postName
type: string
description: "The name of the post."
- name: postOwner
type: string
description: "The user name of the post owner."
- name: postTitle
type: string
- name: postUrl
@ -107,6 +110,9 @@ spec:
- name: pageName
type: string
description: "The name of the single page."
- name: pageOwner
type: string
description: "The user name of the page owner."
- name: pageTitle
type: string
- name: pageUrl
@ -144,6 +150,12 @@ spec:
type: boolean
- name: commentContent
type: string
- name: repliedOwner
type: string
description: "The owner of the comment or reply that has been replied to."
- name: replyOwner
type: string
description: "The user who created the current reply."
- name: replier
type: string
description: "The display name of the replier."

View File

@ -16,7 +16,7 @@ rules:
resources: [ "posts" ]
verbs: [ "*" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "posts", "posts/publish", "posts/unpublish", "posts/recycle", "posts/content", "indices/post" ]
resources: [ "posts", "posts/publish", "posts/unpublish", "posts/recycle", "posts/content", "indices/post", "posts/revert-content" ]
verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
---
apiVersion: v1alpha1
@ -37,5 +37,5 @@ rules:
resources: [ "posts" ]
verbs: [ "get", "list" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "posts", "posts/head-content", "posts/release-content" ]
resources: [ "posts", "posts/head-content", "posts/release-content", "posts/snapshot", "posts/content" ]
verbs: [ "get", "list" ]

View File

@ -15,7 +15,7 @@ rules:
resources: [ "singlepages" ]
verbs: [ "*" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "singlepages", "singlepages/publish", "singlepages/content" ]
resources: [ "singlepages", "singlepages/publish", "singlepages/content", "singlepages/revert-content" ]
verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
---
apiVersion: v1alpha1
@ -35,5 +35,5 @@ rules:
resources: [ "singlepages" ]
verbs: [ "get", "list" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "singlepages", "singlepages/head-content", "singlepages/release-content" ]
resources: [ "singlepages", "singlepages/head-content", "singlepages/release-content", "singlepages/snapshot", "singlepages/content" ]
verbs: [ "get", "list" ]

View File

@ -23,3 +23,6 @@ rules:
- apiGroups: [ "content.halo.run" ]
resources: [ "tags" ]
verbs: [ "get", "list" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "tags" ]
verbs: [ "get", "list" ]

View File

@ -24,7 +24,7 @@ class ContentRequestTest {
ref.setKind(Post.KIND);
ref.setGroup("content.halo.run");
ref.setName("test-post");
contentRequest = new ContentRequest(ref, "snapshot-1", """
contentRequest = new ContentRequest(ref, "snapshot-1", null, """
Four score and seven
years ago our fathers

View File

@ -454,21 +454,6 @@ class CommentNotificationReasonPublisherTest {
}
}
@Test
void identityFromTest() {
var owner = new Comment.CommentOwner();
owner.setKind(User.KIND);
owner.setName("fake-user");
assertThat(CommentNotificationReasonPublisher.identityFrom(owner))
.isEqualTo(UserIdentity.of(owner.getName()));
owner.setKind(Comment.CommentOwner.KIND_EMAIL);
owner.setName("example@example.com");
assertThat(CommentNotificationReasonPublisher.identityFrom(owner))
.isEqualTo(UserIdentity.anonymousWithEmail(owner.getName()));
}
static Comment createComment() {
var comment = new Comment();
comment.setMetadata(new Metadata());

View File

@ -0,0 +1,159 @@
package run.halo.app.content.comment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.annotation.DirtiesContext;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ExtensionStoreUtil;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
import run.halo.app.extension.SchemeManager;
import run.halo.app.extension.index.IndexerFactory;
import run.halo.app.extension.store.ReactiveExtensionStoreClient;
import run.halo.app.infra.utils.JsonUtils;
/**
* Integration tests for {@link CommentServiceImpl}.
*
* @author guqing
* @since 2.15.0
*/
class CommentServiceImplIntegrationTest {
@Nested
@DirtiesContext
@SpringBootTest
class CommentRemoveTest {
private final List<Comment> storedComments = createComments(350);
@Autowired
private SchemeManager schemeManager;
@SpyBean
private ReactiveExtensionClient reactiveClient;
@Autowired
private ReactiveExtensionStoreClient storeClient;
@Autowired
private IndexerFactory indexerFactory;
@SpyBean
private CommentServiceImpl commentService;
Mono<Extension> deleteImmediately(Extension extension) {
var name = extension.getMetadata().getName();
var scheme = schemeManager.get(extension.getClass());
// un-index
var indexer = indexerFactory.getIndexer(extension.groupVersionKind());
indexer.unIndexRecord(extension.getMetadata().getName());
// delete from db
var storeName = ExtensionStoreUtil.buildStoreName(scheme, name);
return storeClient.delete(storeName, extension.getMetadata().getVersion())
.thenReturn(extension);
}
@BeforeEach
void setUp() {
Flux.fromIterable(storedComments)
.flatMap(post -> reactiveClient.create(post))
.as(StepVerifier::create)
.expectNextCount(storedComments.size())
.verifyComplete();
}
@AfterEach
void tearDown() {
Flux.fromIterable(storedComments)
.flatMap(this::deleteImmediately)
.as(StepVerifier::create)
.expectNextCount(storedComments.size())
.verifyComplete();
}
@Test
void commentBatchDeletionTest() {
Ref ref = Ref.of("67",
GroupVersionKind.fromAPIVersionAndKind("content.halo.run/v1alpha1", "SinglePage"));
commentService.removeBySubject(ref)
.as(StepVerifier::create)
.verifyComplete();
verify(reactiveClient, times(storedComments.size())).delete(any(Comment.class));
verify(commentService, times(2)).listCommentsByRef(eq(ref), any());
commentService.listCommentsByRef(ref, PageRequestImpl.ofSize(1))
.as(StepVerifier::create)
.consumeNextWith(result -> {
assertThat(result.getTotal()).isEqualTo(0);
})
.verifyComplete();
}
List<Comment> createComments(int size) {
List<Comment> comments = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
var comment = createComment();
comment.getMetadata().setName("comment-" + i);
comments.add(comment);
}
return comments;
}
}
Comment createComment() {
return JsonUtils.jsonToObject("""
{
"spec": {
"raw": "fake-raw",
"content": "fake-content",
"owner": {
"kind": "User",
"name": "fake-user"
},
"userAgent": "",
"ipAddress": "",
"approvedTime": "2024-02-28T09:15:16.095Z",
"creationTime": "2024-02-28T06:23:42.923294424Z",
"priority": 0,
"top": false,
"allowNotification": false,
"approved": true,
"hidden": false,
"subjectRef": {
"group": "content.halo.run",
"version": "v1alpha1",
"kind": "SinglePage",
"name": "67"
},
"lastReadTime": "2024-02-29T03:39:04.230Z"
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Comment",
"metadata": {
"generateName": "comment-"
}
}
""", Comment.class);
}
}

View File

@ -7,6 +7,7 @@ import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.content.comment.ReplyNotificationSubscriptionHelper.identityFrom;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@ -24,9 +25,8 @@ import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.Ref;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.notification.NotificationCenter;
import run.halo.app.notification.SubscriberEmailResolver;
import run.halo.app.notification.UserIdentity;
/**
* Tests for {@link ReplyNotificationSubscriptionHelper}.
@ -40,9 +40,6 @@ class ReplyNotificationSubscriptionHelperTest {
@Mock
NotificationCenter notificationCenter;
@Mock
SubscriberEmailResolver subscriberEmailResolver;
@InjectMocks
ReplyNotificationSubscriptionHelper notificationSubscriptionHelper;
@ -51,17 +48,12 @@ class ReplyNotificationSubscriptionHelperTest {
var comment = createComment();
var spyNotificationSubscriptionHelper = spy(notificationSubscriptionHelper);
doNothing().when(spyNotificationSubscriptionHelper).subscribeReply(any(), any());
doNothing().when(spyNotificationSubscriptionHelper).subscribeReply(any(UserIdentity.class));
spyNotificationSubscriptionHelper.subscribeNewReplyReasonForComment(comment);
var reasonSubject = Subscription.ReasonSubject.builder()
.apiVersion(comment.getApiVersion())
.kind(comment.getKind())
.name(comment.getMetadata().getName())
.build();
verify(spyNotificationSubscriptionHelper).subscribeReply(eq(reasonSubject),
eq(ReplyNotificationSubscriptionHelper.Identity.fromCommentOwner(
verify(spyNotificationSubscriptionHelper).subscribeReply(
eq(ReplyNotificationSubscriptionHelper.identityFrom(
comment.getSpec().getOwner()))
);
}
@ -80,17 +72,12 @@ class ReplyNotificationSubscriptionHelperTest {
var spyNotificationSubscriptionHelper = spy(notificationSubscriptionHelper);
doNothing().when(spyNotificationSubscriptionHelper).subscribeReply(any(), any());
doNothing().when(spyNotificationSubscriptionHelper).subscribeReply(any(UserIdentity.class));
spyNotificationSubscriptionHelper.subscribeNewReplyReasonForReply(reply);
var reasonSubject = Subscription.ReasonSubject.builder()
.apiVersion(reply.getApiVersion())
.kind(reply.getKind())
.name(reply.getMetadata().getName())
.build();
verify(spyNotificationSubscriptionHelper).subscribeReply(eq(reasonSubject),
eq(ReplyNotificationSubscriptionHelper.Identity.fromCommentOwner(
verify(spyNotificationSubscriptionHelper).subscribeReply(
eq(ReplyNotificationSubscriptionHelper.identityFrom(
reply.getSpec().getOwner()))
);
}
@ -98,48 +85,38 @@ class ReplyNotificationSubscriptionHelperTest {
@Test
void subscribeReplyTest() {
var comment = createComment();
var reasonSubject = Subscription.ReasonSubject.builder()
.apiVersion(comment.getApiVersion())
.kind(comment.getKind())
.name(comment.getMetadata().getName())
.build();
var identity = ReplyNotificationSubscriptionHelper.Identity.fromCommentOwner(
var identity = ReplyNotificationSubscriptionHelper.identityFrom(
comment.getSpec().getOwner());
when(notificationCenter.subscribe(any(), any())).thenReturn(Mono.empty());
var subscriber = new Subscription.Subscriber();
subscriber.setName(AnonymousUserConst.PRINCIPAL + "#" + identity.name());
when(subscriberEmailResolver.ofEmail(eq(identity.name())))
.thenReturn(subscriber);
subscriber.setName(identity.name());
notificationSubscriptionHelper.subscribeReply(reasonSubject, identity);
notificationSubscriptionHelper.subscribeReply(identity);
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType(NotificationReasonConst.SOMEONE_REPLIED_TO_YOU);
interestReason.setSubject(reasonSubject);
interestReason.setExpression("props.repliedOwner == '%s'".formatted(subscriber.getName()));
verify(notificationCenter).subscribe(eq(subscriber), eq(interestReason));
verify(subscriberEmailResolver).ofEmail(eq(identity.name()));
}
@Nested
class IdentityTest {
@Test
void createForCommentOwner() {
var commentOwner = new Comment.CommentOwner();
commentOwner.setKind(Comment.CommentOwner.KIND_EMAIL);
commentOwner.setName("example@example.com");
void identityFromTest() {
var owner = new Comment.CommentOwner();
owner.setKind(User.KIND);
owner.setName("fake-user");
var sub = ReplyNotificationSubscriptionHelper.Identity.fromCommentOwner(commentOwner);
assertThat(sub.isEmail()).isTrue();
assertThat(sub.name()).isEqualTo(commentOwner.getName());
assertThat(identityFrom(owner))
.isEqualTo(UserIdentity.of(owner.getName()));
commentOwner.setKind(User.KIND);
commentOwner.setName("fake-user");
sub = ReplyNotificationSubscriptionHelper.Identity.fromCommentOwner(commentOwner);
assertThat(sub.isEmail()).isFalse();
assertThat(sub.name()).isEqualTo(commentOwner.getName());
owner.setKind(Comment.CommentOwner.KIND_EMAIL);
owner.setName("example@example.com");
assertThat(identityFrom(owner))
.isEqualTo(UserIdentity.anonymousWithEmail(owner.getName()));
}
}

View File

@ -0,0 +1,152 @@
package run.halo.app.content.comment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.annotation.DirtiesContext;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.content.Reply;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ExtensionStoreUtil;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.SchemeManager;
import run.halo.app.extension.index.IndexerFactory;
import run.halo.app.extension.store.ReactiveExtensionStoreClient;
import run.halo.app.infra.utils.JsonUtils;
/**
* Integration tests for {@link ReplyServiceImpl}.
*
* @author guqing
* @since 2.15.0
*/
class ReplyServiceImplIntegrationTest {
@Nested
@DirtiesContext
@SpringBootTest
class ReplyRemoveTest {
private final List<Reply> storedReplies = createReplies(320);
private List<Reply> createReplies(int size) {
List<Reply> replies = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
var reply = JsonUtils.jsonToObject(fakeReplyJson(), Reply.class);
reply.getMetadata().setName("reply-" + i);
replies.add(reply);
}
return replies;
}
@Autowired
private SchemeManager schemeManager;
@SpyBean
private ReactiveExtensionClient reactiveClient;
@Autowired
private ReactiveExtensionStoreClient storeClient;
@Autowired
private IndexerFactory indexerFactory;
@SpyBean
private ReplyServiceImpl replyService;
Mono<Extension> deleteImmediately(Extension extension) {
var name = extension.getMetadata().getName();
var scheme = schemeManager.get(extension.getClass());
// un-index
var indexer = indexerFactory.getIndexer(extension.groupVersionKind());
indexer.unIndexRecord(extension.getMetadata().getName());
// delete from db
var storeName = ExtensionStoreUtil.buildStoreName(scheme, name);
return storeClient.delete(storeName, extension.getMetadata().getVersion())
.thenReturn(extension);
}
@BeforeEach
void setUp() {
Flux.fromIterable(storedReplies)
.flatMap(post -> reactiveClient.create(post))
.as(StepVerifier::create)
.expectNextCount(storedReplies.size())
.verifyComplete();
}
@AfterEach
void tearDown() {
Flux.fromIterable(storedReplies)
.flatMap(this::deleteImmediately)
.as(StepVerifier::create)
.expectNextCount(storedReplies.size())
.verifyComplete();
}
@Test
void removeAllByComment() {
String commentName = "fake-comment";
replyService.removeAllByComment(commentName)
.as(StepVerifier::create)
.verifyComplete();
verify(reactiveClient, times(storedReplies.size())).delete(any(Reply.class));
verify(replyService, times(2)).listRepliesByComment(eq(commentName), any());
replyService.listRepliesByComment(commentName, PageRequestImpl.ofSize(1))
.as(StepVerifier::create)
.consumeNextWith(result -> assertThat(result.getTotal()).isEqualTo(0))
.verifyComplete();
}
}
String fakeReplyJson() {
return """
{
"metadata":{
"name":"fake-reply"
},
"spec":{
"raw":"fake-raw",
"content":"fake-content",
"owner":{
"kind":"User",
"name":"fake-user",
"displayName":"fake-display-name"
},
"creationTime": "2024-03-11T06:23:42.923294424Z",
"ipAddress":"",
"approved": true,
"hidden": false,
"allowNotification": false,
"top": false,
"priority": 0,
"commentName":"fake-comment"
},
"owner":{
"kind":"User",
"displayName":"fake-display-name"
},
"stats":{
"upvote":0
}
}
""";
}
}

View File

@ -24,6 +24,7 @@ import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.EmailVerificationService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
@ -43,6 +44,10 @@ class EmailVerificationCodeTest {
ReactiveExtensionClient client;
@Mock
EmailVerificationService emailVerificationService;
@Mock
UserService userService;
@InjectMocks
UserEndpoint endpoint;
@ -97,9 +102,11 @@ class EmailVerificationCodeTest {
void verifyEmail() {
when(emailVerificationService.verify(anyString(), anyString()))
.thenReturn(Mono.empty());
when(userService.confirmPassword(anyString(), anyString()))
.thenReturn(Mono.just(true));
webClient.post()
.uri("/users/-/verify-email")
.bodyValue(Map.of("code", "fake-code-1"))
.bodyValue(Map.of("code", "fake-code-1", "password", "123456"))
.exchange()
.expectStatus()
.isOk();
@ -107,7 +114,7 @@ class EmailVerificationCodeTest {
// request again to trigger rate limit
webClient.post()
.uri("/users/-/verify-email")
.bodyValue(Map.of("code", "fake-code-2"))
.bodyValue(Map.of("code", "fake-code-2", "password", "123456"))
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.TOO_MANY_REQUESTS);

View File

@ -18,7 +18,7 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import run.halo.app.content.Content;
import run.halo.app.content.ContentUpdateParam;
import run.halo.app.content.PostRequest;
import run.halo.app.content.PostService;
import run.halo.app.content.TestPost;
@ -177,6 +177,6 @@ class PostEndpointTest {
}
PostRequest postRequest(Post post) {
return new PostRequest(post, new Content("B", "<p>B</p>", "MARKDOWN"));
return new PostRequest(post, new ContentUpdateParam(null, "B", "<p>B</p>", "MARKDOWN"));
}
}

View File

@ -7,7 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@ -40,7 +40,6 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.pf4j.PluginManager;
import org.pf4j.PluginState;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
@ -56,6 +55,7 @@ import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.extension.controller.RequeueException;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.plugin.PluginProperties;
import run.halo.app.plugin.SpringPluginManager;
/**
* Tests for {@link PluginReconciler}.
@ -67,7 +67,7 @@ import run.halo.app.plugin.PluginProperties;
class PluginReconcilerTest {
@Mock
PluginManager pluginManager;
SpringPluginManager pluginManager;
@Mock
ExtensionClient client;
@ -185,41 +185,6 @@ class PluginReconcilerTest {
mode for plugin fake-plugin.""", gotException.getMessage());
}
@Test
void shouldUnloadIfFailedToLoad() {
var fakePlugin = createPlugin(name, plugin -> {
var spec = plugin.getSpec();
spec.setVersion("1.2.3");
spec.setLogo("fake-logo.svg");
spec.setEnabled(true);
});
when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin));
when(pluginManager.getPluginsRoots()).thenReturn(List.of(tempPath));
when(pluginManager.getPlugin(name))
// before loading
.thenReturn(null)
.thenReturn(mock(PluginWrapper.class))
;
var expectException = mock(RuntimeException.class);
when(expectException.getMessage()).thenReturn("Something went wrong.");
doThrow(expectException).when(pluginManager).loadPlugin(any(Path.class));
var gotException = assertThrows(RuntimeException.class,
() -> reconciler.reconcile(new Request(name)));
assertEquals(expectException, gotException);
var condition = fakePlugin.getStatus().getConditions().peek();
assertEquals("FAILED", condition.getType());
assertEquals(ConditionStatus.FALSE, condition.getStatus());
assertEquals("UnexpectedState", condition.getReason());
assertEquals(expectException.getMessage(), condition.getMessage());
assertEquals(clock.instant(), condition.getLastTransitionTime());
verify(pluginManager, times(3)).getPlugin(name);
verify(pluginManager).loadPlugin(any(Path.class));
verify(pluginManager).unloadPlugin(name);
}
@Test
void shouldReloadIfReloadAnnotationPresent() {
var fakePlugin = createPlugin(name, plugin -> {
@ -233,15 +198,14 @@ class PluginReconcilerTest {
when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin));
when(pluginManager.getPluginsRoots()).thenReturn(List.of(tempPath));
when(pluginManager.getPlugin(name)).thenReturn(mock(PluginWrapper.class));
when(pluginManager.unloadPlugin(name)).thenReturn(true);
when(pluginManager.reloadPlugin(eq(name), any(Path.class))).thenReturn(true);
when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED);
var result = reconciler.reconcile(new Request(name));
assertFalse(result.reEnqueue());
verify(pluginManager).unloadPlugin(name);
var loadLocation = Paths.get(fakePlugin.getStatus().getLoadLocation());
verify(pluginManager).loadPlugin(loadLocation);
verify(pluginManager).reloadPlugin(name, loadLocation);
}
@Test
@ -262,11 +226,7 @@ class PluginReconcilerTest {
.thenReturn(null)
// get setting extension
.thenReturn(mockPluginWrapperForSetting())
.thenReturn(mockPluginWrapperForStaticResources())
// before starting
.thenReturn(mockPluginWrapper(PluginState.RESOLVED))
// sync plugin state
.thenReturn(mockPluginWrapper(PluginState.STARTED));
.thenReturn(mockPluginWrapperForStaticResources());
when(pluginManager.startPlugin(name)).thenReturn(PluginState.FAILED);
var e = assertThrows(IllegalStateException.class,
@ -293,8 +253,6 @@ class PluginReconcilerTest {
// get setting extension
.thenReturn(mockPluginWrapperForSetting())
.thenReturn(mockPluginWrapperForStaticResources())
// before starting
.thenReturn(mockPluginWrapper(PluginState.RESOLVED))
// sync plugin state
.thenReturn(mockPluginWrapper(PluginState.STARTED));
when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED);
@ -325,7 +283,7 @@ class PluginReconcilerTest {
verify(pluginManager).startPlugin(name);
verify(pluginManager).loadPlugin(loadLocation);
verify(pluginManager, times(5)).getPlugin(name);
verify(pluginManager, times(4)).getPlugin(name);
verify(client).update(fakePlugin);
verify(client).fetch(Setting.class, settingName);
verify(client).create(any(Setting.class));

View File

@ -149,7 +149,7 @@ class PostReconcilerTest {
when(client.fetch(eq(Post.class), eq(name)))
.thenReturn(Optional.of(post));
when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()),
eq(post.getSpec().getBaseSnapshot())))
eq(post.getSpec().getBaseSnapshot())))
.thenReturn(Mono.just(ContentWrapper.builder()
.snapshotName(post.getSpec().getHeadSnapshot())
.raw("hello world")
@ -215,11 +215,7 @@ class PostReconcilerTest {
assertArg(argReason -> {
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_POST);
interestReason.setSubject(Subscription.ReasonSubject.builder()
.apiVersion(post.getApiVersion())
.kind(post.getKind())
.name(post.getMetadata().getName())
.build());
interestReason.setExpression("props.postOwner == 'null'");
assertThat(argReason).isEqualTo(interestReason);
}));
}

View File

@ -232,11 +232,7 @@ class SinglePageReconcilerTest {
assertArg(argReason -> {
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_PAGE);
interestReason.setSubject(Subscription.ReasonSubject.builder()
.apiVersion(page.getApiVersion())
.kind(page.getKind())
.name(page.getMetadata().getName())
.build());
interestReason.setExpression("props.pageOwner == 'null'");
assertThat(argReason).isEqualTo(interestReason);
}));
}

View File

@ -22,6 +22,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
@ -31,6 +32,7 @@ import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.notification.NotificationCenter;
/**
* Tests for {@link UserReconciler}.
@ -46,6 +48,9 @@ class UserReconcilerTest {
@Mock
private ExtensionClient client;
@Mock
private NotificationCenter notificationCenter;
@Mock
private RoleService roleService;
@ -54,6 +59,7 @@ class UserReconcilerTest {
@BeforeEach
void setUp() {
lenient().when(notificationCenter.unsubscribe(any(), any())).thenReturn(Mono.empty());
lenient().when(roleService.listRoleRefs(any())).thenReturn(Flux.empty());
}

View File

@ -0,0 +1,107 @@
package run.halo.app.infra;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Sort;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.ReactiveExtensionClient;
@ExtendWith(MockitoExtension.class)
class ReactiveExtensionPaginatedOperatorImplTest {
@Mock
private ReactiveExtensionClient client;
@InjectMocks
private ReactiveExtensionPaginatedOperatorImpl service;
@Nested
class ListTest {
@BeforeEach
void setUp() {
Instant now = Instant.now();
var items = new ArrayList<>();
// Generate 900 items
for (int j = 0; j < 9; j++) {
items.addAll(generateItems(100, now));
}
// mock new items during the process
Instant otherNow = now.plusSeconds(1000);
items.addAll(generateItems(90, otherNow));
when(client.listBy(any(), any(), any())).thenAnswer(invocation -> {
PageRequest pageRequest = invocation.getArgument(2);
int pageNumber = pageRequest.getPageNumber();
var list = ListResult.subList(items, pageNumber, pageRequest.getPageSize());
var result = new ListResult<>(pageNumber, pageRequest.getPageSize(),
items.size(), list);
return Mono.just(result);
});
}
@Test
public void listTest() {
StepVerifier.create(service.list(FakeExtension.class, new ListOptions()))
.expectNextCount(900)
.verifyComplete();
}
}
@Test
void nextPageTest() {
var result = new ListResult<FakeExtension>(1, 10, 30, List.of());
var sort = Sort.by("metadata.creationTimestamp");
var nextPage = ReactiveExtensionPaginatedOperatorImpl.nextPage(result, sort);
assertThat(nextPage.getPageNumber()).isEqualTo(2);
assertThat(nextPage.getPageSize()).isEqualTo(10);
assertThat(nextPage.getSort()).isEqualTo(sort);
}
@Test
void shouldTakeNextTest() {
var now = Instant.now();
var item = new FakeExtension();
item.setMetadata(new Metadata());
item.getMetadata().setCreationTimestamp(now);
var result = ReactiveExtensionPaginatedOperatorImpl.shouldTakeNext(item, now);
assertThat(result).isTrue();
item.getMetadata().setCreationTimestamp(now.minusSeconds(1));
result = ReactiveExtensionPaginatedOperatorImpl.shouldTakeNext(item, now);
assertThat(result).isTrue();
item.getMetadata().setCreationTimestamp(now.plusSeconds(1));
result = ReactiveExtensionPaginatedOperatorImpl.shouldTakeNext(item, now);
assertThat(result).isFalse();
}
private List<FakeExtension> generateItems(int count, Instant creationTimestamp) {
List<FakeExtension> items = new ArrayList<>();
for (int i = 0; i < count; i++) {
var item = new FakeExtension();
item.setMetadata(new Metadata());
item.getMetadata().setCreationTimestamp(creationTimestamp);
items.add(item);
}
return items;
}
}

View File

@ -13,7 +13,6 @@ import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Locale;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -29,12 +28,8 @@ import run.halo.app.core.extension.notification.NotifierDescriptor;
import run.halo.app.core.extension.notification.Reason;
import run.halo.app.core.extension.notification.ReasonType;
import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.JsonUtils;
/**
* Tests for {@link DefaultNotificationCenter}.
@ -60,6 +55,12 @@ class DefaultNotificationCenterTest {
@Mock
private NotificationSender notificationSender;
@Mock
private RecipientResolver recipientResolver;
@Mock
private SubscriptionService subscriptionService;
@InjectMocks
private DefaultNotificationCenter notificationCenter;
@ -78,21 +79,17 @@ class DefaultNotificationCenterTest {
reason.setMetadata(new Metadata());
reason.getMetadata().setName("reason-a");
var subscriptionReasonSubject = createNewReplyOnCommentSubject();
var spyNotificationCenter = spy(notificationCenter);
var subscriptions = createSubscriptions();
doReturn(Flux.fromIterable(subscriptions))
.when(spyNotificationCenter).listObservers(eq("new-reply-on-comment"),
eq(subscriptionReasonSubject));
var subscriber = new Subscriber(UserIdentity.anonymousWithEmail("A"), "fake-name");
when(recipientResolver.resolve(reason)).thenReturn(Flux.just(subscriber));
doReturn(Mono.empty()).when(spyNotificationCenter)
.dispatchNotification(eq(reason), any());
spyNotificationCenter.notify(reason).block();
verify(spyNotificationCenter).dispatchNotification(eq(reason), any());
verify(spyNotificationCenter).listObservers(eq("new-reply-on-comment"),
eq(subscriptionReasonSubject));
verify(recipientResolver).resolve(eq(reason));
}
List<Subscription> createSubscriptions() {
@ -129,69 +126,16 @@ class DefaultNotificationCenterTest {
var reason = subscription.getSpec().getReason();
doReturn(Flux.just(subscription))
.when(spyNotificationCenter).listSubscription(eq(subscriber), eq(reason));
doReturn(Mono.empty())
.when(spyNotificationCenter).unsubscribe(eq(subscriber), eq(reason));
when(client.create(any(Subscription.class))).thenReturn(Mono.empty());
spyNotificationCenter.subscribe(subscriber, reason).block();
verify(client, times(0)).create(any(Subscription.class));
// not exists subscription will create a new subscription
var newReason = JsonUtils.deepCopy(reason);
newReason.setReasonType("fake-reason-type");
doReturn(Flux.empty())
.when(spyNotificationCenter).listSubscription(eq(subscriber), eq(newReason));
spyNotificationCenter.subscribe(subscriber, newReason).block();
verify(client).create(any(Subscription.class));
}
@Test
public void testUnsubscribe() {
Subscription.Subscriber subscriber = new Subscription.Subscriber();
subscriber.setName("anonymousUser#A");
var spyNotificationCenter = spy(notificationCenter);
var subscriptions = createSubscriptions();
doReturn(Mono.just(new ListResult<>(subscriptions)))
.when(spyNotificationCenter).pageSubscriptionBy(eq(subscriber), any(PageRequest.class));
when(client.delete(any(Subscription.class))).thenReturn(Mono.empty());
spyNotificationCenter.unsubscribe(subscriber).block();
verify(client).delete(any(Subscription.class));
}
@Test
public void testUnsubscribeWithReason() {
var spyNotificationCenter = spy(notificationCenter);
var subscriptions = createSubscriptions();
var subscription = subscriptions.get(0);
var subscriber = subscription.getSpec().getSubscriber();
var reason = subscription.getSpec().getReason();
var newReason = JsonUtils.deepCopy(reason);
newReason.setReasonType("fake-reason-type");
doReturn(Flux.empty())
.when(spyNotificationCenter).listSubscription(eq(subscriber), eq(newReason));
when(client.delete(any(Subscription.class))).thenReturn(Mono.empty());
spyNotificationCenter.unsubscribe(subscriber, newReason).block();
verify(client, times(0)).delete(any(Subscription.class));
doReturn(Flux.just(subscription))
.when(spyNotificationCenter).listSubscription(eq(subscriber), eq(reason));
// exists subscription will be deleted
spyNotificationCenter.unsubscribe(subscriber, reason).block();
verify(client).delete(any(Subscription.class));
}
@Test
public void testGetNotifiersBySubscriber() {
UserNotificationPreference preference = new UserNotificationPreference();
@ -203,8 +147,7 @@ class DefaultNotificationCenterTest {
reason.getMetadata().setName("reason-a");
reason.setSpec(new Reason.Spec());
reason.getSpec().setReasonType("new-reply-on-comment");
var subscriber = new Subscription.Subscriber();
subscriber.setName("anonymousUser#A");
var subscriber = new Subscriber(UserIdentity.anonymousWithEmail("A"), "fake-name");
notificationCenter.getNotifiersBySubscriber(subscriber, reason)
.collectList()
@ -215,7 +158,7 @@ class DefaultNotificationCenterTest {
})
.verifyComplete();
verify(userNotificationPreferenceService).getByUser(eq(subscriber.getName()));
verify(userNotificationPreferenceService).getByUser(eq(subscriber.name()));
}
@Test
@ -234,7 +177,6 @@ class DefaultNotificationCenterTest {
.when(spyNotificationCenter).prepareNotificationElement(any(), any(), any());
doReturn(Mono.empty()).when(spyNotificationCenter).sendNotification(any());
doReturn(Mono.empty()).when(spyNotificationCenter).createNotification(any());
var reason = new Reason();
reason.setMetadata(new Metadata());
@ -243,11 +185,15 @@ class DefaultNotificationCenterTest {
reason.getSpec().setReasonType("new-reply-on-comment");
var subscription = createSubscriptions().get(0);
spyNotificationCenter.dispatchNotification(reason, subscription).block();
var subscriptionName = subscription.getMetadata().getName();
var subscriber =
new Subscriber(UserIdentity.of(subscription.getSpec().getSubscriber().getName()),
subscriptionName);
spyNotificationCenter.dispatchNotification(reason, subscriber).block();
verify(client).fetch(eq(NotifierDescriptor.class), eq("email-notifier"));
verify(spyNotificationCenter).sendNotification(any());
verify(spyNotificationCenter).createNotification(any());
verify(spyNotificationCenter, times(0)).createNotification(any());
}
@Test
@ -282,7 +228,7 @@ class DefaultNotificationCenterTest {
var element = mock(DefaultNotificationCenter.NotificationElement.class);
var mockDescriptor = mock(NotifierDescriptor.class);
when(element.descriptor()).thenReturn(mockDescriptor);
when(element.subscription()).thenReturn(mock(Subscription.class));
when(element.subscriber()).thenReturn(mock(Subscriber.class));
var notifierDescriptorSpec = mock(NotifierDescriptor.Spec.class);
when(mockDescriptor.getSpec()).thenReturn(notifierDescriptorSpec);
when(notifierDescriptorSpec.getNotifierExtName()).thenReturn("fake-notifier-ext");
@ -299,9 +245,12 @@ class DefaultNotificationCenterTest {
var subscription = createSubscriptions().get(0);
var user = mock(User.class);
var subscriberName = subscription.getSpec().getSubscriber().getName();
when(client.fetch(eq(User.class), eq(subscriberName))).thenReturn(Mono.just(user));
when(element.subscription()).thenReturn(subscription);
var subscriptionName = subscription.getMetadata().getName();
var subscriber =
new Subscriber(UserIdentity.of(subscription.getSpec().getSubscriber().getName()),
subscriptionName);
when(client.fetch(eq(User.class), eq(subscriber.name()))).thenReturn(Mono.just(user));
when(element.subscriber()).thenReturn(subscriber);
when(client.create(any(Notification.class))).thenReturn(Mono.empty());
@ -314,7 +263,7 @@ class DefaultNotificationCenterTest {
notificationCenter.createNotification(element).block();
verify(client).fetch(eq(User.class), eq(subscriberName));
verify(client).fetch(eq(User.class), eq(subscriber.name()));
verify(client).create(any(Notification.class));
}
@ -334,8 +283,8 @@ class DefaultNotificationCenterTest {
doReturn(Mono.just(reasonType))
.when(spyNotificationCenter).getReasonType(eq(reasonTypeName));
doReturn("fake-url")
.when(spyNotificationCenter).getUnsubscribeUrl(any());
doReturn(Mono.just("fake-unsubscribe-url"))
.when(spyNotificationCenter).getUnsubscribeUrl(anyString());
final var locale = Locale.CHINESE;
@ -356,98 +305,17 @@ class DefaultNotificationCenterTest {
when(notificationTemplateSelector.select(eq(reasonTypeName), any()))
.thenReturn(Mono.just(template));
var subscription = new Subscription();
subscription.setSpec(new Subscription.Spec());
var subscriber = new Subscription.Subscriber();
subscriber.setName("anonymousUser#A");
subscription.getSpec().setSubscriber(subscriber);
var subscriber = new Subscriber(UserIdentity.anonymousWithEmail("A"), "fake-name");
spyNotificationCenter.inferenceTemplate(reason, subscription, locale).block();
spyNotificationCenter.inferenceTemplate(reason, subscriber, locale).block();
verify(spyNotificationCenter).getReasonType(eq(reasonTypeName));
verify(notificationTemplateSelector).select(eq(reasonTypeName), any());
}
@Test
void listObserverWhenDuplicateSubscribers() {
var sourceSubscriptions = createSubscriptions();
var subscriptionA = sourceSubscriptions.get(0);
var subscriptionB = JsonUtils.deepCopy(subscriptionA);
var subscriptionC = JsonUtils.deepCopy(subscriptionA);
subscriptionC.getSpec().getReason().getSubject().setName(null);
var subscriptions = Flux.just(subscriptionA, subscriptionB, subscriptionC);
DefaultNotificationCenter.distinctByKey(subscriptions)
.as(StepVerifier::create)
.expectNext(subscriptionA)
.verifyComplete();
}
@Nested
class SubscriptionDistinctKeyPredicateTest {
@Test
void differentSubjectName() {
var subscriptionA = createSubscriptions().get(0);
var subscriptionB = JsonUtils.deepCopy(subscriptionA);
assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate()
.test(subscriptionA, subscriptionB)).isTrue();
subscriptionB.getSpec().getReason().getSubject().setName("other");
assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate()
.test(subscriptionA, subscriptionB)).isFalse();
subscriptionB.getSpec().getReason().getSubject().setName(null);
assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate()
.test(subscriptionA, subscriptionB)).isTrue();
}
@Test
void differentSubjectApiVersion() {
var subscriptionA = createSubscriptions().get(0);
var subscriptionB = JsonUtils.deepCopy(subscriptionA);
assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate()
.test(subscriptionA, subscriptionB)).isTrue();
var subjectA = subscriptionA.getSpec().getReason().getSubject();
var gvForA = GroupVersion.parseAPIVersion(subjectA.getApiVersion());
subscriptionB.getSpec().getReason().getSubject()
.setApiVersion(gvForA.group() + "/otherVersion");
assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate()
.test(subscriptionA, subscriptionB)).isTrue();
}
@Test
void differentReasonType() {
var subscriptionA = createSubscriptions().get(0);
var subscriptionB = JsonUtils.deepCopy(subscriptionA);
assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate()
.test(subscriptionA, subscriptionB)).isTrue();
subscriptionB.getSpec().getReason().setReasonType("other");
assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate()
.test(subscriptionA, subscriptionB)).isFalse();
}
@Test
void differentSubscriber() {
var subscriptionA = createSubscriptions().get(0);
var subscriptionB = JsonUtils.deepCopy(subscriptionA);
assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate()
.test(subscriptionA, subscriptionB)).isTrue();
subscriptionB.getSpec().getSubscriber().setName("other");
assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate()
.test(subscriptionA, subscriptionB)).isFalse();
}
}
@Test
void getLocaleFromSubscriberTest() {
var subscription = mock(Subscription.class);
var subscription = mock(Subscriber.class);
notificationCenter.getLocaleFromSubscriber(subscription)
.as(StepVerifier::create)

View File

@ -0,0 +1,180 @@
package run.halo.app.notification;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.notification.Reason;
import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.Metadata;
/**
* Tests for {@link RecipientResolverImpl}.
*
* @author guqing
* @since 2.15.0
*/
@ExtendWith(MockitoExtension.class)
class RecipientResolverImplTest {
@Mock
private SubscriptionService subscriptionService;
@InjectMocks
private RecipientResolverImpl recipientResolver;
@Test
void testExpressionMatch() {
var subscriber1 = new Subscription.Subscriber();
subscriber1.setName("test");
final var subscription1 = createSubscription(subscriber1);
subscription1.getMetadata().setName("test-subscription");
subscription1.getSpec().getReason().setSubject(null);
subscription1.getSpec().getReason().setExpression("props.owner == 'test'");
var subscriber2 = new Subscription.Subscriber();
subscriber2.setName("guqing");
final var subscription2 = createSubscription(subscriber2);
subscription2.getMetadata().setName("guqing-subscription");
subscription2.getSpec().getReason().setSubject(null);
subscription2.getSpec().getReason().setExpression("props.owner == 'guqing'");
var reason = new Reason();
reason.setSpec(new Reason.Spec());
reason.getSpec().setReasonType("new-comment-on-post");
reason.getSpec().setSubject(new Reason.Subject());
reason.getSpec().getSubject().setApiVersion("content.halo.run/v1alpha1");
reason.getSpec().getSubject().setKind("Post");
reason.getSpec().getSubject().setName("fake-post");
var reasonAttributes = new ReasonAttributes();
reasonAttributes.put("owner", "guqing");
reason.getSpec().setAttributes(reasonAttributes);
when(subscriptionService.listByPerPage(anyString()))
.thenReturn(Flux.just(subscription1, subscription2));
recipientResolver.resolve(reason)
.as(StepVerifier::create)
.expectNext(new Subscriber(UserIdentity.of("guqing"), "guqing-subscription"))
.verifyComplete();
verify(subscriptionService).listByPerPage(anyString());
}
@Test
void testSubjectMatch() {
var subscriber = new Subscription.Subscriber();
subscriber.setName("test");
Subscription subscription = createSubscription(subscriber);
when(subscriptionService.listByPerPage(anyString()))
.thenReturn(Flux.just(subscription));
var reason = new Reason();
reason.setSpec(new Reason.Spec());
reason.getSpec().setReasonType("new-comment-on-post");
reason.getSpec().setSubject(new Reason.Subject());
reason.getSpec().getSubject().setApiVersion("content.halo.run/v1alpha1");
reason.getSpec().getSubject().setKind("Post");
reason.getSpec().getSubject().setName("fake-post");
recipientResolver.resolve(reason)
.as(StepVerifier::create)
.expectNext(new Subscriber(UserIdentity.of("test"), "fake-subscription"))
.verifyComplete();
verify(subscriptionService).listByPerPage(anyString());
}
@Test
void distinct() {
// same subscriber to different subscriptions
var subscriber = new Subscription.Subscriber();
subscriber.setName("test");
final var subscription1 = createSubscription(subscriber);
subscription1.getMetadata().setName("sub-1");
final var subscription2 = createSubscription(subscriber);
subscription2.getMetadata().setName("sub-2");
subscription2.getSpec().getReason().setSubject(null);
subscription2.getSpec().getReason().setExpression("props.owner == 'guqing'");
when(subscriptionService.listByPerPage(anyString()))
.thenReturn(Flux.just(subscription1, subscription2));
var reason = new Reason();
reason.setSpec(new Reason.Spec());
reason.getSpec().setReasonType("new-comment-on-post");
reason.getSpec().setSubject(new Reason.Subject());
reason.getSpec().getSubject().setApiVersion("content.halo.run/v1alpha1");
reason.getSpec().getSubject().setKind("Post");
reason.getSpec().getSubject().setName("fake-post");
var reasonAttributes = new ReasonAttributes();
reasonAttributes.put("owner", "guqing");
reason.getSpec().setAttributes(reasonAttributes);
recipientResolver.resolve(reason)
.as(StepVerifier::create)
.expectNextCount(1)
.verifyComplete();
verify(subscriptionService).listByPerPage(anyString());
}
@Test
void subjectMatchTest() {
var subscriber = new Subscription.Subscriber();
subscriber.setName("test");
final var subscription = createSubscription(subscriber);
// match all name subscription
var subject = new Reason.Subject();
subject.setApiVersion("content.halo.run/v1alpha1");
subject.setKind("Post");
subject.setName("fake-post");
assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isTrue();
// different kind
subject = new Reason.Subject();
subject.setApiVersion("content.halo.run/v1alpha1");
subject.setKind("SinglePage");
subject.setName("fake-post");
assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isFalse();
// special case
subscription.getSpec().getReason().getSubject().setName("other-post");
subject = new Reason.Subject();
subject.setApiVersion("content.halo.run/v1alpha1");
subject.setKind("Post");
subject.setName("fake-post");
assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isFalse();
subject.setName("other-post");
assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isTrue();
}
private static Subscription createSubscription(Subscription.Subscriber subscriber) {
Subscription subscription = new Subscription();
subscription.setMetadata(new Metadata());
subscription.getMetadata().setName("fake-subscription");
subscription.setSpec(new Subscription.Spec());
subscription.getSpec().setSubscriber(subscriber);
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType("new-comment-on-post");
interestReason.setSubject(new Subscription.ReasonSubject());
interestReason.getSubject().setApiVersion("content.halo.run/v1alpha1");
interestReason.getSubject().setKind("Post");
subscription.getSpec().setReason(interestReason);
return subscription;
}
}

View File

@ -0,0 +1,74 @@
package run.halo.app.notification;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.OptimisticLockingFailureException;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
/**
* Tests for {@link SubscriptionServiceImpl}.
*
* @author guqing
* @since 2.15.0
*/
@ExtendWith(MockitoExtension.class)
class SubscriptionServiceImplTest {
@Mock
private ReactiveExtensionClient client;
@InjectMocks
private SubscriptionServiceImpl subscriptionService;
@Test
void remove() {
var i = new AtomicLong(1L);
when(client.delete(any(Subscription.class))).thenAnswer(invocation -> {
var subscription = (Subscription) invocation.getArgument(0);
if (i.get() != subscription.getMetadata().getVersion()) {
return Mono.error(new OptimisticLockingFailureException("fake-exception"));
}
return Mono.just(subscription);
});
var subscription = new Subscription();
subscription.setMetadata(new Metadata());
subscription.getMetadata().setName("fake-subscription");
subscription.getMetadata().setVersion(0L);
when(client.fetch(eq(Subscription.class), eq("fake-subscription")))
.thenAnswer(invocation -> {
if (i.incrementAndGet() > 3) {
subscription.getMetadata().setVersion(i.get());
} else {
subscription.getMetadata().setVersion(i.get() - 1);
}
return Mono.just(subscription);
});
subscriptionService.remove(subscription)
.as(StepVerifier::create)
.expectNextCount(1)
.verifyComplete();
// give version=0, but the real version is 1
// give version=1, but the real version is 2
// give version=2, but the real version is 3
// give version=3, but the real version is 3 (delete success)
verify(client, times(3)).fetch(eq(Subscription.class), eq("fake-subscription"));
}
}

View File

@ -0,0 +1,171 @@
package run.halo.app.notification;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeast;
import static org.mockito.Mockito.verify;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.annotation.DirtiesContext;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ExtensionStoreUtil;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.SchemeManager;
import run.halo.app.extension.index.IndexerFactory;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.extension.store.ReactiveExtensionStoreClient;
import run.halo.app.infra.utils.JsonUtils;
/**
* Integration tests for {@link SubscriptionService}.
*
* @author guqing
* @since 2.15.0
*/
@DirtiesContext
@SpringBootTest
class SubscriptionServiceIntegrationTest {
@Autowired
private SchemeManager schemeManager;
@SpyBean
private ReactiveExtensionClient client;
@Autowired
private ReactiveExtensionStoreClient storeClient;
@Autowired
private IndexerFactory indexerFactory;
Mono<Extension> deleteImmediately(Extension extension) {
var name = extension.getMetadata().getName();
var scheme = schemeManager.get(extension.getClass());
// un-index
var indexer = indexerFactory.getIndexer(extension.groupVersionKind());
indexer.unIndexRecord(extension.getMetadata().getName());
// delete from db
var storeName = ExtensionStoreUtil.buildStoreName(scheme, name);
return storeClient.delete(storeName, extension.getMetadata().getVersion())
.thenReturn(extension);
}
@Nested
class RemoveInitialBatchTest {
static int size = 310;
private final List<Subscription> storedSubscriptions = subscriptionsForStore();
@Autowired
private SubscriptionService subscriptionService;
@BeforeEach
void setUp() {
Flux.fromIterable(storedSubscriptions)
.flatMap(comment -> client.create(comment))
.as(StepVerifier::create)
.expectNextCount(storedSubscriptions.size())
.verifyComplete();
}
@AfterEach
void tearDown() {
Flux.fromIterable(storedSubscriptions)
.flatMap(SubscriptionServiceIntegrationTest.this::deleteImmediately)
.as(StepVerifier::create)
.expectNextCount(storedSubscriptions.size())
.verifyComplete();
}
private List<Subscription> subscriptionsForStore() {
List<Subscription> subscriptions = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
var subscription = createSubscription();
subscription.getMetadata().setName("subscription-" + i);
subscriptions.add(subscription);
}
return subscriptions;
}
@Test
void removeTest() {
var subscriber = new Subscription.Subscriber();
subscriber.setName("admin");
var interestReason = new Subscription.InterestReason();
interestReason.setReasonType("new-comment-on-post");
var subject = new Subscription.ReasonSubject();
subject.setApiVersion("content.halo.run/v1alpha1");
subject.setKind("Post");
interestReason.setSubject(subject);
subscriptionService.remove(subscriber, interestReason).block();
verify(client, atLeast(size)).delete(any(Subscription.class));
assertCleanedUp();
}
@Test
void removeBySubscriberTest() {
var subscriber = new Subscription.Subscriber();
subscriber.setName("admin");
subscriptionService.remove(subscriber).block();
verify(client, atLeast(size)).delete(any(Subscription.class));
assertCleanedUp();
}
private void assertCleanedUp() {
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(isNull("metadata.deletionTimestamp")));
client.listBy(Subscription.class, listOptions, PageRequestImpl.ofSize(1))
.as(StepVerifier::create)
.consumeNextWith(result -> {
assertThat(result.getTotal()).isEqualTo(0);
assertThat(result.getItems()).isEmpty();
})
.verifyComplete();
}
}
Subscription createSubscription() {
return JsonUtils.jsonToObject("""
{
"spec": {
"subscriber": {
"name": "admin"
},
"unsubscribeToken": "423530c9-bec7-446e-b73b-dd98ac00ba2b",
"reason": {
"reasonType": "new-comment-on-post",
"subject": {
"name": "5152aea5-c2e8-4717-8bba-2263d46e19d5",
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post"
}
},
"disabled": false
},
"apiVersion": "notification.halo.run/v1alpha1",
"kind": "Subscription",
"metadata": {
"generateName": "subscription-"
}
}
""", Subscription.class);
}
}

View File

@ -19,7 +19,8 @@
设计一个通知功能,可以根据以下目标,实现订阅和推送通知:
- 支持扩展多种通知方式例如邮件、短信、Slack 等。
- 支持通知条件并可扩展,例如 Halo 有新文章发布事件如果用户订阅了新文章发布事件但付费订阅插件决定了此文章只有付费用户才可收到通知、按照付费等级不同决定是否发送新文章通知给对应用户等需要通过实现通知条件的扩展点来满足对应需求。
- 支持通知条件并可扩展,例如 Halo
有新文章发布事件如果用户订阅了新文章发布事件但付费订阅插件决定了此文章只有付费用户才可收到通知、按照付费等级不同决定是否发送新文章通知给对应用户等需要通过实现通知条件的扩展点来满足对应需求。
- 支持定制化选项,例如是否开启通知、通知时段等。
- 支持通知流程,例如通知的发送、接收、查看、标记等。
- 通知内容支持多语言。
@ -97,7 +98,8 @@ spec:
#### Subscription
`Subscription` 自定义模型,定义了特定事件时与要被通知的订阅者之间的关系, 其中 `subscriber` 表示订阅者用户, `unsubscribeToken` 表示退订时的身份验证 token, `reason` 订阅者感兴趣的事件。
`Subscription` 自定义模型,定义了特定事件时与要被通知的订阅者之间的关系, 其中 `subscriber`
表示订阅者用户, `unsubscribeToken` 表示退订时的身份验证 token, `reason` 订阅者感兴趣的事件。
用户可以通过 `Subscription` 来订阅自己感兴趣的事件,当事件触发时会收到通知:
@ -116,13 +118,24 @@ spec:
apiVersion: content.halo.run/v1alpha1
kind: Post
name: 'post-axgu'
# expression: 'props.owner == "guqing"'
```
订阅退订链接 API 规则:`/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe?token={unsubscribeToken}`。
- `spec.reason.subject`:用于根据事件的主体的匹配感兴趣的事件,如果不指定 name 则表示匹配主体与 kind 和 apiVersion
相同的一类事件。
- `spec.expression`:根据表达式匹配感兴趣的事件,例如 `props.owner == "guqing"` 表示只有当事件的属性reason attributes
owner 等于 guqing 时才会触发通知。表达式符合 SpEL
表达式语法,但结果只能是布尔值。参考:[增强 Subscription 模型以支持表达式匹配](https://github.com/halo-dev/halo/issues/5632)
> 当 `spec.expression``spec.reason.subject` 同时存在时,以 `spec.reason.subject` 的结果为准,不建议同时使用。
订阅退订链接 API
规则:`/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe?token={unsubscribeToken}`。
#### 用户通知偏好设置
通过在用户偏好设置的 ConfigMap 中存储一个 `notification` key 用于保存事件类型与通知方式的关系设置,当用户订阅了如 'new-comment-on-post' 事件时会获取对应的通知方式来给用户发送通知。
通过在用户偏好设置的 ConfigMap 中存储一个 `notification` key 用于保存事件类型与通知方式的关系设置,当用户订阅了如 '
new-comment-on-post' 事件时会获取对应的通知方式来给用户发送通知。
```yaml
apiVersion: v1alpha1
@ -153,7 +166,8 @@ data:
#### Notification 站内通知
当用户订阅到事件后会创建 `Notification`, 它与通知方式notifier无关`recipient` 为用户名,类似站内通知,如用户 `guqing` 订阅了评论事件那么当监听到评论事件时会创建一条记录可以在个人中心的通知列表看到一条通知消息。
当用户订阅到事件后会创建 `Notification`, 它与通知方式notifier无关`recipient` 为用户名,类似站内通知,如用户 `guqing`
订阅了评论事件那么当监听到评论事件时会创建一条记录可以在个人中心的通知列表看到一条通知消息。
```yaml
apiVersion: notification.halo.run/v1alpha1
@ -177,6 +191,7 @@ spec:
`GET /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications`
2. 将通知标记为已读:`PUT /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/mark-as-read`
3.
批量将通知标记为已读:`PUT /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/mark-specified-as-read`
#### 通知模板
@ -185,14 +200,18 @@ spec:
它通过定义 `reasonSelector` 来引用事件类别,当事件触发时会根据用户的语言偏好和触发事件的类别来选择一个最佳的通知模板。
选择通知模板的规则为:
1. 根据用户设置的语言,选择从通知模板中定义的 `spec.reasonSelector.language` 的值从更具体到不太具体的顺序例如gl_ES 的值将比 gl 的值具有更高的优先级)。
2. 当通过语言成功匹配到模板时,匹配到的结果可能不止一个,如 `language``zh_CN` 的模板有三个那么会根据 `NotificationTemplate``metadata.creationTimestamp` 字段来选择一个最新的模板。
1. 根据用户设置的语言,选择从通知模板中定义的 `spec.reasonSelector.language` 的值从更具体到不太具体的顺序例如gl_ES 的值将比
gl 的值具有更高的优先级)。
2. 当通过语言成功匹配到模板时,匹配到的结果可能不止一个,如 `language``zh_CN`
的模板有三个那么会根据 `NotificationTemplate``metadata.creationTimestamp` 字段来选择一个最新的模板。
这样的规则有助于用户可以个性化定制某些事件的模板内容。
模板语法使用 ThymeleafEngine 渲染,纯文本模板使用 `textual` 模板模式,语法参考: [usingthymeleaf.html#textual-syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#textual-syntax)
模板语法使用 ThymeleafEngine 渲染,纯文本模板使用 `textual`
模板模式,语法参考: [usingthymeleaf.html#textual-syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#textual-syntax)
`HTML` 则使用标准表达式语法在标签属性中取值,语法参考:[standard-expression-syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax)
`HTML`
则使用标准表达式语法在标签属性中取值,语法参考:[standard-expression-syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax)
在通知中心渲染模板时会在 `ReasonAttributes` 中提供额外属性包括:
@ -224,11 +243,12 @@ spec:
#### 通知器声明及扩展
`NotifierDescriptor` 自定义模型用于声明通知器,通过它来描述通知器的名称、描述和关联的 `ExtensionDefinition` 名称,让用户可以在用户界面知道通知器是什么以及它可以做什么,
`NotifierDescriptor` 自定义模型用于声明通知器,通过它来描述通知器的名称、描述和关联的 `ExtensionDefinition`
名称,让用户可以在用户界面知道通知器是什么以及它可以做什么,
还让 NotificationCenter 知道如何加载通知器和准备通知器需要的设置以发送通知。
```yaml
apiVersion: notification.halo.run/v1alpha1
apiVersion: notification.halo.run/v1alpha1
kind: NotifierDescriptor
metadata:
name: email-notifier
@ -261,52 +281,52 @@ spec:
```java
public interface ReactiveNotifier extends ExtensionPoint {
/**
* Notify user.
*
* @param context notification context must not be null
*/
Mono<Void> notify(NotificationContext context);
/**
* Notify user.
*
* @param context notification context must not be null
*/
Mono<Void> notify(NotificationContext context);
}
@Data
public class NotificationContext {
private Message message;
private Message message;
private ObjectNode receiverConfig;
private ObjectNode receiverConfig;
private ObjectNode senderConfig;
private ObjectNode senderConfig;
@Data
static class Message {
private MessagePayload payload;
@Data
static class Message {
private MessagePayload payload;
private Subject subject;
private Subject subject;
private String recipient;
private String recipient;
private Instant timestamp;
}
private Instant timestamp;
}
@Data
public static class Subject {
private String apiVersion;
private String kind;
private String name;
private String title;
private String url;
}
@Data
public static class Subject {
private String apiVersion;
private String kind;
private String name;
private String title;
private String url;
}
@Data
static class MessagePayload {
private String title;
@Data
static class MessagePayload {
private String title;
private String rawBody;
private String htmlBody;
private String rawBody;
private ReasonAttributes attributes;
}
private String htmlBody;
private ReasonAttributes attributes;
}
}
```

View File

@ -1 +1 @@
version=2.15.0-SNAPSHOT
version=2.16.0-SNAPSHOT

View File

@ -1,5 +1,18 @@
plugins {
id "com.github.node-gradle.node"
id 'idea'
id 'com.github.node-gradle.node'
}
idea {
module {
excludeDirs += file('node_modules/')
excludeDirs += file('packages').listFiles().collect {
file(it.path + '/node_modules/')
}
excludeDirs += file('packages').listFiles().collect {
file(it.path + '/dist/')
}
}
}
tasks.register('clean', Delete) {

View File

@ -2,6 +2,7 @@
import {
Dialog,
IconEye,
IconHistoryLine,
IconPages,
IconSave,
IconSendPlaneFill,
@ -452,6 +453,22 @@ async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
:allow-forced-select="!isUpdateMode"
@select="handleChangeEditorProvider"
/>
<VButton
v-if="isUpdateMode"
size="sm"
type="default"
@click="
$router.push({
name: 'SinglePageSnapshots',
query: { name: routeQueryName },
})
"
>
<template #icon>
<IconHistoryLine class="h-full w-full" />
</template>
{{ $t("core.page_editor.actions.snapshots") }}
</VButton>
<VButton
size="sm"
type="default"

View File

@ -0,0 +1,182 @@
<script setup lang="ts">
import {
Dialog,
IconHistoryLine,
Toast,
VButton,
VCard,
VLoading,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { useRoute } from "vue-router";
import { apiClient } from "@/utils/api-client";
import { computed, watch } from "vue";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { useRouteQuery } from "@vueuse/router";
import SnapshotContent from "./components/SnapshotContent.vue";
import SnapshotListItem from "./components/SnapshotListItem.vue";
import { useI18n } from "vue-i18n";
const queryClient = useQueryClient();
const route = useRoute();
const { t } = useI18n();
const singlePageName = computed(() => route.query.name as string);
const { data: singlePage } = useQuery({
queryKey: ["singlePage-by-name", singlePageName],
queryFn: async () => {
const { data } =
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage({
name: singlePageName.value,
});
return data;
},
enabled: computed(() => !!singlePageName.value),
});
const { data: snapshots, isLoading } = useQuery({
queryKey: ["singlePage-snapshots-by-singlePage-name", singlePageName],
queryFn: async () => {
const { data } = await apiClient.singlePage.listSinglePageSnapshots({
name: singlePageName.value,
});
return data;
},
refetchInterval(data) {
const hasDeletingData = data?.some(
(item) => !!item.metadata.deletionTimestamp
);
return hasDeletingData ? 1000 : false;
},
enabled: computed(() => !!singlePageName.value),
});
const selectedSnapshotName = useRouteQuery<string | undefined>("snapshot-name");
watch(
() => snapshots.value,
(value) => {
if (value && !selectedSnapshotName.value) {
selectedSnapshotName.value = value[0].metadata.name;
}
// Reset selectedSnapshotName if the selected snapshot is deleted
if (
!value?.some(
(snapshot) => snapshot.metadata.name === selectedSnapshotName.value
)
) {
selectedSnapshotName.value = value?.[0].metadata.name;
}
},
{
immediate: true,
}
);
function handleCleanup() {
Dialog.warning({
title: t("core.page_snapshots.operations.cleanup.title"),
description: t("core.page_snapshots.operations.cleanup.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
const { releaseSnapshot, baseSnapshot, headSnapshot } =
singlePage.value?.spec || {};
const snapshotsToDelete = snapshots.value?.filter((snapshot) => {
const { name } = snapshot.metadata;
return ![releaseSnapshot, baseSnapshot, headSnapshot]
.filter(Boolean)
.includes(name);
});
if (!snapshotsToDelete?.length) {
Toast.info(t("core.page_snapshots.operations.cleanup.toast_empty"));
return;
}
for (let i = 0; i < snapshotsToDelete?.length; i++) {
await apiClient.singlePage.deleteSinglePageContent({
name: singlePageName.value,
snapshotName: snapshotsToDelete[i].metadata.name,
});
}
await queryClient.invalidateQueries({
queryKey: ["singlePage-snapshots-by-singlePage-name", singlePageName],
});
Toast.success(t("core.page_snapshots.operations.cleanup.toast_success"));
},
});
}
</script>
<template>
<VPageHeader :title="singlePage?.spec.title">
<template #icon>
<IconHistoryLine class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton size="sm" @click="$router.back()">
{{ $t("core.common.buttons.back") }}
</VButton>
<VButton size="sm" type="danger" @click="handleCleanup">
{{ $t("core.page_snapshots.operations.cleanup.button") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard
style="height: calc(100vh - 5.5rem)"
:body-class="['h-full', '!p-0']"
>
<div class="grid h-full grid-cols-12 divide-y sm:divide-x sm:divide-y-0">
<div
class="relative col-span-12 h-full overflow-auto sm:col-span-6 lg:col-span-3 xl:col-span-2"
>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full w-full"
defer
>
<VLoading v-if="isLoading" />
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li
v-for="snapshot in snapshots"
:key="snapshot.metadata.name"
@click="selectedSnapshotName = snapshot.metadata.name"
>
<SnapshotListItem
:snapshot="snapshot"
:single-page="singlePage"
:selected-snapshot-name="selectedSnapshotName"
/>
</li>
</ul>
</Transition>
</OverlayScrollbarsComponent>
</div>
<div
class="col-span-12 h-full overflow-auto sm:col-span-6 lg:col-span-9 xl:col-span-10"
>
<SnapshotContent
:single-page-name="singlePageName"
:snapshot-name="selectedSnapshotName"
/>
</div>
</div>
</VCard>
</div>
</template>

View File

@ -117,7 +117,7 @@ const handlePublishClick = () => {
};
// Fix me:
// Force update post settings,
// Force update singlePage settings,
// because currently there may be errors caused by changes in version due to asynchronous processing.
const { mutateAsync: singlePageUpdateMutate } = usePageUpdateMutate();

View File

@ -0,0 +1,113 @@
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query";
import { apiClient } from "@/utils/api-client";
import { computed, toRefs } from "vue";
import { Toast, VLoading } from "@halo-dev/components";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
const props = withDefaults(
defineProps<{
singlePageName?: string;
snapshotName?: string;
}>(),
{
singlePageName: undefined,
snapshotName: undefined,
}
);
const { singlePageName, snapshotName } = toRefs(props);
const { data: snapshot, isLoading } = useQuery({
queryKey: ["singlePage-snapshot-by-name", singlePageName, snapshotName],
queryFn: async () => {
if (!singlePageName.value || !snapshotName.value) {
throw new Error("singlePageName and snapshotName are required");
}
const { data } = await apiClient.singlePage.fetchSinglePageContent({
name: singlePageName.value,
snapshotName: snapshotName.value,
});
return data;
},
onError(err) {
if (err instanceof Error) {
Toast.error(err.message);
}
},
enabled: computed(() => !!singlePageName.value && !!snapshotName.value),
});
</script>
<template>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full w-full"
defer
>
<VLoading v-if="isLoading" />
<div
v-else
class="snapshot-content markdown-body h-full w-full p-4"
v-html="snapshot?.content"
></div>
</OverlayScrollbarsComponent>
</template>
<style scoped lang="scss">
::v-deep(.snapshot-content) {
p {
margin-top: 0.75em;
margin-bottom: 0;
}
pre {
background: #0d0d0d;
padding: 0.75rem 1rem;
margin: 0;
code {
background: none;
font-size: 0.8rem;
padding: 0 !important;
border-radius: 0;
}
}
ul[data-type="taskList"] {
list-style: none;
padding: 0;
p {
margin: 0;
}
li {
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
> div {
flex: 1 1 auto;
}
}
}
ul {
list-style: disc !important;
}
ol {
list-style: decimal !important;
}
code br {
display: initial;
}
}
</style>

View File

@ -0,0 +1,140 @@
<script setup lang="ts">
import type { ListedSnapshotDto, SinglePage } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { Dialog, Toast, VButton, VStatusDot, VTag } from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { computed } from "vue";
import { relativeTimeTo } from "@/utils/date";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
singlePage?: SinglePage;
snapshot: ListedSnapshotDto;
selectedSnapshotName?: string;
}>(),
{
singlePage: undefined,
selectedSnapshotName: undefined,
}
);
async function handleRestore() {
Dialog.warning({
title: t("core.page_snapshots.operations.revert.title"),
description: t("core.page_snapshots.operations.revert.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await apiClient.singlePage.revertToSpecifiedSnapshotForSinglePage({
name: props.singlePage?.metadata.name as string,
revertSnapshotForSingleParam: {
snapshotName: props.snapshot.metadata.name,
},
});
await queryClient.invalidateQueries({
queryKey: ["singlePage-snapshots-by-singlePage-name"],
});
Toast.success(t("core.page_snapshots.operations.revert.toast_success"));
},
});
}
function handleDelete() {
Dialog.warning({
title: t("core.page_snapshots.operations.delete.title"),
description: t("core.page_snapshots.operations.delete.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await apiClient.singlePage.deleteSinglePageContent({
name: props.singlePage?.metadata.name as string,
snapshotName: props.snapshot.metadata.name,
});
await queryClient.invalidateQueries({
queryKey: ["singlePage-snapshots-by-singlePage-name"],
});
Toast.success(t("core.common.toast.delete_success"));
},
});
}
const isSelected = computed(() => {
return props.selectedSnapshotName === props.snapshot.metadata.name;
});
const isReleased = computed(() => {
return (
props.singlePage?.spec.releaseSnapshot === props.snapshot.metadata.name
);
});
const isHead = computed(() => {
const { headSnapshot, releaseSnapshot } = props.singlePage?.spec || {};
return (
headSnapshot !== releaseSnapshot &&
headSnapshot === props.snapshot.metadata.name
);
});
const isBase = computed(() => {
return props.singlePage?.spec.baseSnapshot === props.snapshot.metadata.name;
});
</script>
<template>
<div
class="group relative flex cursor-pointer flex-col gap-5 p-4"
:class="{ 'bg-gray-50': isSelected }"
>
<div
v-if="isSelected"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div class="flex items-center justify-between">
<div
class="truncate text-sm"
:class="{
'font-semibold': isSelected,
}"
>
{{ relativeTimeTo(snapshot.metadata.creationTimestamp) }}
</div>
<div class="inline-flex flex-none items-center space-x-3">
<VTag v-if="isReleased" theme="primary">
{{ $t("core.page_snapshots.status.released") }}
</VTag>
<VTag v-if="isHead">
{{ $t("core.page_snapshots.status.draft") }}
</VTag>
<VTag v-if="isBase">
{{ $t("core.page_snapshots.status.base") }}
</VTag>
<VStatusDot
v-if="snapshot.metadata.deletionTimestamp"
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</div>
</div>
<div class="flex h-6 items-end justify-between gap-2">
<div class="flex-1 truncate text-xs text-gray-600">
{{ snapshot.spec.owner }}
</div>
<div
v-if="!isReleased"
class="hidden flex-none space-x-2 group-hover:block"
>
<VButton v-if="!isHead" size="xs" @click="handleRestore()">
{{ $t("core.page_snapshots.operations.revert.button") }}
</VButton>
<VButton v-if="!isBase" size="xs" type="danger" @click="handleDelete">
{{ $t("core.common.buttons.delete") }}
</VButton>
</div>
</div>
</div>
</template>

View File

@ -35,7 +35,7 @@ export function usePageUpdateMutate() {
},
retry: 3,
onError: (error) => {
console.error("Failed to update post", error);
console.error("Failed to update singlePage", error);
Toast.error(t("core.common.toast.server_internal_error"));
},
});

View File

@ -6,6 +6,7 @@ import SinglePageEditor from "./SinglePageEditor.vue";
import SinglePageStatsWidget from "./widgets/SinglePageStatsWidget.vue";
import { IconPages } from "@halo-dev/components";
import { markRaw } from "vue";
import SinglePageSnapshots from "./SinglePageSnapshots.vue";
export default definePlugin({
components: {
@ -54,6 +55,17 @@ export default definePlugin({
permissions: ["system:singlepages:manage"],
},
},
{
path: "snapshots",
name: "SinglePageSnapshots",
component: SinglePageSnapshots,
meta: {
title: "core.page_snapshots.title",
searchable: false,
hideFooter: true,
permissions: ["system:singlepages:manage"],
},
},
],
},
],

View File

@ -3,6 +3,7 @@ import {
Dialog,
IconBookRead,
IconEye,
IconHistoryLine,
IconSave,
IconSendPlaneFill,
IconSettings,
@ -480,6 +481,19 @@ async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
:allow-forced-select="!isUpdateMode"
@select="handleChangeEditorProvider"
/>
<VButton
v-if="isUpdateMode"
size="sm"
type="default"
@click="
$router.push({ name: 'PostSnapshots', query: { name: name } })
"
>
<template #icon>
<IconHistoryLine class="h-full w-full" />
</template>
{{ $t("core.post_editor.actions.snapshots") }}
</VButton>
<VButton
size="sm"
type="default"

View File

@ -0,0 +1,182 @@
<script setup lang="ts">
import {
Dialog,
IconHistoryLine,
Toast,
VButton,
VCard,
VLoading,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { useRoute } from "vue-router";
import { apiClient } from "@/utils/api-client";
import { computed, watch } from "vue";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { useRouteQuery } from "@vueuse/router";
import SnapshotContent from "@console/modules/contents/posts/components/SnapshotContent.vue";
import SnapshotListItem from "@console/modules/contents/posts/components/SnapshotListItem.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const route = useRoute();
const queryClient = useQueryClient();
const postName = computed(() => route.query.name as string);
const { data: post } = useQuery({
queryKey: ["post-by-name", postName],
queryFn: async () => {
const { data } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: postName.value,
});
return data;
},
enabled: computed(() => !!postName.value),
});
const { data: snapshots, isLoading } = useQuery({
queryKey: ["post-snapshots-by-post-name", postName],
queryFn: async () => {
const { data } = await apiClient.post.listPostSnapshots({
name: postName.value,
});
return data;
},
refetchInterval(data) {
const hasDeletingData = data?.some(
(item) => !!item.metadata.deletionTimestamp
);
return hasDeletingData ? 1000 : false;
},
enabled: computed(() => !!postName.value),
});
const selectedSnapshotName = useRouteQuery<string | undefined>("snapshot-name");
watch(
() => snapshots.value,
(value) => {
if (value && !selectedSnapshotName.value) {
selectedSnapshotName.value = value[0].metadata.name;
}
// Reset selectedSnapshotName if the selected snapshot is deleted
if (
!value?.some(
(snapshot) => snapshot.metadata.name === selectedSnapshotName.value
)
) {
selectedSnapshotName.value = value?.[0].metadata.name;
}
},
{
immediate: true,
}
);
function handleCleanup() {
Dialog.warning({
title: t("core.post_snapshots.operations.cleanup.title"),
description: t("core.post_snapshots.operations.cleanup.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
const { releaseSnapshot, baseSnapshot, headSnapshot } =
post.value?.spec || {};
const snapshotsToDelete = snapshots.value?.filter((snapshot) => {
const { name } = snapshot.metadata;
return ![releaseSnapshot, baseSnapshot, headSnapshot]
.filter(Boolean)
.includes(name);
});
if (!snapshotsToDelete?.length) {
Toast.info(t("core.post_snapshots.operations.cleanup.toast_empty"));
return;
}
for (let i = 0; i < snapshotsToDelete?.length; i++) {
await apiClient.post.deletePostContent({
name: postName.value,
snapshotName: snapshotsToDelete[i].metadata.name,
});
}
await queryClient.invalidateQueries({
queryKey: ["post-snapshots-by-post-name", postName],
});
Toast.success(t("core.post_snapshots.operations.cleanup.toast_success"));
},
});
}
</script>
<template>
<VPageHeader :title="post?.spec.title">
<template #icon>
<IconHistoryLine class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton size="sm" @click="$router.back()">
{{ $t("core.common.buttons.back") }}
</VButton>
<VButton size="sm" type="danger" @click="handleCleanup">
{{ $t("core.post_snapshots.operations.cleanup.button") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard
style="height: calc(100vh - 5.5rem)"
:body-class="['h-full', '!p-0']"
>
<div class="grid h-full grid-cols-12 divide-y sm:divide-x sm:divide-y-0">
<div
class="relative col-span-12 h-full overflow-auto sm:col-span-6 lg:col-span-3 xl:col-span-2"
>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full w-full"
defer
>
<VLoading v-if="isLoading" />
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li
v-for="snapshot in snapshots"
:key="snapshot.metadata.name"
@click="selectedSnapshotName = snapshot.metadata.name"
>
<SnapshotListItem
:snapshot="snapshot"
:post="post"
:selected-snapshot-name="selectedSnapshotName"
/>
</li>
</ul>
</Transition>
</OverlayScrollbarsComponent>
</div>
<div
class="col-span-12 h-full overflow-auto sm:col-span-6 lg:col-span-9 xl:col-span-10"
>
<SnapshotContent
:post-name="postName"
:snapshot-name="selectedSnapshotName"
/>
</div>
</div>
</VCard>
</div>
</template>

View File

@ -0,0 +1,113 @@
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query";
import { apiClient } from "@/utils/api-client";
import { computed, toRefs } from "vue";
import { Toast, VLoading } from "@halo-dev/components";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
const props = withDefaults(
defineProps<{
postName?: string;
snapshotName?: string;
}>(),
{
postName: undefined,
snapshotName: undefined,
}
);
const { postName, snapshotName } = toRefs(props);
const { data: snapshot, isLoading } = useQuery({
queryKey: ["post-snapshot-by-name", postName, snapshotName],
queryFn: async () => {
if (!postName.value || !snapshotName.value) {
throw new Error("postName and snapshotName are required");
}
const { data } = await apiClient.post.fetchPostContent({
name: postName.value,
snapshotName: snapshotName.value,
});
return data;
},
onError(err) {
if (err instanceof Error) {
Toast.error(err.message);
}
},
enabled: computed(() => !!postName.value && !!snapshotName.value),
});
</script>
<template>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full w-full"
defer
>
<VLoading v-if="isLoading" />
<div
v-else
class="snapshot-content markdown-body h-full w-full p-4"
v-html="snapshot?.content"
></div>
</OverlayScrollbarsComponent>
</template>
<style scoped lang="scss">
::v-deep(.snapshot-content) {
p {
margin-top: 0.75em;
margin-bottom: 0;
}
pre {
background: #0d0d0d;
padding: 0.75rem 1rem;
margin: 0;
code {
background: none;
font-size: 0.8rem;
padding: 0 !important;
border-radius: 0;
}
}
ul[data-type="taskList"] {
list-style: none;
padding: 0;
p {
margin: 0;
}
li {
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
> div {
flex: 1 1 auto;
}
}
}
ul {
list-style: disc !important;
}
ol {
list-style: decimal !important;
}
code br {
display: initial;
}
}
</style>

View File

@ -0,0 +1,138 @@
<script setup lang="ts">
import type { ListedSnapshotDto, Post } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { Dialog, Toast, VButton, VStatusDot, VTag } from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { computed } from "vue";
import { relativeTimeTo } from "@/utils/date";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
post?: Post;
snapshot: ListedSnapshotDto;
selectedSnapshotName?: string;
}>(),
{
post: undefined,
selectedSnapshotName: undefined,
}
);
async function handleRestore() {
Dialog.warning({
title: t("core.post_snapshots.operations.revert.title"),
description: t("core.post_snapshots.operations.revert.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await apiClient.post.revertToSpecifiedSnapshotForPost({
name: props.post?.metadata.name as string,
revertSnapshotForPostParam: {
snapshotName: props.snapshot.metadata.name,
},
});
await queryClient.invalidateQueries({
queryKey: ["post-snapshots-by-post-name"],
});
Toast.success(t("core.post_snapshots.operations.revert.toast_success"));
},
});
}
function handleDelete() {
Dialog.warning({
title: t("core.post_snapshots.operations.delete.title"),
description: t("core.post_snapshots.operations.delete.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await apiClient.post.deletePostContent({
name: props.post?.metadata.name as string,
snapshotName: props.snapshot.metadata.name,
});
await queryClient.invalidateQueries({
queryKey: ["post-snapshots-by-post-name"],
});
Toast.success(t("core.common.toast.delete_success"));
},
});
}
const isSelected = computed(() => {
return props.selectedSnapshotName === props.snapshot.metadata.name;
});
const isReleased = computed(() => {
return props.post?.spec.releaseSnapshot === props.snapshot.metadata.name;
});
const isHead = computed(() => {
const { headSnapshot, releaseSnapshot } = props.post?.spec || {};
return (
headSnapshot !== releaseSnapshot &&
headSnapshot === props.snapshot.metadata.name
);
});
const isBase = computed(() => {
return props.post?.spec.baseSnapshot === props.snapshot.metadata.name;
});
</script>
<template>
<div
class="group relative flex cursor-pointer flex-col gap-5 p-4"
:class="{ 'bg-gray-50': isSelected }"
>
<div
v-if="isSelected"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div class="flex items-center justify-between">
<div
class="truncate text-sm"
:class="{
'font-semibold': isSelected,
}"
>
{{ relativeTimeTo(snapshot.metadata.creationTimestamp) }}
</div>
<div class="inline-flex flex-none items-center space-x-3">
<VTag v-if="isReleased" theme="primary">
{{ $t("core.post_snapshots.status.released") }}
</VTag>
<VTag v-if="isHead">
{{ $t("core.post_snapshots.status.draft") }}
</VTag>
<VTag v-if="isBase">
{{ $t("core.post_snapshots.status.base") }}
</VTag>
<VStatusDot
v-if="snapshot.metadata.deletionTimestamp"
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</div>
</div>
<div class="flex h-6 items-end justify-between gap-2">
<div class="flex-1 truncate text-xs text-gray-600">
{{ snapshot.spec.owner }}
</div>
<div
v-if="!isReleased"
class="hidden flex-none space-x-2 group-hover:block"
>
<VButton v-if="!isHead" size="xs" @click="handleRestore()">
{{ $t("core.post_snapshots.operations.revert.button") }}
</VButton>
<VButton v-if="!isBase" size="xs" type="danger" @click="handleDelete">
{{ $t("core.common.buttons.delete") }}
</VButton>
</div>
</div>
</div>
</template>

View File

@ -10,6 +10,7 @@ import TagList from "./tags/TagList.vue";
import PostStatsWidget from "./widgets/PostStatsWidget.vue";
import RecentPublishedWidget from "./widgets/RecentPublishedWidget.vue";
import { markRaw } from "vue";
import PostSnapshots from "./PostSnapshots.vue";
export default definePlugin({
components: {
@ -60,6 +61,17 @@ export default definePlugin({
permissions: ["system:posts:manage"],
},
},
{
path: "snapshots",
name: "PostSnapshots",
component: PostSnapshots,
meta: {
title: "core.post_snapshots.title",
searchable: false,
hideFooter: true,
permissions: ["system:posts:manage"],
},
},
{
path: "categories",
component: BlankLayout,

View File

@ -154,14 +154,11 @@ const { startFields, endFields } = useEntityFieldItemExtensionPoint<Plugin>(
!enabled || (enabled && phase === PluginStatusPhaseEnum.Started);
const getStatusDotState = () => {
if (
enabled &&
phase !==
(PluginStatusPhaseEnum.Started || PluginStatusPhaseEnum.Failed)
) {
return "default";
if (enabled && phase === PluginStatusPhaseEnum.Failed) {
return "error";
}
return "error";
return "default";
};
return [

View File

@ -33,16 +33,8 @@ export function usePluginLifeCycle(
const { enabled } = plugin.value.spec || {};
const { phase } = plugin.value.status || {};
// Starting up
if (
enabled &&
phase !== (PluginStatusPhaseEnum.Started || PluginStatusPhaseEnum.Failed)
) {
return t("core.common.status.starting_up");
}
// Starting failed
if (!isStarted.value) {
if (enabled && phase === PluginStatusPhaseEnum.Failed) {
const lastCondition = plugin.value.status?.conditions?.[0];
return (
@ -51,6 +43,14 @@ export function usePluginLifeCycle(
.join(":") || "Unknown"
);
}
// Starting up
if (
enabled &&
phase !== (PluginStatusPhaseEnum.Started || PluginStatusPhaseEnum.Failed)
) {
return t("core.common.status.starting_up");
}
};
const { isLoading: changingStatus, mutate: changeStatus } = useMutation({

View File

@ -1,6 +1,6 @@
{
"name": "@halo-dev/api-client",
"version": "2.15.0",
"version": "2.16.0",
"description": "",
"scripts": {
"build": "unbuild",

View File

@ -120,6 +120,7 @@ models/condition.ts
models/config-map-list.ts
models/config-map-ref.ts
models/config-map.ts
models/content-update-param.ts
models/content-vo.ts
models/content-wrapper.ts
models/content.ts
@ -168,6 +169,8 @@ models/listed-single-page-list.ts
models/listed-single-page-vo-list.ts
models/listed-single-page-vo.ts
models/listed-single-page.ts
models/listed-snapshot-dto.ts
models/listed-snapshot-spec.ts
models/listed-user.ts
models/login-history.ts
models/mark-specified-request.ts
@ -240,6 +243,7 @@ models/register-verify-email-request.ts
models/reply-list.ts
models/reply-request.ts
models/reply-spec.ts
models/reply-status.ts
models/reply-vo-list.ts
models/reply-vo.ts
models/reply.ts
@ -247,6 +251,8 @@ models/reset-password-request.ts
models/reverse-proxy-list.ts
models/reverse-proxy-rule.ts
models/reverse-proxy.ts
models/revert-snapshot-for-post-param.ts
models/revert-snapshot-for-single-param.ts
models/role-binding-list.ts
models/role-binding.ts
models/role-list.ts

View File

@ -28,15 +28,67 @@ import { ContentWrapper } from '../models';
// @ts-ignore
import { ListedPostList } from '../models';
// @ts-ignore
import { ListedSnapshotDto } from '../models';
// @ts-ignore
import { Post } from '../models';
// @ts-ignore
import { PostRequest } from '../models';
// @ts-ignore
import { RevertSnapshotForPostParam } from '../models';
/**
* ApiConsoleHaloRunV1alpha1PostApi - axios parameter creator
* @export
*/
export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Delete a content for post.
* @param {string} name
* @param {string} snapshotName
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deletePostContent: async (name: string, snapshotName: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('deletePostContent', 'name', name)
// verify required parameter 'snapshotName' is not null or undefined
assertParamExists('deletePostContent', 'snapshotName', snapshotName)
const localVarPath = `/apis/api.console.halo.run/v1alpha1/posts/{name}/content`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (snapshotName !== undefined) {
localVarQueryParameter['snapshotName'] = snapshotName;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Draft a post.
* @param {PostRequest} postRequest
@ -80,6 +132,54 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (confi
options: localVarRequestOptions,
};
},
/**
* Fetch content of post.
* @param {string} name
* @param {string} snapshotName
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
fetchPostContent: async (name: string, snapshotName: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('fetchPostContent', 'name', name)
// verify required parameter 'snapshotName' is not null or undefined
assertParamExists('fetchPostContent', 'snapshotName', snapshotName)
const localVarPath = `/apis/api.console.halo.run/v1alpha1/posts/{name}/content`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (snapshotName !== undefined) {
localVarQueryParameter['snapshotName'] = snapshotName;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Fetch head content of post.
* @param {string} name
@ -153,6 +253,47 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (confi
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* List all snapshots for post content.
* @param {string} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listPostSnapshots: async (name: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('listPostSnapshots', 'name', name)
const localVarPath = `/apis/api.console.halo.run/v1alpha1/posts/{name}/snapshot`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -321,6 +462,53 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (confi
options: localVarRequestOptions,
};
},
/**
* Revert to specified snapshot for post content.
* @param {string} name
* @param {RevertSnapshotForPostParam} revertSnapshotForPostParam
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
revertToSpecifiedSnapshotForPost: async (name: string, revertSnapshotForPostParam: RevertSnapshotForPostParam, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('revertToSpecifiedSnapshotForPost', 'name', name)
// verify required parameter 'revertSnapshotForPostParam' is not null or undefined
assertParamExists('revertToSpecifiedSnapshotForPost', 'revertSnapshotForPostParam', revertSnapshotForPostParam)
const localVarPath = `/apis/api.console.halo.run/v1alpha1/posts/{name}/revert-content`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(revertSnapshotForPostParam, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Publish a post.
* @param {string} name
@ -466,6 +654,19 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (confi
export const ApiConsoleHaloRunV1alpha1PostApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator(configuration)
return {
/**
* Delete a content for post.
* @param {string} name
* @param {string} snapshotName
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deletePostContent(name: string, snapshotName: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ContentWrapper>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deletePostContent(name, snapshotName, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.deletePostContent']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Draft a post.
* @param {PostRequest} postRequest
@ -478,6 +679,19 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function(configuration?: Confi
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.draftPost']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Fetch content of post.
* @param {string} name
* @param {string} snapshotName
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async fetchPostContent(name: string, snapshotName: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ContentWrapper>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.fetchPostContent(name, snapshotName, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.fetchPostContent']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Fetch head content of post.
* @param {string} name
@ -502,6 +716,18 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function(configuration?: Confi
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.fetchPostReleaseContent']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* List all snapshots for post content.
* @param {string} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listPostSnapshots(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<ListedSnapshotDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listPostSnapshots(name, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.listPostSnapshots']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* List posts.
* @param {number} [page] Page number. Default is 0.
@ -545,6 +771,19 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function(configuration?: Confi
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.recyclePost']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Revert to specified snapshot for post content.
* @param {string} name
* @param {RevertSnapshotForPostParam} revertSnapshotForPostParam
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async revertToSpecifiedSnapshotForPost(name: string, revertSnapshotForPostParam: RevertSnapshotForPostParam, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Post>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.revertToSpecifiedSnapshotForPost(name, revertSnapshotForPostParam, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.revertToSpecifiedSnapshotForPost']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Publish a post.
* @param {string} name
@ -593,6 +832,15 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function(configuration?: Confi
export const ApiConsoleHaloRunV1alpha1PostApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ApiConsoleHaloRunV1alpha1PostApiFp(configuration)
return {
/**
* Delete a content for post.
* @param {ApiConsoleHaloRunV1alpha1PostApiDeletePostContentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deletePostContent(requestParameters: ApiConsoleHaloRunV1alpha1PostApiDeletePostContentRequest, options?: RawAxiosRequestConfig): AxiosPromise<ContentWrapper> {
return localVarFp.deletePostContent(requestParameters.name, requestParameters.snapshotName, options).then((request) => request(axios, basePath));
},
/**
* Draft a post.
* @param {ApiConsoleHaloRunV1alpha1PostApiDraftPostRequest} requestParameters Request parameters.
@ -602,6 +850,15 @@ export const ApiConsoleHaloRunV1alpha1PostApiFactory = function (configuration?:
draftPost(requestParameters: ApiConsoleHaloRunV1alpha1PostApiDraftPostRequest, options?: RawAxiosRequestConfig): AxiosPromise<Post> {
return localVarFp.draftPost(requestParameters.postRequest, options).then((request) => request(axios, basePath));
},
/**
* Fetch content of post.
* @param {ApiConsoleHaloRunV1alpha1PostApiFetchPostContentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
fetchPostContent(requestParameters: ApiConsoleHaloRunV1alpha1PostApiFetchPostContentRequest, options?: RawAxiosRequestConfig): AxiosPromise<ContentWrapper> {
return localVarFp.fetchPostContent(requestParameters.name, requestParameters.snapshotName, options).then((request) => request(axios, basePath));
},
/**
* Fetch head content of post.
* @param {ApiConsoleHaloRunV1alpha1PostApiFetchPostHeadContentRequest} requestParameters Request parameters.
@ -620,6 +877,15 @@ export const ApiConsoleHaloRunV1alpha1PostApiFactory = function (configuration?:
fetchPostReleaseContent(requestParameters: ApiConsoleHaloRunV1alpha1PostApiFetchPostReleaseContentRequest, options?: RawAxiosRequestConfig): AxiosPromise<ContentWrapper> {
return localVarFp.fetchPostReleaseContent(requestParameters.name, options).then((request) => request(axios, basePath));
},
/**
* List all snapshots for post content.
* @param {ApiConsoleHaloRunV1alpha1PostApiListPostSnapshotsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listPostSnapshots(requestParameters: ApiConsoleHaloRunV1alpha1PostApiListPostSnapshotsRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<ListedSnapshotDto>> {
return localVarFp.listPostSnapshots(requestParameters.name, options).then((request) => request(axios, basePath));
},
/**
* List posts.
* @param {ApiConsoleHaloRunV1alpha1PostApiListPostsRequest} requestParameters Request parameters.
@ -647,6 +913,15 @@ export const ApiConsoleHaloRunV1alpha1PostApiFactory = function (configuration?:
recyclePost(requestParameters: ApiConsoleHaloRunV1alpha1PostApiRecyclePostRequest, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.recyclePost(requestParameters.name, options).then((request) => request(axios, basePath));
},
/**
* Revert to specified snapshot for post content.
* @param {ApiConsoleHaloRunV1alpha1PostApiRevertToSpecifiedSnapshotForPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
revertToSpecifiedSnapshotForPost(requestParameters: ApiConsoleHaloRunV1alpha1PostApiRevertToSpecifiedSnapshotForPostRequest, options?: RawAxiosRequestConfig): AxiosPromise<Post> {
return localVarFp.revertToSpecifiedSnapshotForPost(requestParameters.name, requestParameters.revertSnapshotForPostParam, options).then((request) => request(axios, basePath));
},
/**
* Publish a post.
* @param {ApiConsoleHaloRunV1alpha1PostApiUnpublishPostRequest} requestParameters Request parameters.
@ -677,6 +952,27 @@ export const ApiConsoleHaloRunV1alpha1PostApiFactory = function (configuration?:
};
};
/**
* Request parameters for deletePostContent operation in ApiConsoleHaloRunV1alpha1PostApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1PostApiDeletePostContentRequest
*/
export interface ApiConsoleHaloRunV1alpha1PostApiDeletePostContentRequest {
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1PostApiDeletePostContent
*/
readonly name: string
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1PostApiDeletePostContent
*/
readonly snapshotName: string
}
/**
* Request parameters for draftPost operation in ApiConsoleHaloRunV1alpha1PostApi.
* @export
@ -691,6 +987,27 @@ export interface ApiConsoleHaloRunV1alpha1PostApiDraftPostRequest {
readonly postRequest: PostRequest
}
/**
* Request parameters for fetchPostContent operation in ApiConsoleHaloRunV1alpha1PostApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1PostApiFetchPostContentRequest
*/
export interface ApiConsoleHaloRunV1alpha1PostApiFetchPostContentRequest {
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1PostApiFetchPostContent
*/
readonly name: string
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1PostApiFetchPostContent
*/
readonly snapshotName: string
}
/**
* Request parameters for fetchPostHeadContent operation in ApiConsoleHaloRunV1alpha1PostApi.
* @export
@ -719,6 +1036,20 @@ export interface ApiConsoleHaloRunV1alpha1PostApiFetchPostReleaseContentRequest
readonly name: string
}
/**
* Request parameters for listPostSnapshots operation in ApiConsoleHaloRunV1alpha1PostApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1PostApiListPostSnapshotsRequest
*/
export interface ApiConsoleHaloRunV1alpha1PostApiListPostSnapshotsRequest {
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1PostApiListPostSnapshots
*/
readonly name: string
}
/**
* Request parameters for listPosts operation in ApiConsoleHaloRunV1alpha1PostApi.
* @export
@ -810,6 +1141,27 @@ export interface ApiConsoleHaloRunV1alpha1PostApiRecyclePostRequest {
readonly name: string
}
/**
* Request parameters for revertToSpecifiedSnapshotForPost operation in ApiConsoleHaloRunV1alpha1PostApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1PostApiRevertToSpecifiedSnapshotForPostRequest
*/
export interface ApiConsoleHaloRunV1alpha1PostApiRevertToSpecifiedSnapshotForPostRequest {
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1PostApiRevertToSpecifiedSnapshotForPost
*/
readonly name: string
/**
*
* @type {RevertSnapshotForPostParam}
* @memberof ApiConsoleHaloRunV1alpha1PostApiRevertToSpecifiedSnapshotForPost
*/
readonly revertSnapshotForPostParam: RevertSnapshotForPostParam
}
/**
* Request parameters for unpublishPost operation in ApiConsoleHaloRunV1alpha1PostApi.
* @export
@ -873,6 +1225,17 @@ export interface ApiConsoleHaloRunV1alpha1PostApiUpdatePostContentRequest {
* @extends {BaseAPI}
*/
export class ApiConsoleHaloRunV1alpha1PostApi extends BaseAPI {
/**
* Delete a content for post.
* @param {ApiConsoleHaloRunV1alpha1PostApiDeletePostContentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1PostApi
*/
public deletePostContent(requestParameters: ApiConsoleHaloRunV1alpha1PostApiDeletePostContentRequest, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).deletePostContent(requestParameters.name, requestParameters.snapshotName, options).then((request) => request(this.axios, this.basePath));
}
/**
* Draft a post.
* @param {ApiConsoleHaloRunV1alpha1PostApiDraftPostRequest} requestParameters Request parameters.
@ -884,6 +1247,17 @@ export class ApiConsoleHaloRunV1alpha1PostApi extends BaseAPI {
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).draftPost(requestParameters.postRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Fetch content of post.
* @param {ApiConsoleHaloRunV1alpha1PostApiFetchPostContentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1PostApi
*/
public fetchPostContent(requestParameters: ApiConsoleHaloRunV1alpha1PostApiFetchPostContentRequest, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).fetchPostContent(requestParameters.name, requestParameters.snapshotName, options).then((request) => request(this.axios, this.basePath));
}
/**
* Fetch head content of post.
* @param {ApiConsoleHaloRunV1alpha1PostApiFetchPostHeadContentRequest} requestParameters Request parameters.
@ -906,6 +1280,17 @@ export class ApiConsoleHaloRunV1alpha1PostApi extends BaseAPI {
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).fetchPostReleaseContent(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
/**
* List all snapshots for post content.
* @param {ApiConsoleHaloRunV1alpha1PostApiListPostSnapshotsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1PostApi
*/
public listPostSnapshots(requestParameters: ApiConsoleHaloRunV1alpha1PostApiListPostSnapshotsRequest, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).listPostSnapshots(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
/**
* List posts.
* @param {ApiConsoleHaloRunV1alpha1PostApiListPostsRequest} requestParameters Request parameters.
@ -939,6 +1324,17 @@ export class ApiConsoleHaloRunV1alpha1PostApi extends BaseAPI {
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).recyclePost(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
/**
* Revert to specified snapshot for post content.
* @param {ApiConsoleHaloRunV1alpha1PostApiRevertToSpecifiedSnapshotForPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1PostApi
*/
public revertToSpecifiedSnapshotForPost(requestParameters: ApiConsoleHaloRunV1alpha1PostApiRevertToSpecifiedSnapshotForPostRequest, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).revertToSpecifiedSnapshotForPost(requestParameters.name, requestParameters.revertSnapshotForPostParam, options).then((request) => request(this.axios, this.basePath));
}
/**
* Publish a post.
* @param {ApiConsoleHaloRunV1alpha1PostApiUnpublishPostRequest} requestParameters Request parameters.

View File

@ -28,8 +28,12 @@ import { ContentWrapper } from '../models';
// @ts-ignore
import { ListedSinglePageList } from '../models';
// @ts-ignore
import { ListedSnapshotDto } from '../models';
// @ts-ignore
import { Post } from '../models';
// @ts-ignore
import { RevertSnapshotForSingleParam } from '../models';
// @ts-ignore
import { SinglePage } from '../models';
// @ts-ignore
import { SinglePageRequest } from '../models';
@ -39,6 +43,54 @@ import { SinglePageRequest } from '../models';
*/
export const ApiConsoleHaloRunV1alpha1SinglePageApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Delete a content for post.
* @param {string} name
* @param {string} snapshotName
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteSinglePageContent: async (name: string, snapshotName: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('deleteSinglePageContent', 'name', name)
// verify required parameter 'snapshotName' is not null or undefined
assertParamExists('deleteSinglePageContent', 'snapshotName', snapshotName)
const localVarPath = `/apis/api.console.halo.run/v1alpha1/singlepages/{name}/content`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (snapshotName !== undefined) {
localVarQueryParameter['snapshotName'] = snapshotName;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Draft a single page.
* @param {SinglePageRequest} singlePageRequest
@ -82,6 +134,54 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiAxiosParamCreator = function
options: localVarRequestOptions,
};
},
/**
* Fetch content of single page.
* @param {string} name
* @param {string} snapshotName
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
fetchSinglePageContent: async (name: string, snapshotName: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('fetchSinglePageContent', 'name', name)
// verify required parameter 'snapshotName' is not null or undefined
assertParamExists('fetchSinglePageContent', 'snapshotName', snapshotName)
const localVarPath = `/apis/api.console.halo.run/v1alpha1/singlepages/{name}/content`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (snapshotName !== undefined) {
localVarQueryParameter['snapshotName'] = snapshotName;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Fetch head content of single page.
* @param {string} name
@ -155,6 +255,47 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiAxiosParamCreator = function
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* List all snapshots for single page content.
* @param {string} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listSinglePageSnapshots: async (name: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('listSinglePageSnapshots', 'name', name)
const localVarPath = `/apis/api.console.halo.run/v1alpha1/singlepages/{name}/snapshot`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -287,6 +428,53 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiAxiosParamCreator = function
options: localVarRequestOptions,
};
},
/**
* Revert to specified snapshot for single page content.
* @param {string} name
* @param {RevertSnapshotForSingleParam} revertSnapshotForSingleParam
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
revertToSpecifiedSnapshotForSinglePage: async (name: string, revertSnapshotForSingleParam: RevertSnapshotForSingleParam, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('revertToSpecifiedSnapshotForSinglePage', 'name', name)
// verify required parameter 'revertSnapshotForSingleParam' is not null or undefined
assertParamExists('revertToSpecifiedSnapshotForSinglePage', 'revertSnapshotForSingleParam', revertSnapshotForSingleParam)
const localVarPath = `/apis/api.console.halo.run/v1alpha1/singlepages/{name}/revert-content`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(revertSnapshotForSingleParam, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Update a single page.
* @param {string} name
@ -391,6 +579,19 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiAxiosParamCreator = function
export const ApiConsoleHaloRunV1alpha1SinglePageApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ApiConsoleHaloRunV1alpha1SinglePageApiAxiosParamCreator(configuration)
return {
/**
* Delete a content for post.
* @param {string} name
* @param {string} snapshotName
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteSinglePageContent(name: string, snapshotName: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ContentWrapper>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteSinglePageContent(name, snapshotName, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1SinglePageApi.deleteSinglePageContent']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Draft a single page.
* @param {SinglePageRequest} singlePageRequest
@ -403,6 +604,19 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiFp = function(configuration?:
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1SinglePageApi.draftSinglePage']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Fetch content of single page.
* @param {string} name
* @param {string} snapshotName
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async fetchSinglePageContent(name: string, snapshotName: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ContentWrapper>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.fetchSinglePageContent(name, snapshotName, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1SinglePageApi.fetchSinglePageContent']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Fetch head content of single page.
* @param {string} name
@ -427,6 +641,18 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiFp = function(configuration?:
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1SinglePageApi.fetchSinglePageReleaseContent']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* List all snapshots for single page content.
* @param {string} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listSinglePageSnapshots(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<ListedSnapshotDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listSinglePageSnapshots(name, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1SinglePageApi.listSinglePageSnapshots']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* List single pages.
* @param {number} [page] Page number. Default is 0.
@ -459,6 +685,19 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiFp = function(configuration?:
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1SinglePageApi.publishSinglePage']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Revert to specified snapshot for single page content.
* @param {string} name
* @param {RevertSnapshotForSingleParam} revertSnapshotForSingleParam
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async revertToSpecifiedSnapshotForSinglePage(name: string, revertSnapshotForSingleParam: RevertSnapshotForSingleParam, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Post>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.revertToSpecifiedSnapshotForSinglePage(name, revertSnapshotForSingleParam, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1SinglePageApi.revertToSpecifiedSnapshotForSinglePage']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Update a single page.
* @param {string} name
@ -495,6 +734,15 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiFp = function(configuration?:
export const ApiConsoleHaloRunV1alpha1SinglePageApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ApiConsoleHaloRunV1alpha1SinglePageApiFp(configuration)
return {
/**
* Delete a content for post.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiDeleteSinglePageContentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteSinglePageContent(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiDeleteSinglePageContentRequest, options?: RawAxiosRequestConfig): AxiosPromise<ContentWrapper> {
return localVarFp.deleteSinglePageContent(requestParameters.name, requestParameters.snapshotName, options).then((request) => request(axios, basePath));
},
/**
* Draft a single page.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiDraftSinglePageRequest} requestParameters Request parameters.
@ -504,6 +752,15 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiFactory = function (configura
draftSinglePage(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiDraftSinglePageRequest, options?: RawAxiosRequestConfig): AxiosPromise<SinglePage> {
return localVarFp.draftSinglePage(requestParameters.singlePageRequest, options).then((request) => request(axios, basePath));
},
/**
* Fetch content of single page.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageContentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
fetchSinglePageContent(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageContentRequest, options?: RawAxiosRequestConfig): AxiosPromise<ContentWrapper> {
return localVarFp.fetchSinglePageContent(requestParameters.name, requestParameters.snapshotName, options).then((request) => request(axios, basePath));
},
/**
* Fetch head content of single page.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageHeadContentRequest} requestParameters Request parameters.
@ -522,6 +779,15 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiFactory = function (configura
fetchSinglePageReleaseContent(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageReleaseContentRequest, options?: RawAxiosRequestConfig): AxiosPromise<ContentWrapper> {
return localVarFp.fetchSinglePageReleaseContent(requestParameters.name, options).then((request) => request(axios, basePath));
},
/**
* List all snapshots for single page content.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiListSinglePageSnapshotsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listSinglePageSnapshots(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiListSinglePageSnapshotsRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<ListedSnapshotDto>> {
return localVarFp.listSinglePageSnapshots(requestParameters.name, options).then((request) => request(axios, basePath));
},
/**
* List single pages.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiListSinglePagesRequest} requestParameters Request parameters.
@ -540,6 +806,15 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiFactory = function (configura
publishSinglePage(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiPublishSinglePageRequest, options?: RawAxiosRequestConfig): AxiosPromise<SinglePage> {
return localVarFp.publishSinglePage(requestParameters.name, options).then((request) => request(axios, basePath));
},
/**
* Revert to specified snapshot for single page content.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiRevertToSpecifiedSnapshotForSinglePageRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
revertToSpecifiedSnapshotForSinglePage(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiRevertToSpecifiedSnapshotForSinglePageRequest, options?: RawAxiosRequestConfig): AxiosPromise<Post> {
return localVarFp.revertToSpecifiedSnapshotForSinglePage(requestParameters.name, requestParameters.revertSnapshotForSingleParam, options).then((request) => request(axios, basePath));
},
/**
* Update a single page.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiUpdateDraftSinglePageRequest} requestParameters Request parameters.
@ -561,6 +836,27 @@ export const ApiConsoleHaloRunV1alpha1SinglePageApiFactory = function (configura
};
};
/**
* Request parameters for deleteSinglePageContent operation in ApiConsoleHaloRunV1alpha1SinglePageApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1SinglePageApiDeleteSinglePageContentRequest
*/
export interface ApiConsoleHaloRunV1alpha1SinglePageApiDeleteSinglePageContentRequest {
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApiDeleteSinglePageContent
*/
readonly name: string
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApiDeleteSinglePageContent
*/
readonly snapshotName: string
}
/**
* Request parameters for draftSinglePage operation in ApiConsoleHaloRunV1alpha1SinglePageApi.
* @export
@ -575,6 +871,27 @@ export interface ApiConsoleHaloRunV1alpha1SinglePageApiDraftSinglePageRequest {
readonly singlePageRequest: SinglePageRequest
}
/**
* Request parameters for fetchSinglePageContent operation in ApiConsoleHaloRunV1alpha1SinglePageApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageContentRequest
*/
export interface ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageContentRequest {
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageContent
*/
readonly name: string
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageContent
*/
readonly snapshotName: string
}
/**
* Request parameters for fetchSinglePageHeadContent operation in ApiConsoleHaloRunV1alpha1SinglePageApi.
* @export
@ -603,6 +920,20 @@ export interface ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageReleaseCon
readonly name: string
}
/**
* Request parameters for listSinglePageSnapshots operation in ApiConsoleHaloRunV1alpha1SinglePageApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1SinglePageApiListSinglePageSnapshotsRequest
*/
export interface ApiConsoleHaloRunV1alpha1SinglePageApiListSinglePageSnapshotsRequest {
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApiListSinglePageSnapshots
*/
readonly name: string
}
/**
* Request parameters for listSinglePages operation in ApiConsoleHaloRunV1alpha1SinglePageApi.
* @export
@ -687,6 +1018,27 @@ export interface ApiConsoleHaloRunV1alpha1SinglePageApiPublishSinglePageRequest
readonly name: string
}
/**
* Request parameters for revertToSpecifiedSnapshotForSinglePage operation in ApiConsoleHaloRunV1alpha1SinglePageApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1SinglePageApiRevertToSpecifiedSnapshotForSinglePageRequest
*/
export interface ApiConsoleHaloRunV1alpha1SinglePageApiRevertToSpecifiedSnapshotForSinglePageRequest {
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApiRevertToSpecifiedSnapshotForSinglePage
*/
readonly name: string
/**
*
* @type {RevertSnapshotForSingleParam}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApiRevertToSpecifiedSnapshotForSinglePage
*/
readonly revertSnapshotForSingleParam: RevertSnapshotForSingleParam
}
/**
* Request parameters for updateDraftSinglePage operation in ApiConsoleHaloRunV1alpha1SinglePageApi.
* @export
@ -736,6 +1088,17 @@ export interface ApiConsoleHaloRunV1alpha1SinglePageApiUpdateSinglePageContentRe
* @extends {BaseAPI}
*/
export class ApiConsoleHaloRunV1alpha1SinglePageApi extends BaseAPI {
/**
* Delete a content for post.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiDeleteSinglePageContentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApi
*/
public deleteSinglePageContent(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiDeleteSinglePageContentRequest, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1SinglePageApiFp(this.configuration).deleteSinglePageContent(requestParameters.name, requestParameters.snapshotName, options).then((request) => request(this.axios, this.basePath));
}
/**
* Draft a single page.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiDraftSinglePageRequest} requestParameters Request parameters.
@ -747,6 +1110,17 @@ export class ApiConsoleHaloRunV1alpha1SinglePageApi extends BaseAPI {
return ApiConsoleHaloRunV1alpha1SinglePageApiFp(this.configuration).draftSinglePage(requestParameters.singlePageRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Fetch content of single page.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageContentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApi
*/
public fetchSinglePageContent(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageContentRequest, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1SinglePageApiFp(this.configuration).fetchSinglePageContent(requestParameters.name, requestParameters.snapshotName, options).then((request) => request(this.axios, this.basePath));
}
/**
* Fetch head content of single page.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiFetchSinglePageHeadContentRequest} requestParameters Request parameters.
@ -769,6 +1143,17 @@ export class ApiConsoleHaloRunV1alpha1SinglePageApi extends BaseAPI {
return ApiConsoleHaloRunV1alpha1SinglePageApiFp(this.configuration).fetchSinglePageReleaseContent(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
/**
* List all snapshots for single page content.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiListSinglePageSnapshotsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApi
*/
public listSinglePageSnapshots(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiListSinglePageSnapshotsRequest, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1SinglePageApiFp(this.configuration).listSinglePageSnapshots(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
/**
* List single pages.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiListSinglePagesRequest} requestParameters Request parameters.
@ -791,6 +1176,17 @@ export class ApiConsoleHaloRunV1alpha1SinglePageApi extends BaseAPI {
return ApiConsoleHaloRunV1alpha1SinglePageApiFp(this.configuration).publishSinglePage(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
/**
* Revert to specified snapshot for single page content.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiRevertToSpecifiedSnapshotForSinglePageRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1SinglePageApi
*/
public revertToSpecifiedSnapshotForSinglePage(requestParameters: ApiConsoleHaloRunV1alpha1SinglePageApiRevertToSpecifiedSnapshotForSinglePageRequest, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1SinglePageApiFp(this.configuration).revertToSpecifiedSnapshotForSinglePage(requestParameters.name, requestParameters.revertSnapshotForSingleParam, options).then((request) => request(this.axios, this.basePath));
}
/**
* Update a single page.
* @param {ApiConsoleHaloRunV1alpha1SinglePageApiUpdateDraftSinglePageRequest} requestParameters Request parameters.

View File

@ -31,16 +31,16 @@ export const ApiConsoleHaloRunV1alpha1TagApiAxiosParamCreator = function (config
return {
/**
* List Post Tags.
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {string} [keyword] Keyword for searching.
* @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp, name
* @param {number} [page] Page number. Default is 0.
* @param {number} [size] Size number. Default is 0.
* @param {Array<string>} [labelSelector] Label selector. e.g.: hidden!&#x3D;true
* @param {Array<string>} [fieldSelector] Field selector. e.g.: metadata.name&#x3D;&#x3D;halo
* @param {Array<string>} [sort] Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
* @param {string} [keyword] Post tags filtered by keyword.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listPostTags: async (fieldSelector?: Array<string>, keyword?: string, labelSelector?: Array<string>, page?: number, size?: number, sort?: Array<string>, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
listPostTags: async (page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, keyword?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/apis/api.console.halo.run/v1alpha1/tags`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -61,18 +61,6 @@ export const ApiConsoleHaloRunV1alpha1TagApiAxiosParamCreator = function (config
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (fieldSelector) {
localVarQueryParameter['fieldSelector'] = fieldSelector;
}
if (keyword !== undefined) {
localVarQueryParameter['keyword'] = keyword;
}
if (labelSelector) {
localVarQueryParameter['labelSelector'] = labelSelector;
}
if (page !== undefined) {
localVarQueryParameter['page'] = page;
}
@ -81,8 +69,20 @@ export const ApiConsoleHaloRunV1alpha1TagApiAxiosParamCreator = function (config
localVarQueryParameter['size'] = size;
}
if (labelSelector) {
localVarQueryParameter['labelSelector'] = labelSelector;
}
if (fieldSelector) {
localVarQueryParameter['fieldSelector'] = fieldSelector;
}
if (sort) {
localVarQueryParameter['sort'] = Array.from(sort);
localVarQueryParameter['sort'] = sort;
}
if (keyword !== undefined) {
localVarQueryParameter['keyword'] = keyword;
}
@ -108,17 +108,17 @@ export const ApiConsoleHaloRunV1alpha1TagApiFp = function(configuration?: Config
return {
/**
* List Post Tags.
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {string} [keyword] Keyword for searching.
* @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp, name
* @param {number} [page] Page number. Default is 0.
* @param {number} [size] Size number. Default is 0.
* @param {Array<string>} [labelSelector] Label selector. e.g.: hidden!&#x3D;true
* @param {Array<string>} [fieldSelector] Field selector. e.g.: metadata.name&#x3D;&#x3D;halo
* @param {Array<string>} [sort] Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
* @param {string} [keyword] Post tags filtered by keyword.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listPostTags(fieldSelector?: Array<string>, keyword?: string, labelSelector?: Array<string>, page?: number, size?: number, sort?: Array<string>, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<TagList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listPostTags(fieldSelector, keyword, labelSelector, page, size, sort, options);
async listPostTags(page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, keyword?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<TagList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listPostTags(page, size, labelSelector, fieldSelector, sort, keyword, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1TagApi.listPostTags']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -140,7 +140,7 @@ export const ApiConsoleHaloRunV1alpha1TagApiFactory = function (configuration?:
* @throws {RequiredError}
*/
listPostTags(requestParameters: ApiConsoleHaloRunV1alpha1TagApiListPostTagsRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise<TagList> {
return localVarFp.listPostTags(requestParameters.fieldSelector, requestParameters.keyword, requestParameters.labelSelector, requestParameters.page, requestParameters.size, requestParameters.sort, options).then((request) => request(axios, basePath));
return localVarFp.listPostTags(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.keyword, options).then((request) => request(axios, basePath));
},
};
};
@ -152,46 +152,46 @@ export const ApiConsoleHaloRunV1alpha1TagApiFactory = function (configuration?:
*/
export interface ApiConsoleHaloRunV1alpha1TagApiListPostTagsRequest {
/**
* Field selector for filtering.
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly fieldSelector?: Array<string>
/**
* Keyword for searching.
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly keyword?: string
/**
* Label selector for filtering.
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly labelSelector?: Array<string>
/**
* The page number. Zero indicates no page.
* Page number. Default is 0.
* @type {number}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly page?: number
/**
* Size of one page. Zero indicates no limit.
* Size number. Default is 0.
* @type {number}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly size?: number
/**
* Sort property and direction of the list result. Supported fields: creationTimestamp, name
* Label selector. e.g.: hidden!&#x3D;true
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly labelSelector?: Array<string>
/**
* Field selector. e.g.: metadata.name&#x3D;&#x3D;halo
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly fieldSelector?: Array<string>
/**
* Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly sort?: Array<string>
/**
* Post tags filtered by keyword.
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1TagApiListPostTags
*/
readonly keyword?: string
}
/**
@ -209,7 +209,7 @@ export class ApiConsoleHaloRunV1alpha1TagApi extends BaseAPI {
* @memberof ApiConsoleHaloRunV1alpha1TagApi
*/
public listPostTags(requestParameters: ApiConsoleHaloRunV1alpha1TagApiListPostTagsRequest = {}, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1TagApiFp(this.configuration).listPostTags(requestParameters.fieldSelector, requestParameters.keyword, requestParameters.labelSelector, requestParameters.page, requestParameters.size, requestParameters.sort, options).then((request) => request(this.axios, this.basePath));
return ApiConsoleHaloRunV1alpha1TagApiFp(this.configuration).listPostTags(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.keyword, options).then((request) => request(this.axios, this.basePath));
}
}

View File

@ -32,6 +32,12 @@ export interface CommentStatus {
* @memberof CommentStatus
*/
'lastReplyTime'?: string;
/**
*
* @type {number}
* @memberof CommentStatus
*/
'observedVersion'?: number;
/**
*
* @type {number}

View File

@ -0,0 +1,48 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface ContentUpdateParam
*/
export interface ContentUpdateParam {
/**
*
* @type {string}
* @memberof ContentUpdateParam
*/
'content'?: string;
/**
*
* @type {string}
* @memberof ContentUpdateParam
*/
'raw'?: string;
/**
*
* @type {string}
* @memberof ContentUpdateParam
*/
'rawType'?: string;
/**
*
* @type {number}
* @memberof ContentUpdateParam
*/
'version'?: number;
}

View File

@ -39,6 +39,7 @@ export * from './config-map';
export * from './config-map-list';
export * from './config-map-ref';
export * from './content';
export * from './content-update-param';
export * from './content-vo';
export * from './content-wrapper';
export * from './contributor';
@ -85,6 +86,8 @@ export * from './listed-single-page';
export * from './listed-single-page-list';
export * from './listed-single-page-vo';
export * from './listed-single-page-vo-list';
export * from './listed-snapshot-dto';
export * from './listed-snapshot-spec';
export * from './listed-user';
export * from './login-history';
export * from './mark-specified-request';
@ -158,12 +161,15 @@ export * from './reply';
export * from './reply-list';
export * from './reply-request';
export * from './reply-spec';
export * from './reply-status';
export * from './reply-vo';
export * from './reply-vo-list';
export * from './reset-password-request';
export * from './reverse-proxy';
export * from './reverse-proxy-list';
export * from './reverse-proxy-rule';
export * from './revert-snapshot-for-post-param';
export * from './revert-snapshot-for-single-param';
export * from './role';
export * from './role-binding';
export * from './role-binding-list';

View File

@ -23,6 +23,12 @@ import { InterestReasonSubject } from './interest-reason-subject';
* @interface InterestReason
*/
export interface InterestReason {
/**
* The expression to be interested in
* @type {string}
* @memberof InterestReason
*/
'expression'?: string;
/**
* The name of the reason definition to be interested in
* @type {string}

View File

@ -0,0 +1,42 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import { ListedSnapshotSpec } from './listed-snapshot-spec';
// May contain unused imports in some cases
// @ts-ignore
import { Metadata } from './metadata';
/**
*
* @export
* @interface ListedSnapshotDto
*/
export interface ListedSnapshotDto {
/**
*
* @type {Metadata}
* @memberof ListedSnapshotDto
*/
'metadata': Metadata;
/**
*
* @type {ListedSnapshotSpec}
* @memberof ListedSnapshotDto
*/
'spec': ListedSnapshotSpec;
}

View File

@ -0,0 +1,36 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface ListedSnapshotSpec
*/
export interface ListedSnapshotSpec {
/**
*
* @type {string}
* @memberof ListedSnapshotSpec
*/
'modifyTime'?: string;
/**
*
* @type {string}
* @memberof ListedSnapshotSpec
*/
'owner': string;
}

View File

@ -79,8 +79,7 @@ export const PluginStatusLastProbeStateEnum = {
Resolved: 'RESOLVED',
Started: 'STARTED',
Stopped: 'STOPPED',
Failed: 'FAILED',
Unloaded: 'UNLOADED'
Failed: 'FAILED'
} as const;
export type PluginStatusLastProbeStateEnum = typeof PluginStatusLastProbeStateEnum[keyof typeof PluginStatusLastProbeStateEnum];

View File

@ -15,7 +15,7 @@
// May contain unused imports in some cases
// @ts-ignore
import { Content } from './content';
import { ContentUpdateParam } from './content-update-param';
// May contain unused imports in some cases
// @ts-ignore
import { Post } from './post';
@ -28,10 +28,10 @@ import { Post } from './post';
export interface PostRequest {
/**
*
* @type {Content}
* @type {ContentUpdateParam}
* @memberof PostRequest
*/
'content'?: Content;
'content'?: ContentUpdateParam;
/**
*
* @type {Post}

View File

@ -0,0 +1,30 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface ReplyStatus
*/
export interface ReplyStatus {
/**
*
* @type {number}
* @memberof ReplyStatus
*/
'observedVersion'?: number;
}

View File

@ -19,6 +19,9 @@ import { Metadata } from './metadata';
// May contain unused imports in some cases
// @ts-ignore
import { ReplySpec } from './reply-spec';
// May contain unused imports in some cases
// @ts-ignore
import { ReplyStatus } from './reply-status';
/**
*
@ -50,5 +53,11 @@ export interface Reply {
* @memberof Reply
*/
'spec': ReplySpec;
/**
*
* @type {ReplyStatus}
* @memberof Reply
*/
'status': ReplyStatus;
}

View File

@ -0,0 +1,30 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface RevertSnapshotForPostParam
*/
export interface RevertSnapshotForPostParam {
/**
*
* @type {string}
* @memberof RevertSnapshotForPostParam
*/
'snapshotName': string;
}

View File

@ -0,0 +1,30 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface RevertSnapshotForSingleParam
*/
export interface RevertSnapshotForSingleParam {
/**
*
* @type {string}
* @memberof RevertSnapshotForSingleParam
*/
'snapshotName': string;
}

View File

@ -15,7 +15,7 @@
// May contain unused imports in some cases
// @ts-ignore
import { Content } from './content';
import { ContentUpdateParam } from './content-update-param';
// May contain unused imports in some cases
// @ts-ignore
import { SinglePage } from './single-page';
@ -28,10 +28,10 @@ import { SinglePage } from './single-page';
export interface SinglePageRequest {
/**
*
* @type {Content}
* @type {ContentUpdateParam}
* @memberof SinglePageRequest
*/
'content': Content;
'content': ContentUpdateParam;
/**
*
* @type {SinglePage}

Some files were not shown because too many files have changed in this diff Show More