@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.
Files changed (93) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +208 -0
  5. package/docker-compose.yml +42 -0
  6. package/docs/ROADMAP.md +101 -0
  7. package/docs/api/README.md +102 -0
  8. package/docs/architecture/001-zero-dependency-core.md +61 -0
  9. package/docs/architecture/002-structured-content-model.md +70 -0
  10. package/docs/architecture/003-hook-based-extension.md +82 -0
  11. package/docs/architecture/004-api-first-architecture.md +122 -0
  12. package/docs/architecture/README.md +24 -0
  13. package/docs/logo.svg +40 -0
  14. package/docs/research/ai-era-cms-user-research.md +247 -0
  15. package/docs/zh/README.md +81 -0
  16. package/docs/zh/guides/deploy.md +75 -0
  17. package/docs/zh/guides/mcp.md +84 -0
  18. package/docs/zh/guides/promotion.md +51 -0
  19. package/marketplace.json +78 -0
  20. package/package.json +60 -0
  21. package/packages/core/src/auth.js +158 -0
  22. package/packages/core/src/content-type.js +244 -0
  23. package/packages/core/src/core.test.js +406 -0
  24. package/packages/core/src/errors.js +60 -0
  25. package/packages/core/src/hooks.js +104 -0
  26. package/packages/core/src/index.js +16 -0
  27. package/packages/core/src/server.test.js +149 -0
  28. package/packages/core/src/sm-crypto.js +31 -0
  29. package/packages/core/src/sqlite-store.js +354 -0
  30. package/packages/core/src/store.js +174 -0
  31. package/packages/core/src/tokenizer.js +89 -0
  32. package/packages/core/src/vector-index.js +131 -0
  33. package/packages/llm-providers/src/index.js +181 -0
  34. package/packages/mcp/src/index.js +355 -0
  35. package/packages/server/public/admin/assets/index-DApxOVTx.js +191 -0
  36. package/packages/server/public/admin/assets/index-DtMvdQm9.css +1 -0
  37. package/packages/server/public/admin/index.html +28 -0
  38. package/packages/server/public/aurora/style.css +1173 -0
  39. package/packages/server/public/favicon.svg +46 -0
  40. package/packages/server/public/theme/index.html +288 -0
  41. package/packages/server/public/theme/style.css +133 -0
  42. package/packages/server/public/theme-minimal/index.html +223 -0
  43. package/packages/server/public/theme-minimal/style.css +109 -0
  44. package/packages/server/public/ws-test.html +106 -0
  45. package/packages/server/src/activitypub.js +228 -0
  46. package/packages/server/src/audit.js +104 -0
  47. package/packages/server/src/auth-provider.js +76 -0
  48. package/packages/server/src/body-parser.js +52 -0
  49. package/packages/server/src/bootstrap.js +272 -0
  50. package/packages/server/src/collab.js +154 -0
  51. package/packages/server/src/config.js +136 -0
  52. package/packages/server/src/context.js +86 -0
  53. package/packages/server/src/email.js +317 -0
  54. package/packages/server/src/index.js +195 -0
  55. package/packages/server/src/logger.js +78 -0
  56. package/packages/server/src/media-store.js +213 -0
  57. package/packages/server/src/middleware/auth.js +203 -0
  58. package/packages/server/src/middleware/cors.js +15 -0
  59. package/packages/server/src/middleware/error-handler.js +49 -0
  60. package/packages/server/src/middleware/rate-limit.js +118 -0
  61. package/packages/server/src/multipart.js +150 -0
  62. package/packages/server/src/notify.js +126 -0
  63. package/packages/server/src/pipeline.js +206 -0
  64. package/packages/server/src/plugin-installer.js +139 -0
  65. package/packages/server/src/plugin-manager.js +165 -0
  66. package/packages/server/src/relationships.js +217 -0
  67. package/packages/server/src/revisions.js +114 -0
  68. package/packages/server/src/router.js +194 -0
  69. package/packages/server/src/routes/activitypub.js +140 -0
  70. package/packages/server/src/routes/api.js +363 -0
  71. package/packages/server/src/routes/audit.js +222 -0
  72. package/packages/server/src/routes/auth.js +205 -0
  73. package/packages/server/src/routes/collab.js +90 -0
  74. package/packages/server/src/routes/export.js +77 -0
  75. package/packages/server/src/routes/graphql.js +344 -0
  76. package/packages/server/src/routes/media.js +169 -0
  77. package/packages/server/src/routes/plugin-marketplace.js +171 -0
  78. package/packages/server/src/routes/relationships.js +133 -0
  79. package/packages/server/src/routes/rss.js +92 -0
  80. package/packages/server/src/routes/sso.js +211 -0
  81. package/packages/server/src/routes/theme.js +119 -0
  82. package/packages/server/src/routes/webhook.js +94 -0
  83. package/packages/server/src/routes/wechat.js +115 -0
  84. package/packages/server/src/routes/workflow.js +157 -0
  85. package/packages/server/src/scheduler.js +96 -0
  86. package/packages/server/src/search.js +100 -0
  87. package/packages/server/src/server.test.js +295 -0
  88. package/packages/server/src/sso-analytics.js +78 -0
  89. package/packages/server/src/static.js +70 -0
  90. package/packages/server/src/theme-engine.js +119 -0
  91. package/packages/server/src/webhook.js +192 -0
  92. package/packages/server/src/websocket.js +308 -0
  93. 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
+ }