remnote-bridge 0.1.6 → 0.1.8
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/README.md +30 -6
- package/dist/cli/commands/connect.js +31 -2
- package/dist/cli/commands/health.js +111 -1
- package/dist/cli/commands/setup.js +112 -0
- package/dist/cli/daemon/daemon.js +101 -20
- package/dist/cli/daemon/headless-browser.js +291 -0
- package/dist/cli/daemon/static-server.js +84 -0
- package/dist/cli/handlers/edit-handler.js +89 -3
- package/dist/cli/handlers/read-handler.js +16 -0
- package/dist/cli/handlers/tree-edit-handler.js +59 -28
- package/dist/cli/handlers/tree-parser.js +110 -3
- package/dist/cli/main.js +22 -6
- package/dist/cli/server/ws-server.js +62 -1
- package/dist/mcp/daemon-client.js +4 -1
- package/dist/mcp/instructions.js +97 -12
- package/dist/mcp/resources/edit-rem-guide.js +53 -0
- package/dist/mcp/resources/edit-tree-guide.js +60 -0
- package/dist/mcp/resources/error-reference.js +8 -1
- package/dist/mcp/resources/outline-format.js +29 -1
- package/dist/mcp/resources/rem-object-fields.js +6 -4
- package/dist/mcp/resources/separator-flashcard.js +5 -5
- package/dist/mcp/tools/infra-tools.js +39 -9
- package/package.json +5 -1
- package/remnote-plugin/dist/bridge-icon.svg +8 -0
- package/remnote-plugin/dist/bridge_widget-sandbox.js +65 -0
- package/remnote-plugin/dist/bridge_widget.js +65 -0
- package/remnote-plugin/dist/index-sandbox.css +591 -0
- package/remnote-plugin/dist/index-sandbox.js +64 -0
- package/remnote-plugin/dist/index.css +591 -0
- package/remnote-plugin/dist/index.html +9 -0
- package/remnote-plugin/dist/index.js +64 -0
- package/remnote-plugin/dist/manifest.json +22 -0
- package/remnote-plugin/src/bridge/message-router.ts +11 -0
- package/remnote-plugin/src/services/add-to-portal.ts +40 -0
- package/remnote-plugin/src/services/create-portal.ts +47 -0
- package/remnote-plugin/src/services/remove-from-portal.ts +40 -0
- package/remnote-plugin/src/services/write-rem-fields.ts +39 -0
- package/remnote-plugin/src/types.ts +7 -4
- package/skills/remnote-bridge/SKILL.md +90 -8
- package/skills/remnote-bridge/instructions/connect.md +48 -10
- package/skills/remnote-bridge/instructions/disconnect.md +1 -1
- package/skills/remnote-bridge/instructions/edit-rem.md +67 -4
- package/skills/remnote-bridge/instructions/edit-tree.md +100 -10
- package/skills/remnote-bridge/instructions/health.md +67 -1
- package/skills/remnote-bridge/instructions/overall.md +19 -4
- package/skills/remnote-bridge/instructions/read-rem.md +5 -2
- package/skills/remnote-bridge/instructions/setup.md +130 -0
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
const LINE_MARKER_RE = /<!--(\S+)((?:\s+\S+)*)-->$/;
|
|
15
15
|
/** 省略占位符正则 */
|
|
16
16
|
const ELIDED_LINE_RE = /^<!--\.\.\.elided\s/;
|
|
17
|
-
/**
|
|
17
|
+
/** 纯元数据注释正则(不含 remId,仅匹配 type:xxx、doc、tag:xxx) */
|
|
18
|
+
const METADATA_ONLY_RE = /<!--((?:type:\S+|doc|tag:\S+)(?:\s+(?:type:\S+|doc|tag:\S+))*)-->$/;
|
|
19
|
+
/** Portal 新增行正则:<!--portal refs:id1,id2--> 或 <!--portal--> */
|
|
20
|
+
const NEW_PORTAL_RE = /^<!--portal(?:\s+refs:(\S+))?-->$/;
|
|
21
|
+
/** 解析单行,提取缩进、内容、remId,以及 Portal 新增行标记 */
|
|
18
22
|
function parseLine(line) {
|
|
19
23
|
// 计算缩进(每 2 空格一级)
|
|
20
24
|
const stripped = line.replace(/^ */, '');
|
|
@@ -24,7 +28,21 @@ function parseLine(line) {
|
|
|
24
28
|
if (ELIDED_LINE_RE.test(stripped)) {
|
|
25
29
|
return { depth, rawContent: stripped, remId: null, metadata: '', isElided: true };
|
|
26
30
|
}
|
|
27
|
-
//
|
|
31
|
+
// 检测 Portal 新增行:<!--portal refs:id1,id2--> 或 <!--portal-->
|
|
32
|
+
const portalMatch = stripped.match(NEW_PORTAL_RE);
|
|
33
|
+
if (portalMatch) {
|
|
34
|
+
const refs = portalMatch[1] ? portalMatch[1].split(',') : [];
|
|
35
|
+
return { depth, rawContent: '', remId: null, metadata: '', isElided: false, isPortal: true, portalRefs: refs };
|
|
36
|
+
}
|
|
37
|
+
// 先检测纯元数据注释(不含 remId,新增行专用)
|
|
38
|
+
// 必须在 LINE_MARKER_RE 之前,否则 type:concept 等会被误认为 remId
|
|
39
|
+
const metaOnlyMatch = stripped.match(METADATA_ONLY_RE);
|
|
40
|
+
if (metaOnlyMatch) {
|
|
41
|
+
const metadata = metaOnlyMatch[1].trim();
|
|
42
|
+
const contentPart = stripped.slice(0, metaOnlyMatch.index).trimEnd();
|
|
43
|
+
return { depth, rawContent: contentPart, remId: null, metadata, isElided: false };
|
|
44
|
+
}
|
|
45
|
+
// 匹配行尾标记(含 remId)
|
|
28
46
|
const match = stripped.match(LINE_MARKER_RE);
|
|
29
47
|
if (match) {
|
|
30
48
|
const remId = match[1];
|
|
@@ -50,7 +68,7 @@ export function parseOutline(text) {
|
|
|
50
68
|
// stack[i] = depth 为 i 的最近节点
|
|
51
69
|
const stack = [];
|
|
52
70
|
for (const line of lines) {
|
|
53
|
-
const { depth, rawContent, remId, isElided } = parseLine(line);
|
|
71
|
+
const { depth, rawContent, remId, metadata, isElided, isPortal, portalRefs } = parseLine(line);
|
|
54
72
|
const node = {
|
|
55
73
|
remId,
|
|
56
74
|
depth,
|
|
@@ -58,6 +76,9 @@ export function parseOutline(text) {
|
|
|
58
76
|
rawLine: line,
|
|
59
77
|
children: [],
|
|
60
78
|
isElided,
|
|
79
|
+
metadata: metadata || undefined,
|
|
80
|
+
isPortal: isPortal || undefined,
|
|
81
|
+
portalRefs: portalRefs?.length ? portalRefs : undefined,
|
|
61
82
|
};
|
|
62
83
|
if (depth === 0) {
|
|
63
84
|
roots.push(node);
|
|
@@ -78,6 +99,33 @@ export function parseOutline(text) {
|
|
|
78
99
|
}
|
|
79
100
|
return roots;
|
|
80
101
|
}
|
|
102
|
+
// ────────────────────────── 元数据解析 ──────────────────────────
|
|
103
|
+
/**
|
|
104
|
+
* 解析 HTML 注释中的元数据字符串为结构化属性。
|
|
105
|
+
*
|
|
106
|
+
* 支持的标记:type:concept、type:descriptor、doc、tag:Name(id)
|
|
107
|
+
*/
|
|
108
|
+
export function parseMetadata(metadataStr) {
|
|
109
|
+
const result = {};
|
|
110
|
+
const tokens = metadataStr.split(/\s+/);
|
|
111
|
+
for (const token of tokens) {
|
|
112
|
+
if (token === 'type:concept')
|
|
113
|
+
result.type = 'concept';
|
|
114
|
+
else if (token === 'type:descriptor')
|
|
115
|
+
result.type = 'descriptor';
|
|
116
|
+
else if (token === 'doc')
|
|
117
|
+
result.isDocument = true;
|
|
118
|
+
else if (token.startsWith('tag:')) {
|
|
119
|
+
const idMatch = token.match(/\(([^)]+)\)$/);
|
|
120
|
+
if (idMatch) {
|
|
121
|
+
if (!result.tags)
|
|
122
|
+
result.tags = [];
|
|
123
|
+
result.tags.push(idMatch[1]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
81
129
|
/**
|
|
82
130
|
* 从新增行的 rawContent 中解析 Markdown 前缀和箭头分隔符,提取属性。
|
|
83
131
|
*
|
|
@@ -249,6 +297,9 @@ function collectNewLines(roots) {
|
|
|
249
297
|
parentId,
|
|
250
298
|
position: childPos,
|
|
251
299
|
parentIsMultiline: isContentMultiline(node.rawContent),
|
|
300
|
+
metadata: child.metadata ? parseMetadata(child.metadata) : undefined,
|
|
301
|
+
isPortal: child.isPortal,
|
|
302
|
+
portalRefs: child.portalRefs,
|
|
252
303
|
});
|
|
253
304
|
// 新增行也可能有子节点(嵌套新增)
|
|
254
305
|
collectNewLinesUnderNew(child, result, myIndex);
|
|
@@ -278,6 +329,9 @@ function collectNewLinesUnderNew(parent, result, parentIndex) {
|
|
|
278
329
|
parentId: `__new_${parentIndex}__`,
|
|
279
330
|
position: childPos,
|
|
280
331
|
parentIsMultiline: isContentMultiline(parent.rawContent),
|
|
332
|
+
metadata: child.metadata ? parseMetadata(child.metadata) : undefined,
|
|
333
|
+
isPortal: child.isPortal,
|
|
334
|
+
portalRefs: child.portalRefs,
|
|
281
335
|
});
|
|
282
336
|
if (child.children.length > 0) {
|
|
283
337
|
collectNewLinesUnderNew(child, result, myIndex);
|
|
@@ -285,6 +339,38 @@ function collectNewLinesUnderNew(parent, result, parentIndex) {
|
|
|
285
339
|
childPos++;
|
|
286
340
|
}
|
|
287
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* 检测新增行(无 remId)是否劫持了已有节点的 children。
|
|
344
|
+
* 遍历新树,找到无 remId 的节点,检查其 children 中是否包含旧树中已存在的 remId。
|
|
345
|
+
*/
|
|
346
|
+
function findCapturedChildren(roots, oldMap) {
|
|
347
|
+
function walk(node) {
|
|
348
|
+
if (node.remId === null && !node.isElided && !node.isPortal) {
|
|
349
|
+
// 新增行:检查它的 children 中是否有已存在的 remId
|
|
350
|
+
const captured = [];
|
|
351
|
+
for (const child of node.children) {
|
|
352
|
+
if (child.remId && oldMap.has(child.remId)) {
|
|
353
|
+
captured.push(child.remId);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (captured.length > 0) {
|
|
357
|
+
return { newNodeContent: node.rawContent || '(empty)', capturedIds: captured };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const child of node.children) {
|
|
361
|
+
const result = walk(child);
|
|
362
|
+
if (result)
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
for (const root of roots) {
|
|
368
|
+
const result = walk(root);
|
|
369
|
+
if (result)
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
288
374
|
/** 递归收集所有省略占位符行的 rawContent */
|
|
289
375
|
function collectElidedLines(roots) {
|
|
290
376
|
const result = new Set();
|
|
@@ -335,6 +421,24 @@ export function diffTrees(oldRoots, newRoots) {
|
|
|
335
421
|
};
|
|
336
422
|
}
|
|
337
423
|
}
|
|
424
|
+
// ── 子节点劫持检测:新增行不应"抢走"已有节点的 children ──
|
|
425
|
+
// 典型场景:在有子节点的 Rem 和它的 children 之间插入同级新行,
|
|
426
|
+
// 导致 children 被解析为新行的子节点而非原父节点的子节点。
|
|
427
|
+
{
|
|
428
|
+
const captured = findCapturedChildren(newRoots, oldMap);
|
|
429
|
+
if (captured) {
|
|
430
|
+
return {
|
|
431
|
+
type: 'children_captured',
|
|
432
|
+
message: `New line "${captured.newNodeContent}" accidentally captured existing children (${captured.capturedIds.join(', ')}). ` +
|
|
433
|
+
`Insert the new line after the last child, not between a parent Rem and its children.`,
|
|
434
|
+
details: {
|
|
435
|
+
newNodeContent: captured.newNodeContent,
|
|
436
|
+
capturedIds: captured.capturedIds,
|
|
437
|
+
hint: 'Place the new line after all siblings of the target level, not right after a Rem that has children below it.',
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
338
442
|
// ── 内容变更检测 ──
|
|
339
443
|
const modifiedRems = [];
|
|
340
444
|
for (const [remId, newInfo] of newMap) {
|
|
@@ -400,6 +504,9 @@ export function diffTrees(oldRoots, newRoots) {
|
|
|
400
504
|
parentId: nl.parentId,
|
|
401
505
|
position: nl.position,
|
|
402
506
|
parentIsMultiline: nl.parentIsMultiline,
|
|
507
|
+
metadata: nl.metadata,
|
|
508
|
+
isPortal: nl.isPortal,
|
|
509
|
+
portalRefs: nl.portalRefs,
|
|
403
510
|
});
|
|
404
511
|
}
|
|
405
512
|
// ── D4 步骤 2: 移动(父节点变化) ──
|
package/dist/cli/main.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 伞命令:CLI 命令 + mcp 子命令 + install 子命令。
|
|
6
6
|
*/
|
|
7
|
+
import { createRequire } from 'module';
|
|
7
8
|
import { Command } from 'commander';
|
|
8
9
|
import { connectCommand } from './commands/connect.js';
|
|
10
|
+
import { setupCommand } from './commands/setup.js';
|
|
9
11
|
import { healthCommand } from './commands/health.js';
|
|
10
12
|
import { disconnectCommand } from './commands/disconnect.js';
|
|
11
13
|
import { readRemCommand } from './commands/read-rem.js';
|
|
@@ -17,6 +19,8 @@ import { readContextCommand } from './commands/read-context.js';
|
|
|
17
19
|
import { searchCommand } from './commands/search.js';
|
|
18
20
|
import { installSkillCommand, installSkillCopyCommand } from './commands/install-skill.js';
|
|
19
21
|
import { cleanCommand } from './commands/clean.js';
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { version } = require('../../package.json');
|
|
20
24
|
const program = new Command();
|
|
21
25
|
/**
|
|
22
26
|
* --json 模式下解析 JSON 输入参数。
|
|
@@ -54,21 +58,33 @@ function parseJsonInput(command, jsonStr, requiredFields = []) {
|
|
|
54
58
|
program
|
|
55
59
|
.name('remnote-bridge')
|
|
56
60
|
.description('RemNote Bridge — CLI + MCP Server + Plugin')
|
|
57
|
-
.version(
|
|
61
|
+
.version(version)
|
|
58
62
|
.option('--json', '以 JSON 格式输出(适用于程序化调用)');
|
|
59
63
|
program
|
|
60
|
-
.command('
|
|
61
|
-
.description('
|
|
64
|
+
.command('setup')
|
|
65
|
+
.description('启动 Chrome 让用户登录 RemNote(headless 模式前置步骤)')
|
|
62
66
|
.action(async () => {
|
|
63
67
|
const { json } = program.opts();
|
|
64
|
-
await
|
|
68
|
+
await setupCommand({ json });
|
|
69
|
+
});
|
|
70
|
+
program
|
|
71
|
+
.command('connect')
|
|
72
|
+
.description('启动守护进程,等待 Plugin 连接')
|
|
73
|
+
.option('--dev', '开发模式:使用 webpack-dev-server(支持 HMR)')
|
|
74
|
+
.option('--headless', '无头模式:自动启动 headless Chrome 加载 Plugin(需先 setup)')
|
|
75
|
+
.option('--remote-debugging-port <port>', 'Chrome 远程调试端口(仅 --headless)', parseInt)
|
|
76
|
+
.action(async (cmdOpts) => {
|
|
77
|
+
const { json } = program.opts();
|
|
78
|
+
await connectCommand({ json, dev: cmdOpts.dev, headless: cmdOpts.headless, remoteDebuggingPort: cmdOpts.remoteDebuggingPort });
|
|
65
79
|
});
|
|
66
80
|
program
|
|
67
81
|
.command('health')
|
|
68
82
|
.description('检查守护进程、Plugin 连接和 SDK 状态')
|
|
69
|
-
.
|
|
83
|
+
.option('--diagnose', '诊断 headless Chrome(截图 + 状态 + console 错误)')
|
|
84
|
+
.option('--reload', '重载 headless Chrome 页面')
|
|
85
|
+
.action(async (cmdOpts) => {
|
|
70
86
|
const { json } = program.opts();
|
|
71
|
-
await healthCommand({ json });
|
|
87
|
+
await healthCommand({ json, diagnose: cmdOpts.diagnose, reload: cmdOpts.reload });
|
|
72
88
|
});
|
|
73
89
|
program
|
|
74
90
|
.command('disconnect')
|
|
@@ -49,6 +49,9 @@ export class BridgeServer {
|
|
|
49
49
|
pongTimeoutMs: config.pongTimeoutMs ?? 10_000,
|
|
50
50
|
onLog: config.onLog,
|
|
51
51
|
getTimeoutRemaining: config.getTimeoutRemaining,
|
|
52
|
+
getHeadlessStatus: config.getHeadlessStatus,
|
|
53
|
+
diagnoseHeadless: config.diagnoseHeadless,
|
|
54
|
+
reloadHeadless: config.reloadHeadless,
|
|
52
55
|
};
|
|
53
56
|
this.defaults = config.defaults ?? DEFAULT_DEFAULTS;
|
|
54
57
|
const defaults = this.defaults;
|
|
@@ -200,6 +203,59 @@ export class BridgeServer {
|
|
|
200
203
|
ws.send(JSON.stringify(response));
|
|
201
204
|
return;
|
|
202
205
|
}
|
|
206
|
+
// diagnose:headless 诊断(不需要 Plugin)
|
|
207
|
+
if (request.action === 'diagnose') {
|
|
208
|
+
if (!this.config.diagnoseHeadless) {
|
|
209
|
+
const response = {
|
|
210
|
+
id: request.id,
|
|
211
|
+
error: '非 headless 模式,不支持 diagnose',
|
|
212
|
+
};
|
|
213
|
+
ws.send(JSON.stringify(response));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const diagResult = await this.config.diagnoseHeadless();
|
|
218
|
+
if (diagResult) {
|
|
219
|
+
// 补充 pluginConnected 和 sdkReady
|
|
220
|
+
diagResult.pluginConnected = this.pluginSocket?.readyState === WebSocket.OPEN;
|
|
221
|
+
diagResult.sdkReady = this.pluginSdkReady;
|
|
222
|
+
}
|
|
223
|
+
const response = { id: request.id, result: diagResult };
|
|
224
|
+
ws.send(JSON.stringify(response));
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
const response = {
|
|
228
|
+
id: request.id,
|
|
229
|
+
error: err instanceof Error ? err.message : String(err),
|
|
230
|
+
};
|
|
231
|
+
ws.send(JSON.stringify(response));
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// headless_reload:重载 headless Chrome 页面(不需要 Plugin)
|
|
236
|
+
if (request.action === 'headless_reload') {
|
|
237
|
+
if (!this.config.reloadHeadless) {
|
|
238
|
+
const response = {
|
|
239
|
+
id: request.id,
|
|
240
|
+
error: '非 headless 模式,不支持 reload',
|
|
241
|
+
};
|
|
242
|
+
ws.send(JSON.stringify(response));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const reloadResult = await this.config.reloadHeadless();
|
|
247
|
+
const response = { id: request.id, result: reloadResult };
|
|
248
|
+
ws.send(JSON.stringify(response));
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
const response = {
|
|
252
|
+
id: request.id,
|
|
253
|
+
error: err instanceof Error ? err.message : String(err),
|
|
254
|
+
};
|
|
255
|
+
ws.send(JSON.stringify(response));
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
203
259
|
// 以下 action 都需要 Plugin 连接
|
|
204
260
|
if (!this.pluginSocket || this.pluginSocket.readyState !== WebSocket.OPEN) {
|
|
205
261
|
const response = {
|
|
@@ -325,11 +381,16 @@ export class BridgeServer {
|
|
|
325
381
|
}
|
|
326
382
|
/** 获取当前状态(timeoutRemaining 通过构造时注入的回调获取) */
|
|
327
383
|
getStatus() {
|
|
328
|
-
|
|
384
|
+
const result = {
|
|
329
385
|
pluginConnected: this.pluginSocket?.readyState === WebSocket.OPEN,
|
|
330
386
|
sdkReady: this.pluginSdkReady,
|
|
331
387
|
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
332
388
|
timeoutRemaining: this.config.getTimeoutRemaining?.() ?? 0,
|
|
333
389
|
};
|
|
390
|
+
const headlessStatus = this.config.getHeadlessStatus?.();
|
|
391
|
+
if (headlessStatus) {
|
|
392
|
+
result.headless = headlessStatus;
|
|
393
|
+
}
|
|
394
|
+
return result;
|
|
334
395
|
}
|
|
335
396
|
}
|
|
@@ -46,8 +46,11 @@ export class CliError extends Error {
|
|
|
46
46
|
*/
|
|
47
47
|
export async function callCli(command, payload, options) {
|
|
48
48
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
49
|
-
// 构造参数列表:--json <command> [jsonStr]
|
|
49
|
+
// 构造参数列表:--json <command> [flags...] [jsonStr]
|
|
50
50
|
const args = ['--json', command];
|
|
51
|
+
if (options?.flags) {
|
|
52
|
+
args.push(...options.flags);
|
|
53
|
+
}
|
|
51
54
|
if (payload !== undefined) {
|
|
52
55
|
args.push(JSON.stringify(payload));
|
|
53
56
|
}
|
package/dist/mcp/instructions.js
CHANGED
|
@@ -3,6 +3,8 @@ export const SERVER_INSTRUCTIONS = `
|
|
|
3
3
|
|
|
4
4
|
RemNote 知识库操作工具集。你可以通过这些工具读取、搜索、编辑用户的 RemNote 笔记和知识结构。不能操控闪卡(Card/Flashcard)本身——闪卡由 RemNote 根据 Rem 属性自动生成;也不能管理 Plugin 或系统配置。
|
|
5
5
|
|
|
6
|
+
> **架构备注**:本 MCP Server 是 \\\`remnote-bridge\\\` CLI 的包装层,每个工具调用在底层都会转化为一次 CLI 子进程调用(\\\`--json\\\` 模式)。因此工具的参数、返回值、错误信息与 CLI 完全一致。如果你在错误消息中看到"守护进程"、"daemon"等字样,指的就是 CLI 的后台守护进程。
|
|
7
|
+
|
|
6
8
|
---
|
|
7
9
|
|
|
8
10
|
## 1. Core Concepts
|
|
@@ -29,13 +31,13 @@ Rem 有两个独立维度:**type**(闪卡语义)和 **isDocument**(页
|
|
|
29
31
|
| \\\`default\\\` | 普通 Rem | 正常字重 |
|
|
30
32
|
| \\\`portal\\\` | 嵌入引用容器 | 紫色边框(**只读**,不可设置) |
|
|
31
33
|
|
|
32
|
-
###
|
|
34
|
+
### 闪卡的操作方式
|
|
33
35
|
|
|
34
|
-
闪卡由 Rem 的 \\\`type\\\`、\\\`backText\\\`、\\\`practiceDirection\\\`
|
|
36
|
+
闪卡由 Rem 的 \\\`type\\\`、\\\`backText\\\`、\\\`practiceDirection\\\` 三个字段控制。创建/修改闪卡,操作的是这些**字段**,而非分隔符。
|
|
35
37
|
|
|
36
|
-
**禁止**:在文本中插入分隔符(\\\`::\\\`、\\\`;;\\\`、\\\`>>\\\` 等)来创建闪卡。分隔符是 RemNote
|
|
38
|
+
**禁止**:在文本中插入分隔符(\\\`::\\\`、\\\`;;\\\`、\\\`>>\\\` 等)来创建闪卡。分隔符是 RemNote 编辑器的输入语法,工具端无法识别。
|
|
37
39
|
|
|
38
|
-
| 闪卡操作 |
|
|
40
|
+
| 闪卡操作 | 方法 |
|
|
39
41
|
|:---------|:---------|
|
|
40
42
|
| 创建概念定义 | \\\`edit_tree\\\` 新增行 \\\`概念 ↔ 定义\\\`,再 \\\`edit_rem\\\` 设 \\\`type: "concept"\\\` |
|
|
41
43
|
| 创建正向问答 | \\\`edit_tree\\\` 新增行 \\\`问题 → 答案\\\` |
|
|
@@ -44,7 +46,7 @@ Rem 有两个独立维度:**type**(闪卡语义)和 **isDocument**(页
|
|
|
44
46
|
|
|
45
47
|
### 理解用户意图:分隔符映射
|
|
46
48
|
|
|
47
|
-
用户在 RemNote
|
|
49
|
+
用户在 RemNote 编辑器中通过分隔符创建闪卡。当用户提到这些分隔符时,你需要理解其意图并映射到对应的工具操作:
|
|
48
50
|
|
|
49
51
|
| 用户说 / 编辑器分隔符 | 对应的 type | 对应的 practiceDirection |
|
|
50
52
|
|:----------------------|:-----------|:-----------------------|
|
|
@@ -63,6 +65,16 @@ Rem 有两个独立维度:**type**(闪卡语义)和 **isDocument**(页
|
|
|
63
65
|
| Tag \\\`##\\\` | 附加标签 | 分类标记 |
|
|
64
66
|
| Portal \\\`((\\\` | 嵌入实时视图 | 编辑同步,大纲中标为 \\\`type:portal\\\` |
|
|
65
67
|
|
|
68
|
+
### Portal 操作速查
|
|
69
|
+
|
|
70
|
+
| 操作 | 命令 | 方式 |
|
|
71
|
+
|:-----|:-----|:-----|
|
|
72
|
+
| 创建 Portal | \\\`edit_tree\\\` | 新增行 \\\`<!--portal refs:id1,id2-->\\\` |
|
|
73
|
+
| 删除 Portal | \\\`edit_tree\\\` | 从大纲中移除 Portal 行(与删除普通行相同) |
|
|
74
|
+
| 修改引用列表(增删引用的 Rem) | \\\`edit_rem\\\` | str_replace 简化 JSON 中的 \\\`portalDirectlyIncludedRem\\\` 数组 |
|
|
75
|
+
| 移动 Portal(换父节点/位置) | \\\`edit_tree\\\` | 与移动普通行相同 |
|
|
76
|
+
| 读取 Portal | \\\`read_rem\\\` | 自动输出 9 字段简化 JSON |
|
|
77
|
+
|
|
66
78
|
### Powerup 过滤
|
|
67
79
|
|
|
68
80
|
RemNote 的格式设置(标题、高亮、代码等)会注入隐藏的系统 Tag 和子 Rem。这些噪音数据**默认自动过滤**,你通常无需关心。
|
|
@@ -73,6 +85,8 @@ RemNote 的格式设置(标题、高亮、代码等)会注入隐藏的系统
|
|
|
73
85
|
|
|
74
86
|
所有操作都依赖一个活跃的会话。会话 = 守护进程的生命周期。
|
|
75
87
|
|
|
88
|
+
### 标准模式(需要用户配合)
|
|
89
|
+
|
|
76
90
|
\\\`\\\`\\\`
|
|
77
91
|
connect → 启动 daemon(幂等,重复调用安全)
|
|
78
92
|
↓
|
|
@@ -85,21 +99,72 @@ health → 确认三层就绪(daemon / Plugin / SDK)
|
|
|
85
99
|
disconnect → 关闭 daemon,清空所有缓存
|
|
86
100
|
\\\`\\\`\\\`
|
|
87
101
|
|
|
102
|
+
### Headless 模式(自动连接)
|
|
103
|
+
|
|
104
|
+
标准模式每次 connect 后都需要用户手动操作 RemNote。Headless 模式通过 setup(一次性)+ headless Chrome 实现自动连接,后续 connect 无需用户介入。
|
|
105
|
+
|
|
106
|
+
#### 首次使用(setup)
|
|
107
|
+
|
|
108
|
+
\\\`setup\\\` 会弹出 Chrome 窗口,用户需要完成两件事:
|
|
109
|
+
1. **登录 RemNote**
|
|
110
|
+
2. **配置 dev plugin**:插件图标 → 开发你的插件 → 填入 \\\`http://localhost:8080\\\`
|
|
111
|
+
|
|
112
|
+
完成后**彻底退出 Chrome**(macOS 必须 Cmd+Q,仅关窗口不够)。
|
|
113
|
+
|
|
114
|
+
**你必须这样与用户交互**:
|
|
115
|
+
1. 调用 \\\`setup\\\`
|
|
116
|
+
2. 立即告知用户:
|
|
117
|
+
"已打开 Chrome 浏览器。请完成以下操作:
|
|
118
|
+
1. 登录 RemNote
|
|
119
|
+
2. 在 RemNote 中配置开发插件:点击左下角插件图标 → 开发你的插件 → 输入 http://localhost:8080
|
|
120
|
+
3. 完成后彻底退出 Chrome(macOS 请按 Cmd+Q)"
|
|
121
|
+
3. 等待 \\\`setup\\\` 返回(阻塞,最长 10 分钟)
|
|
122
|
+
4. 成功 → 进入下一步 \\\`connect(headless=true)\\\`
|
|
123
|
+
|
|
124
|
+
setup 只需执行一次。之后每次连接直接用 \\\`connect(headless=true)\\\`。
|
|
125
|
+
|
|
126
|
+
#### 后续使用
|
|
127
|
+
|
|
128
|
+
\\\`\\\`\\\`
|
|
129
|
+
connect(headless=true) → 启动 daemon + headless Chrome 自动加载 RemNote 和 Plugin
|
|
130
|
+
↓
|
|
131
|
+
health → 等待三层就绪(Plugin 需要 10-30 秒连接,可多次轮询)
|
|
132
|
+
↓
|
|
133
|
+
业务操作(read / search / edit)
|
|
134
|
+
↓
|
|
135
|
+
disconnect → 关闭 daemon + headless Chrome,清空所有缓存
|
|
136
|
+
\\\`\\\`\\\`
|
|
137
|
+
|
|
138
|
+
**无需任何用户操作**——headless Chrome 在后台自动完成登录和 Plugin 加载。
|
|
139
|
+
|
|
140
|
+
#### 排查
|
|
141
|
+
|
|
142
|
+
- \\\`health(diagnose=true)\\\`:截图 + Chrome 状态 + console 错误(确认页面是否正常加载)
|
|
143
|
+
- \\\`health(reload=true)\\\`:重载 headless Chrome 页面(Plugin 未连接时尝试)
|
|
144
|
+
- 如果 Plugin 始终不连接,可能是 RemNote 登录 session 过期,需重新 setup
|
|
145
|
+
|
|
88
146
|
**关键要点**:
|
|
89
147
|
- \\\`connect\\\` 是所有业务操作的前提,未 connect 时任何命令都会报"守护进程未运行"
|
|
90
148
|
- \\\`health\\\` 检查三层状态:daemon 运行 → Plugin 已连接 → SDK 就绪,三者全部通过才能执行业务命令
|
|
91
149
|
- \\\`disconnect\\\` 会销毁所有缓存,之前的 read 结果全部失效
|
|
92
150
|
- daemon 默认 30 分钟无活动自动关闭
|
|
93
151
|
|
|
94
|
-
###
|
|
152
|
+
### Windows 注意事项
|
|
95
153
|
|
|
96
|
-
|
|
154
|
+
- **默认模式秒级启动**:使用预构建 plugin,无需安装依赖
|
|
155
|
+
- **\`--dev\` 模式首次较慢**:会自动安装 remnote-plugin 的依赖(约 600+ 个包),在 Windows 上可能需要 30-60 秒,connect 超时设为 60 秒
|
|
156
|
+
- **\`--dev\` 依赖自动修复**:如果 webpack-dev-server 因依赖损坏而崩溃,daemon 会自动清洁重装依赖(删除 node_modules 后重新安装)并重试,最多重试 2 次
|
|
157
|
+
- **端口残留**:多次 connect 失败后可能出现端口被占用(EADDRINUSE),用 \\\`remnote-bridge disconnect\\\` 或手动终止占用端口的进程后重试
|
|
158
|
+
|
|
159
|
+
### ⚠️ 标准模式:connect 后需要用户配合(重要)
|
|
160
|
+
|
|
161
|
+
\\\`connect\\\`(不传 headless)成功只意味着 daemon 和 Plugin 服务已启动,**Plugin 并未自动连接**。用户必须在 RemNote 中完成以下操作,Plugin 才能连接到 daemon:
|
|
97
162
|
|
|
98
163
|
**首次使用**(RemNote 从未加载过此插件):
|
|
99
164
|
1. 打开 RemNote 桌面端或网页端
|
|
100
165
|
2. 点击左侧边栏底部的插件图标(拼图形状)
|
|
101
166
|
3. 点击「开发你的插件」(Develop Your Plugin)
|
|
102
|
-
4. 在输入框中填入 \\\`http://localhost:8080\\\`(即 connect 输出的
|
|
167
|
+
4. 在输入框中填入 \\\`http://localhost:8080\\\`(即 connect 输出的 Plugin 服务地址)
|
|
103
168
|
5. 等待插件加载完成
|
|
104
169
|
|
|
105
170
|
**非首次使用**(之前已加载过此插件):
|
|
@@ -196,31 +261,50 @@ newStr: "text": [\\n "点击",\\n {\\n "i": "m",\\n "iUrl": "ht
|
|
|
196
261
|
|
|
197
262
|
1. \\\`read_tree\\\` 获取目标区域的 Markdown 大纲(建立缓存)
|
|
198
263
|
2. 在大纲中用 str_replace 进行结构修改:
|
|
199
|
-
- **新增**:插入无 remId
|
|
264
|
+
- **新增**:插入无 remId 注释的新行(通过缩进确定父子关系)。可在行尾加元数据注释指定属性:\\\`新行 <!--type:concept doc tag:Name(id)-->\\\`
|
|
200
265
|
- **删除**:移除带 remId 的行(必须同时删除所有子行)
|
|
201
266
|
- **移动**:改变行的缩进级别或位置
|
|
202
267
|
- **重排**:调换同级行的顺序
|
|
203
268
|
|
|
204
269
|
**红线**:edit_tree **禁止修改已有行的文字内容**——改内容必须用 edit_rem。edit_tree 只做结构操作。
|
|
205
270
|
|
|
271
|
+
**⚠️ 新增行的插入位置**:新行必须插在目标层级所有兄弟的**末尾**,不能插在一个有子节点的 Rem 和它的 children 之间。否则 children 会被新行"劫持",触发 \\\`children_captured\\\` 错误。
|
|
272
|
+
|
|
273
|
+
\\\`\\\`\\\`
|
|
274
|
+
❌ 错误:插在父 Rem 和 children 之间
|
|
275
|
+
oldStr: 水分子 ↓ <!--idA-->
|
|
276
|
+
newStr: 水分子 ↓ <!--idA-->
|
|
277
|
+
新行 ← children 会变成新行的子节点!
|
|
278
|
+
|
|
279
|
+
✅ 正确:插在末尾
|
|
280
|
+
oldStr: 最后一个兄弟 <!--idZ-->
|
|
281
|
+
newStr: 最后一个兄弟 <!--idZ-->
|
|
282
|
+
新行
|
|
283
|
+
\\\`\\\`\\\`
|
|
284
|
+
|
|
285
|
+
**需要"创建新节点并把已有 children 移过去"?** 分两步:
|
|
286
|
+
1. 第一次 \\\`edit_tree\\\`:在末尾创建新节点(获得 remId)
|
|
287
|
+
2. 第二次 \\\`edit_tree\\\`:把已有行移动到新节点下面(此时新节点已有 remId,走正常 move 逻辑)
|
|
288
|
+
|
|
206
289
|
### 场景 F:创建 / 修改闪卡
|
|
207
290
|
|
|
208
291
|
> 用户说:"创建一个概念定义"、"做个正向问答卡"、"把这个改成 concept"
|
|
209
292
|
|
|
210
293
|
闪卡由 \\\`type\\\`、\\\`backText\\\`、\\\`practiceDirection\\\` 三个字段控制。操作方式:
|
|
211
294
|
|
|
212
|
-
**创建新闪卡**(\\\`edit_tree\\\` 新增行 +
|
|
295
|
+
**创建新闪卡**(\\\`edit_tree\\\` 新增行 + 箭头 + 可选元数据注释):
|
|
213
296
|
- 正向问答:\\\`问题 → 答案\\\`
|
|
214
297
|
- 双向问答:\\\`问题 ↔ 答案\\\`
|
|
215
298
|
- 多行答案:\\\`问题 ↓\\\`(子行自动成为答案)
|
|
216
|
-
- 概念定义:\\\`概念 ↔
|
|
299
|
+
- 概念定义:\\\`概念 ↔ 定义 <!--type:concept-->\\\`(一步完成,无需再 edit_rem)
|
|
300
|
+
- 描述属性:\\\`属性 → 值 <!--type:descriptor-->\\\`
|
|
217
301
|
|
|
218
302
|
**修改现有 Rem 的闪卡行为**(\\\`read_rem\\\` → \\\`edit_rem\\\`):
|
|
219
303
|
- 改类型:修改 \\\`type\\\` 字段(\\\`"default"\\\` → \\\`"concept"\\\`)
|
|
220
304
|
- 改方向:修改 \\\`practiceDirection\\\`(\\\`"forward"\\\` / \\\`"backward"\\\` / \\\`"both"\\\` / \\\`"none"\\\`)
|
|
221
305
|
- 加/改背面:修改 \\\`backText\\\` 字段
|
|
222
306
|
|
|
223
|
-
**禁止**:在文本内容中插入 \\\`::\\\`、\\\`;;\\\`、\\\`>>\\\` 等分隔符——这些是 RemNote
|
|
307
|
+
**禁止**:在文本内容中插入 \\\`::\\\`、\\\`;;\\\`、\\\`>>\\\` 等分隔符——这些是 RemNote 编辑器语法,工具端不识别。
|
|
224
308
|
|
|
225
309
|
### 场景 G:排查连接问题
|
|
226
310
|
|
|
@@ -317,6 +401,7 @@ newStr: "text": [\\n "点击",\\n {\\n "i": "m",\\n "iUrl": "ht
|
|
|
317
401
|
| Content modification not allowed | edit_tree 中修改了行内容 | 改用 \\\`edit_rem\\\` 修改内容 |
|
|
318
402
|
| orphan_detected | 删了父行但保留了子行 | 同时删除所有子行 |
|
|
319
403
|
| folded_delete | 删除有隐藏子节点的行 | 用更大 depth 重新 read_tree |
|
|
404
|
+
| children_captured | 新行插在父 Rem 和它的 children 之间,劫持了已有子节点 | 把新行插到所有兄弟的**末尾**而非紧跟父 Rem 之后(见下方说明) |
|
|
320
405
|
|
|
321
406
|
完整错误参考见 \\\`resource://error-reference\\\`。
|
|
322
407
|
`;
|
|
@@ -176,6 +176,59 @@ newStr: "practiceDirection": "both"
|
|
|
176
176
|
| 混淆 highlightColor 和 h | 前者字符串 \\\`"Red"\\\`,后者数字 \\\`1\\\` | 参考上方对比表 |
|
|
177
177
|
| 漏 onlyAudio | \\\`i:"a"\\\` 的 \\\`onlyAudio\\\` 是必填 | true=音频,false=视频 |
|
|
178
178
|
| JSON 语法错 | 引号、逗号、括号不完整 | 检查替换边界 |
|
|
179
|
+
| Portal oldStr 不匹配 | Portal 编辑在简化 JSON 上匹配,不是完整 JSON | 检查 oldStr 是否匹配 9 字段简化 JSON |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Portal 编辑
|
|
184
|
+
|
|
185
|
+
当被编辑的 Rem 是 Portal(type === 'portal')时,edit_rem 自动切换到 Portal 专用路径。
|
|
186
|
+
|
|
187
|
+
**edit_rem 只能修改 Portal 的引用列表和位置属性。创建 Portal 和删除 Portal 请使用 \\\`edit_tree\\\`。**
|
|
188
|
+
|
|
189
|
+
### 操作目标:简化 JSON
|
|
190
|
+
|
|
191
|
+
Portal 的 str_replace 在 **9 字段简化 JSON** 上执行(而非完整 51 字段):
|
|
192
|
+
|
|
193
|
+
\\\`\\\`\\\`json
|
|
194
|
+
{
|
|
195
|
+
"id": "abc123",
|
|
196
|
+
"type": "portal",
|
|
197
|
+
"portalType": "portal",
|
|
198
|
+
"portalDirectlyIncludedRem": ["remId1", "remId2"],
|
|
199
|
+
"parent": "parentId",
|
|
200
|
+
"positionAmongstSiblings": 3,
|
|
201
|
+
"children": ["remId1", "remId2"],
|
|
202
|
+
"createdAt": 1709000000000,
|
|
203
|
+
"updatedAt": 1709000000000
|
|
204
|
+
}
|
|
205
|
+
\\\`\\\`\\\`
|
|
206
|
+
|
|
207
|
+
### 可写字段
|
|
208
|
+
|
|
209
|
+
| 字段 | 写入方式 |
|
|
210
|
+
|:-----|:---------|
|
|
211
|
+
| \\\`portalDirectlyIncludedRem\\\` | diff 数组 → addToPortal / removeFromPortal |
|
|
212
|
+
| \\\`parent\\\` | setParent() |
|
|
213
|
+
| \\\`positionAmongstSiblings\\\` | setParent(parent, position) |
|
|
214
|
+
|
|
215
|
+
其余字段(id、type、portalType、children、createdAt、updatedAt)为只读,修改只产生警告。
|
|
216
|
+
|
|
217
|
+
### 示例
|
|
218
|
+
|
|
219
|
+
添加引用:
|
|
220
|
+
|
|
221
|
+
\\\`\\\`\\\`
|
|
222
|
+
oldStr: "portalDirectlyIncludedRem": ["remId1", "remId2"]
|
|
223
|
+
newStr: "portalDirectlyIncludedRem": ["remId1", "remId2", "remId3"]
|
|
224
|
+
\\\`\\\`\\\`
|
|
225
|
+
|
|
226
|
+
移除引用:
|
|
227
|
+
|
|
228
|
+
\\\`\\\`\\\`
|
|
229
|
+
oldStr: "portalDirectlyIncludedRem": ["remId1", "remId2"]
|
|
230
|
+
newStr: "portalDirectlyIncludedRem": ["remId1"]
|
|
231
|
+
\\\`\\\`\\\`
|
|
179
232
|
|
|
180
233
|
---
|
|
181
234
|
|