├── gradle.properties
├── ui
├── env.d.ts
├── tsconfig.json
├── tsconfig.vitest.json
├── tsconfig.app.json
├── tsconfig.node.json
├── vite.config.ts
├── src
│ ├── domain
│ │ └── index.d.ts
│ ├── views
│ │ ├── ImportArtical.vue
│ │ ├── HomeView.vue
│ │ ├── ExportArtical.vue
│ │ └── ExportListItem.vue
│ ├── index.ts
│ ├── components
│ │ └── entity
│ │ │ └── EntityDropdownItems.vue
│ └── assets
│ │ └── logo.svg
├── build.gradle
├── eslint.config.ts
└── package.json
├── src
├── main
│ ├── resources
│ │ ├── logo.png
│ │ ├── plugin.yaml
│ │ └── extensions
│ │ │ └── roleTemplate.yaml
│ └── java
│ │ └── cn
│ │ └── lyn4ever
│ │ └── export2md
│ │ ├── service
│ │ ├── ExportService.java
│ │ ├── ImportService.java
│ │ ├── PostService.java
│ │ └── impl
│ │ │ ├── ImportServiceV2.java
│ │ │ ├── PostServiceImpl.java
│ │ │ └── ExportServiceImpl.java
│ │ ├── halo
│ │ ├── Content.java
│ │ ├── PostRequest.java
│ │ ├── ContentWrapper.java
│ │ ├── ContentRequest.java
│ │ └── service
│ │ │ └── AbstractContentService.java
│ │ ├── schema
│ │ ├── ImportLogSchema.java
│ │ └── ExportLogSchema.java
│ │ ├── util
│ │ ├── FileUtil.java
│ │ └── PatchUtils.java
│ │ ├── HaloPluginExport2docPlugin.java
│ │ └── rest
│ │ ├── ImportController.java
│ │ └── ExportController.java
└── test
│ └── java
│ └── cn
│ └── lyn4ever
│ └── export2md
│ └── HaloPluginExport2docPluginTest.java
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── settings.gradle
├── .github
└── workflows
│ ├── ci.yaml
│ └── cd.yaml
├── .gitignore
├── README.md
├── gradlew.bat
├── gradlew
├── .editorconfig
└── LICENSE
/gradle.properties:
--------------------------------------------------------------------------------
1 | version=1.2.5
2 |
--------------------------------------------------------------------------------
/ui/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/src/main/resources/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lyn4ever29/halo-plugin-export-md/HEAD/src/main/resources/logo.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lyn4ever29/halo-plugin-export-md/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | }
5 | }
6 | rootProject.name = 'halo-plugin-export2doc'
7 | include 'ui'
8 |
--------------------------------------------------------------------------------
/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.node.json"
6 | },
7 | {
8 | "path": "./tsconfig.app.json"
9 | },
10 | {
11 | "path": "./tsconfig.vitest.json"
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/ui/tsconfig.vitest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.app.json",
3 | "include": ["src/**/__tests__/*", "env.d.ts"],
4 | "exclude": [],
5 | "compilerOptions": {
6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
7 |
8 | "lib": [],
9 | "types": ["node", "jsdom"]
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/ui/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.dom.json",
3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
4 | "exclude": ["src/**/__tests__/*"],
5 | "compilerOptions": {
6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
7 |
8 | "paths": {
9 | "@/*": ["./src/*"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/service/ExportService.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.service;
2 |
3 | import cn.lyn4ever.export2md.schema.ExportLogSchema;
4 |
5 | /**
6 | * @author Lyn4ever29
7 | * @url https://jhacker.cn
8 | * @date 2023/12/5
9 | */
10 | public interface ExportService {
11 | public void runTask(ExportLogSchema exportLogSchema);
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | ci:
13 | uses: halo-sigs/reusable-workflows/.github/workflows/plugin-ci.yaml@v3
14 | with:
15 | ui-path: "ui"
16 | pnpm-version: 9
17 | node-version: 22
18 | java-version: 21
19 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/halo/Content.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.halo;
2 |
3 | import lombok.AllArgsConstructor;
4 | import lombok.Data;
5 | import lombok.NoArgsConstructor;
6 |
7 | @Data
8 | @NoArgsConstructor
9 | @AllArgsConstructor
10 | public class Content {
11 | private String raw;
12 | private String content;
13 | private String rawType;
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | #distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
4 | distributionUrl=https\://mirrors.aliyun.com/gradle/distributions/v9.0.0/gradle-9.0.0-all.zip
5 | networkTimeout=10000
6 | validateDistributionUrl=true
7 | zipStoreBase=GRADLE_USER_HOME
8 | zipStorePath=wrapper/dists
9 |
--------------------------------------------------------------------------------
/ui/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node20/tsconfig.json",
3 | "include": [
4 | "vite.config.*",
5 | "vitest.config.*"
6 | ],
7 | "compilerOptions": {
8 | "composite": true,
9 | "noEmit": true,
10 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
11 | "module": "ESNext",
12 | "moduleResolution": "Bundler",
13 | "types": ["node"]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/service/ImportService.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.service;
2 |
3 | import reactor.core.publisher.Mono;
4 | import run.halo.app.core.extension.content.Post;
5 | import java.io.File;
6 |
7 | /**
8 | * @author Lyn4ever29
9 | * @url https://jhacker.cn
10 | * @date 2023/11/12
11 | */
12 | public interface ImportService {
13 |
14 |
15 | /**
16 | * 运行导出任务
17 | *
18 | * @param filePart
19 | */
20 | Mono runTask(File file);
21 |
22 | }
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/.github/workflows/cd.yaml:
--------------------------------------------------------------------------------
1 | name: CD
2 |
3 | on:
4 | release:
5 | types:
6 | - published
7 |
8 | jobs:
9 | cd:
10 | uses: halo-sigs/reusable-workflows/.github/workflows/plugin-cd.yaml@v3
11 | permissions:
12 | contents: write
13 | with:
14 | ui-path: "ui"
15 | pnpm-version: 9
16 | node-version: 22
17 | java-version: 21
18 | # Remove skip-appstore-release and set app-id if you want to release to the App Store
19 | skip-appstore-release: true
20 | # app-id: app-xyz
21 |
--------------------------------------------------------------------------------
/src/test/java/cn/lyn4ever/export2md/HaloPluginExport2docPluginTest.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import org.junit.jupiter.api.extension.ExtendWith;
5 | import org.mockito.InjectMocks;
6 | import org.mockito.Mock;
7 | import org.mockito.junit.jupiter.MockitoExtension;
8 |
9 | import run.halo.app.plugin.PluginContext;
10 |
11 | @ExtendWith(MockitoExtension.class)
12 | class HaloPluginExport2docPluginTest {
13 |
14 | @Mock
15 | PluginContext context;
16 |
17 | @InjectMocks
18 | HaloPluginExport2docPlugin plugin;
19 |
20 | @Test
21 | void contextLoads() {
22 | // plugin.start();
23 | // plugin.stop();
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/ui/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from 'url'
2 |
3 | import { viteConfig } from '@halo-dev/ui-plugin-bundler-kit'
4 | import Icons from 'unplugin-icons/vite'
5 | import { configDefaults } from 'vitest/config'
6 |
7 | // For more info,
8 | // please see https://github.com/halo-dev/halo/tree/main/ui/packages/ui-plugin-bundler-kit
9 | export default viteConfig({
10 | vite: {
11 | plugins: [Icons({ compiler: 'vue3' })],
12 | resolve: {
13 | alias: {
14 | '@': fileURLToPath(new URL('./src', import.meta.url)),
15 | },
16 | },
17 |
18 | // If you don't use Vitest, you can remove the following configuration
19 | test: {
20 | environment: 'jsdom',
21 | exclude: [...configDefaults.exclude, 'e2e/**'],
22 | root: fileURLToPath(new URL('./', import.meta.url)),
23 | },
24 | },
25 | })
26 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/halo/PostRequest.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.halo;
2 |
3 | import lombok.Data;
4 | import run.halo.app.core.extension.content.Post;
5 | import run.halo.app.extension.Ref;
6 |
7 | /**
8 | * Post and content data for creating and updating post.
9 | *
10 | * @author guqing
11 | * @since 2.0.0
12 | */
13 | @Data
14 | public class PostRequest {
15 |
16 |
17 | private Post post;
18 | private Content content;
19 |
20 | public PostRequest(Post post, Content content) {
21 | this.post = post;
22 | this.content = content;
23 | }
24 |
25 | public ContentRequest contentRequest() {
26 | Ref subjectRef = Ref.of(post);
27 | return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.getRaw(),
28 | content.getContent(), content.getRawType());
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/main/resources/plugin.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: plugin.halo.run/v1alpha1
2 | kind: Plugin
3 | metadata:
4 | # The name defines how the plugin is invoked,A unique name
5 | name: export2doc
6 | spec:
7 | enabled: true
8 | requires: ">=2.12.0"
9 | author:
10 | name: Lyn4ever29
11 | website: https://jhacker.cn
12 | logo: logo.png
13 | # 'homepage' usually links to the GitHub repository of the plugin
14 | homepage: https://jhacker.cn/2023/halo-plugin-export2doc
15 | repo: https://github.com/halo-dev/plugin-starter
16 | issues: https://github.com/halo-dev/plugin-starter/issues
17 | # 'displayName' explains what the plugin does in only a few words
18 | displayName: "文章导入导出"
19 | description: "导出文章为markdown、HTML文件,同时支持导入markdown文件"
20 | license:
21 | - name: "GPL-3.0"
22 | url: "https://github.com/halo-dev/plugin-starter/blob/main/LICENSE"
23 |
24 |
--------------------------------------------------------------------------------
/ui/src/domain/index.d.ts:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "@halo-dev/api-client";
2 | /**
3 | *
4 | * @export
5 | * @interface ExportLog
6 | */
7 | export interface ExportLog {
8 | name: string;
9 | costSeconds: number;
10 | createTime: Date;
11 | status: "a" | "b" | "c";
12 | tag: string;
13 | category: string;
14 | beginTime: string;
15 | endTime: string;
16 | remainMetaData: boolean;
17 | remainCategory: boolean;
18 | kind: "ExportLog";
19 | apiVersion: "cn.lyn4ever.export2doc/v1alpha1";
20 | metadata: Metadata;
21 | }
22 |
23 | export interface ProblemDetail {
24 | detail: string;
25 | instance: string;
26 | status: number;
27 | title: string;
28 | type?: string;
29 | }
30 |
31 | export interface ListedExportLogList {
32 | first: boolean;
33 | hasNext: boolean;
34 | hasPrevious: boolean;
35 | items: Array;
36 | last: boolean;
37 | page: number;
38 | size: number;
39 | total: number;
40 | totalPages: number;
41 | }
42 |
--------------------------------------------------------------------------------
/ui/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'base'
3 | id "com.github.node-gradle.node" version "7.1.0"
4 | }
5 |
6 | group 'cn.lyn4ever.export2doc.ui'
7 |
8 | tasks.register('pnpmBuild', PnpmTask) {
9 | group = 'build'
10 | description = 'Build the UI project using pnpm'
11 | args = ['build']
12 | dependsOn tasks.named('pnpmInstall')
13 | inputs.dir(layout.projectDirectory.dir('src'))
14 | inputs.files(fileTree(
15 | dir: layout.projectDirectory,
16 | includes: ['*.cjs', '*.ts', '*.js', '*.json', '*.yaml']))
17 | outputs.dir(layout.buildDirectory.dir('dist'))
18 | }
19 |
20 | tasks.register('pnpmCheck', PnpmTask) {
21 | group = 'verification'
22 | description = 'Run unit tests for the UI project using pnpm'
23 | args = ['test:unit']
24 | dependsOn tasks.named('pnpmInstall')
25 | }
26 |
27 | tasks.named('check') {
28 | dependsOn tasks.named('pnpmCheck')
29 | }
30 |
31 | tasks.named('assemble') {
32 | dependsOn tasks.named('pnpmBuild')
33 | }
34 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/service/PostService.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.service;
2 |
3 | import cn.lyn4ever.export2md.halo.ContentWrapper;
4 | import cn.lyn4ever.export2md.halo.PostRequest;
5 | import org.springframework.lang.NonNull;
6 | import reactor.core.publisher.Mono;
7 | import run.halo.app.core.extension.content.Post;
8 | import java.io.File;
9 |
10 | public interface PostService {
11 | Mono draftPost(PostRequest postRequest);
12 |
13 | Mono updatePost(PostRequest postRequest);
14 |
15 | Mono updateBy(@NonNull Post post);
16 |
17 | Mono getHeadContent(String postName);
18 |
19 | Mono getHeadContent(Post post);
20 |
21 | Mono getReleaseContent(String postName);
22 |
23 | Mono getReleaseContent(Post post);
24 |
25 | Mono publish(Post post);
26 |
27 | Mono unpublish(Post post);
28 |
29 | Mono getByUsername(String postName, String username);
30 |
31 | PostRequest formatPost(File file);
32 | }
33 |
--------------------------------------------------------------------------------
/ui/eslint.config.ts:
--------------------------------------------------------------------------------
1 | import { globalIgnores } from 'eslint/config'
2 | import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
3 | import pluginVue from 'eslint-plugin-vue'
4 | import pluginVitest from '@vitest/eslint-plugin'
5 | import pluginOxlint from 'eslint-plugin-oxlint'
6 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
7 |
8 | // To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
9 | // import { configureVueProject } from '@vue/eslint-config-typescript'
10 | // configureVueProject({ scriptLangs: ['ts', 'tsx'] })
11 | // More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
12 |
13 | export default defineConfigWithVueTs(
14 | {
15 | name: 'app/files-to-lint',
16 | files: ['**/*.{ts,mts,tsx,vue}'],
17 | },
18 |
19 | globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
20 |
21 | pluginVue.configs['flat/essential'],
22 | vueTsConfigs.recommended,
23 |
24 | {
25 | ...pluginVitest.configs.recommended,
26 | files: ['src/**/__tests__/*'],
27 | },
28 | ...pluginOxlint.configs['flat/recommended'],
29 | skipFormatting,
30 | )
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Maven
2 | target/
3 | logs/
4 | !.mvn/wrapper/maven-wrapper.jar
5 |
6 | ### Gradle
7 | .gradle
8 | /build/
9 | /out/
10 | !gradle/wrapper/gradle-wrapper.jar
11 | bin/
12 |
13 | ### Node
14 | node_modules/
15 |
16 | ### STS
17 | .apt_generated
18 | .classpath
19 | .factorypath
20 | .project
21 | .settings
22 | .springBeans
23 | .sts4-cache
24 |
25 | ### IntelliJ IDEA
26 | .idea
27 | *.iws
28 | *.iml
29 | *.ipr
30 | log/
31 |
32 | ### NetBeans
33 | nbproject/private/
34 | build/
35 | nbbuild/
36 | dist/
37 | nbdist/
38 | .nb-gradle/
39 |
40 | ### Mac
41 | .DS_Store
42 | */.DS_Store
43 |
44 | ### VSCode
45 | *.project
46 | *.factorypath
47 | .vscode
48 | !.vscode/extensions.json
49 |
50 | ### Compiled class file
51 | *.class
52 |
53 | ### Log file
54 | logs
55 | *.log
56 | npm-debug.log*
57 | yarn-debug.log*
58 | yarn-error.log*
59 | pnpm-debug.log*
60 | lerna-debug.log*
61 |
62 | ### BlueJ files
63 | *.ctxt
64 |
65 | ### Package Files
66 | *.war
67 | *.nar
68 | *.ear
69 | *.zip
70 | *.tar.gz
71 | *.rar
72 |
73 | ### Local file
74 | application-local.yml
75 | application-local.yaml
76 | application-local.properties
77 |
78 | /ui/build/
79 | /workplace/
80 | /src/main/resources/console/
81 |
--------------------------------------------------------------------------------
/src/main/resources/extensions/roleTemplate.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1alpha1
2 | kind: Role
3 | metadata:
4 | name: role-template-export2doc-view
5 | labels:
6 | halo.run/role-template: "true"
7 | annotations:
8 | rbac.authorization.halo.run/module: "文章导入导出"
9 | rbac.authorization.halo.run/display-name: "文章导入导出查看"
10 | rbac.authorization.halo.run/ui-permissions: |
11 | ["plugin:export2doc:view"]
12 | rules:
13 | - apiGroups: [ "cn.lyn4ever.export2doc" ]
14 | resources: [ "exportLogs"]
15 | verbs: [ "list" ]
16 | ---
17 | apiVersion: v1alpha1
18 | kind: Role
19 | metadata:
20 | name: role-template-export2doc-manage
21 | labels:
22 | halo.run/role-template: "true"
23 | annotations:
24 | rbac.authorization.halo.run/module: "文章导入导出"
25 | rbac.authorization.halo.run/display-name: "文章导入导出管理"
26 | rbac.authorization.halo.run/ui-permissions: |
27 | ["plugin:export2doc:manage"]
28 | rbac.authorization.halo.run/dependencies: |
29 | ["role-template-export2doc-view"]
30 | rules:
31 | - apiGroups: [ "api.plugin.halo.run" ]
32 | resources: [ "plugins/doImport/**","plugins/doExport/**"]
33 | resourceNames: [ "export2doc" ]
34 | verbs: [ "create", "get"]
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/schema/ImportLogSchema.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.schema;
2 |
3 | import io.swagger.v3.oas.annotations.media.Schema;
4 | import lombok.Data;
5 | import lombok.EqualsAndHashCode;
6 | import lombok.ToString;
7 | import run.halo.app.extension.AbstractExtension;
8 | import run.halo.app.extension.GVK;
9 |
10 | import java.time.LocalDateTime;
11 | import java.util.Date;
12 |
13 | /**
14 | * @author Lyn4ever29
15 | * @url ...
16 | * @date 2023/11/1
17 | */
18 | @Data
19 | @ToString
20 | @EqualsAndHashCode(callSuper = true)
21 | @GVK(kind = "ImportLog", group = "cn.lyn4ever.export2doc",
22 | version = "v1alpha1", singular = "importLog", plural = "importLogs")
23 | public class ImportLogSchema extends AbstractExtension {
24 | @Schema
25 | private String name = LocalDateTime.now().toString();
26 | @Schema
27 | private Date createTime = new Date();
28 | @Schema
29 | private Long costSeconds;
30 | @Schema
31 | private String title;
32 | /**
33 | * 文章name
34 | */
35 | @Schema
36 | private String postName;
37 |
38 | /**
39 | * 状态
40 | * a-失败
41 | * b-导出中
42 | * c-成功
43 | */
44 | @Schema
45 | private String status;
46 | }
47 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/util/FileUtil.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.util;
2 |
3 | import cn.hutool.core.io.file.FileNameUtil;
4 |
5 | import java.nio.file.Path;
6 | import java.nio.file.Paths;
7 |
8 | /**
9 | * @author Lyn4ever29
10 | * @url https://jhacker.cn
11 | * @date 2023/11/11
12 | */
13 | public class FileUtil {
14 | private static final String ROOT_PATH="/.halo2/plugins/export2doc_files";
15 | public enum DirPath{
16 | EXPORT,
17 | IMPORT;
18 | }
19 |
20 |
21 | /**
22 | * 判断文件名是否合法
23 | *
24 | * @param name
25 | * @return
26 | */
27 | public static boolean isCorrectName(String name) {
28 | if (name == null || name.length() > 255) {
29 | return false;
30 | }
31 | //不能包含 /\:*?"<>|
32 | return !FileNameUtil.containsInvalid(name);
33 | }
34 |
35 |
36 | /**
37 | * 获取导出文件的路径
38 | *
39 | * @return
40 | */
41 | public static Path getDocFile(DirPath dirPath) {
42 | String userHome = System.getProperty("user.home");
43 | Path path = Paths.get(userHome, ROOT_PATH).resolve(dirPath.name().toLowerCase());
44 | if (!path.toFile().exists()) {
45 | path.toFile().mkdirs();
46 | }
47 | return path;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/ui/src/views/ImportArtical.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
28 |
29 |
30 |
34 | 开始导入
35 |
36 |
37 |
38 |
39 |
40 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/HaloPluginExport2docPlugin.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md;
2 |
3 | import cn.lyn4ever.export2md.schema.ExportLogSchema;
4 | import cn.lyn4ever.export2md.schema.ImportLogSchema;
5 | import org.pf4j.PluginManager;
6 | import run.halo.app.plugin.PluginContext;
7 | import org.springframework.stereotype.Component;
8 | import run.halo.app.extension.SchemeManager;
9 | import run.halo.app.plugin.BasePlugin;
10 |
11 | /**
12 | * @author Lyn4ever29
13 | * @url https://jhacker.cn
14 | * @date 2023/11/1
15 | */
16 | @Component
17 | public class HaloPluginExport2docPlugin extends BasePlugin {
18 | private final SchemeManager schemeManager;
19 |
20 | public HaloPluginExport2docPlugin(PluginContext context, SchemeManager schemeManager) {
21 | super(context);
22 | this.schemeManager = schemeManager;
23 | }
24 |
25 | /**
26 | * This method is called by the application when the plugin is started.
27 | * See {@link PluginManager#startPlugin(String)}.
28 | */
29 | @Override
30 | public void start() {
31 | // 插件启动时注册自定义模型
32 | schemeManager.register(ExportLogSchema.class);
33 | schemeManager.register(ImportLogSchema.class);
34 | }
35 |
36 | @Override
37 | public void stop() {
38 | // 插件停用时取消注册自定义模型
39 | schemeManager.unregister(schemeManager.get(ExportLogSchema.class));
40 | schemeManager.unregister(schemeManager.get(ImportLogSchema.class));
41 |
42 | }
43 |
44 | /**
45 | * This method is called by the application when the plugin is deleted.
46 | * See {@link PluginManager#deletePlugin(String)}.
47 | */
48 | @Override
49 | public void delete() {
50 | super.delete();
51 | }
52 | }
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/schema/ExportLogSchema.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.schema;
2 |
3 | import io.swagger.v3.oas.annotations.media.Schema;
4 | import java.time.LocalDateTime;
5 | import java.util.Date;
6 | import lombok.Data;
7 | import lombok.EqualsAndHashCode;
8 | import lombok.ToString;
9 | import run.halo.app.extension.AbstractExtension;
10 | import run.halo.app.extension.GVK;
11 |
12 | /**
13 | * @author Lyn4ever29
14 | * @url ...
15 | * @date 2023/11/1
16 | */
17 | @Data
18 | @ToString
19 | @EqualsAndHashCode(callSuper = true)
20 | @GVK(kind = "ExportLog", group = "cn.lyn4ever.export2doc",
21 | version = "v1alpha1", singular = "exportLog", plural = "exportLogs")
22 | public class ExportLogSchema extends AbstractExtension {
23 | @Schema
24 | private String name = LocalDateTime.now().toString();
25 | @Schema
26 | private Date createTime = new Date();
27 | @Schema
28 | private Long costSeconds;
29 | @Schema
30 | private String tag;
31 | @Schema
32 | private String category;
33 |
34 | @Schema
35 | private String beginTime;
36 |
37 | @Schema
38 | private String endTime;
39 |
40 | @Schema(defaultValue = "true")
41 | private Boolean remainMetaData;
42 |
43 | @Schema(defaultValue = "true")
44 | private Boolean remainCategory;
45 |
46 | /**
47 | * 状态
48 | * a-失败
49 | * b-导出中
50 | * c-成功
51 | */
52 | @Schema
53 | private String status;
54 |
55 | // @Schema(requiredMode = REQUIRED)
56 | // private ExportConfigSchema.TodoSpec spec;
57 | //
58 | // @Data
59 | // public static class TodoSpec {
60 | //
61 | // @Schema(requiredMode = REQUIRED, minLength = 1)
62 | // private String title;
63 | //
64 | // @Schema(defaultValue = "false")
65 | // private Boolean done;
66 | // }
67 | }
68 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/halo/ContentWrapper.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.halo;
2 |
3 | import cn.lyn4ever.export2md.util.PatchUtils;
4 | import lombok.Builder;
5 | import lombok.Data;
6 | import org.apache.commons.lang3.StringUtils;
7 | import org.springframework.util.Assert;
8 | import run.halo.app.core.extension.content.Snapshot;
9 |
10 | /**
11 | * @author guqing
12 | * @since 2.0.0
13 | */
14 | @Data
15 | @Builder
16 | public class ContentWrapper {
17 | private String snapshotName;
18 | private String raw;
19 | private String content;
20 | private String rawType;
21 |
22 | public static ContentWrapper patchSnapshot(Snapshot patchSnapshot, Snapshot baseSnapshot) {
23 | Assert.notNull(baseSnapshot, "The baseSnapshot must not be null.");
24 | String baseSnapshotName = baseSnapshot.getMetadata().getName();
25 | if (StringUtils.equals(patchSnapshot.getMetadata().getName(), baseSnapshotName)) {
26 | return ContentWrapper.builder()
27 | .snapshotName(patchSnapshot.getMetadata().getName())
28 | .raw(patchSnapshot.getSpec().getRawPatch())
29 | .content(patchSnapshot.getSpec().getContentPatch())
30 | .rawType(patchSnapshot.getSpec().getRawType())
31 | .build();
32 | }
33 | String patchedContent = PatchUtils.applyPatch(baseSnapshot.getSpec().getContentPatch(),
34 | patchSnapshot.getSpec().getContentPatch());
35 | String patchedRaw = PatchUtils.applyPatch(baseSnapshot.getSpec().getRawPatch(),
36 | patchSnapshot.getSpec().getRawPatch());
37 | return ContentWrapper.builder()
38 | .snapshotName(patchSnapshot.getMetadata().getName())
39 | .raw(patchedRaw)
40 | .content(patchedContent)
41 | .rawType(patchSnapshot.getSpec().getRawType())
42 | .build();
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "scripts": {
4 | "build": "run-p type-check \"build-only {@}\" --",
5 | "build-only": "vite build",
6 | "dev": "vite build --watch --mode=development",
7 | "lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
8 | "lint:eslint": "eslint . --fix",
9 | "lint": "run-s lint:*",
10 | "prettier": "prettier --write src/",
11 | "test:unit": "vitest --passWithNoTests",
12 | "type-check": "vue-tsc --build"
13 | },
14 | "prettier": {
15 | "printWidth": 100,
16 | "semi": false,
17 | "singleQuote": true,
18 | "tabWidth": 2
19 | },
20 | "dependencies": {
21 | "@halo-dev/api-client": "^2.21.1",
22 | "@halo-dev/components": "^2.21.0",
23 | "@halo-dev/console-shared": "^2.21.0",
24 | "@tanstack/vue-query": "^4.29.1",
25 | "@vueuse/router": "^10.7.1",
26 | "axios": "^1.7.2",
27 | "canvas-confetti": "^1.9.3",
28 | "turndown": "^7.2.1",
29 | "vue": "^3.5.17"
30 | },
31 | "devDependencies": {
32 | "@halo-dev/ui-plugin-bundler-kit": "^2.21.2",
33 | "@iconify/json": "^2.2.350",
34 | "@tsconfig/node20": "^20.1.6",
35 | "@types/canvas-confetti": "^1.9.0",
36 | "@types/jsdom": "^21.1.7",
37 | "@types/node": "^20.19.1",
38 | "@vitest/eslint-plugin": "^1.2.7",
39 | "@vue/eslint-config-prettier": "^10.2.0",
40 | "@vue/eslint-config-typescript": "^14.5.1",
41 | "@vue/test-utils": "^2.4.6",
42 | "@vue/tsconfig": "^0.7.0",
43 | "eslint": "^9.29.0",
44 | "eslint-plugin-oxlint": "^0.16.12",
45 | "eslint-plugin-vue": "~10.0.1",
46 | "jsdom": "^26.1.0",
47 | "npm-run-all2": "^7.0.2",
48 | "oxlint": "^0.16.12",
49 | "prettier": "^3.6.0",
50 | "sass": "^1.89.2",
51 | "typescript": "~5.8.3",
52 | "unplugin-icons": "^22.1.0",
53 | "vite": "^5.3.2",
54 | "vitest": "^3.2.4",
55 | "vue-tsc": "^2.2.10"
56 | },
57 | "packageManager": "pnpm@9.15.9"
58 | }
--------------------------------------------------------------------------------
/ui/src/index.ts:
--------------------------------------------------------------------------------
1 | import {definePlugin, type OperationItem} from "@halo-dev/console-shared";
2 | import HomeView from "./views/HomeView.vue";
3 | import {IconArrowUpDownLine, VDropdownItem} from "@halo-dev/components";
4 | import {markRaw, type Ref} from "vue";
5 | import type {ListedPost} from "@halo-dev/api-client";
6 |
7 | // @ts-ignore
8 | export default definePlugin({
9 | components: {},
10 | routes: [
11 | {
12 | parentName: "ToolsRoot",
13 | route: {
14 | path: "export2doc",
15 | name: "export2doc",
16 | component: HomeView,
17 | meta: {
18 | title: "文章导入导出",
19 | searchable: true,
20 | description: "导出文章为 Markdown、HTML 文件,同时支持导入 Markdown 文件",
21 | permissions: ["plugin:export2doc:view"],
22 | menu: {
23 | name: "文章导入导出",
24 | group: "tool",
25 | icon: markRaw(IconArrowUpDownLine),
26 | priority: 0,
27 | },
28 | },
29 | },
30 | },
31 | ],
32 | extensionPoints: {
33 | "post:list-item:operation:create": ():OperationItem[] => {
34 | return [
35 | {
36 | priority: 21,
37 | component: markRaw(VDropdownItem),
38 | label: "导出",
39 | permissions: [],
40 | action: (item: ListedPost | undefined):void => {
41 | if (item != undefined) {
42 | window.location.href = '/apis/api.plugin.halo.run/v1alpha1/plugins/export2doc/doExport/export_one/' + item.post.metadata.name;
43 | }
44 | },
45 | hidden: false,
46 | children: [],
47 | },
48 | ];
49 | }
50 | }
51 | });
52 |
--------------------------------------------------------------------------------
/ui/src/components/entity/EntityDropdownItems.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
25 |
26 |
31 |
36 | {{ dropdownItem.label }}
37 |
38 |
39 |
40 |
47 | {{ childItem.label }}
48 |
49 |
50 |
51 |
52 |
60 | {{ dropdownItem.label }}
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/halo/ContentRequest.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.halo;
2 |
3 | import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
4 |
5 | import cn.lyn4ever.export2md.util.PatchUtils;
6 | import io.swagger.v3.oas.annotations.media.Schema;
7 | import java.util.HashMap;
8 | import org.apache.commons.lang3.StringUtils;
9 | import run.halo.app.core.extension.content.Snapshot;
10 | import run.halo.app.extension.Metadata;
11 | import run.halo.app.extension.Ref;
12 |
13 | /**
14 | * @copy
15 | * @author guqing
16 | * @since 2.0.0
17 | */
18 | public record ContentRequest(@Schema(requiredMode = REQUIRED) Ref subjectRef,
19 | String headSnapshotName,
20 | @Schema(requiredMode = REQUIRED) String raw,
21 | @Schema(requiredMode = REQUIRED) String content,
22 | @Schema(requiredMode = REQUIRED) String rawType) {
23 |
24 | public Snapshot toSnapshot() {
25 | final Snapshot snapshot = new Snapshot();
26 |
27 | Metadata metadata = new Metadata();
28 | metadata.setAnnotations(new HashMap<>());
29 | snapshot.setMetadata(metadata);
30 |
31 | Snapshot.SnapShotSpec snapShotSpec = new Snapshot.SnapShotSpec();
32 | snapShotSpec.setSubjectRef(subjectRef);
33 |
34 | snapShotSpec.setRawType(rawType);
35 | snapShotSpec.setRawPatch(StringUtils.defaultString(raw()));
36 | snapShotSpec.setContentPatch(StringUtils.defaultString(content()));
37 |
38 | snapshot.setSpec(snapShotSpec);
39 | return snapshot;
40 | }
41 |
42 | public String rawPatchFrom(String originalRaw) {
43 | // originalRaw content from v1
44 | return PatchUtils.diffToJsonPatch(originalRaw, this.raw);
45 | }
46 |
47 | public String contentPatchFrom(String originalContent) {
48 | // originalContent from v1
49 | return PatchUtils.diffToJsonPatch(originalContent, this.content);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Halo 2.0 插件——文章导入导出插件
2 |
3 | ## 功能说明
4 | - 快速导出文章为markdown或者html文件
5 | - 导出后打包下载
6 | - 提供文章导入功能,支持导入markdown文件
7 |
8 | ## 预览
9 | - 文章列表导出功能
10 |
11 | 
12 |
13 | - 导出列表
14 |
15 | 
16 |
17 |
18 | ## 安装
19 | - 下载[Release](https://github.com/Lyn4ever29/halo-plugin-export-md/releases)版本,直接安装即可
20 | - 在Halo应用市场,支持一键安装[文章导入导出](https://www.halo.run/store/apps/app-vWbpZ)
21 |
22 |
23 | ## 更新日志
24 | - v1.2.5
25 | - 适配Halo2.21版本,感谢[Zhangfen21082](https://github.com/Zhangfen21082)网友的修复
26 | - 修改markdown4j库为[flexmark-java](https://github.com/vsch/flexmark-java)
27 | - 修改插件编写方式为最新版本,将console目录同步修改为ui目录
28 | - v1.2.4
29 | - 将导入导出页面的入口放置在工具菜单项下作为子菜单项(2.12版本的特性)。
30 | - 需要注意,此改动需要 2.12 的支持
31 | - v1.2.3 优化导入导出的 UI。(来自[ruibaby](https://github.com/ruibaby)的PR)
32 | - 支持显示空状态,引导用户导出文章。
33 | - 支持导出/删除记录之后自动更新列表数据,不再需要手动刷新页面。
34 | - 使用 Halo 在全局注册的 Uppy 组件,减少构建产物体积。(已同步修改 plugin.yaml 的 requires 为 2.11)
35 | - 使用 Halo 官方新提供的 @halo-dev/ui-plugin-bundler-kit 用于构建插件。
36 | - 移除部分无用样式和注释。
37 | - v1.2.2
38 | - 导出文章时,添加封面图字段
39 | - 添加权限控制,感谢[chengzhongxue](https://github.com/chengzhongxue)的PR
40 | - v1.2.1
41 | - 修复halo2.11版本升级后,导入功能失效的问题(由于Halo修改了文章发布机制,导致导入失效,已更新至最新代码)。
42 | - 如果是halo2.10版本,请使用**v1.1.4**
43 | - v1.2.0
44 | - 更改导出文章压缩包目录为halo2工作目录
45 | - 修改有关草稿箱的说明,导入后的文章处于待发布状态,需要用户自行发布
46 | - v1.1.4 在文章列表添加导出快捷方式,可以导出单文件。
47 | - v1.1.0
48 | - 支持导入Markdown文件
49 | - 导出的Mardkdown文件支持属性,属性示例如下:
50 |
51 | ```yaml
52 | ---
53 | title: 试试Nacos作注册中心和配置中心,爱不释手的感觉
54 | date: 2023-04-22 20:28:05
55 | auther: lyn4ever
56 | excerpt: 在使用SpringCloud做分布式微服务架构时,注册中心是必不可少的一个组件。
57 | permalink: /2022/166359134426
58 | categories:
59 | - java
60 | - springcloud
61 | tags:
62 | - springcloud
63 | - nacos
64 | - 注册中心
65 | ---
66 | ```
67 | - v1.0.0
68 | - 简单导出功能
69 |
70 |
--------------------------------------------------------------------------------
/ui/src/views/HomeView.vue:
--------------------------------------------------------------------------------
1 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
55 |
56 |
57 |
58 | 导入文章
59 |
60 |
61 |
62 |
63 |
64 | 导出文章
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | @rem This is normally unused
30 | set APP_BASE_NAME=%~n0
31 | set APP_HOME=%DIRNAME%
32 |
33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35 |
36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38 |
39 | @rem Find java.exe
40 | if defined JAVA_HOME goto findJavaFromJavaHome
41 |
42 | set JAVA_EXE=java.exe
43 | %JAVA_EXE% -version >NUL 2>&1
44 | if %ERRORLEVEL% equ 0 goto execute
45 |
46 | echo.
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
48 | echo.
49 | echo Please set the JAVA_HOME variable in your environment to match the
50 | echo location of your Java installation.
51 |
52 | goto fail
53 |
54 | :findJavaFromJavaHome
55 | set JAVA_HOME=%JAVA_HOME:"=%
56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57 |
58 | if exist "%JAVA_EXE%" goto execute
59 |
60 | echo.
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
62 | echo.
63 | echo Please set the JAVA_HOME variable in your environment to match the
64 | echo location of your Java installation.
65 |
66 | goto fail
67 |
68 | :execute
69 | @rem Setup the command line
70 |
71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72 |
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if %ERRORLEVEL% equ 0 goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | set EXIT_CODE=%ERRORLEVEL%
85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
87 | exit /b %EXIT_CODE%
88 |
89 | :mainEnd
90 | if "%OS%"=="Windows_NT" endlocal
91 |
92 | :omega
93 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/util/PatchUtils.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.util;
2 |
3 | import com.fasterxml.jackson.core.type.TypeReference;
4 | import com.github.difflib.DiffUtils;
5 | import com.github.difflib.patch.*;
6 | import com.google.common.base.Splitter;
7 | import lombok.Data;
8 | import org.apache.commons.lang3.StringUtils;
9 | import run.halo.app.infra.utils.JsonUtils;
10 |
11 | import java.util.Collections;
12 | import java.util.List;
13 |
14 | /**
15 | * @author guqing
16 | * @since 2.0.0
17 | */
18 | public class PatchUtils {
19 | private static final String DELIMITER = "\n";
20 | private static final Splitter lineSplitter = Splitter.on(DELIMITER);
21 |
22 | public static Patch create(String deltasJson) {
23 | List deltas = JsonUtils.jsonToObject(deltasJson, new TypeReference<>() {
24 | });
25 | Patch patch = new Patch<>();
26 | for (Delta delta : deltas) {
27 | StringChunk sourceChunk = delta.getSource();
28 | StringChunk targetChunk = delta.getTarget();
29 | Chunk orgChunk = new Chunk<>(sourceChunk.getPosition(), sourceChunk.getLines(),
30 | sourceChunk.getChangePosition());
31 | Chunk revChunk = new Chunk<>(targetChunk.getPosition(), targetChunk.getLines(),
32 | targetChunk.getChangePosition());
33 | switch (delta.getType()) {
34 | case DELETE -> patch.addDelta(new DeleteDelta<>(orgChunk, revChunk));
35 | case INSERT -> patch.addDelta(new InsertDelta<>(orgChunk, revChunk));
36 | case CHANGE -> patch.addDelta(new ChangeDelta<>(orgChunk, revChunk));
37 | default -> throw new IllegalArgumentException("Unsupported delta type.");
38 | }
39 | }
40 | return patch;
41 | }
42 |
43 | public static String patchToJson(Patch patch) {
44 | List> deltas = patch.getDeltas();
45 | return JsonUtils.objectToJson(deltas);
46 | }
47 |
48 | public static String applyPatch(String original, String patchJson) {
49 | Patch patch = PatchUtils.create(patchJson);
50 | try {
51 | return String.join(DELIMITER, patch.applyTo(breakLine(original)));
52 | } catch (PatchFailedException e) {
53 | throw new RuntimeException(e);
54 | }
55 | }
56 |
57 | public static String diffToJsonPatch(String original, String revised) {
58 | Patch patch = DiffUtils.diff(breakLine(original), breakLine(revised));
59 | return PatchUtils.patchToJson(patch);
60 | }
61 |
62 | public static List breakLine(String content) {
63 | if (StringUtils.isBlank(content)) {
64 | return Collections.emptyList();
65 | }
66 | return lineSplitter.splitToList(content);
67 | }
68 |
69 | @Data
70 | public static class Delta {
71 | private StringChunk source;
72 | private StringChunk target;
73 | private DeltaType type;
74 | }
75 |
76 | @Data
77 | public static class StringChunk {
78 | private int position;
79 | private List lines;
80 | private List changePosition;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/ui/src/views/ExportArtical.vue:
--------------------------------------------------------------------------------
1 |
90 |
91 |
92 |
93 |
94 |
95 |
99 |
100 |
101 | 刷新
102 | 导出
103 |
104 |
105 |
106 |
107 |
108 |
109 |
114 |
115 |
116 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/rest/ImportController.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.rest;
2 |
3 | import cn.lyn4ever.export2md.service.ImportService;
4 | import cn.lyn4ever.export2md.util.FileUtil;
5 | import java.io.File;
6 | import lombok.extern.slf4j.Slf4j;
7 | import org.springframework.beans.factory.annotation.Autowired;
8 | import org.springframework.http.MediaType;
9 | import org.springframework.http.codec.multipart.FilePart;
10 | import org.springframework.web.bind.annotation.CookieValue;
11 | import org.springframework.web.bind.annotation.PostMapping;
12 | import org.springframework.web.bind.annotation.RequestMapping;
13 | import org.springframework.web.bind.annotation.RequestPart;
14 | import org.springframework.web.bind.annotation.RestController;
15 | import reactor.core.publisher.Flux;
16 | import reactor.core.publisher.Mono;
17 | import reactor.core.scheduler.Schedulers;
18 | import run.halo.app.core.extension.content.Post;
19 | import run.halo.app.extension.ReactiveExtensionClient;
20 | import run.halo.app.plugin.ApiVersion;
21 |
22 | /**
23 | * 自定义导入接口
24 | *
25 | * @author Lyn4ever29
26 | * @url https://jhacker.cn
27 | * @date 2023/11/1
28 | */
29 | @ApiVersion("v1alpha1")
30 | @RequestMapping("/doImport")
31 | @RestController
32 | @Slf4j
33 | public class ImportController {
34 | // /apis/api.plugin.halo.run/v1alpha1/plugins/export2doc/doImport/**
35 |
36 |
37 | @Autowired
38 | private ImportService importService;
39 |
40 | @Autowired
41 | private ReactiveExtensionClient reactiveClient;
42 |
43 |
44 | @PostMapping(value = "/import1", consumes = {
45 | MediaType.TEXT_MARKDOWN_VALUE,
46 | MediaType.TEXT_EVENT_STREAM_VALUE,
47 | "text/*",
48 | MediaType.APPLICATION_PROBLEM_JSON_VALUE,
49 | MediaType.MULTIPART_FORM_DATA_VALUE})
50 | @Deprecated
51 | public Flux importPost1(@CookieValue("XSRF-TOKEN") String token,
52 | @CookieValue("SESSION") String session,
53 | @RequestPart("file") final Flux filePartFlux) {
54 |
55 | //保存文件
56 | //保存文件
57 |
58 |
59 | return filePartFlux.flatMap(filePart -> {
60 | File file =
61 | new File(FileUtil.getDocFile(FileUtil.DirPath.IMPORT).toFile().getAbsolutePath()
62 | + "/" + filePart.filename());
63 | return filePart.transferTo(file)
64 | .flatMap(f -> importService.runTask(file));
65 | });
66 |
67 | }
68 |
69 | @PostMapping(value = "/import", consumes = {
70 | MediaType.TEXT_MARKDOWN_VALUE,
71 | MediaType.TEXT_EVENT_STREAM_VALUE,
72 | "text/*",
73 | MediaType.APPLICATION_PROBLEM_JSON_VALUE,
74 | MediaType.MULTIPART_FORM_DATA_VALUE})
75 | public Flux importPost(@RequestPart("file") final Flux filePartFlux) {
76 |
77 | //保存文件
78 | //保存文件
79 |
80 | return filePartFlux.publishOn(Schedulers.boundedElastic()).flatMap(filePart -> {
81 | File file =
82 | new File(FileUtil.getDocFile(FileUtil.DirPath.IMPORT).toFile().getAbsolutePath()
83 | + "/" + filePart.filename());
84 | // importService.importPost(file,token, session);
85 |
86 | filePart.transferTo(file).block();
87 | return importService.runTask(file);
88 | });
89 |
90 | }
91 |
92 |
93 | // return flux.flatMap(map->{
94 | // List fileParts = map.get("file");
95 | // fileParts.forEach(it->{
96 | // new Thread(() -> {
97 | // importService.runTask(it);
98 | // }).start();
99 | // });
100 | // return null;
101 | // }).then(Mono.just("OK"));
102 |
103 |
104 | }
105 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/service/impl/ImportServiceV2.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.service.impl;
2 |
3 | import cn.hutool.core.lang.UUID;
4 | import cn.lyn4ever.export2md.halo.PostRequest;
5 | import cn.lyn4ever.export2md.schema.ImportLogSchema;
6 | import cn.lyn4ever.export2md.service.ImportService;
7 | import cn.lyn4ever.export2md.service.PostService;
8 | import java.io.File;
9 | import java.security.Principal;
10 | import java.util.Date;
11 | import lombok.RequiredArgsConstructor;
12 | import org.springframework.security.core.context.ReactiveSecurityContextHolder;
13 | import org.springframework.security.core.context.SecurityContext;
14 | import org.springframework.stereotype.Service;
15 | import reactor.core.publisher.Mono;
16 | import run.halo.app.core.extension.content.Post;
17 | import run.halo.app.extension.Metadata;
18 | import run.halo.app.extension.ReactiveExtensionClient;
19 |
20 | /**
21 | * @author Lyn4ever29
22 | * @url https://jhacker.cn
23 | * @date 2023/12/5
24 | */
25 | @Service
26 | @RequiredArgsConstructor
27 | public class ImportServiceV2 implements ImportService {
28 | private final PostService postService;
29 | private final ReactiveExtensionClient client;
30 |
31 | /**
32 | * 运行导出任务
33 | *
34 | * @param file
35 | * @return
36 | */
37 | @Override
38 | public Mono runTask(File file) {
39 | long old = System.currentTimeMillis();
40 | return ReactiveSecurityContextHolder.getContext()
41 | .map(SecurityContext::getAuthentication)
42 | .map(Principal::getName)
43 | .flatMap(owner -> {
44 | PostRequest postRequest = postService.formatPost(file);
45 | Post post = postRequest.getPost();
46 | // post.getStatus().setContributors(List.of(owner));
47 |
48 | // https://jhacker.cn/apis/api.console.halo.run/v1alpha1/posts
49 | return postService.draftPost(postRequest).doOnSuccess(re->{
50 |
51 | ImportLogSchema schema = new ImportLogSchema();
52 | schema.setCreateTime(new Date());
53 | schema.setPostName(post.getMetadata().getName());
54 | schema.setTitle(post.getSpec().getTitle());
55 | schema.setCostSeconds(System.currentTimeMillis() - old);
56 | schema.setMetadata(new Metadata());
57 | schema.getMetadata().setName(UUID.fastUUID().toString(false));
58 |
59 | client.create(schema);
60 | });
61 |
62 | });
63 |
64 |
65 |
66 | /*
67 | {
68 | "post":{
69 | "spec":{
70 | "title":"111",
71 | "slug":"111",
72 | "template":"",
73 | "cover":"",
74 | "deleted":false,
75 | "publish":false,
76 | "pinned":false,
77 | "allowComment":true,
78 | "visible":"PUBLIC",
79 | "priority":0,
80 | "excerpt":{
81 | "autoGenerate":true,
82 | "raw":""
83 | },
84 | "categories":[
85 | ],
86 | "tags":[
87 | ],
88 | "htmlMetas":[
89 | ]
90 | },
91 | "apiVersion":"content.halo.run/v1alpha1",
92 | "kind":"Post",
93 | "metadata":{
94 | "name":"3ad608e7-a0cb-482b-9eff-fc5883a3575b",
95 | "annotations":{
96 | "content.halo.run/preferred-editor":"bytemd"
97 | }
98 | }
99 | },
100 | "content":{
101 | "raw":" ## title\n name\n - 都\n - [x] 哈哈\n - [ ] haode ",
102 | "content":"title
\nname
\n",
106 | "rawType":"markdown"
107 | }
108 | }
109 | */
110 |
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/ui/src/views/ExportListItem.vue:
--------------------------------------------------------------------------------
1 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | {{ exportLog.name }}
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
128 |
129 |
130 |
131 |
132 |
133 | {{ exportLog.costSeconds }}ms
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | {{ formatDate(exportLog.createTime) }}
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
--------------------------------------------------------------------------------
/ui/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/rest/ExportController.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.rest;
2 |
3 | import cn.lyn4ever.export2md.schema.ExportLogSchema;
4 | import cn.lyn4ever.export2md.service.impl.ExportServiceImpl;
5 | import cn.lyn4ever.export2md.util.FileUtil;
6 | import lombok.extern.slf4j.Slf4j;
7 | import org.apache.commons.lang3.StringUtils;
8 | import org.springframework.beans.factory.annotation.Autowired;
9 | import org.springframework.http.HttpHeaders;
10 | import org.springframework.http.MediaType;
11 | import org.springframework.http.ZeroCopyHttpOutputMessage;
12 | import org.springframework.http.server.reactive.ServerHttpResponse;
13 | import org.springframework.web.bind.annotation.*;
14 | import reactor.core.publisher.Mono;
15 | import reactor.core.scheduler.Schedulers;
16 | import run.halo.app.extension.ExtensionClient;
17 | import run.halo.app.extension.Metadata;
18 | import run.halo.app.extension.ReactiveExtensionClient;
19 | import run.halo.app.plugin.ApiVersion;
20 |
21 | import java.io.File;
22 | import java.io.UnsupportedEncodingException;
23 | import java.net.URLEncoder;
24 | import java.nio.charset.StandardCharsets;
25 | import java.nio.file.Path;
26 | import java.util.Arrays;
27 | import java.util.Date;
28 |
29 | /**
30 | * 自定义导出接口
31 | *
32 | * @author Lyn4ever29
33 | * @url https://jhacker.cn
34 | * @date 2023/11/1
35 | */
36 | @ApiVersion("v1alpha1")
37 | @RequestMapping("/doExport")
38 | @RestController
39 | @Slf4j
40 | public class ExportController {
41 | // /apis/plugin.api.halo.run/v1alpha1/plugins/export-anything/doExport/**
42 |
43 | private final String EXPORT_ONE_DIR = "markdown_post";
44 |
45 | @Autowired
46 | private ExportServiceImpl exportService;
47 |
48 | @Autowired
49 | private ReactiveExtensionClient reactiveClient;
50 | @Autowired
51 | private ExtensionClient commonClient;
52 |
53 |
54 | @PostMapping("/export")
55 | public Mono export(@RequestBody final ExportLogSchema exportLogSchema) {
56 | exportLogSchema.setCreateTime(new Date());
57 | exportLogSchema.setStatus("b");
58 | //设置元数据才能保存
59 | exportLogSchema.setMetadata(new Metadata());
60 | exportLogSchema.getMetadata().setName(exportLogSchema.getName());
61 |
62 | return reactiveClient.create(exportLogSchema).doOnSuccess(
63 | exportLogSchema1 -> {
64 | Thread t = new Thread(() -> {
65 | exportService.runTask(exportLogSchema);
66 | });
67 | t.start();
68 | }
69 | );
70 | }
71 |
72 | /**
73 | * 导出单篇文章
74 | *
75 | * @param name
76 | * @return
77 | */
78 | @GetMapping("/export_one/{name}")
79 | public Mono fetchHeadContent(@PathVariable("name") String name, ServerHttpResponse response) throws UnsupportedEncodingException {
80 |
81 | return Mono.fromCallable(() -> {
82 | //写文件
83 | String fileName = exportService.writePost(name, EXPORT_ONE_DIR);
84 | File file = new File(fileName);
85 |
86 | ZeroCopyHttpOutputMessage zeroCopyResponse = (ZeroCopyHttpOutputMessage) response;
87 | HttpHeaders headers = zeroCopyResponse.getHeaders();
88 | headers.set("Content-Disposition", "attachment; filename=" + URLEncoder.encode(file.getName(), StandardCharsets.UTF_8));
89 | headers.set("file-name", URLEncoder.encode(file.getName(), StandardCharsets.UTF_8));
90 | headers.set("Access-Control-Allow-Origin", "*");
91 | MediaType application = new MediaType("application", "octet-stream", StandardCharsets.UTF_8);
92 | headers.setContentType(application);
93 | zeroCopyResponse.writeWith(file, 0, file.length()).subscribe();
94 | return "";
95 | })
96 | .publishOn(Schedulers.boundedElastic()).then();
97 |
98 |
99 | }
100 |
101 | @PostMapping("/del")
102 | public Mono delete(@RequestBody String[] names) {
103 |
104 | Arrays.stream(names).parallel().forEach(name ->
105 | reactiveClient.fetch(ExportLogSchema.class, name)
106 | .publishOn(Schedulers.boundedElastic())
107 | .doOnSuccess(exportLogSchema -> {
108 | reactiveClient.delete(exportLogSchema).doOnSuccess(exportLogSchema1 -> {
109 | //删除文件
110 | exportService.delete(name);
111 | }).subscribe();
112 | }).subscribe());
113 | return Mono.empty();
114 | }
115 |
116 | @GetMapping("/down/{path}")
117 | public Mono down(@PathVariable("path") String path, ServerHttpResponse response) {
118 | if (StringUtils.isBlank(path)) {
119 | return Mono.empty();
120 | }
121 | if (path.split("\\.").length > 2) {
122 | //包含太多.的路径,不可下载
123 | return Mono.empty();
124 | }
125 |
126 | Path docFile = FileUtil.getDocFile(FileUtil.DirPath.EXPORT);
127 | File file = new File(docFile.toFile().getAbsolutePath() + "/" + path + ".zip");
128 | if (!file.exists()) {
129 | //todo 适配旧版本,未来会删除
130 | file = new File(docFile.toFile().getAbsolutePath() + "/../" + path + ".zip");
131 | if (!file.exists()) {
132 | //todo 适配旧版本,未来会删除
133 | file = new File(System.getProperty("user.home")+"/.halo/plugins/export2doc_files/" + path + ".zip");
134 | }
135 | if (!file.exists()) {
136 | throw new RuntimeException("文件不存在");
137 | }
138 | }
139 |
140 | ZeroCopyHttpOutputMessage zeroCopyResponse = (ZeroCopyHttpOutputMessage) response;
141 | HttpHeaders headers = zeroCopyResponse.getHeaders();
142 | headers.set("Content-Disposition", "attachment; filename=" + URLEncoder.encode(file.getName(), StandardCharsets.UTF_8));
143 | headers.set("file-name", URLEncoder.encode(path, StandardCharsets.UTF_8));
144 | headers.set("Access-Control-Allow-Origin", "*");
145 | MediaType application = new MediaType("application", "octet-stream", StandardCharsets.UTF_8);
146 | headers.setContentType(application);
147 | zeroCopyResponse.writeWith(file, 0, file.length()).subscribe();
148 |
149 | return Mono.empty();
150 | }
151 |
152 |
153 | }
154 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/halo/service/AbstractContentService.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.halo.service;
2 |
3 | import cn.lyn4ever.export2md.halo.ContentRequest;
4 | import cn.lyn4ever.export2md.halo.ContentWrapper;
5 | import lombok.AllArgsConstructor;
6 | import lombok.extern.slf4j.Slf4j;
7 | import org.apache.commons.lang3.StringUtils;
8 | import org.springframework.lang.NonNull;
9 | import org.springframework.lang.Nullable;
10 | import org.springframework.security.core.context.ReactiveSecurityContextHolder;
11 | import org.springframework.security.core.context.SecurityContext;
12 | import org.springframework.util.Assert;
13 | import reactor.core.publisher.Mono;
14 | import run.halo.app.core.extension.content.Snapshot;
15 | import run.halo.app.extension.MetadataUtil;
16 | import run.halo.app.extension.ReactiveExtensionClient;
17 | import java.security.Principal;
18 | import java.time.Instant;
19 | import java.util.UUID;
20 |
21 | /**
22 | * Abstract Service for {@link Snapshot}.
23 | *
24 | * @author guqing
25 | * @since 2.0.0
26 | */
27 | @Slf4j
28 | @AllArgsConstructor
29 | public abstract class AbstractContentService {
30 |
31 | private final ReactiveExtensionClient client;
32 |
33 | public Mono getContent(String snapshotName, String baseSnapshotName) {
34 | if (StringUtils.isBlank(snapshotName) || StringUtils.isBlank(baseSnapshotName)) {
35 | return Mono.empty();
36 | }
37 | // TODO: refactor this method to use client.get instead of fetch but please be careful
38 | return client.fetch(Snapshot.class, baseSnapshotName)
39 | .doOnNext(this::checkBaseSnapshot)
40 | .flatMap(baseSnapshot -> {
41 | if (StringUtils.equals(snapshotName, baseSnapshotName)) {
42 | var contentWrapper = ContentWrapper.patchSnapshot(baseSnapshot, baseSnapshot);
43 | return Mono.just(contentWrapper);
44 | }
45 | return client.fetch(Snapshot.class, snapshotName)
46 | .map(snapshot -> ContentWrapper.patchSnapshot(snapshot, baseSnapshot));
47 | })
48 | .switchIfEmpty(Mono.defer(() -> {
49 | log.error("The content snapshot [{}] or base snapshot [{}] not found.",
50 | snapshotName, baseSnapshotName);
51 | return Mono.empty();
52 | }));
53 | }
54 |
55 | protected void checkBaseSnapshot(Snapshot snapshot) {
56 | Assert.notNull(snapshot, "The snapshot must not be null.");
57 | if (!isBaseSnapshot(snapshot)) {
58 | throw new IllegalArgumentException(
59 | String.format("The snapshot [%s] is not a base snapshot.",
60 | snapshot.getMetadata().getName()));
61 | }
62 | }
63 |
64 | protected Mono draftContent(@Nullable String baseSnapshotName,
65 | ContentRequest contentRequest,
66 | @Nullable String parentSnapshotName) {
67 | Snapshot snapshot = contentRequest.toSnapshot();
68 | snapshot.getMetadata().setName(UUID.randomUUID().toString());
69 | snapshot.getSpec().setParentSnapshotName(parentSnapshotName);
70 |
71 | final String baseSnapshotNameToUse =
72 | StringUtils.defaultIfBlank(baseSnapshotName, snapshot.getMetadata().getName());
73 | return client.fetch(Snapshot.class, baseSnapshotName)
74 | .doOnNext(this::checkBaseSnapshot)
75 | .defaultIfEmpty(snapshot)
76 | .map(baseSnapshot -> determineRawAndContentPatch(snapshot, baseSnapshot,
77 | contentRequest)
78 | )
79 | .flatMap(source -> getContextUsername()
80 | .map(username -> {
81 | Snapshot.addContributor(source, username);
82 | source.getSpec().setOwner(username);
83 | return source;
84 | })
85 | .defaultIfEmpty(source)
86 | )
87 | .flatMap(snapshotToCreate -> client.create(snapshotToCreate)
88 | .flatMap(head -> restoredContent(baseSnapshotNameToUse, head)));
89 | }
90 |
91 | protected Mono draftContent(String baseSnapshotName, ContentRequest content) {
92 | return this.draftContent(baseSnapshotName, content, content.headSnapshotName());
93 | }
94 |
95 | protected Mono updateContent(String baseSnapshotName,
96 | ContentRequest contentRequest) {
97 | Assert.notNull(contentRequest, "The contentRequest must not be null");
98 | Assert.notNull(baseSnapshotName, "The baseSnapshotName must not be null");
99 | Assert.notNull(contentRequest.headSnapshotName(), "The headSnapshotName must not be null");
100 | return client.fetch(Snapshot.class, contentRequest.headSnapshotName())
101 | .flatMap(headSnapshot -> client.fetch(Snapshot.class, baseSnapshotName)
102 | .map(baseSnapshot -> determineRawAndContentPatch(headSnapshot, baseSnapshot,
103 | contentRequest)
104 | )
105 | )
106 | .flatMap(headSnapshot -> getContextUsername()
107 | .map(username -> {
108 | Snapshot.addContributor(headSnapshot, username);
109 | return headSnapshot;
110 | })
111 | .defaultIfEmpty(headSnapshot)
112 | )
113 | .flatMap(client::update)
114 | .flatMap(head -> restoredContent(baseSnapshotName, head));
115 | }
116 |
117 | protected Mono restoredContent(String baseSnapshotName, Snapshot headSnapshot) {
118 | return client.fetch(Snapshot.class, baseSnapshotName)
119 | .doOnNext(this::checkBaseSnapshot)
120 | .map(baseSnapshot -> ContentWrapper.patchSnapshot(headSnapshot, baseSnapshot));
121 | }
122 |
123 | protected Snapshot determineRawAndContentPatch(Snapshot snapshotToUse,
124 | Snapshot baseSnapshot,
125 | ContentRequest contentRequest) {
126 | Assert.notNull(baseSnapshot, "The baseSnapshot must not be null.");
127 | Assert.notNull(contentRequest, "The contentRequest must not be null.");
128 | Assert.notNull(snapshotToUse, "The snapshotToUse not be null.");
129 | String originalRaw = baseSnapshot.getSpec().getRawPatch();
130 | String originalContent = baseSnapshot.getSpec().getContentPatch();
131 | String baseSnapshotName = baseSnapshot.getMetadata().getName();
132 |
133 | snapshotToUse.getSpec().setLastModifyTime(Instant.now());
134 | // it is the v1 snapshot, set the content directly
135 | if (StringUtils.equals(baseSnapshotName,
136 | snapshotToUse.getMetadata().getName())) {
137 | snapshotToUse.getSpec().setRawPatch(contentRequest.raw());
138 | snapshotToUse.getSpec().setContentPatch(contentRequest.content());
139 | MetadataUtil.nullSafeAnnotations(snapshotToUse)
140 | .put(Snapshot.KEEP_RAW_ANNO, Boolean.TRUE.toString());
141 | } else {
142 | // otherwise diff a patch based on the v1 snapshot
143 | String revisedRaw = contentRequest.rawPatchFrom(originalRaw);
144 | String revisedContent = contentRequest.contentPatchFrom(originalContent);
145 | snapshotToUse.getSpec().setRawPatch(revisedRaw);
146 | snapshotToUse.getSpec().setContentPatch(revisedContent);
147 | }
148 | return snapshotToUse;
149 | }
150 |
151 | protected Mono getContextUsername() {
152 | return ReactiveSecurityContextHolder.getContext()
153 | .map(SecurityContext::getAuthentication)
154 | .map(Principal::getName);
155 | }
156 |
157 |
158 | /**
159 | * 更新自halo
160 | * @param snapshot
161 | * @return
162 | */
163 | private static boolean isBaseSnapshot(@NonNull Snapshot snapshot) {
164 | var annotations = snapshot.getMetadata().getAnnotations();
165 | if (annotations == null) {
166 | return false;
167 | }
168 | return Boolean.parseBoolean(annotations.get(Snapshot.KEEP_RAW_ANNO));
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | # This is normally unused
84 | # shellcheck disable=SC2034
85 | APP_BASE_NAME=${0##*/}
86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
87 |
88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 |
142 | # Increase the maximum file descriptors if we can.
143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
144 | case $MAX_FD in #(
145 | max*)
146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
147 | # shellcheck disable=SC3045
148 | MAX_FD=$( ulimit -H -n ) ||
149 | warn "Could not query maximum file descriptor limit"
150 | esac
151 | case $MAX_FD in #(
152 | '' | soft) :;; #(
153 | *)
154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
155 | # shellcheck disable=SC3045
156 | ulimit -n "$MAX_FD" ||
157 | warn "Could not set maximum file descriptor limit to $MAX_FD"
158 | esac
159 | fi
160 |
161 | # Collect all arguments for the java command, stacking in reverse order:
162 | # * args from the command line
163 | # * the main class name
164 | # * -classpath
165 | # * -D...appname settings
166 | # * --module-path (only if needed)
167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
168 |
169 | # For Cygwin or MSYS, switch paths to Windows format before running java
170 | if "$cygwin" || "$msys" ; then
171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
173 |
174 | JAVACMD=$( cygpath --unix "$JAVACMD" )
175 |
176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
177 | for arg do
178 | if
179 | case $arg in #(
180 | -*) false ;; # don't mess with options #(
181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
182 | [ -e "$t" ] ;; #(
183 | *) false ;;
184 | esac
185 | then
186 | arg=$( cygpath --path --ignore --mixed "$arg" )
187 | fi
188 | # Roll the args list around exactly as many times as the number of
189 | # args, so each arg winds up back in the position where it started, but
190 | # possibly modified.
191 | #
192 | # NB: a `for` loop captures its iteration list before it begins, so
193 | # changing the positional parameters here affects neither the number of
194 | # iterations, nor the values presented in `arg`.
195 | shift # remove old arg
196 | set -- "$@" "$arg" # push replacement arg
197 | done
198 | fi
199 |
200 | # Collect all arguments for the java command;
201 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
202 | # shell script including quotes and variable substitutions, so put them in
203 | # double quotes to make sure that they get re-expanded; and
204 | # * put everything else in single quotes, so that it's not re-expanded.
205 |
206 | set -- \
207 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
208 | -classpath "$CLASSPATH" \
209 | org.gradle.wrapper.GradleWrapperMain \
210 | "$@"
211 |
212 | # Stop when "xargs" is not available.
213 | if ! command -v xargs >/dev/null 2>&1
214 | then
215 | die "xargs is not available"
216 | fi
217 |
218 | # Use "xargs" to parse quoted args.
219 | #
220 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
221 | #
222 | # In Bash we could simply go:
223 | #
224 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
225 | # set -- "${ARGS[@]}" "$@"
226 | #
227 | # but POSIX shell has neither arrays nor command substitution, so instead we
228 | # post-process each arg (as a line of input to sed) to backslash-escape any
229 | # character that might be a shell metacharacter, then use eval to reverse
230 | # that process (while maintaining the separation between arguments), and wrap
231 | # the whole thing up as a single "set" statement.
232 | #
233 | # This will of course break if any of these variables contains a newline or
234 | # an unmatched quote.
235 | #
236 |
237 | eval "set -- $(
238 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
239 | xargs -n1 |
240 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
241 | tr '\n' ' '
242 | )" '"$@"'
243 |
244 | exec "$JAVACMD" "$@"
245 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/service/impl/PostServiceImpl.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.service.impl;
2 |
3 | import cn.hutool.core.lang.UUID;
4 | import cn.hutool.json.JSONUtil;
5 | import cn.lyn4ever.export2md.halo.Content;
6 | import cn.lyn4ever.export2md.halo.ContentRequest;
7 | import cn.lyn4ever.export2md.halo.ContentWrapper;
8 | import cn.lyn4ever.export2md.halo.PostRequest;
9 | import cn.lyn4ever.export2md.halo.service.AbstractContentService;
10 | import cn.lyn4ever.export2md.service.PostService;
11 | import java.io.BufferedReader;
12 | import java.io.File;
13 | import java.io.FileNotFoundException;
14 | import java.io.FileReader;
15 | import java.io.IOException;
16 | import java.time.Duration;
17 | import java.time.Instant;
18 | import java.util.Objects;
19 | import java.util.StringJoiner;
20 | import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter;
21 | import lombok.extern.slf4j.Slf4j;
22 | import org.apache.commons.lang3.StringUtils;
23 | import org.springframework.dao.OptimisticLockingFailureException;
24 | import org.springframework.lang.NonNull;
25 | import org.springframework.stereotype.Component;
26 | import reactor.core.publisher.Mono;
27 | import reactor.util.retry.Retry;
28 | import run.halo.app.core.extension.content.Post;
29 | import run.halo.app.extension.Metadata;
30 | import run.halo.app.extension.ReactiveExtensionClient;
31 | import run.halo.app.extension.Ref;
32 | import run.halo.app.infra.Condition;
33 | import run.halo.app.infra.ConditionStatus;
34 |
35 | /**
36 | * A default implementation of {@link PostService}.
37 | *
38 | * @author guqing
39 | * @since 2.0.0
40 | */
41 | @Slf4j
42 | @Component
43 | public class PostServiceImpl extends AbstractContentService implements PostService {
44 | private final ReactiveExtensionClient client;
45 |
46 | public PostServiceImpl(ReactiveExtensionClient client) {
47 | super(client);
48 | this.client = client;
49 | }
50 |
51 |
52 | /**
53 | * 保存文章
54 | *
55 | * @param postRequest
56 | * @return
57 | */
58 | @Override
59 | public Mono draftPost(PostRequest postRequest) {
60 | System.out.println(JSONUtil.toJsonStr(postRequest));
61 | return Mono.defer(
62 | () -> {
63 | Post post = postRequest.getPost();
64 | return getContextUsername()
65 | .map(username -> {
66 | post.getSpec().setOwner(username);
67 | return post;
68 | })
69 | .defaultIfEmpty(post);
70 | }
71 | )
72 | //保存文章
73 | .flatMap(client::create)
74 | .flatMap(post -> {
75 | System.out.println("保存文章" + post.toString());
76 | if (postRequest.getContent() == null) {
77 | return Mono.just(post);
78 | }
79 | var contentRequest =
80 | new ContentRequest(Ref.of(post), post.getSpec().getHeadSnapshot(),
81 | postRequest.getContent().getRaw(), postRequest.getContent().getContent(),
82 | postRequest.getContent().getRawType());
83 | //保存文章内容
84 | return draftContent(post.getSpec().getBaseSnapshot(), contentRequest)
85 | .flatMap(contentWrapper -> waitForPostToDraftConcludingWork(
86 | post.getMetadata().getName(),
87 | contentWrapper)
88 | );
89 | })
90 | .retryWhen(Retry.backoff(5, Duration.ofMillis(100))
91 | .filter(OptimisticLockingFailureException.class::isInstance));
92 | }
93 |
94 | private Mono waitForPostToDraftConcludingWork(String postName,
95 | ContentWrapper contentWrapper) {
96 | return Mono.defer(() -> client.fetch(Post.class, postName)
97 | .flatMap(post -> {
98 | post.getSpec().setBaseSnapshot(contentWrapper.getSnapshotName());
99 | post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName());
100 | if (Objects.equals(true, post.getSpec().getPublish())) {
101 | post.getSpec().setReleaseSnapshot(post.getSpec().getHeadSnapshot());
102 | }
103 | Condition condition = Condition.builder()
104 | .type(Post.PostPhase.DRAFT.name())
105 | .reason("DraftedSuccessfully")
106 | .message("Drafted post successfully.")
107 | .status(ConditionStatus.TRUE)
108 | .lastTransitionTime(Instant.now())
109 | .build();
110 | Post.PostStatus status = post.getStatusOrDefault();
111 | status.setPhase(Post.PostPhase.DRAFT.name());
112 | status.getConditionsOrDefault().addAndEvictFIFO(condition);
113 | return client.update(post);
114 | }))
115 | .retryWhen(Retry.backoff(5, Duration.ofMillis(100))
116 | .filter(OptimisticLockingFailureException.class::isInstance));
117 | }
118 |
119 | @Override
120 | public Mono updatePost(PostRequest postRequest) {
121 | Post post = postRequest.getPost();
122 | String headSnapshot = post.getSpec().getHeadSnapshot();
123 | String releaseSnapshot = post.getSpec().getReleaseSnapshot();
124 | String baseSnapshot = post.getSpec().getBaseSnapshot();
125 |
126 | if (StringUtils.equals(releaseSnapshot, headSnapshot)) {
127 | // create new snapshot to update first
128 | return draftContent(baseSnapshot, postRequest.contentRequest(), headSnapshot)
129 | .flatMap(contentWrapper -> {
130 | post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName());
131 | return client.update(post);
132 | });
133 | }
134 | return Mono.defer(() -> updateContent(baseSnapshot, postRequest.contentRequest())
135 | .flatMap(contentWrapper -> {
136 | post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName());
137 | return client.update(post);
138 | }))
139 | .retryWhen(Retry.backoff(5, Duration.ofMillis(100))
140 | .filter(throwable -> throwable instanceof OptimisticLockingFailureException));
141 | }
142 |
143 | @Override
144 | public Mono updateBy(@NonNull Post post) {
145 | return client.update(post);
146 | }
147 |
148 | @Override
149 | public Mono getHeadContent(String postName) {
150 | return client.get(Post.class, postName)
151 | .flatMap(this::getHeadContent);
152 | }
153 |
154 | @Override
155 | public Mono getHeadContent(Post post) {
156 | var headSnapshot = post.getSpec().getHeadSnapshot();
157 | return getContent(headSnapshot, post.getSpec().getBaseSnapshot());
158 | }
159 |
160 | @Override
161 | public Mono getReleaseContent(String postName) {
162 | return client.get(Post.class, postName)
163 | .flatMap(this::getReleaseContent);
164 | }
165 |
166 | @Override
167 | public Mono getReleaseContent(Post post) {
168 | var releaseSnapshot = post.getSpec().getReleaseSnapshot();
169 | return getContent(releaseSnapshot, post.getSpec().getBaseSnapshot());
170 | }
171 |
172 | @Override
173 | public Mono publish(Post post) {
174 | return Mono.just(post)
175 | .doOnNext(p -> {
176 | var spec = post.getSpec();
177 | spec.setPublish(true);
178 | if (spec.getHeadSnapshot() == null) {
179 | spec.setHeadSnapshot(spec.getBaseSnapshot());
180 | }
181 | spec.setReleaseSnapshot(spec.getHeadSnapshot());
182 | }).flatMap(client::update);
183 | }
184 |
185 | @Override
186 | public Mono unpublish(Post post) {
187 | return Mono.just(post)
188 | .doOnNext(p -> p.getSpec().setPublish(false))
189 | .flatMap(client::update);
190 | }
191 |
192 | @Override
193 | public Mono getByUsername(String postName, String username) {
194 | return client.get(Post.class, postName)
195 | .filter(post -> post.getSpec() != null)
196 | .filter(post -> Objects.equals(username, post.getSpec().getOwner()));
197 | }
198 |
199 | @Override
200 | public PostRequest formatPost(File file) {
201 | BufferedReader reader = null;
202 | try {
203 | reader = new BufferedReader(new FileReader(file));
204 | } catch (FileNotFoundException e) {
205 | throw new RuntimeException(e);
206 | }
207 |
208 | StringJoiner sj = new StringJoiner("\n");
209 | reader.lines().forEach(sj::add);
210 |
211 | String title = file.getName().split(".md")[0];
212 |
213 | Post post = new Post();
214 |
215 | Post.PostSpec postSpec = new Post.PostSpec();
216 | postSpec.setTitle(title);
217 | postSpec.setSlug(UUID.fastUUID().toString(false));
218 | postSpec.setAllowComment(true);
219 | postSpec.setDeleted(false);
220 | Post.Excerpt excerpt = new Post.Excerpt();
221 | excerpt.setAutoGenerate(true);
222 | excerpt.setRaw("");
223 | postSpec.setExcerpt(excerpt);
224 | postSpec.setPriority(0);
225 | postSpec.setVisible(Post.VisibleEnum.PUBLIC);
226 | postSpec.setPublish(false);
227 | postSpec.setPinned(false);
228 |
229 |
230 | Post.PostStatus postStatus = new Post.PostStatus();
231 | //草稿箱,待发布状态
232 | postStatus.setPhase(Post.PostPhase.DRAFT.name());
233 | // postStatus.setContributors(List.of(owner));
234 |
235 | post.setSpec(postSpec);
236 | // post.setStatus(postStatus);
237 | //设置元数据才能保存
238 | Metadata postMeta = new Metadata();
239 | postMeta.setName(UUID.fastUUID().toString(false));
240 | // postMeta.setAnnotations(Map.of("content.halo.run/preferred-editor","bytemd"));
241 | post.setMetadata(postMeta);
242 |
243 |
244 | try {
245 | return new PostRequest(post, new Content(sj.toString(),
246 | FlexmarkHtmlConverter.builder()
247 | .build()
248 | .convert(sj.toString()),
249 | "markdown")
250 | );
251 | } catch (Exception e) {
252 | throw new RuntimeException(e);
253 | }
254 |
255 | }
256 | }
257 |
--------------------------------------------------------------------------------
/src/main/java/cn/lyn4ever/export2md/service/impl/ExportServiceImpl.java:
--------------------------------------------------------------------------------
1 | package cn.lyn4ever.export2md.service.impl;
2 |
3 | import cn.hutool.core.util.ZipUtil;
4 | import cn.lyn4ever.export2md.halo.ContentWrapper;
5 | import cn.lyn4ever.export2md.schema.ExportLogSchema;
6 | import cn.lyn4ever.export2md.service.ExportService;
7 | import cn.lyn4ever.export2md.util.FileUtil;
8 | import lombok.extern.slf4j.Slf4j;
9 | import org.apache.commons.lang3.StringUtils;
10 | import org.springframework.beans.factory.annotation.Autowired;
11 | import org.springframework.stereotype.Component;
12 | import org.springframework.util.Assert;
13 | import run.halo.app.core.extension.content.Category;
14 | import run.halo.app.core.extension.content.Post;
15 | import run.halo.app.core.extension.content.Snapshot;
16 | import run.halo.app.core.extension.content.Tag;
17 | import run.halo.app.extension.ExtensionClient;
18 | import run.halo.app.extension.ListResult;
19 | import run.halo.app.extension.MetadataOperator;
20 | import run.halo.app.extension.MetadataUtil;
21 |
22 | import java.io.BufferedWriter;
23 | import java.io.File;
24 | import java.io.FileWriter;
25 | import java.io.IOException;
26 | import java.nio.file.Path;
27 | import java.time.ZoneId;
28 | import java.time.format.DateTimeFormatter;
29 | import java.util.*;
30 | import java.util.concurrent.atomic.AtomicReference;
31 | import java.util.function.Predicate;
32 |
33 | /**
34 | * @author Lyn4ever29
35 | * @url https://jhacker.cn
36 | * @date 2023/11/12
37 | */
38 | @Component
39 | @Slf4j
40 | public class ExportServiceImpl implements ExportService {
41 |
42 |
43 | public static ThreadLocal