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.
Files changed (47) hide show
  1. package/README.md +30 -6
  2. package/dist/cli/commands/connect.js +31 -2
  3. package/dist/cli/commands/health.js +111 -1
  4. package/dist/cli/commands/setup.js +112 -0
  5. package/dist/cli/daemon/daemon.js +101 -20
  6. package/dist/cli/daemon/headless-browser.js +291 -0
  7. package/dist/cli/daemon/static-server.js +84 -0
  8. package/dist/cli/handlers/edit-handler.js +89 -3
  9. package/dist/cli/handlers/read-handler.js +16 -0
  10. package/dist/cli/handlers/tree-edit-handler.js +59 -28
  11. package/dist/cli/handlers/tree-parser.js +110 -3
  12. package/dist/cli/main.js +22 -6
  13. package/dist/cli/server/ws-server.js +62 -1
  14. package/dist/mcp/daemon-client.js +4 -1
  15. package/dist/mcp/instructions.js +97 -12
  16. package/dist/mcp/resources/edit-rem-guide.js +53 -0
  17. package/dist/mcp/resources/edit-tree-guide.js +60 -0
  18. package/dist/mcp/resources/error-reference.js +8 -1
  19. package/dist/mcp/resources/outline-format.js +29 -1
  20. package/dist/mcp/resources/rem-object-fields.js +6 -4
  21. package/dist/mcp/resources/separator-flashcard.js +5 -5
  22. package/dist/mcp/tools/infra-tools.js +39 -9
  23. package/package.json +5 -1
  24. package/remnote-plugin/dist/bridge-icon.svg +8 -0
  25. package/remnote-plugin/dist/bridge_widget-sandbox.js +65 -0
  26. package/remnote-plugin/dist/bridge_widget.js +65 -0
  27. package/remnote-plugin/dist/index-sandbox.css +591 -0
  28. package/remnote-plugin/dist/index-sandbox.js +64 -0
  29. package/remnote-plugin/dist/index.css +591 -0
  30. package/remnote-plugin/dist/index.html +9 -0
  31. package/remnote-plugin/dist/index.js +64 -0
  32. package/remnote-plugin/dist/manifest.json +22 -0
  33. package/remnote-plugin/src/bridge/message-router.ts +11 -0
  34. package/remnote-plugin/src/services/add-to-portal.ts +40 -0
  35. package/remnote-plugin/src/services/create-portal.ts +47 -0
  36. package/remnote-plugin/src/services/remove-from-portal.ts +40 -0
  37. package/remnote-plugin/src/services/write-rem-fields.ts +39 -0
  38. package/remnote-plugin/src/types.ts +7 -4
  39. package/skills/remnote-bridge/SKILL.md +90 -8
  40. package/skills/remnote-bridge/instructions/connect.md +48 -10
  41. package/skills/remnote-bridge/instructions/disconnect.md +1 -1
  42. package/skills/remnote-bridge/instructions/edit-rem.md +67 -4
  43. package/skills/remnote-bridge/instructions/edit-tree.md +100 -10
  44. package/skills/remnote-bridge/instructions/health.md +67 -1
  45. package/skills/remnote-bridge/instructions/overall.md +19 -4
  46. package/skills/remnote-bridge/instructions/read-rem.md +5 -2
  47. package/skills/remnote-bridge/instructions/setup.md +130 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Headless Chrome 浏览器管理器
3
+ *
4
+ * 在 daemon 中启动 headless Chrome 加载 RemNote Plugin 页面,
5
+ * 实现无 GUI 环境下的全自动连接。
6
+ *
7
+ * 属于进程管理层(daemon/),与 static-server.ts、dev-server.ts 平级。
8
+ */
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import os from 'os';
12
+ // ── 工具函数(setup 命令复用) ──
13
+ /**
14
+ * 按平台自动检测 Chrome/Chromium 路径。
15
+ * 返回 null 表示未找到。
16
+ */
17
+ export function findChromePath() {
18
+ const platform = os.platform();
19
+ const candidates = [];
20
+ if (platform === 'darwin') {
21
+ candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary');
22
+ }
23
+ else if (platform === 'win32') {
24
+ const programFiles = process.env['PROGRAMFILES'] || 'C:\\Program Files';
25
+ const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
26
+ const localAppData = process.env['LOCALAPPDATA'] || '';
27
+ candidates.push(path.join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'));
28
+ }
29
+ else {
30
+ // Linux
31
+ candidates.push('/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium');
32
+ }
33
+ for (const p of candidates) {
34
+ if (fs.existsSync(p))
35
+ return p;
36
+ }
37
+ return null;
38
+ }
39
+ /**
40
+ * 检测是否有桌面环境(GUI)。
41
+ * macOS/Windows 始终返回 true,Linux 检查 DISPLAY/WAYLAND_DISPLAY。
42
+ */
43
+ export function hasDisplay() {
44
+ const platform = os.platform();
45
+ if (platform === 'darwin' || platform === 'win32')
46
+ return true;
47
+ return !!(process.env['DISPLAY'] || process.env['WAYLAND_DISPLAY']);
48
+ }
49
+ /**
50
+ * 返回 headless Chrome 的默认 profile 目录。
51
+ */
52
+ export function getDefaultProfileDir() {
53
+ return path.join(os.homedir(), '.remnote-bridge', 'chrome-profile');
54
+ }
55
+ /**
56
+ * 返回 setup 完成标记文件路径。
57
+ */
58
+ export function getSetupDonePath() {
59
+ return path.join(getDefaultProfileDir(), '.setup-done');
60
+ }
61
+ const MAX_AUTO_RELOAD = 5;
62
+ const AUTO_RELOAD_DELAY_MS = 10_000;
63
+ const CONSOLE_ERROR_BUFFER_SIZE = 20;
64
+ export class HeadlessBrowserManager {
65
+ browser = null;
66
+ page = null;
67
+ status = 'stopped';
68
+ reloadCount = 0;
69
+ lastError = null;
70
+ consoleErrors = [];
71
+ options;
72
+ autoReloadTimer = null;
73
+ constructor(options) {
74
+ this.options = options;
75
+ }
76
+ log(message, level = 'info') {
77
+ this.options.onLog?.(message, level);
78
+ }
79
+ /**
80
+ * 启动 headless Chrome,打开 RemNote Plugin 页面。
81
+ * 不等待 Plugin WS 连接建立——Plugin 自行连接,health 检测状态。
82
+ */
83
+ async start() {
84
+ this.status = 'starting';
85
+ this.log('[headless] 正在启动 Chrome...');
86
+ const chromePath = this.options.chromePath ?? findChromePath();
87
+ if (!chromePath) {
88
+ this.status = 'crashed';
89
+ this.lastError = '未找到 Chrome/Chromium,请通过 --chrome-path 指定路径';
90
+ throw new Error(this.lastError);
91
+ }
92
+ const userDataDir = this.options.userDataDir ?? getDefaultProfileDir();
93
+ fs.mkdirSync(userDataDir, { recursive: true });
94
+ try {
95
+ // 动态 import puppeteer-core(避免未安装时报错)
96
+ const puppeteer = await import('puppeteer-core');
97
+ const launchOptions = {
98
+ executablePath: chromePath,
99
+ headless: 'shell',
100
+ userDataDir,
101
+ args: [
102
+ '--no-first-run',
103
+ '--no-default-browser-check',
104
+ '--disable-gpu',
105
+ '--disable-dev-shm-usage',
106
+ ],
107
+ };
108
+ if (this.options.remoteDebuggingPort) {
109
+ launchOptions.args.push(`--remote-debugging-port=${this.options.remoteDebuggingPort}`);
110
+ }
111
+ this.browser = await puppeteer.default.launch(launchOptions);
112
+ this.page = await this.browser.newPage();
113
+ // console 监控:仅收集 error 级别
114
+ this.page.on('console', (msg) => {
115
+ if (msg.type() === 'error') {
116
+ const text = `[${new Date().toISOString()}] ${msg.text()}`;
117
+ this.consoleErrors.push(text);
118
+ if (this.consoleErrors.length > CONSOLE_ERROR_BUFFER_SIZE) {
119
+ this.consoleErrors.shift();
120
+ }
121
+ }
122
+ });
123
+ // 页面崩溃/关闭 → 自动恢复
124
+ this.page.on('close', () => {
125
+ if (this.status === 'running') {
126
+ this.log('[headless] 页面意外关闭,将尝试自动恢复', 'warn');
127
+ this.scheduleAutoReload();
128
+ }
129
+ });
130
+ this.browser.on('disconnected', () => {
131
+ if (this.status === 'running' || this.status === 'reloading') {
132
+ this.log('[headless] Chrome 进程断开', 'warn');
133
+ this.status = 'crashed';
134
+ this.lastError = 'Chrome 进程断开';
135
+ this.browser = null;
136
+ this.page = null;
137
+ }
138
+ });
139
+ // 导航到 RemNote Plugin 页面
140
+ await this.page.goto(this.options.remNoteUrl, {
141
+ waitUntil: 'domcontentloaded',
142
+ timeout: 60_000,
143
+ });
144
+ this.status = 'running';
145
+ this.log(`[headless] Chrome 已启动,页面: ${this.options.remNoteUrl}`);
146
+ }
147
+ catch (err) {
148
+ this.status = 'crashed';
149
+ this.lastError = err instanceof Error ? err.message : String(err);
150
+ this.log(`[headless] 启动失败: ${this.lastError}`, 'error');
151
+ // 尝试清理
152
+ try {
153
+ await this.browser?.close();
154
+ }
155
+ catch { /* ignore */ }
156
+ this.browser = null;
157
+ this.page = null;
158
+ throw err;
159
+ }
160
+ }
161
+ /**
162
+ * 关闭 Chrome 和页面。
163
+ */
164
+ async stop() {
165
+ if (this.autoReloadTimer) {
166
+ clearTimeout(this.autoReloadTimer);
167
+ this.autoReloadTimer = null;
168
+ }
169
+ this.status = 'stopped';
170
+ this.log('[headless] 正在关闭 Chrome...');
171
+ try {
172
+ if (this.page) {
173
+ await this.page.close().catch(() => { });
174
+ this.page = null;
175
+ }
176
+ if (this.browser) {
177
+ await this.browser.close().catch(() => { });
178
+ this.browser = null;
179
+ }
180
+ }
181
+ catch (err) {
182
+ this.log(`[headless] 关闭时出错: ${err}`, 'warn');
183
+ }
184
+ this.log('[headless] Chrome 已关闭');
185
+ }
186
+ /**
187
+ * 获取诊断信息。
188
+ */
189
+ getDiagnostics() {
190
+ return {
191
+ status: this.status,
192
+ chromeConnected: this.browser !== null && this.browser.connected,
193
+ pageUrl: this.page?.url() ?? null,
194
+ reloadCount: this.reloadCount,
195
+ lastError: this.lastError,
196
+ recentConsoleErrors: [...this.consoleErrors],
197
+ };
198
+ }
199
+ /**
200
+ * 截图当前页面,返回截图文件路径。
201
+ */
202
+ async takeScreenshot() {
203
+ if (!this.page)
204
+ return null;
205
+ const dir = path.join(os.homedir(), '.remnote-bridge');
206
+ fs.mkdirSync(dir, { recursive: true });
207
+ const filePath = path.join(dir, `headless-screenshot-${Date.now()}.png`);
208
+ try {
209
+ await this.page.screenshot({ path: filePath, fullPage: true });
210
+ this.log(`[headless] 截图已保存: ${filePath}`);
211
+ return filePath;
212
+ }
213
+ catch (err) {
214
+ this.log(`[headless] 截图失败: ${err}`, 'warn');
215
+ return null;
216
+ }
217
+ }
218
+ /**
219
+ * 手动重载页面(重置 reloadCount)。
220
+ */
221
+ async manualReload() {
222
+ this.log('[headless] 手动重载...');
223
+ this.reloadCount = 0;
224
+ await this.doReload();
225
+ }
226
+ scheduleAutoReload() {
227
+ if (this.reloadCount >= MAX_AUTO_RELOAD) {
228
+ this.status = 'crashed';
229
+ this.lastError = `自动恢复次数已达上限 (${MAX_AUTO_RELOAD})`;
230
+ this.log(`[headless] ${this.lastError}`, 'error');
231
+ return;
232
+ }
233
+ this.autoReloadTimer = setTimeout(async () => {
234
+ this.autoReloadTimer = null;
235
+ try {
236
+ await this.doReload();
237
+ }
238
+ catch (err) {
239
+ this.log(`[headless] 自动恢复失败: ${err}`, 'error');
240
+ }
241
+ }, AUTO_RELOAD_DELAY_MS);
242
+ }
243
+ async doReload() {
244
+ this.status = 'reloading';
245
+ this.reloadCount++;
246
+ try {
247
+ // 如果浏览器已断开,需要重新启动
248
+ if (!this.browser || !this.browser.connected) {
249
+ this.log('[headless] 浏览器已断开,重新启动...', 'warn');
250
+ this.browser = null;
251
+ this.page = null;
252
+ await this.start();
253
+ return;
254
+ }
255
+ // 关闭旧页面,创建新页面
256
+ if (this.page) {
257
+ await this.page.close().catch(() => { });
258
+ }
259
+ this.page = await this.browser.newPage();
260
+ // 重新注册 console 监控
261
+ this.page.on('console', (msg) => {
262
+ if (msg.type() === 'error') {
263
+ const text = `[${new Date().toISOString()}] ${msg.text()}`;
264
+ this.consoleErrors.push(text);
265
+ if (this.consoleErrors.length > CONSOLE_ERROR_BUFFER_SIZE) {
266
+ this.consoleErrors.shift();
267
+ }
268
+ }
269
+ });
270
+ this.page.on('close', () => {
271
+ if (this.status === 'running') {
272
+ this.log('[headless] 页面意外关闭,将尝试自动恢复', 'warn');
273
+ this.scheduleAutoReload();
274
+ }
275
+ });
276
+ await this.page.goto(this.options.remNoteUrl, {
277
+ waitUntil: 'domcontentloaded',
278
+ timeout: 60_000,
279
+ });
280
+ this.status = 'running';
281
+ this.lastError = null;
282
+ this.log(`[headless] 重载完成 (第 ${this.reloadCount} 次)`);
283
+ }
284
+ catch (err) {
285
+ this.status = 'crashed';
286
+ this.lastError = err instanceof Error ? err.message : String(err);
287
+ this.log(`[headless] 重载失败: ${this.lastError}`, 'error');
288
+ throw err;
289
+ }
290
+ }
291
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 轻量静态文件服务器
3
+ *
4
+ * 用 Node.js 内置 http 模块 serve remnote-plugin/dist/ 目录。
5
+ * 替代 webpack-dev-server,用于非开发模式(预构建 plugin)。
6
+ */
7
+ import http from 'http';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ const MIME_TYPES = {
11
+ '.html': 'text/html',
12
+ '.js': 'application/javascript',
13
+ '.css': 'text/css',
14
+ '.json': 'application/json',
15
+ '.svg': 'image/svg+xml',
16
+ '.png': 'image/png',
17
+ '.ico': 'image/x-icon',
18
+ };
19
+ export class StaticServer {
20
+ server = null;
21
+ options;
22
+ constructor(options) {
23
+ this.options = options;
24
+ }
25
+ start() {
26
+ const { distDir, port, onLog } = this.options;
27
+ return new Promise((resolve, reject) => {
28
+ this.server = http.createServer((req, res) => {
29
+ // CORS headers(与 webpack.config.js 一致)
30
+ res.setHeader('Access-Control-Allow-Origin', '*');
31
+ res.setHeader('Access-Control-Allow-Headers', 'baggage, sentry-trace');
32
+ // OPTIONS preflight
33
+ if (req.method === 'OPTIONS') {
34
+ res.writeHead(204);
35
+ res.end();
36
+ return;
37
+ }
38
+ const urlPath = req.url?.split('?')[0] || '/';
39
+ const filePath = path.resolve(distDir, urlPath === '/' ? 'index.html' : '.' + urlPath);
40
+ // 防止目录遍历(resolve 规范化后,确保仍在 distDir + sep 下)
41
+ const safePrefix = distDir.endsWith(path.sep) ? distDir : distDir + path.sep;
42
+ if (!filePath.startsWith(safePrefix) && filePath !== distDir) {
43
+ res.writeHead(403);
44
+ res.end('Forbidden');
45
+ return;
46
+ }
47
+ fs.readFile(filePath, (err, data) => {
48
+ if (err) {
49
+ res.writeHead(404);
50
+ res.end('Not Found');
51
+ return;
52
+ }
53
+ const ext = path.extname(filePath);
54
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
55
+ res.writeHead(200, { 'Content-Type': contentType });
56
+ res.end(data);
57
+ });
58
+ });
59
+ this.server.on('error', (err) => {
60
+ onLog?.(`[static-server] 启动失败: ${err.message}`, 'error');
61
+ reject(err);
62
+ });
63
+ this.server.listen(port, '127.0.0.1', () => {
64
+ onLog?.(`[static-server] 已启动 http://127.0.0.1:${port} (serving ${distDir})`, 'info');
65
+ resolve();
66
+ });
67
+ });
68
+ }
69
+ stop() {
70
+ return new Promise((resolve) => {
71
+ if (!this.server) {
72
+ resolve();
73
+ return;
74
+ }
75
+ this.server.close(() => {
76
+ this.server = null;
77
+ resolve();
78
+ });
79
+ });
80
+ }
81
+ isRunning() {
82
+ return this.server !== null && this.server.listening;
83
+ }
84
+ }
@@ -7,13 +7,17 @@
7
7
  * 防线 1:缓存存在性检查(必须先 read 再 edit)
8
8
  * 防线 2:乐观并发检测(当前 JSON 与缓存 JSON 比较)
9
9
  * 防线 3:str_replace 精确匹配(old_str 必须唯一匹配)
10
+ *
11
+ * Portal 专用路径:type === 'portal' 时,在简化 JSON(9 字段)上执行 str_replace,
12
+ * 推导变更后调用专用写入逻辑。
10
13
  */
14
+ import { PORTAL_FIELDS } from './read-handler.js';
11
15
  /** 只读字段集合 — 变更这些字段只产生警告,不执行写入 */
12
16
  const READ_ONLY_FIELDS = new Set([
13
17
  'id',
14
18
  'children',
15
19
  'isTable',
16
- 'portalType', 'portalDirectlyIncludedRem',
20
+ 'portalType',
17
21
  'propertyType',
18
22
  'aliases',
19
23
  'remsBeingReferenced', 'deepRemsBeingReferenced', 'remsReferencingThis',
@@ -26,6 +30,10 @@ const READ_ONLY_FIELDS = new Set([
26
30
  'isPowerup', 'isPowerupEnum', 'isPowerupProperty',
27
31
  'isPowerupPropertyListItem', 'isPowerupSlot',
28
32
  ]);
33
+ /** Portal 简化 JSON 中的只读字段 */
34
+ const PORTAL_READONLY_FIELDS = new Set([
35
+ 'id', 'type', 'portalType', 'children', 'createdAt', 'updatedAt',
36
+ ]);
29
37
  export class EditHandler {
30
38
  cache;
31
39
  forwardToPlugin;
@@ -47,6 +55,86 @@ export class EditHandler {
47
55
  // 不更新缓存 — 迫使 AI re-read
48
56
  throw new Error(`Rem ${remId} has been modified since last read. Please read it again before editing.`);
49
57
  }
58
+ // ── 检测 Portal 类型,分流到专用路径 ──
59
+ const cachedObj = JSON.parse(cachedJson);
60
+ if (cachedObj.type === 'portal') {
61
+ return this.handlePortalEdit(remId, cachedJson, cachedObj, oldStr, newStr);
62
+ }
63
+ // ── 普通 Rem 路径 ──
64
+ return this.handleNormalEdit(remId, cachedJson, cachedObj, oldStr, newStr);
65
+ }
66
+ /** Portal 专用编辑路径:在简化 JSON 上执行 str_replace */
67
+ async handlePortalEdit(remId, cachedJson, fullObj, oldStr, newStr) {
68
+ // 从完整对象提取简化 JSON
69
+ const simplified = {};
70
+ for (const field of PORTAL_FIELDS) {
71
+ if (field in fullObj) {
72
+ simplified[field] = fullObj[field];
73
+ }
74
+ }
75
+ const simplifiedJson = JSON.stringify(simplified, null, 2);
76
+ // ── 防线 3: str_replace 在简化 JSON 上精确匹配 ──
77
+ const matchCount = countOccurrences(simplifiedJson, oldStr);
78
+ if (matchCount === 0) {
79
+ throw new Error(`old_str not found in the simplified Portal JSON of rem ${remId}`);
80
+ }
81
+ if (matchCount > 1) {
82
+ throw new Error(`old_str matches ${matchCount} locations in Portal rem ${remId}. ` +
83
+ `Make old_str more specific to match exactly once.`);
84
+ }
85
+ const modifiedSimplifiedJson = simplifiedJson.replace(oldStr, newStr);
86
+ // JSON 解析
87
+ let modified;
88
+ try {
89
+ modified = JSON.parse(modifiedSimplifiedJson);
90
+ }
91
+ catch {
92
+ throw new Error('The replacement produced invalid JSON. Check your new_str for syntax errors.');
93
+ }
94
+ // 推导变更字段
95
+ const changes = {};
96
+ const warnings = [];
97
+ for (const key of Object.keys(modified)) {
98
+ if (JSON.stringify(modified[key]) !== JSON.stringify(simplified[key])) {
99
+ if (PORTAL_READONLY_FIELDS.has(key)) {
100
+ warnings.push(`Field '${key}' is read-only and was ignored`);
101
+ }
102
+ else {
103
+ changes[key] = modified[key];
104
+ }
105
+ }
106
+ }
107
+ // 空变更检查
108
+ if (Object.keys(changes).length === 0) {
109
+ return { ok: true, changes: [], warnings };
110
+ }
111
+ // ── 发送变更到 Plugin ──
112
+ const writeResult = (await this.forwardToPlugin('write_rem_fields', {
113
+ remId,
114
+ changes,
115
+ }));
116
+ if (writeResult.failed) {
117
+ return {
118
+ ok: false,
119
+ changes: [],
120
+ warnings,
121
+ error: `Failed to update field '${writeResult.failed.field}': ${writeResult.failed.error}`,
122
+ appliedChanges: writeResult.applied,
123
+ failedField: writeResult.failed.field,
124
+ };
125
+ }
126
+ // ── 写入成功 → 从 Plugin 重新获取完整 Rem 并更新缓存(D5)──
127
+ const freshRemObject = await this.forwardToPlugin('read_rem', { remId });
128
+ const freshJson = JSON.stringify(freshRemObject, null, 2);
129
+ this.cache.set('rem:' + remId, freshJson);
130
+ return {
131
+ ok: true,
132
+ changes: Object.keys(changes),
133
+ warnings,
134
+ };
135
+ }
136
+ /** 普通 Rem 编辑路径:在完整 JSON 上执行 str_replace */
137
+ async handleNormalEdit(remId, cachedJson, original, oldStr, newStr) {
50
138
  // ── 防线 3: str_replace 精确匹配 ──
51
139
  const matchCount = countOccurrences(cachedJson, oldStr);
52
140
  if (matchCount === 0) {
@@ -57,7 +145,6 @@ export class EditHandler {
57
145
  `Make old_str more specific to match exactly once.`);
58
146
  }
59
147
  const modifiedJson = cachedJson.replace(oldStr, newStr);
60
- // ── 后处理校验 ──
61
148
  // 1. JSON 解析
62
149
  let modified;
63
150
  try {
@@ -66,7 +153,6 @@ export class EditHandler {
66
153
  catch {
67
154
  throw new Error('The replacement produced invalid JSON. Check your new_str for syntax errors.');
68
155
  }
69
- const original = JSON.parse(cachedJson);
70
156
  // 2. 推导变更字段
71
157
  const changes = {};
72
158
  const warnings = [];
@@ -17,6 +17,12 @@ const RF_FIELDS = new Set([
17
17
  'embeddedQueueViewMode',
18
18
  'localUpdatedAt', 'lastPracticed',
19
19
  ]);
20
+ /** Portal 简化输出字段(type === 'portal' 时默认输出这 9 个字段) */
21
+ export const PORTAL_FIELDS = [
22
+ 'id', 'type', 'portalType', 'portalDirectlyIncludedRem',
23
+ 'parent', 'positionAmongstSiblings', 'children',
24
+ 'createdAt', 'updatedAt',
25
+ ];
20
26
  export class ReadHandler {
21
27
  cache;
22
28
  forwardToPlugin;
@@ -59,6 +65,16 @@ export class ReadHandler {
59
65
  }
60
66
  }
61
67
  }
68
+ else if (remObject.type === 'portal') {
69
+ // Portal 简化模式:只输出 9 个关键字段
70
+ const obj = remObject;
71
+ result = {};
72
+ for (const field of PORTAL_FIELDS) {
73
+ if (field in obj) {
74
+ result[field] = obj[field];
75
+ }
76
+ }
77
+ }
62
78
  else {
63
79
  // 默认模式:排除 R-F 字段
64
80
  const obj = remObject;
@@ -90,37 +90,68 @@ export class TreeEditHandler {
90
90
  }
91
91
  parentId = actualId;
92
92
  }
93
- // 解析 Markdown 前缀 + 箭头分隔符 → 属性
94
- const { cleanContent, powerups, backText, practiceDirection } = parsePowerupPrefix(op.content);
95
- const createResult = await this.forwardToPlugin('create_rem', {
96
- content: cleanContent,
97
- parentId,
98
- position: op.position,
99
- });
100
- // 合并所有需要写入的属性(Powerup + 箭头分隔符推导的字段)
101
- const changes = { ...powerups };
102
- if (backText !== undefined)
103
- changes.backText = backText;
104
- if (practiceDirection !== undefined)
105
- changes.practiceDirection = practiceDirection;
106
- // 父节点为 multiline 时,子行标记 isCardItem
107
- // ⚠ SDK bug: setIsCardItem(true) 会偷偷设 practiceDirection: "forward"
108
- // 但 practiceDirection 应该只存在于父行(问题行),card-item(答案行)上不应该有。
109
- // 如果 card-item 带着 practiceDirection: "forward" 且有子行,会被 RemNote 错误渲染成 multiline 卡片。
110
- // 对策:setIsCardItem(true) 后立即用 practiceDirection: 'none' 覆盖掉副作用。
111
- if (op.parentIsMultiline) {
112
- changes.isCardItem = true;
113
- if (!changes.practiceDirection)
114
- changes.practiceDirection = 'none';
93
+ if (op.isPortal) {
94
+ // ── Portal 创建路径 ──
95
+ // 1. 创建空 Portal 并设置父节点
96
+ const portalResult = await this.forwardToPlugin('create_portal', {
97
+ parentId,
98
+ position: op.position,
99
+ });
100
+ // 2. 逐个添加引用
101
+ if (op.portalRefs?.length) {
102
+ for (const refId of op.portalRefs) {
103
+ await this.forwardToPlugin('add_to_portal', {
104
+ portalId: portalResult.remId,
105
+ remId: refId,
106
+ });
107
+ }
108
+ }
109
+ // 记录新创建的 remId,供后续嵌套引用
110
+ newRemIdMap.set(i, portalResult.remId);
115
111
  }
116
- if (Object.keys(changes).length > 0) {
117
- await this.forwardToPlugin('write_rem_fields', {
118
- remId: createResult.remId,
119
- changes,
112
+ else {
113
+ // ── 普通 Rem 创建路径 ──
114
+ // 解析 Markdown 前缀 + 箭头分隔符 → 属性
115
+ const { cleanContent, powerups, backText, practiceDirection } = parsePowerupPrefix(op.content);
116
+ const createResult = await this.forwardToPlugin('create_rem', {
117
+ content: cleanContent,
118
+ parentId,
119
+ position: op.position,
120
120
  });
121
+ // 合并所有需要写入的属性(Powerup + 箭头分隔符推导的字段)
122
+ const changes = { ...powerups };
123
+ if (backText !== undefined)
124
+ changes.backText = backText;
125
+ if (practiceDirection !== undefined)
126
+ changes.practiceDirection = practiceDirection;
127
+ // 父节点为 multiline 时,子行标记 isCardItem
128
+ // ⚠ SDK bug: setIsCardItem(true) 会偷偷设 practiceDirection: "forward"
129
+ // 但 practiceDirection 应该只存在于父行(问题行),card-item(答案行)上不应该有。
130
+ // 如果 card-item 带着 practiceDirection: "forward" 且有子行,会被 RemNote 错误渲染成 multiline 卡片。
131
+ // 对策:setIsCardItem(true) 后立即用 practiceDirection: 'none' 覆盖掉副作用。
132
+ // 合并 HTML 注释中的元数据(type、doc、tag)
133
+ if (op.metadata) {
134
+ if (op.metadata.type)
135
+ changes.type = op.metadata.type;
136
+ if (op.metadata.isDocument)
137
+ changes.isDocument = op.metadata.isDocument;
138
+ if (op.metadata.tags)
139
+ changes.tags = op.metadata.tags;
140
+ }
141
+ if (op.parentIsMultiline) {
142
+ changes.isCardItem = true;
143
+ if (!changes.practiceDirection)
144
+ changes.practiceDirection = 'none';
145
+ }
146
+ if (Object.keys(changes).length > 0) {
147
+ await this.forwardToPlugin('write_rem_fields', {
148
+ remId: createResult.remId,
149
+ changes,
150
+ });
151
+ }
152
+ // 记录新创建的 remId,供后续嵌套引用
153
+ newRemIdMap.set(i, createResult.remId);
121
154
  }
122
- // 记录新创建的 remId,供后续嵌套引用
123
- newRemIdMap.set(i, createResult.remId);
124
155
  break;
125
156
  }
126
157
  case 'delete': {