@claudelaw/taichu 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +13 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/docker-compose.yml +42 -0
- package/docs/ROADMAP.md +101 -0
- package/docs/api/README.md +102 -0
- package/docs/architecture/001-zero-dependency-core.md +61 -0
- package/docs/architecture/002-structured-content-model.md +70 -0
- package/docs/architecture/003-hook-based-extension.md +82 -0
- package/docs/architecture/004-api-first-architecture.md +122 -0
- package/docs/architecture/README.md +24 -0
- package/docs/logo.svg +40 -0
- package/docs/research/ai-era-cms-user-research.md +247 -0
- package/docs/zh/README.md +81 -0
- package/docs/zh/guides/deploy.md +75 -0
- package/docs/zh/guides/mcp.md +84 -0
- package/docs/zh/guides/promotion.md +51 -0
- package/marketplace.json +78 -0
- package/package.json +60 -0
- package/packages/core/src/auth.js +158 -0
- package/packages/core/src/content-type.js +244 -0
- package/packages/core/src/core.test.js +406 -0
- package/packages/core/src/errors.js +60 -0
- package/packages/core/src/hooks.js +104 -0
- package/packages/core/src/index.js +16 -0
- package/packages/core/src/server.test.js +149 -0
- package/packages/core/src/sm-crypto.js +31 -0
- package/packages/core/src/sqlite-store.js +354 -0
- package/packages/core/src/store.js +174 -0
- package/packages/core/src/tokenizer.js +89 -0
- package/packages/core/src/vector-index.js +131 -0
- package/packages/llm-providers/src/index.js +181 -0
- package/packages/mcp/src/index.js +355 -0
- package/packages/server/public/admin/assets/index-DApxOVTx.js +191 -0
- package/packages/server/public/admin/assets/index-DtMvdQm9.css +1 -0
- package/packages/server/public/admin/index.html +28 -0
- package/packages/server/public/aurora/style.css +1173 -0
- package/packages/server/public/favicon.svg +46 -0
- package/packages/server/public/theme/index.html +288 -0
- package/packages/server/public/theme/style.css +133 -0
- package/packages/server/public/theme-minimal/index.html +223 -0
- package/packages/server/public/theme-minimal/style.css +109 -0
- package/packages/server/public/ws-test.html +106 -0
- package/packages/server/src/activitypub.js +228 -0
- package/packages/server/src/audit.js +104 -0
- package/packages/server/src/auth-provider.js +76 -0
- package/packages/server/src/body-parser.js +52 -0
- package/packages/server/src/bootstrap.js +272 -0
- package/packages/server/src/collab.js +154 -0
- package/packages/server/src/config.js +136 -0
- package/packages/server/src/context.js +86 -0
- package/packages/server/src/email.js +317 -0
- package/packages/server/src/index.js +195 -0
- package/packages/server/src/logger.js +78 -0
- package/packages/server/src/media-store.js +213 -0
- package/packages/server/src/middleware/auth.js +203 -0
- package/packages/server/src/middleware/cors.js +15 -0
- package/packages/server/src/middleware/error-handler.js +49 -0
- package/packages/server/src/middleware/rate-limit.js +118 -0
- package/packages/server/src/multipart.js +150 -0
- package/packages/server/src/notify.js +126 -0
- package/packages/server/src/pipeline.js +206 -0
- package/packages/server/src/plugin-installer.js +139 -0
- package/packages/server/src/plugin-manager.js +165 -0
- package/packages/server/src/relationships.js +217 -0
- package/packages/server/src/revisions.js +114 -0
- package/packages/server/src/router.js +194 -0
- package/packages/server/src/routes/activitypub.js +140 -0
- package/packages/server/src/routes/api.js +363 -0
- package/packages/server/src/routes/audit.js +222 -0
- package/packages/server/src/routes/auth.js +205 -0
- package/packages/server/src/routes/collab.js +90 -0
- package/packages/server/src/routes/export.js +77 -0
- package/packages/server/src/routes/graphql.js +344 -0
- package/packages/server/src/routes/media.js +169 -0
- package/packages/server/src/routes/plugin-marketplace.js +171 -0
- package/packages/server/src/routes/relationships.js +133 -0
- package/packages/server/src/routes/rss.js +92 -0
- package/packages/server/src/routes/sso.js +211 -0
- package/packages/server/src/routes/theme.js +119 -0
- package/packages/server/src/routes/webhook.js +94 -0
- package/packages/server/src/routes/wechat.js +115 -0
- package/packages/server/src/routes/workflow.js +157 -0
- package/packages/server/src/scheduler.js +96 -0
- package/packages/server/src/search.js +100 -0
- package/packages/server/src/server.test.js +295 -0
- package/packages/server/src/sso-analytics.js +78 -0
- package/packages/server/src/static.js +70 -0
- package/packages/server/src/theme-engine.js +119 -0
- package/packages/server/src/webhook.js +192 -0
- package/packages/server/src/websocket.js +308 -0
- package/scripts/cli.js +90 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Installer — 插件安装/卸载/更新
|
|
3
|
+
*
|
|
4
|
+
* 从 GitHub repository 或 npm 下载安装插件。
|
|
5
|
+
* 插件保存在项目 `plugins/` 目录下。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, rmSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { createLogger } from './logger.js';
|
|
12
|
+
|
|
13
|
+
const log = createLogger('plugin-installer');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_PLUGINS_DIR = join(process.cwd(), 'plugins');
|
|
16
|
+
const PLUGINS_DIR = process.env.TAICHU_PLUGINS_DIR || DEFAULT_PLUGINS_DIR;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Install a plugin from a GitHub repository.
|
|
20
|
+
* @param {string} repo — full GitHub repo, e.g. "taichu/plugin-seo"
|
|
21
|
+
* @param {object} [opts]
|
|
22
|
+
* @param {string} [opts.version] — specific version/tag
|
|
23
|
+
* @returns {Promise<{success: boolean, name?: string, version?: string, error?: string}>}
|
|
24
|
+
*/
|
|
25
|
+
export async function installPlugin(repo, opts = {}) {
|
|
26
|
+
const pluginName = repo.split('/').pop();
|
|
27
|
+
|
|
28
|
+
if (!existsSync(PLUGINS_DIR)) {
|
|
29
|
+
mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const targetDir = join(PLUGINS_DIR, pluginName);
|
|
33
|
+
if (existsSync(targetDir)) {
|
|
34
|
+
return { success: false, error: `Plugin "${pluginName}" already installed at ${targetDir}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const url = `https://github.com/${repo}.git`;
|
|
38
|
+
log.info(`Installing plugin: ${url}`);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const args = ['clone', '--depth', '1'];
|
|
42
|
+
if (opts.version) {
|
|
43
|
+
args.push('--branch', opts.version);
|
|
44
|
+
}
|
|
45
|
+
args.push(url, targetDir);
|
|
46
|
+
|
|
47
|
+
execSync(`git ${args.join(' ')}`, { stdio: 'pipe', timeout: 30000 });
|
|
48
|
+
|
|
49
|
+
// Verify manifest
|
|
50
|
+
const manifestPath = join(targetDir, 'taichu.plugin.json');
|
|
51
|
+
if (!existsSync(manifestPath)) {
|
|
52
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
53
|
+
return { success: false, error: `No taichu.plugin.json found in "${repo}"` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
57
|
+
log.info(`Plugin installed: ${manifest.name} v${manifest.version}`);
|
|
58
|
+
|
|
59
|
+
return { success: true, name: manifest.name, version: manifest.version };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
// Cleanup on failure
|
|
62
|
+
if (existsSync(targetDir)) {
|
|
63
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
return { success: false, error: err.message };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Uninstall a plugin.
|
|
71
|
+
* @param {string} name — plugin name or directory name
|
|
72
|
+
*/
|
|
73
|
+
export async function uninstallPlugin(name) {
|
|
74
|
+
const targetDir = join(PLUGINS_DIR, name);
|
|
75
|
+
if (!existsSync(targetDir)) {
|
|
76
|
+
// Try searching by manifest name
|
|
77
|
+
const entries = existsSync(PLUGINS_DIR) ? readdirSync(PLUGINS_DIR, { withFileTypes: true }) : [];
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
const mp = join(PLUGINS_DIR, entry.name, 'taichu.plugin.json');
|
|
81
|
+
if (existsSync(mp)) {
|
|
82
|
+
const manifest = JSON.parse(readFileSync(mp, 'utf-8'));
|
|
83
|
+
if (manifest.name === name) {
|
|
84
|
+
rmSync(join(PLUGINS_DIR, entry.name), { recursive: true, force: true });
|
|
85
|
+
log.info(`Plugin uninstalled: ${name}`);
|
|
86
|
+
return { success: true };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { success: false, error: `Plugin "${name}" not found` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
95
|
+
log.info(`Plugin uninstalled: ${name}`);
|
|
96
|
+
return { success: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* List installed plugins with manifest info.
|
|
101
|
+
*/
|
|
102
|
+
export function listInstalled() {
|
|
103
|
+
if (!existsSync(PLUGINS_DIR)) return [];
|
|
104
|
+
|
|
105
|
+
const entries = readdirSync(PLUGINS_DIR, { withFileTypes: true });
|
|
106
|
+
const plugins = [];
|
|
107
|
+
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
const mp = join(PLUGINS_DIR, entry.name, 'taichu.plugin.json');
|
|
111
|
+
if (existsSync(mp)) {
|
|
112
|
+
try {
|
|
113
|
+
const manifest = JSON.parse(readFileSync(mp, 'utf-8'));
|
|
114
|
+
plugins.push({
|
|
115
|
+
...manifest,
|
|
116
|
+
installedPath: join(PLUGINS_DIR, entry.name),
|
|
117
|
+
directory: entry.name
|
|
118
|
+
});
|
|
119
|
+
} catch (_) {}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return plugins;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if a plugin is installed.
|
|
129
|
+
*/
|
|
130
|
+
export function isInstalled(name) {
|
|
131
|
+
return listInstalled().some(p => p.name === name);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get plugins directory path.
|
|
136
|
+
*/
|
|
137
|
+
export function getPluginsDir() {
|
|
138
|
+
return PLUGINS_DIR;
|
|
139
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin System — 扩展协议与沙箱
|
|
3
|
+
*
|
|
4
|
+
* Taichu 插件基于标准 npm 包 + manifest.json 声明。
|
|
5
|
+
*
|
|
6
|
+
* 插件清单 (taichu.plugin.json):
|
|
7
|
+
* {
|
|
8
|
+
* "name": "@taichu/plugin-seo",
|
|
9
|
+
* "version": "1.0.0",
|
|
10
|
+
* "description": "SEO optimization plugin for Taichu",
|
|
11
|
+
* "hooks": ["afterCreate", "afterUpdate"],
|
|
12
|
+
* "routes": false,
|
|
13
|
+
* "adminPanel": false,
|
|
14
|
+
* "permissions": ["content:read", "content:write"]
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* 插件代码 (index.js):
|
|
18
|
+
* export default function(api) {
|
|
19
|
+
* api.hook('afterCreate', async (doc) => {
|
|
20
|
+
* await api.logger.info('SEO: optimizing', { id: doc.id });
|
|
21
|
+
* });
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { join, dirname } from 'node:path';
|
|
27
|
+
import { pathToFileURL } from 'node:url';
|
|
28
|
+
import { createLogger } from './logger.js';
|
|
29
|
+
|
|
30
|
+
const log = createLogger('plugin');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} PluginManifest
|
|
34
|
+
* @property {string} name
|
|
35
|
+
* @property {string} version
|
|
36
|
+
* @property {string} description
|
|
37
|
+
* @property {string[]} hooks
|
|
38
|
+
* @property {boolean} routes
|
|
39
|
+
* @property {boolean} adminPanel
|
|
40
|
+
* @property {string[]} permissions
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {object} PluginAPI
|
|
45
|
+
* @property {object} store — Taichu Store instance
|
|
46
|
+
* @property {object} hooks — Hook system
|
|
47
|
+
* @property {object} logger — Plugin-specific logger
|
|
48
|
+
* @property {object} config — App config
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
class PluginManager {
|
|
52
|
+
constructor() {
|
|
53
|
+
/** @type {Map<string, { manifest: PluginManifest, module: any, api: PluginAPI }>} */
|
|
54
|
+
this.plugins = new Map();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Load a plugin from a directory.
|
|
59
|
+
* @param {string} pluginPath — path to plugin directory (must contain taichu.plugin.json)
|
|
60
|
+
* @param {PluginAPI} api
|
|
61
|
+
*/
|
|
62
|
+
async load(pluginPath, api) {
|
|
63
|
+
const manifestPath = join(pluginPath, 'taichu.plugin.json');
|
|
64
|
+
if (!existsSync(manifestPath)) {
|
|
65
|
+
throw new Error(`Plugin manifest not found: ${manifestPath}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
69
|
+
if (!manifest.name || !manifest.version) {
|
|
70
|
+
throw new Error('Plugin manifest must have "name" and "version"');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (this.plugins.has(manifest.name)) {
|
|
74
|
+
log.warn(`Plugin "${manifest.name}" already loaded, skipping`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
log.info(`Loading plugin: ${manifest.name} v${manifest.version}`);
|
|
79
|
+
|
|
80
|
+
// Import plugin module
|
|
81
|
+
const modulePath = join(pluginPath, 'index.js');
|
|
82
|
+
if (!existsSync(modulePath)) {
|
|
83
|
+
throw new Error(`Plugin entry point not found: ${modulePath}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const url = pathToFileURL(modulePath).href;
|
|
87
|
+
const mod = await import(url);
|
|
88
|
+
const pluginFn = mod.default || mod;
|
|
89
|
+
|
|
90
|
+
if (typeof pluginFn !== 'function') {
|
|
91
|
+
throw new Error(`Plugin "${manifest.name}" must export a default function`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Build plugin API
|
|
95
|
+
const pluginApi = {
|
|
96
|
+
store: api.store,
|
|
97
|
+
hooks: api.hooks,
|
|
98
|
+
logger: createLogger(`plugin:${manifest.name}`),
|
|
99
|
+
config: api.config,
|
|
100
|
+
/** Register a hook handler */
|
|
101
|
+
hook(name, fn, priority) {
|
|
102
|
+
api.hooks.on(name, fn, priority);
|
|
103
|
+
},
|
|
104
|
+
/** Register a custom REST route handler */
|
|
105
|
+
route(method, path, handler) {
|
|
106
|
+
api.hooks.route(method, path, handler);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Execute plugin
|
|
111
|
+
await pluginFn(pluginApi);
|
|
112
|
+
|
|
113
|
+
this.plugins.set(manifest.name, { manifest, module: mod, api: pluginApi });
|
|
114
|
+
log.info(`Plugin loaded: ${manifest.name}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Load all plugins from a directory.
|
|
119
|
+
*/
|
|
120
|
+
async loadAll(pluginsDir, api) {
|
|
121
|
+
if (!existsSync(pluginsDir)) return;
|
|
122
|
+
|
|
123
|
+
const { readdirSync } = await import('node:fs');
|
|
124
|
+
const entries = readdirSync(pluginsDir, { withFileTypes: true });
|
|
125
|
+
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (entry.isDirectory()) {
|
|
128
|
+
const pluginPath = join(pluginsDir, entry.name);
|
|
129
|
+
const manifestPath = join(pluginPath, 'taichu.plugin.json');
|
|
130
|
+
if (existsSync(manifestPath)) {
|
|
131
|
+
try {
|
|
132
|
+
await this.load(pluginPath, api);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
log.error(`Failed to load plugin "${entry.name}": ${err.message}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** List loaded plugins */
|
|
142
|
+
list() {
|
|
143
|
+
return Array.from(this.plugins.values()).map(p => ({
|
|
144
|
+
name: p.manifest.name,
|
|
145
|
+
version: p.manifest.version,
|
|
146
|
+
description: p.manifest.description,
|
|
147
|
+
hooks: p.manifest.hooks,
|
|
148
|
+
permissions: p.manifest.permissions
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Get plugin count */
|
|
153
|
+
get count() {
|
|
154
|
+
return this.plugins.size;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Singleton ──────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
let _pm = null;
|
|
161
|
+
|
|
162
|
+
export function getPluginManager() {
|
|
163
|
+
if (!_pm) _pm = new PluginManager();
|
|
164
|
+
return _pm;
|
|
165
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Relationships — 内容关系图谱
|
|
3
|
+
*
|
|
4
|
+
* 管理文档间的引用关系,支持:
|
|
5
|
+
* - related_to / parent_of / child_of / references / translated_from
|
|
6
|
+
* - 双向索引(反向查找)
|
|
7
|
+
* - 子图遍历(BFS,可配深度)
|
|
8
|
+
*
|
|
9
|
+
* 关系存储在文档 data._relationships 数组中。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createLogger } from './logger.js';
|
|
13
|
+
|
|
14
|
+
const log = createLogger('relationships');
|
|
15
|
+
|
|
16
|
+
const VALID_TYPES = [
|
|
17
|
+
'related_to', // 通用关联
|
|
18
|
+
'parent_of', // 父子层级
|
|
19
|
+
'child_of', // 反向父子(自动维护)
|
|
20
|
+
'references', // 引用
|
|
21
|
+
'translated_from' // 翻译源
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} Relationship
|
|
26
|
+
* @property {string} type — 关系类型
|
|
27
|
+
* @property {string} targetId — 目标文档 ID
|
|
28
|
+
* @property {object} [meta] — 附加元数据
|
|
29
|
+
* @property {string} createdAt — ISO 8601
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Add a relationship between two documents.
|
|
34
|
+
* @param {object} store — content store
|
|
35
|
+
* @param {string} sourceId — 源文档 ID
|
|
36
|
+
* @param {string} targetId — 目标文档 ID
|
|
37
|
+
* @param {string} type — 关系类型
|
|
38
|
+
* @param {object} [meta] — 元数据
|
|
39
|
+
*/
|
|
40
|
+
export async function addRelationship(store, sourceId, targetId, type, meta = {}) {
|
|
41
|
+
if (!VALID_TYPES.includes(type)) {
|
|
42
|
+
throw new Error(`Invalid relationship type: ${type}. Must be one of: ${VALID_TYPES.join(', ')}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sourceDoc = await store.get(sourceId);
|
|
46
|
+
const targetDoc = await store.get(targetId);
|
|
47
|
+
if (!sourceDoc) throw new Error(`Source document not found: ${sourceId}`);
|
|
48
|
+
if (!targetDoc) throw new Error(`Target document not found: ${targetId}`);
|
|
49
|
+
if (sourceId === targetId) throw new Error('Cannot create self-referencing relationship');
|
|
50
|
+
|
|
51
|
+
// Check for duplicate
|
|
52
|
+
const existing = sourceDoc.data._relationships || [];
|
|
53
|
+
if (existing.some(r => r.targetId === targetId && r.type === type)) {
|
|
54
|
+
return { alreadyExists: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rel = { type, targetId, meta, createdAt: new Date().toISOString() };
|
|
58
|
+
existing.push(rel);
|
|
59
|
+
|
|
60
|
+
await store.update(sourceId, {
|
|
61
|
+
data: { _relationships: existing }
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Auto-maintain reverse relationship for parent_of
|
|
65
|
+
if (type === 'parent_of') {
|
|
66
|
+
const targetRels = targetDoc.data._relationships || [];
|
|
67
|
+
if (!targetRels.some(r => r.targetId === sourceId && r.type === 'child_of')) {
|
|
68
|
+
targetRels.push({ type: 'child_of', targetId: sourceId, autoCreated: true, createdAt: new Date().toISOString() });
|
|
69
|
+
await store.update(targetId, { data: { _relationships: targetRels } });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
log.debug(`Relationship created: ${sourceId} --[${type}]--> ${targetId}`);
|
|
74
|
+
return { created: true, relationship: rel };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Remove a relationship.
|
|
79
|
+
*/
|
|
80
|
+
export async function removeRelationship(store, sourceId, targetId, type) {
|
|
81
|
+
const sourceDoc = await store.get(sourceId);
|
|
82
|
+
if (!sourceDoc) throw new Error(`Document not found: ${sourceId}`);
|
|
83
|
+
|
|
84
|
+
const rels = (sourceDoc.data._relationships || []).filter(
|
|
85
|
+
r => !(r.targetId === targetId && (!type || r.type === type))
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (rels.length === (sourceDoc.data._relationships || []).length) {
|
|
89
|
+
return { notFound: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await store.update(sourceId, {
|
|
93
|
+
data: { _relationships: rels.length ? rels : undefined }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Clean up reverse child_of if parent_of was removed
|
|
97
|
+
if (type === 'parent_of' || !type) {
|
|
98
|
+
try {
|
|
99
|
+
const targetDoc = await store.get(targetId);
|
|
100
|
+
if (targetDoc) {
|
|
101
|
+
const targetRels = (targetDoc.data._relationships || []).filter(
|
|
102
|
+
r => !(r.type === 'child_of' && r.targetId === sourceId && r.autoCreated)
|
|
103
|
+
);
|
|
104
|
+
if (targetRels.length !== (targetDoc.data._relationships || []).length) {
|
|
105
|
+
await store.update(targetId, { data: { _relationships: targetRels.length ? targetRels : undefined } });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (_) {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
log.debug(`Relationship removed: ${sourceId} --[${type}]--> ${targetId}`);
|
|
112
|
+
return { removed: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get all relationships for a document (outgoing).
|
|
117
|
+
* @returns {Promise<Relationship[]>}
|
|
118
|
+
*/
|
|
119
|
+
export async function getRelationships(store, sourceId) {
|
|
120
|
+
const doc = await store.get(sourceId);
|
|
121
|
+
if (!doc) return [];
|
|
122
|
+
return (doc.data._relationships || []).map(r => ({
|
|
123
|
+
...r,
|
|
124
|
+
sourceId,
|
|
125
|
+
sourceType: doc.type,
|
|
126
|
+
sourceTitle: doc.data?.title || doc.data?.name || ''
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Find documents that reference a target (incoming relationships).
|
|
132
|
+
*/
|
|
133
|
+
export async function getBacklinks(store, targetId) {
|
|
134
|
+
// Scan all documents — for larger datasets, this should use a dedicated index
|
|
135
|
+
const allDocs = await store.list({ limit: 10000 });
|
|
136
|
+
const backlinks = [];
|
|
137
|
+
for (const doc of allDocs) {
|
|
138
|
+
const rels = doc.data._relationships || [];
|
|
139
|
+
for (const r of rels) {
|
|
140
|
+
if (r.targetId === targetId) {
|
|
141
|
+
backlinks.push({
|
|
142
|
+
...r,
|
|
143
|
+
sourceId: doc.id,
|
|
144
|
+
sourceType: doc.type,
|
|
145
|
+
sourceTitle: doc.data?.title || doc.data?.name || ''
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return backlinks;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get full relationship context (outgoing + incoming).
|
|
155
|
+
*/
|
|
156
|
+
export async function getAllRelationships(store, docId) {
|
|
157
|
+
const [outgoing, incoming] = await Promise.all([
|
|
158
|
+
getRelationships(store, docId),
|
|
159
|
+
getBacklinks(store, docId)
|
|
160
|
+
]);
|
|
161
|
+
return { outgoing, incoming, total: outgoing.length + incoming.length };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Traverse relationship subgraph via BFS.
|
|
166
|
+
* @param {object} store
|
|
167
|
+
* @param {string} startId
|
|
168
|
+
* @param {object} opts
|
|
169
|
+
* @param {number} [opts.depth=2] — max traversal depth
|
|
170
|
+
* @param {string[]} [opts.types] — filter to specific relationship types
|
|
171
|
+
* @returns {Promise<{nodes: object[], edges: object[]}>}
|
|
172
|
+
*/
|
|
173
|
+
export async function traverseGraph(store, startId, opts = {}) {
|
|
174
|
+
const depth = opts.depth || 2;
|
|
175
|
+
const typeFilter = opts.types || null;
|
|
176
|
+
|
|
177
|
+
const visited = new Set();
|
|
178
|
+
const nodes = [];
|
|
179
|
+
const edges = [];
|
|
180
|
+
const queue = [{ id: startId, depth: 0 }];
|
|
181
|
+
|
|
182
|
+
while (queue.length > 0) {
|
|
183
|
+
const { id, depth: currentDepth } = queue.shift();
|
|
184
|
+
if (visited.has(id)) continue;
|
|
185
|
+
visited.add(id);
|
|
186
|
+
|
|
187
|
+
const doc = await store.get(id);
|
|
188
|
+
if (!doc) continue;
|
|
189
|
+
|
|
190
|
+
nodes.push({
|
|
191
|
+
id: doc.id,
|
|
192
|
+
type: doc.type,
|
|
193
|
+
title: doc.data?.title || doc.data?.name || '',
|
|
194
|
+
status: doc.status,
|
|
195
|
+
depth: currentDepth
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (currentDepth >= depth) continue;
|
|
199
|
+
|
|
200
|
+
const rels = doc.data._relationships || [];
|
|
201
|
+
for (const r of rels) {
|
|
202
|
+
if (typeFilter && !typeFilter.includes(r.type)) continue;
|
|
203
|
+
if (visited.has(r.targetId)) continue;
|
|
204
|
+
|
|
205
|
+
edges.push({
|
|
206
|
+
from: doc.id,
|
|
207
|
+
to: r.targetId,
|
|
208
|
+
type: r.type,
|
|
209
|
+
meta: r.meta || {}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
queue.push({ id: r.targetId, depth: currentDepth + 1 });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { nodes, edges };
|
|
217
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version History — 内容版本管理与 Diff
|
|
3
|
+
*
|
|
4
|
+
* 每次 create/update 自动创建版本快照。
|
|
5
|
+
* API: GET /api/content/:type/:id/revisions
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getStore } from './context.js';
|
|
9
|
+
import { createLogger } from './logger.js';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
const log = createLogger('revision');
|
|
13
|
+
const MAX_REVISIONS = parseInt(process.env.TAICHU_MAX_REVISIONS) || 100;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Snapshot a document revision.
|
|
17
|
+
* Called after create or update.
|
|
18
|
+
*/
|
|
19
|
+
export async function snapshotRevision(doc, actor = {}) {
|
|
20
|
+
try {
|
|
21
|
+
const store = getStore();
|
|
22
|
+
|
|
23
|
+
// Get current revision count for this document
|
|
24
|
+
const existing = await store.list({ type: 'revision', limit: MAX_REVISIONS });
|
|
25
|
+
const docRevisions = existing.filter(r => r.data.docId === doc.id);
|
|
26
|
+
|
|
27
|
+
// Delete oldest if over limit
|
|
28
|
+
if (docRevisions.length >= MAX_REVISIONS) {
|
|
29
|
+
const oldest = docRevisions.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))[0];
|
|
30
|
+
if (oldest) await store.delete(oldest.id);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await store.create({
|
|
34
|
+
type: 'revision',
|
|
35
|
+
data: {
|
|
36
|
+
docId: doc.id,
|
|
37
|
+
docType: doc.type,
|
|
38
|
+
data: doc.data,
|
|
39
|
+
status: doc.status,
|
|
40
|
+
meta: doc.meta || {},
|
|
41
|
+
author: actor.id || 'system',
|
|
42
|
+
authorType: actor.type || 'system',
|
|
43
|
+
timestamp: doc.updatedAt || new Date().toISOString()
|
|
44
|
+
},
|
|
45
|
+
status: 'active'
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.error(`Failed to snapshot revision for ${doc.id}: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get revision history for a document.
|
|
54
|
+
*/
|
|
55
|
+
export async function getRevisions(docId, limit = 20) {
|
|
56
|
+
const store = getStore();
|
|
57
|
+
const docs = await store.list({ type: 'revision', limit: 200 });
|
|
58
|
+
return docs
|
|
59
|
+
.filter(r => r.data.docId === docId)
|
|
60
|
+
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
|
61
|
+
.slice(0, limit)
|
|
62
|
+
.map(r => ({
|
|
63
|
+
id: r.id,
|
|
64
|
+
timestamp: r.data.timestamp || r.createdAt,
|
|
65
|
+
status: r.data.status,
|
|
66
|
+
author: r.data.author,
|
|
67
|
+
authorType: r.data.authorType,
|
|
68
|
+
docType: r.data.docType,
|
|
69
|
+
data: r.data.data,
|
|
70
|
+
meta: r.data.meta
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Restore a document to a specific revision.
|
|
76
|
+
*/
|
|
77
|
+
export async function restoreRevision(docId, revisionId) {
|
|
78
|
+
const store = getStore();
|
|
79
|
+
const revisions = await getRevisions(docId, 200);
|
|
80
|
+
const rev = await store.get(revisionId);
|
|
81
|
+
|
|
82
|
+
if (!rev || rev.type !== 'revision') return null;
|
|
83
|
+
|
|
84
|
+
const doc = await store.get(docId);
|
|
85
|
+
if (!doc) return null;
|
|
86
|
+
|
|
87
|
+
return store.update(docId, {
|
|
88
|
+
data: rev.data.data,
|
|
89
|
+
status: rev.data.status,
|
|
90
|
+
meta: rev.data.meta
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Simple field-level diff between two objects.
|
|
96
|
+
*/
|
|
97
|
+
export function diffObjects(oldData, newData) {
|
|
98
|
+
const changes = [];
|
|
99
|
+
const allKeys = new Set([...Object.keys(oldData || {}), ...Object.keys(newData || {})]);
|
|
100
|
+
|
|
101
|
+
for (const key of allKeys) {
|
|
102
|
+
const oldVal = JSON.stringify(oldData?.[key]);
|
|
103
|
+
const newVal = JSON.stringify(newData?.[key]);
|
|
104
|
+
if (oldVal !== newVal) {
|
|
105
|
+
changes.push({
|
|
106
|
+
field: key,
|
|
107
|
+
from: oldData?.[key],
|
|
108
|
+
to: newData?.[key]
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return changes;
|
|
114
|
+
}
|