remnote-bridge 0.1.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/dist/cli/commands/connect.d.ts +12 -0
- package/dist/cli/commands/connect.js +124 -0
- package/dist/cli/commands/disconnect.d.ts +11 -0
- package/dist/cli/commands/disconnect.js +100 -0
- package/dist/cli/commands/edit-rem.d.ts +13 -0
- package/dist/cli/commands/edit-rem.js +83 -0
- package/dist/cli/commands/edit-tree.d.ts +14 -0
- package/dist/cli/commands/edit-tree.js +67 -0
- package/dist/cli/commands/health.d.ts +12 -0
- package/dist/cli/commands/health.js +100 -0
- package/dist/cli/commands/install-skill.d.ts +6 -0
- package/dist/cli/commands/install-skill.js +39 -0
- package/dist/cli/commands/read-context.d.ts +20 -0
- package/dist/cli/commands/read-context.js +77 -0
- package/dist/cli/commands/read-globe.d.ts +16 -0
- package/dist/cli/commands/read-globe.js +60 -0
- package/dist/cli/commands/read-rem.d.ts +16 -0
- package/dist/cli/commands/read-rem.js +80 -0
- package/dist/cli/commands/read-tree.d.ts +17 -0
- package/dist/cli/commands/read-tree.js +85 -0
- package/dist/cli/commands/search.d.ts +12 -0
- package/dist/cli/commands/search.js +65 -0
- package/dist/cli/config.d.ts +55 -0
- package/dist/cli/config.js +139 -0
- package/dist/cli/daemon/daemon.d.ts +11 -0
- package/dist/cli/daemon/daemon.js +186 -0
- package/dist/cli/daemon/dev-server.d.ts +26 -0
- package/dist/cli/daemon/dev-server.js +81 -0
- package/dist/cli/daemon/pid.d.ts +34 -0
- package/dist/cli/daemon/pid.js +67 -0
- package/dist/cli/daemon/send-request.d.ts +24 -0
- package/dist/cli/daemon/send-request.js +92 -0
- package/dist/cli/handlers/context-read-handler.d.ts +18 -0
- package/dist/cli/handlers/context-read-handler.js +24 -0
- package/dist/cli/handlers/edit-handler.d.ts +30 -0
- package/dist/cli/handlers/edit-handler.js +133 -0
- package/dist/cli/handlers/globe-read-handler.d.ts +17 -0
- package/dist/cli/handlers/globe-read-handler.js +23 -0
- package/dist/cli/handlers/read-handler.d.ts +16 -0
- package/dist/cli/handlers/read-handler.js +78 -0
- package/dist/cli/handlers/rem-cache.d.ts +19 -0
- package/dist/cli/handlers/rem-cache.js +63 -0
- package/dist/cli/handlers/tree-edit-handler.d.ts +30 -0
- package/dist/cli/handlers/tree-edit-handler.js +188 -0
- package/dist/cli/handlers/tree-parser.d.ts +95 -0
- package/dist/cli/handlers/tree-parser.js +506 -0
- package/dist/cli/handlers/tree-read-handler.d.ts +28 -0
- package/dist/cli/handlers/tree-read-handler.js +53 -0
- package/dist/cli/main.d.ts +7 -0
- package/dist/cli/main.js +300 -0
- package/dist/cli/protocol.d.ts +39 -0
- package/dist/cli/protocol.js +35 -0
- package/dist/cli/server/config-server.d.ts +26 -0
- package/dist/cli/server/config-server.js +363 -0
- package/dist/cli/server/ws-server.d.ts +68 -0
- package/dist/cli/server/ws-server.js +335 -0
- package/dist/cli/utils/output.d.ts +11 -0
- package/dist/cli/utils/output.js +13 -0
- package/dist/mcp/daemon-client.d.ts +31 -0
- package/dist/mcp/daemon-client.js +99 -0
- package/dist/mcp/index.d.ts +7 -0
- package/dist/mcp/index.js +68 -0
- package/dist/mcp/instructions.d.ts +1 -0
- package/dist/mcp/instructions.js +249 -0
- package/dist/mcp/resources/edit-tree-guide.d.ts +1 -0
- package/dist/mcp/resources/edit-tree-guide.js +197 -0
- package/dist/mcp/resources/error-reference.d.ts +1 -0
- package/dist/mcp/resources/error-reference.js +132 -0
- package/dist/mcp/resources/outline-format.d.ts +1 -0
- package/dist/mcp/resources/outline-format.js +104 -0
- package/dist/mcp/resources/rem-object-fields.d.ts +1 -0
- package/dist/mcp/resources/rem-object-fields.js +331 -0
- package/dist/mcp/resources/separator-flashcard.d.ts +1 -0
- package/dist/mcp/resources/separator-flashcard.js +120 -0
- package/dist/mcp/tools/edit-tools.d.ts +5 -0
- package/dist/mcp/tools/edit-tools.js +47 -0
- package/dist/mcp/tools/infra-tools.d.ts +5 -0
- package/dist/mcp/tools/infra-tools.js +43 -0
- package/dist/mcp/tools/read-tools.d.ts +5 -0
- package/dist/mcp/tools/read-tools.js +195 -0
- package/dist/mcp/types.d.ts +12 -0
- package/dist/mcp/types.js +4 -0
- package/docs/instruction/connect.md +158 -0
- package/docs/instruction/disconnect.md +146 -0
- package/docs/instruction/edit-rem.md +509 -0
- package/docs/instruction/edit-tree.md +419 -0
- package/docs/instruction/health.md +159 -0
- package/docs/instruction/overall.md +751 -0
- package/docs/instruction/read-context.md +353 -0
- package/docs/instruction/read-globe.md +206 -0
- package/docs/instruction/read-rem.md +476 -0
- package/docs/instruction/read-tree.md +428 -0
- package/docs/instruction/search.md +196 -0
- package/package.json +41 -0
- package/remnote-plugin/package.json +48 -0
- package/remnote-plugin/postcss.config.js +5 -0
- package/remnote-plugin/public/bridge-icon.svg +8 -0
- package/remnote-plugin/public/manifest.json +22 -0
- package/remnote-plugin/src/bridge/message-router.ts +57 -0
- package/remnote-plugin/src/bridge/websocket-client.ts +245 -0
- package/remnote-plugin/src/index.css +1 -0
- package/remnote-plugin/src/services/breadcrumb.ts +26 -0
- package/remnote-plugin/src/services/create-rem.ts +59 -0
- package/remnote-plugin/src/services/delete-rem.ts +29 -0
- package/remnote-plugin/src/services/index.ts +16 -0
- package/remnote-plugin/src/services/move-rem.ts +39 -0
- package/remnote-plugin/src/services/powerup-filter.ts +31 -0
- package/remnote-plugin/src/services/read-context.ts +368 -0
- package/remnote-plugin/src/services/read-globe.ts +197 -0
- package/remnote-plugin/src/services/read-rem.ts +284 -0
- package/remnote-plugin/src/services/read-tree.ts +222 -0
- package/remnote-plugin/src/services/rem-builder.ts +124 -0
- package/remnote-plugin/src/services/reorder-children.ts +61 -0
- package/remnote-plugin/src/services/search.ts +56 -0
- package/remnote-plugin/src/services/write-rem-fields.ts +254 -0
- package/remnote-plugin/src/settings.ts +12 -0
- package/remnote-plugin/src/style.css +45 -0
- package/remnote-plugin/src/test-scripts/AGENTS.md +46 -0
- package/remnote-plugin/src/test-scripts/test-actions.ts +230 -0
- package/remnote-plugin/src/test-scripts/test-powerup-rendering.ts +722 -0
- package/remnote-plugin/src/test-scripts/test-rem-type-mapping.ts +283 -0
- package/remnote-plugin/src/test-scripts/test-richtext-builder.ts +207 -0
- package/remnote-plugin/src/test-scripts/test-richtext-matrix.ts +332 -0
- package/remnote-plugin/src/test-scripts/test-richtext-remaining.ts +245 -0
- package/remnote-plugin/src/test-scripts/test-rw-fields.ts +399 -0
- package/remnote-plugin/src/types.ts +419 -0
- package/remnote-plugin/src/utils/elision.ts +45 -0
- package/remnote-plugin/src/utils/index.ts +10 -0
- package/remnote-plugin/src/utils/tree-serializer.ts +269 -0
- package/remnote-plugin/src/widgets/bridge_widget.tsx +170 -0
- package/remnote-plugin/src/widgets/index.tsx +82 -0
- package/remnote-plugin/tailwind.config.js +7 -0
- package/remnote-plugin/tsconfig.json +21 -0
- package/remnote-plugin/webpack.config.js +125 -0
- package/skill/SKILL.md +428 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tree-parser.ts — 大纲解析 + diff 算法
|
|
3
|
+
*
|
|
4
|
+
* CLI handlers 层的纯函数。解析 Markdown 大纲文本为树结构,
|
|
5
|
+
* 对比新旧两棵树生成增量操作列表。
|
|
6
|
+
*
|
|
7
|
+
* 不依赖 server / commands / daemon(强约束)。
|
|
8
|
+
*/
|
|
9
|
+
// ────────────────────────── 行解析 ──────────────────────────
|
|
10
|
+
/**
|
|
11
|
+
* 行尾标记正则(D5: 从行尾匹配)。
|
|
12
|
+
* 捕获组:[1] = remId, [2] = 可选的 key:value 元数据串
|
|
13
|
+
*/
|
|
14
|
+
const LINE_MARKER_RE = /<!--(\S+)((?:\s+\S+)*)-->$/;
|
|
15
|
+
/** 省略占位符正则 */
|
|
16
|
+
const ELIDED_LINE_RE = /^<!--\.\.\.elided\s/;
|
|
17
|
+
/** 解析单行,提取缩进、内容、remId */
|
|
18
|
+
function parseLine(line) {
|
|
19
|
+
// 计算缩进(每 2 空格一级)
|
|
20
|
+
const stripped = line.replace(/^ */, '');
|
|
21
|
+
const indentChars = line.length - stripped.length;
|
|
22
|
+
const depth = Math.floor(indentChars / 2);
|
|
23
|
+
// 检测省略占位符行
|
|
24
|
+
if (ELIDED_LINE_RE.test(stripped)) {
|
|
25
|
+
return { depth, rawContent: stripped, remId: null, metadata: '', isElided: true };
|
|
26
|
+
}
|
|
27
|
+
// 匹配行尾标记
|
|
28
|
+
const match = stripped.match(LINE_MARKER_RE);
|
|
29
|
+
if (match) {
|
|
30
|
+
const remId = match[1];
|
|
31
|
+
const metadata = match[2].trim();
|
|
32
|
+
// rawContent = 去掉行尾标记后的内容(也去掉标记前的空格)
|
|
33
|
+
const contentPart = stripped.slice(0, match.index).trimEnd();
|
|
34
|
+
return { depth, rawContent: contentPart, remId, metadata, isElided: false };
|
|
35
|
+
}
|
|
36
|
+
// 无标记 = 新增行
|
|
37
|
+
return { depth, rawContent: stripped, remId: null, metadata: '', isElided: false };
|
|
38
|
+
}
|
|
39
|
+
// ────────────────────────── 大纲解析 ──────────────────────────
|
|
40
|
+
/**
|
|
41
|
+
* 解析 Markdown 大纲文本为树结构。
|
|
42
|
+
*
|
|
43
|
+
* 使用栈追踪当前路径,根据缩进级别确定父子关系。
|
|
44
|
+
*/
|
|
45
|
+
export function parseOutline(text) {
|
|
46
|
+
const lines = text.split('\n').filter(l => l.trim() !== '');
|
|
47
|
+
if (lines.length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
const roots = [];
|
|
50
|
+
// stack[i] = depth 为 i 的最近节点
|
|
51
|
+
const stack = [];
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const { depth, rawContent, remId, isElided } = parseLine(line);
|
|
54
|
+
const node = {
|
|
55
|
+
remId,
|
|
56
|
+
depth,
|
|
57
|
+
rawContent,
|
|
58
|
+
rawLine: line,
|
|
59
|
+
children: [],
|
|
60
|
+
isElided,
|
|
61
|
+
};
|
|
62
|
+
if (depth === 0) {
|
|
63
|
+
roots.push(node);
|
|
64
|
+
stack.length = 1;
|
|
65
|
+
stack[0] = node;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// 找到父节点(depth - 1 层的最近节点)
|
|
69
|
+
const parent = stack[depth - 1];
|
|
70
|
+
if (!parent) {
|
|
71
|
+
throw new Error(`缩进跳级:行 "${line.trim()}" 的缩进级别为 ${depth},` +
|
|
72
|
+
`但找不到上一级(${depth - 1})的父节点。请检查缩进是否正确。`);
|
|
73
|
+
}
|
|
74
|
+
parent.children.push(node);
|
|
75
|
+
stack.length = depth + 1;
|
|
76
|
+
stack[depth] = node;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return roots;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 从新增行的 rawContent 中解析 Markdown 前缀和箭头分隔符,提取属性。
|
|
83
|
+
*
|
|
84
|
+
* 解析顺序与 read-tree 输出的嵌套顺序对称:Header → Todo → Code → 箭头分隔符。
|
|
85
|
+
*
|
|
86
|
+
* 箭头分隔符(v2 格式):
|
|
87
|
+
* 中间箭头(有 backText):` → ` ` ← ` ` ↔ ` ` ↓ ` ` ↑ ` ` ↕ `
|
|
88
|
+
* 尾部箭头(无 backText,multiline):` ↓` ` ↑` ` ↕`
|
|
89
|
+
*
|
|
90
|
+
* ⚠️ 已知限制:使用 indexOf 匹配第一个箭头,如果用户新增行的内容本身包含
|
|
91
|
+
* 箭头字符(如 `A → B → C`),会被误切割为 text + backText。
|
|
92
|
+
* 对于 read→edit 往返的已有行不受影响(序列化/反序列化对称)。
|
|
93
|
+
*/
|
|
94
|
+
export function parsePowerupPrefix(rawContent) {
|
|
95
|
+
const powerups = {};
|
|
96
|
+
if (rawContent === '---') {
|
|
97
|
+
return { cleanContent: '', powerups: { addPowerup: 'dv' } };
|
|
98
|
+
}
|
|
99
|
+
let content = rawContent;
|
|
100
|
+
// Header(从长到短匹配)
|
|
101
|
+
if (content.startsWith('### ')) {
|
|
102
|
+
powerups.fontSize = 'H3';
|
|
103
|
+
content = content.slice(4);
|
|
104
|
+
}
|
|
105
|
+
else if (content.startsWith('## ')) {
|
|
106
|
+
powerups.fontSize = 'H2';
|
|
107
|
+
content = content.slice(3);
|
|
108
|
+
}
|
|
109
|
+
else if (content.startsWith('# ')) {
|
|
110
|
+
powerups.fontSize = 'H1';
|
|
111
|
+
content = content.slice(2);
|
|
112
|
+
}
|
|
113
|
+
// Todo
|
|
114
|
+
if (content.startsWith('- [x] ')) {
|
|
115
|
+
powerups.isTodo = true;
|
|
116
|
+
powerups.todoStatus = 'Finished';
|
|
117
|
+
content = content.slice(6);
|
|
118
|
+
}
|
|
119
|
+
else if (content.startsWith('- [ ] ')) {
|
|
120
|
+
powerups.isTodo = true;
|
|
121
|
+
content = content.slice(6);
|
|
122
|
+
}
|
|
123
|
+
// Code
|
|
124
|
+
if (content.startsWith('`') && content.endsWith('`') && content.length >= 2) {
|
|
125
|
+
powerups.isCode = true;
|
|
126
|
+
content = content.slice(1, -1);
|
|
127
|
+
}
|
|
128
|
+
// 箭头分隔符解析
|
|
129
|
+
let backText;
|
|
130
|
+
let practiceDirection;
|
|
131
|
+
let isMultiline;
|
|
132
|
+
// 中间箭头(有 backText)— 按搜索顺序逐个匹配
|
|
133
|
+
const midArrows = [
|
|
134
|
+
[' ↔ ', 'both', false],
|
|
135
|
+
[' ↕ ', 'both', true],
|
|
136
|
+
[' → ', 'forward', false],
|
|
137
|
+
[' ← ', 'backward', false],
|
|
138
|
+
[' ↓ ', 'forward', true],
|
|
139
|
+
[' ↑ ', 'backward', true],
|
|
140
|
+
];
|
|
141
|
+
for (const [arrow, dir, multi] of midArrows) {
|
|
142
|
+
const idx = content.indexOf(arrow);
|
|
143
|
+
if (idx !== -1) {
|
|
144
|
+
backText = content.slice(idx + arrow.length);
|
|
145
|
+
content = content.slice(0, idx);
|
|
146
|
+
practiceDirection = dir;
|
|
147
|
+
isMultiline = multi;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// 尾部箭头(无 backText,multiline)
|
|
152
|
+
if (backText === undefined) {
|
|
153
|
+
const tailArrows = [
|
|
154
|
+
[' ↕', 'both'],
|
|
155
|
+
[' ↓', 'forward'],
|
|
156
|
+
[' ↑', 'backward'],
|
|
157
|
+
];
|
|
158
|
+
for (const [arrow, dir] of tailArrows) {
|
|
159
|
+
if (content.endsWith(arrow)) {
|
|
160
|
+
content = content.slice(0, -arrow.length);
|
|
161
|
+
practiceDirection = dir;
|
|
162
|
+
isMultiline = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const hasChanges = Object.keys(powerups).length > 0
|
|
168
|
+
|| backText !== undefined
|
|
169
|
+
|| practiceDirection !== undefined;
|
|
170
|
+
if (!hasChanges)
|
|
171
|
+
return { cleanContent: rawContent, powerups: {} };
|
|
172
|
+
const result = { cleanContent: content, powerups };
|
|
173
|
+
if (backText !== undefined)
|
|
174
|
+
result.backText = backText;
|
|
175
|
+
if (practiceDirection !== undefined)
|
|
176
|
+
result.practiceDirection = practiceDirection;
|
|
177
|
+
if (isMultiline !== undefined)
|
|
178
|
+
result.isMultiline = isMultiline;
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
// ────────────────────────── Multiline 检测 ──────────────────────────
|
|
182
|
+
/** multiline 箭头正则:中间箭头 ↓↑↕ 或尾部箭头 ↓↑↕ */
|
|
183
|
+
const MULTILINE_MID_RE = / [↓↑↕] /;
|
|
184
|
+
const MULTILINE_TAIL_RE = / [↓↑↕]$/;
|
|
185
|
+
/** 从行内容判断是否为 multiline 父节点(内容包含 ↓↑↕ 箭头) */
|
|
186
|
+
export function isContentMultiline(rawContent) {
|
|
187
|
+
return MULTILINE_MID_RE.test(rawContent) || MULTILINE_TAIL_RE.test(rawContent);
|
|
188
|
+
}
|
|
189
|
+
function buildOldMap(roots) {
|
|
190
|
+
const map = new Map();
|
|
191
|
+
function walk(node, parentId) {
|
|
192
|
+
if (node.remId) {
|
|
193
|
+
const childrenIds = node.children
|
|
194
|
+
.filter(c => c.remId !== null)
|
|
195
|
+
.map(c => c.remId);
|
|
196
|
+
// 检查折叠标记
|
|
197
|
+
const foldedMatch = node.rawLine.match(/children:(\d+)/);
|
|
198
|
+
const hasFoldedChildren = foldedMatch !== null && node.children.length === 0;
|
|
199
|
+
const foldedCount = foldedMatch ? parseInt(foldedMatch[1], 10) : 0;
|
|
200
|
+
map.set(node.remId, {
|
|
201
|
+
parentId,
|
|
202
|
+
content: node.rawContent,
|
|
203
|
+
childrenIds,
|
|
204
|
+
hasFoldedChildren,
|
|
205
|
+
foldedCount,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
for (const child of node.children) {
|
|
209
|
+
walk(child, node.remId);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const root of roots) {
|
|
213
|
+
walk(root, null);
|
|
214
|
+
}
|
|
215
|
+
return map;
|
|
216
|
+
}
|
|
217
|
+
function buildNewMap(roots) {
|
|
218
|
+
const map = new Map();
|
|
219
|
+
function walk(node, parentId, position) {
|
|
220
|
+
if (node.remId) {
|
|
221
|
+
map.set(node.remId, { parentId, content: node.rawContent, position });
|
|
222
|
+
}
|
|
223
|
+
let childPos = 0;
|
|
224
|
+
for (const child of node.children) {
|
|
225
|
+
walk(child, node.remId, childPos);
|
|
226
|
+
childPos++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
let rootPos = 0;
|
|
230
|
+
for (const root of roots) {
|
|
231
|
+
walk(root, null, rootPos);
|
|
232
|
+
rootPos++;
|
|
233
|
+
}
|
|
234
|
+
return map;
|
|
235
|
+
}
|
|
236
|
+
function collectNewLines(roots) {
|
|
237
|
+
const result = [];
|
|
238
|
+
function walk(node, parentId) {
|
|
239
|
+
let childPos = 0;
|
|
240
|
+
for (const child of node.children) {
|
|
241
|
+
if (child.remId === null && !child.isElided) {
|
|
242
|
+
if (!parentId) {
|
|
243
|
+
// 新增行不能作为根节点的兄弟(根 remId 为 null 时理论不会到这里)
|
|
244
|
+
throw new Error('新增行缺少父节点');
|
|
245
|
+
}
|
|
246
|
+
const myIndex = result.length;
|
|
247
|
+
result.push({
|
|
248
|
+
content: child.rawContent,
|
|
249
|
+
parentId,
|
|
250
|
+
position: childPos,
|
|
251
|
+
parentIsMultiline: isContentMultiline(node.rawContent),
|
|
252
|
+
});
|
|
253
|
+
// 新增行也可能有子节点(嵌套新增)
|
|
254
|
+
collectNewLinesUnderNew(child, result, myIndex);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
walk(child, child.remId);
|
|
258
|
+
}
|
|
259
|
+
childPos++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const root of roots) {
|
|
263
|
+
walk(root, root.remId);
|
|
264
|
+
}
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* 新增行下面的嵌套新增行。
|
|
269
|
+
* 这些行的 parentId 需要在创建时动态分配(临时占位标记)。
|
|
270
|
+
* parentIndex 是父行在 result 数组中的索引,所有兄弟子行共享同一个父引用。
|
|
271
|
+
*/
|
|
272
|
+
function collectNewLinesUnderNew(parent, result, parentIndex) {
|
|
273
|
+
let childPos = 0;
|
|
274
|
+
for (const child of parent.children) {
|
|
275
|
+
const myIndex = result.length;
|
|
276
|
+
result.push({
|
|
277
|
+
content: child.rawContent,
|
|
278
|
+
parentId: `__new_${parentIndex}__`,
|
|
279
|
+
position: childPos,
|
|
280
|
+
parentIsMultiline: isContentMultiline(parent.rawContent),
|
|
281
|
+
});
|
|
282
|
+
if (child.children.length > 0) {
|
|
283
|
+
collectNewLinesUnderNew(child, result, myIndex);
|
|
284
|
+
}
|
|
285
|
+
childPos++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** 递归收集所有省略占位符行的 rawContent */
|
|
289
|
+
function collectElidedLines(roots) {
|
|
290
|
+
const result = new Set();
|
|
291
|
+
function walk(node) {
|
|
292
|
+
if (node.isElided) {
|
|
293
|
+
result.add(node.rawContent);
|
|
294
|
+
}
|
|
295
|
+
for (const child of node.children) {
|
|
296
|
+
walk(child);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const root of roots)
|
|
300
|
+
walk(root);
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* 对比新旧大纲树,生成操作列表或报错。
|
|
305
|
+
*
|
|
306
|
+
* @param oldRoots 旧大纲解析结果
|
|
307
|
+
* @param newRoots 新大纲解析结果(str_replace 后)
|
|
308
|
+
* @returns 操作列表或错误
|
|
309
|
+
*/
|
|
310
|
+
export function diffTrees(oldRoots, newRoots) {
|
|
311
|
+
const oldMap = buildOldMap(oldRoots);
|
|
312
|
+
const newMap = buildNewMap(newRoots);
|
|
313
|
+
// ── D7: 根节点校验 ──
|
|
314
|
+
if (oldRoots.length > 0 && newRoots.length > 0) {
|
|
315
|
+
const oldRoot = oldRoots[0];
|
|
316
|
+
const newRoot = newRoots[0];
|
|
317
|
+
if (oldRoot.remId && newRoot.remId !== oldRoot.remId) {
|
|
318
|
+
return {
|
|
319
|
+
type: 'root_modified',
|
|
320
|
+
message: 'Root node cannot be changed, deleted or moved.',
|
|
321
|
+
details: { expected: oldRoot.remId, actual: newRoot.remId },
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// ── 省略行防线:检测旧大纲中的省略行是否在新大纲中被删除/修改 ──
|
|
326
|
+
const oldElidedLines = collectElidedLines(oldRoots);
|
|
327
|
+
const newElidedLines = collectElidedLines(newRoots);
|
|
328
|
+
// 旧大纲中的每个省略行必须在新大纲中原样存在
|
|
329
|
+
for (const elidedLine of oldElidedLines) {
|
|
330
|
+
if (!newElidedLines.has(elidedLine)) {
|
|
331
|
+
return {
|
|
332
|
+
type: 'elided_modified',
|
|
333
|
+
message: 'Cannot delete or modify elided region directly. Use read-tree with the parent remId to expand first.',
|
|
334
|
+
details: { elidedLine },
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// ── 内容变更检测 ──
|
|
339
|
+
const modifiedRems = [];
|
|
340
|
+
for (const [remId, newInfo] of newMap) {
|
|
341
|
+
const oldInfo = oldMap.get(remId);
|
|
342
|
+
if (oldInfo && oldInfo.content !== newInfo.content) {
|
|
343
|
+
modifiedRems.push({
|
|
344
|
+
remId,
|
|
345
|
+
original_content: oldInfo.content,
|
|
346
|
+
new_content: newInfo.content,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (modifiedRems.length > 0) {
|
|
351
|
+
return {
|
|
352
|
+
type: 'content_modified',
|
|
353
|
+
message: 'Content modification of existing Rem is not allowed in tree edit mode.',
|
|
354
|
+
details: {
|
|
355
|
+
modified_rems: modifiedRems,
|
|
356
|
+
hint: `Use edit-rem ${modifiedRems[0].remId} --old-str ... --new-str ... for content changes.`,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// ── D6: 折叠节点删除检测 ──
|
|
361
|
+
const deletedIds = new Set();
|
|
362
|
+
for (const remId of oldMap.keys()) {
|
|
363
|
+
if (!newMap.has(remId)) {
|
|
364
|
+
deletedIds.add(remId);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
for (const remId of deletedIds) {
|
|
368
|
+
const oldInfo = oldMap.get(remId);
|
|
369
|
+
if (oldInfo.hasFoldedChildren) {
|
|
370
|
+
return {
|
|
371
|
+
type: 'folded_delete',
|
|
372
|
+
message: `Cannot delete ${remId} because it has ${oldInfo.foldedCount} hidden children. Use read-tree to expand first.`,
|
|
373
|
+
details: { remId, hidden_children_count: oldInfo.foldedCount },
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// ── 孤儿检测 ──
|
|
378
|
+
for (const remId of deletedIds) {
|
|
379
|
+
const oldInfo = oldMap.get(remId);
|
|
380
|
+
for (const childId of oldInfo.childrenIds) {
|
|
381
|
+
if (!deletedIds.has(childId)) {
|
|
382
|
+
return {
|
|
383
|
+
type: 'orphan_detected',
|
|
384
|
+
message: `Cannot delete ${remId} because it has children that were not removed.`,
|
|
385
|
+
details: {
|
|
386
|
+
orphaned_children: oldInfo.childrenIds.filter(id => !deletedIds.has(id)),
|
|
387
|
+
hint: 'Either remove all children too, or move them to another parent first.',
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const operations = [];
|
|
394
|
+
// ── D4 步骤 1: 新增(按出现顺序,从浅到深) ──
|
|
395
|
+
const newLines = collectNewLines(newRoots);
|
|
396
|
+
for (const nl of newLines) {
|
|
397
|
+
operations.push({
|
|
398
|
+
type: 'create',
|
|
399
|
+
content: nl.content,
|
|
400
|
+
parentId: nl.parentId,
|
|
401
|
+
position: nl.position,
|
|
402
|
+
parentIsMultiline: nl.parentIsMultiline,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// ── D4 步骤 2: 移动(父节点变化) ──
|
|
406
|
+
for (const [remId, newInfo] of newMap) {
|
|
407
|
+
const oldInfo = oldMap.get(remId);
|
|
408
|
+
if (!oldInfo)
|
|
409
|
+
continue; // 新节点不在旧树中(不应该出现,已有 remId 的节点应该在旧树中)
|
|
410
|
+
if (oldInfo.parentId !== newInfo.parentId && newInfo.parentId !== null) {
|
|
411
|
+
// 从新旧树中查找父节点内容以检测 multiline
|
|
412
|
+
const fromParentNode = oldInfo.parentId ? findNodeById(oldRoots, oldInfo.parentId) : null;
|
|
413
|
+
const toParentNode = findNodeById(newRoots, newInfo.parentId);
|
|
414
|
+
// 检测被移动节点自身是否有 practiceDirection(箭头分隔符)
|
|
415
|
+
const selfPd = parsePowerupPrefix(oldInfo.content).practiceDirection;
|
|
416
|
+
operations.push({
|
|
417
|
+
type: 'move',
|
|
418
|
+
remId,
|
|
419
|
+
fromParentId: oldInfo.parentId ?? '',
|
|
420
|
+
toParentId: newInfo.parentId,
|
|
421
|
+
position: newInfo.position,
|
|
422
|
+
fromParentIsMultiline: fromParentNode ? isContentMultiline(fromParentNode.rawContent) : false,
|
|
423
|
+
toParentIsMultiline: toParentNode ? isContentMultiline(toParentNode.rawContent) : false,
|
|
424
|
+
selfHasPracticeDirection: selfPd !== undefined,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// ── D4 步骤 3: 重排(同父节点内位置变化) ──
|
|
429
|
+
// 按父节点分组,比较 children 顺序
|
|
430
|
+
const newChildrenByParent = new Map();
|
|
431
|
+
for (const [remId, newInfo] of newMap) {
|
|
432
|
+
if (newInfo.parentId === null)
|
|
433
|
+
continue;
|
|
434
|
+
if (!newChildrenByParent.has(newInfo.parentId)) {
|
|
435
|
+
newChildrenByParent.set(newInfo.parentId, []);
|
|
436
|
+
}
|
|
437
|
+
// 只收集未被删除且未被移动的节点
|
|
438
|
+
const oldInfo = oldMap.get(remId);
|
|
439
|
+
if (oldInfo && oldInfo.parentId === newInfo.parentId) {
|
|
440
|
+
newChildrenByParent.get(newInfo.parentId).push(remId);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
for (const [parentId, newOrder] of newChildrenByParent) {
|
|
444
|
+
const oldInfo = oldMap.get(parentId);
|
|
445
|
+
if (!oldInfo)
|
|
446
|
+
continue;
|
|
447
|
+
// 过滤出在新旧中都存在且父节点未变的 children
|
|
448
|
+
const oldOrder = oldInfo.childrenIds.filter(id => newMap.has(id) && newMap.get(id).parentId === parentId);
|
|
449
|
+
if (oldOrder.length === newOrder.length && !oldOrder.every((id, i) => id === newOrder[i])) {
|
|
450
|
+
// 需要包含所有新 children(含新增行的 placeholder 和移入的节点)
|
|
451
|
+
// 获取完整的新 children 顺序
|
|
452
|
+
const fullNewOrder = [];
|
|
453
|
+
const newRoot = findNodeById(newRoots, parentId);
|
|
454
|
+
if (newRoot) {
|
|
455
|
+
for (const child of newRoot.children) {
|
|
456
|
+
if (child.remId)
|
|
457
|
+
fullNewOrder.push(child.remId);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (fullNewOrder.length > 0) {
|
|
461
|
+
operations.push({ type: 'reorder', parentId, order: fullNewOrder });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// ── D4 步骤 4: 删除(按层级从深到浅) ──
|
|
466
|
+
const deleteOps = Array.from(deletedIds).map(remId => {
|
|
467
|
+
// 计算深度
|
|
468
|
+
let depth = 0;
|
|
469
|
+
let currentId = remId;
|
|
470
|
+
while (currentId) {
|
|
471
|
+
const info = oldMap.get(currentId);
|
|
472
|
+
if (!info || !info.parentId)
|
|
473
|
+
break;
|
|
474
|
+
currentId = info.parentId;
|
|
475
|
+
depth++;
|
|
476
|
+
}
|
|
477
|
+
return { remId, depth };
|
|
478
|
+
});
|
|
479
|
+
// 从深到浅排序
|
|
480
|
+
deleteOps.sort((a, b) => b.depth - a.depth);
|
|
481
|
+
for (const { remId } of deleteOps) {
|
|
482
|
+
operations.push({ type: 'delete', remId });
|
|
483
|
+
}
|
|
484
|
+
return { operations };
|
|
485
|
+
}
|
|
486
|
+
/** 在树中按 remId 查找节点 */
|
|
487
|
+
function findNodeById(roots, remId) {
|
|
488
|
+
for (const root of roots) {
|
|
489
|
+
if (root.remId === remId)
|
|
490
|
+
return root;
|
|
491
|
+
const found = findNodeByIdRecursive(root.children, remId);
|
|
492
|
+
if (found)
|
|
493
|
+
return found;
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
function findNodeByIdRecursive(nodes, remId) {
|
|
498
|
+
for (const node of nodes) {
|
|
499
|
+
if (node.remId === remId)
|
|
500
|
+
return node;
|
|
501
|
+
const found = findNodeByIdRecursive(node.children, remId);
|
|
502
|
+
if (found)
|
|
503
|
+
return found;
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeReadHandler — read-tree 请求的业务编排
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. 转发到 Plugin 获取序列化的 Markdown 大纲
|
|
6
|
+
* 2. 以 'tree:' + remId 为 key 缓存大纲文本
|
|
7
|
+
* 3. 返回大纲结果给 CLI command
|
|
8
|
+
*/
|
|
9
|
+
import type { DefaultsConfig } from '../config.js';
|
|
10
|
+
import { RemCache } from './rem-cache.js';
|
|
11
|
+
export interface TreeReadResult {
|
|
12
|
+
rootId: string;
|
|
13
|
+
depth: number;
|
|
14
|
+
nodeCount: number;
|
|
15
|
+
outline: string;
|
|
16
|
+
powerupFiltered?: {
|
|
17
|
+
tags: number;
|
|
18
|
+
children: number;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export declare class TreeReadHandler {
|
|
22
|
+
private cache;
|
|
23
|
+
private forwardToPlugin;
|
|
24
|
+
private onLog?;
|
|
25
|
+
private defaults;
|
|
26
|
+
constructor(cache: RemCache, forwardToPlugin: (action: string, payload: Record<string, unknown>) => Promise<unknown>, onLog?: ((message: string, level: "info" | "warn" | "error") => void) | undefined, defaults?: DefaultsConfig);
|
|
27
|
+
handleReadTree(payload: Record<string, unknown>): Promise<TreeReadResult>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeReadHandler — read-tree 请求的业务编排
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. 转发到 Plugin 获取序列化的 Markdown 大纲
|
|
6
|
+
* 2. 以 'tree:' + remId 为 key 缓存大纲文本
|
|
7
|
+
* 3. 返回大纲结果给 CLI command
|
|
8
|
+
*/
|
|
9
|
+
import { DEFAULT_DEFAULTS } from '../config.js';
|
|
10
|
+
export class TreeReadHandler {
|
|
11
|
+
cache;
|
|
12
|
+
forwardToPlugin;
|
|
13
|
+
onLog;
|
|
14
|
+
defaults;
|
|
15
|
+
constructor(cache, forwardToPlugin, onLog, defaults) {
|
|
16
|
+
this.cache = cache;
|
|
17
|
+
this.forwardToPlugin = forwardToPlugin;
|
|
18
|
+
this.onLog = onLog;
|
|
19
|
+
this.defaults = defaults ?? DEFAULT_DEFAULTS;
|
|
20
|
+
}
|
|
21
|
+
async handleReadTree(payload) {
|
|
22
|
+
const remId = payload.remId;
|
|
23
|
+
if (!remId) {
|
|
24
|
+
throw new Error('缺少 remId 参数');
|
|
25
|
+
}
|
|
26
|
+
const depth = payload.depth ?? this.defaults.readTreeDepth;
|
|
27
|
+
const maxNodes = payload.maxNodes ?? this.defaults.maxNodes;
|
|
28
|
+
const maxSiblings = payload.maxSiblings ?? this.defaults.maxSiblings;
|
|
29
|
+
const ancestorLevels = payload.ancestorLevels ?? this.defaults.readTreeAncestorLevels;
|
|
30
|
+
const includePowerup = payload.includePowerup ?? this.defaults.readTreeIncludePowerup;
|
|
31
|
+
// 检查旧缓存
|
|
32
|
+
const cacheKey = 'tree:' + remId;
|
|
33
|
+
const previousCachedAt = this.cache.getCreatedAt(cacheKey);
|
|
34
|
+
// 转发到 Plugin 的 read_tree service
|
|
35
|
+
const result = await this.forwardToPlugin('read_tree', {
|
|
36
|
+
remId, depth, maxNodes, maxSiblings, ancestorLevels, includePowerup,
|
|
37
|
+
});
|
|
38
|
+
// 缓存大纲文本 + 读取参数(供 edit-tree 乐观并发检测时复现相同查询)
|
|
39
|
+
this.cache.set(cacheKey, result.outline);
|
|
40
|
+
this.cache.set('tree-depth:' + remId, String(depth));
|
|
41
|
+
this.cache.set('tree-maxNodes:' + remId, String(maxNodes));
|
|
42
|
+
this.cache.set('tree-maxSiblings:' + remId, String(maxSiblings));
|
|
43
|
+
this.onLog?.(`缓存树 ${remId.slice(0, 8)}... (${result.nodeCount} 节点, ${result.outline.length} bytes)`, 'info');
|
|
44
|
+
// 附加缓存覆盖提示
|
|
45
|
+
if (previousCachedAt) {
|
|
46
|
+
result._cacheOverridden = {
|
|
47
|
+
id: remId,
|
|
48
|
+
previousCachedAt,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
}
|