remnote-bridge 0.1.9 → 0.1.11

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.
@@ -49,7 +49,7 @@ export function createMessageRouter(plugin: ReactRNPlugin): (request: BridgeRequ
49
49
  case 'read_globe':
50
50
  return readGlobe(plugin, request.payload as { depth?: number; maxNodes?: number; maxSiblings?: number });
51
51
  case 'read_context':
52
- return readContext(plugin, request.payload as { mode?: 'focus' | 'page'; ancestorLevels?: number; maxNodes?: number; maxSiblings?: number; depth?: number });
52
+ return readContext(plugin, request.payload as { mode?: 'focus' | 'page'; ancestorLevels?: number; maxNodes?: number; maxSiblings?: number; depth?: number; focusRemId?: string });
53
53
  case 'search':
54
54
  return search(plugin, request.payload as { query: string; numResults?: number });
55
55
 
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ReactRNPlugin, PluginRem as Rem } from '@remnote/plugin-sdk';
8
+ import { safeToMarkdown } from './rem-builder';
8
9
 
9
10
  /**
10
11
  * 从 rem 向上追溯到根,返回路径名称数组(从根到当前)。
@@ -17,7 +18,7 @@ export async function buildBreadcrumb(
17
18
  let current: Rem | undefined = rem;
18
19
 
19
20
  while (current) {
20
- const text = await plugin.richText.toMarkdown(current.text ?? []);
21
+ const text = await safeToMarkdown(plugin, current.text ?? []);
21
22
  path.unshift(text.replace(/\n/g, ' ').trim() || current._id);
22
23
  current = await current.getParentRem();
23
24
  }
@@ -19,7 +19,7 @@ import {
19
19
  import { filterNoisyChildren } from './powerup-filter';
20
20
  import { sliceSiblings } from '../utils/elision';
21
21
  import { buildBreadcrumb } from './breadcrumb';
22
- import { buildFullSerializableRem as buildFullRem } from './rem-builder';
22
+ import { buildFullSerializableRem as buildFullRem, safeToMarkdown } from './rem-builder';
23
23
 
24
24
  export interface ReadContextPayload {
25
25
  mode?: 'focus' | 'page';
@@ -27,6 +27,7 @@ export interface ReadContextPayload {
27
27
  maxNodes?: number;
28
28
  maxSiblings?: number;
29
29
  depth?: number;
30
+ focusRemId?: string;
30
31
  }
31
32
 
32
33
  export interface ReadContextResult {
@@ -46,12 +47,14 @@ export async function readContext(
46
47
  maxNodes = 200,
47
48
  maxSiblings = 20,
48
49
  depth = 3,
50
+ focusRemId,
49
51
  } = payload;
50
52
 
51
53
  if (mode === 'page') {
54
+ if (focusRemId) throw new Error('focusRemId 仅在 focus 模式下有效,page 模式下请勿指定');
52
55
  return readContextPage(plugin, { maxNodes, maxSiblings, depth });
53
56
  }
54
- return readContextFocus(plugin, { ancestorLevels, maxNodes, maxSiblings });
57
+ return readContextFocus(plugin, { ancestorLevels, maxNodes, maxSiblings, focusRemId });
55
58
  }
56
59
 
57
60
  // ────────────────────────── Page 模式 ──────────────────────────
@@ -87,10 +90,16 @@ async function readContextPage(
87
90
 
88
91
  async function readContextFocus(
89
92
  plugin: ReactRNPlugin,
90
- opts: { ancestorLevels: number; maxNodes: number; maxSiblings: number },
93
+ opts: { ancestorLevels: number; maxNodes: number; maxSiblings: number; focusRemId?: string },
91
94
  ): Promise<ReadContextResult> {
92
- const focusRem = await plugin.focus.getFocusedRem();
93
- if (!focusRem) throw new Error('当前没有聚焦的 Rem,请先在 RemNote 中点击一个 Rem');
95
+ let focusRem: Rem | undefined;
96
+ if (opts.focusRemId) {
97
+ focusRem = await plugin.rem.findOne(opts.focusRemId);
98
+ if (!focusRem) throw new Error(`指定的 Rem 不存在: ${opts.focusRemId}`);
99
+ } else {
100
+ focusRem = await plugin.focus.getFocusedRem();
101
+ if (!focusRem) throw new Error('当前没有聚焦的 Rem,请先在 RemNote 中点击一个 Rem');
102
+ }
94
103
 
95
104
  const breadcrumb = await buildBreadcrumb(plugin, focusRem);
96
105
 
@@ -350,7 +359,7 @@ async function buildMinimalSerializableRem(
350
359
  ) {
351
360
  const isPortal = rem.type === 6;
352
361
  const [markdownText, isDocument, portalIncludedRems] = await Promise.all([
353
- plugin.richText.toMarkdown(rem.text ?? []),
362
+ safeToMarkdown(plugin, rem.text ?? []),
354
363
  rem.isDocument(),
355
364
  isPortal ? rem.getPortalDirectlyIncludedRem() : Promise.resolve([]),
356
365
  ]);
@@ -21,6 +21,7 @@ import {
21
21
  } from '../utils/tree-serializer';
22
22
  import { sliceSiblings } from '../utils/elision';
23
23
  import { filterNoisyChildren } from './powerup-filter';
24
+ import { safeToMarkdown } from './rem-builder';
24
25
 
25
26
  export interface ReadGlobePayload {
26
27
  depth?: number;
@@ -75,7 +76,7 @@ export async function readGlobe(
75
76
 
76
77
  const isPortal = rem.type === 6;
77
78
  const [markdownText, portalIncludedRems] = await Promise.all([
78
- plugin.richText.toMarkdown(rem.text ?? []),
79
+ safeToMarkdown(plugin, rem.text ?? []),
79
80
  isPortal ? rem.getPortalDirectlyIncludedRem() : Promise.resolve([]),
80
81
  ]);
81
82
 
@@ -18,7 +18,7 @@ import {
18
18
  } from '../utils/tree-serializer';
19
19
  import { filterNoisyChildren } from './powerup-filter';
20
20
  import { sliceSiblings } from '../utils/elision';
21
- import { buildFullSerializableRem, sanitizeNewlines } from './rem-builder';
21
+ import { buildFullSerializableRem, sanitizeNewlines, safeToMarkdown } from './rem-builder';
22
22
 
23
23
  export interface ReadTreePayload {
24
24
  remId: string;
@@ -190,7 +190,7 @@ export async function readTree(
190
190
  const parent = await current.getParentRem();
191
191
  if (!parent) break;
192
192
  const [name, children, isDoc] = await Promise.all([
193
- plugin.richText.toMarkdown(parent.text ?? []),
193
+ safeToMarkdown(plugin, parent.text ?? []),
194
194
  parent.getChildrenRem(),
195
195
  parent.isDocument(),
196
196
  ]);
@@ -11,6 +11,56 @@ import type { ReactRNPlugin, PluginRem as Rem } from '@remnote/plugin-sdk';
11
11
  import type { SerializableRem } from '../utils/tree-serializer';
12
12
  import { filterNoisyTags } from './powerup-filter';
13
13
 
14
+ /**
15
+ * SDK richText.toMarkdown 的安全包装。
16
+ * SDK 不认识某些 RichText 类型(如 "i":"u" URL 链接),会抛 Invalid input。
17
+ * 失败时回退到本地解析。
18
+ */
19
+ export async function safeToMarkdown(
20
+ plugin: ReactRNPlugin,
21
+ richText: unknown[],
22
+ ): Promise<string> {
23
+ try {
24
+ return await plugin.richText.toMarkdown(richText);
25
+ } catch {
26
+ return richTextFallback(richText);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * 本地 RichText → 纯文本回退,处理 SDK 不支持的类型。
32
+ *
33
+ * 覆盖 RICH_TEXT_ELEMENT_TYPE 枚举全部 12 种类型 + 遗留 "u" 类型:
34
+ * m=TEXT, q=REM, i=IMAGE, a=AUDIO, x=LATEX, p=PLUGIN,
35
+ * g=GLOBAL_NAME, s=CARD_DELIMITER, n=ANNOTATION,
36
+ * fi=FLASHCARD_ICON, ai=ADD_ICON, o=DEPRECATED_CODE, u=URL(遗留)
37
+ */
38
+ function richTextFallback(richText: unknown[]): string {
39
+ return richText.map(item => {
40
+ if (typeof item === 'string') return item;
41
+ if (typeof item !== 'object' || item === null) return '';
42
+ const obj = item as Record<string, unknown>;
43
+ switch (obj.i) {
44
+ case 'm': return String(obj.text ?? '');
45
+ case 'q': return `[[${String(obj._id ?? '')}]]`;
46
+ case 'u': return obj.title
47
+ ? `[${String(obj.title)}](${String(obj.url)})`
48
+ : String(obj.url ?? '');
49
+ case 'x': return `$${String(obj.text ?? '')}$`;
50
+ case 'i': return `![image](${String(obj.url ?? '')})`;
51
+ case 'a': return `[audio](${String(obj.url ?? '')})`;
52
+ case 'p': return String(obj.text ?? ''); // PLUGIN
53
+ case 'g': return String(obj.text ?? ''); // GLOBAL_NAME
54
+ case 'n': return String(obj.text ?? ''); // ANNOTATION
55
+ case 'o': return String(obj.text ?? ''); // DEPRECATED_CODE
56
+ case 's': // CARD_DELIMITER
57
+ case 'fi': // FLASHCARD_ICON
58
+ case 'ai': return ''; // ADD_ICON(纯视觉标记)
59
+ default: return String(obj.text ?? obj.url ?? '');
60
+ }
61
+ }).join('');
62
+ }
63
+
14
64
  export interface BuildFullRemOptions {
15
65
  /** 是否保留 Powerup 系统 Tag(默认 false = 过滤掉) */
16
66
  includePowerup?: boolean;
@@ -46,8 +96,8 @@ export async function buildFullSerializableRem(
46
96
  hasDvPowerup,
47
97
  portalIncludedRems,
48
98
  ] = await Promise.all([
49
- plugin.richText.toMarkdown(rem.text ?? []),
50
- rem.backText ? plugin.richText.toMarkdown(rem.backText) : Promise.resolve(null),
99
+ safeToMarkdown(plugin, rem.text ?? []),
100
+ rem.backText ? safeToMarkdown(plugin, rem.backText) : Promise.resolve(null),
51
101
  rem.getType(),
52
102
  rem.isCardItem(),
53
103
  rem.isDocument(),
@@ -80,7 +130,7 @@ export async function buildFullSerializableRem(
80
130
  const tags = await Promise.all(
81
131
  tagRems.map(async (t) => ({
82
132
  id: t._id,
83
- name: sanitizeNewlines(await plugin.richText.toMarkdown(t.text ?? [])),
133
+ name: sanitizeNewlines(await safeToMarkdown(plugin, t.text ?? [])),
84
134
  })),
85
135
  );
86
136
 
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { ReactRNPlugin } from '@remnote/plugin-sdk';
10
+ import { safeToMarkdown } from './rem-builder';
10
11
 
11
12
  export interface SearchPayload {
12
13
  query: string;
@@ -39,7 +40,7 @@ export async function search(
39
40
 
40
41
  const results: SearchResultItem[] = [];
41
42
  for (const rem of rems) {
42
- const markdownText = await plugin.richText.toMarkdown(rem.text ?? []);
43
+ const markdownText = await safeToMarkdown(plugin, rem.text ?? []);
43
44
  const isDocument = await rem.isDocument();
44
45
  results.push({
45
46
  remId: rem._id,
@@ -27,7 +27,7 @@
27
27
  ### 人类模式
28
28
 
29
29
  ```bash
30
- remnote-bridge read-context [--mode <mode>] [--ancestor-levels <N>] [--depth <N>] [--max-nodes <N>] [--max-siblings <N>]
30
+ remnote-bridge read-context [--mode <mode>] [--ancestor-levels <N>] [--depth <N>] [--max-nodes <N>] [--max-siblings <N>] [--focus-rem-id <remId>]
31
31
  ```
32
32
 
33
33
  | 参数/选项 | 类型 | 必需 | 说明 |
@@ -37,6 +37,7 @@ remnote-bridge read-context [--mode <mode>] [--ancestor-levels <N>] [--depth <N>
37
37
  | `--depth <N>` | integer | 否 | 展开深度(默认 3,仅 page 模式) |
38
38
  | `--max-nodes <N>` | integer | 否 | 全局节点上限(默认 200) |
39
39
  | `--max-siblings <N>` | integer | 否 | 每个父节点下展示的 children 上限(默认 20) |
40
+ | `--focus-rem-id <remId>` | string | 否 | 指定鱼眼中心 Rem ID(仅 focus 模式,默认使用当前焦点) |
40
41
 
41
42
  Focus 模式输出示例:
42
43
 
@@ -73,6 +74,7 @@ Page 模式输出示例:
73
74
 
74
75
  ```bash
75
76
  remnote-bridge read-context --json '{"mode":"focus","ancestorLevels":3,"maxNodes":100}'
77
+ remnote-bridge read-context --json '{"mode":"focus","focusRemId":"kLrIOHJLyMd8Y2lyA","ancestorLevels":2}'
76
78
  remnote-bridge read-context --json '{"mode":"page","depth":5,"maxSiblings":10}'
77
79
  ```
78
80
 
@@ -87,6 +89,7 @@ remnote-bridge read-context --json '{"mode":"page","depth":5,"maxSiblings":10}'
87
89
  | `depth` | number | 否 | 展开深度(默认 3,仅 page 模式) |
88
90
  | `maxNodes` | number | 否 | 全局节点上限(默认 200) |
89
91
  | `maxSiblings` | number | 否 | 每个父节点下展示的 children 上限(默认 20) |
92
+ | `focusRemId` | string | 否 | 指定鱼眼中心 Rem ID(仅 focus 模式,默认使用当前焦点) |
90
93
 
91
94
  ---
92
95
 
@@ -124,7 +127,7 @@ remnote-bridge read-context --json '{"mode":"page","depth":5,"maxSiblings":10}'
124
127
  }
125
128
  ```
126
129
 
127
- ### 无焦点 Rem(focus 模式)
130
+ ### 无焦点 Rem(focus 模式,未指定 focusRemId)
128
131
 
129
132
  ```json
130
133
  {
@@ -135,6 +138,28 @@ remnote-bridge read-context --json '{"mode":"page","depth":5,"maxSiblings":10}'
135
138
  }
136
139
  ```
137
140
 
141
+ ### focusRemId 指定的 Rem 不存在
142
+
143
+ ```json
144
+ {
145
+ "ok": false,
146
+ "command": "read-context",
147
+ "error": "指定的 Rem 不存在: kLrIOHJLyMd8Y2lyA",
148
+ "timestamp": "2026-03-07T10:00:00.000Z"
149
+ }
150
+ ```
151
+
152
+ ### page 模式下误传 focusRemId
153
+
154
+ ```json
155
+ {
156
+ "ok": false,
157
+ "command": "read-context",
158
+ "error": "focusRemId 仅在 focus 模式下有效,page 模式下请勿指定",
159
+ "timestamp": "2026-03-07T10:00:00.000Z"
160
+ }
161
+ ```
162
+
138
163
  ### 无打开的页面(page 模式)
139
164
 
140
165
  ```json
@@ -182,15 +207,15 @@ remnote-bridge read-context --json '{"mode":"page","depth":5,"maxSiblings":10}'
182
207
  ## 内部流程
183
208
 
184
209
  ```
185
- 1. CLI 解析参数(mode, ancestorLevels, depth, maxNodes, maxSiblings)
210
+ 1. CLI 解析参数(mode, ancestorLevels, depth, maxNodes, maxSiblings, focusRemId
186
211
  2. sendRequest → WS → daemon
187
212
  3. daemon ContextReadHandler:
188
213
  ├─ 合并配置默认值(config.ts)
189
- └─ forwardToPlugin('read_context', { mode, ancestorLevels, depth, maxNodes, maxSiblings })
214
+ └─ forwardToPlugin('read_context', { mode, ancestorLevels, depth, maxNodes, maxSiblings, focusRemId })
190
215
  4. Plugin 端 readContext() 分支:
191
216
 
192
217
  ├─ mode === 'focus' → readContextFocus():
193
- │ ├─ plugin.focus.getFocusedRem() → 获取焦点 Rem
218
+ │ ├─ focusRemId ? plugin.rem.findOne(focusRemId) : plugin.focus.getFocusedRem() → 获取焦点 Rem
194
219
  │ ├─ 向上追溯 ancestorLevels 层 → ancestorPath[]
195
220
  │ ├─ buildBreadcrumb(plugin, focusRem) → 面包屑路径
196
221
  │ ├─ 从最顶层祖先开始递归 buildFisheyeNode():
@@ -274,8 +299,8 @@ focus 模式的核心特点是**渐进式展开**——离焦点越近,展开
274
299
 
275
300
  | 维度 | focus 模式 | page 模式 |
276
301
  |:-----|:----------|:----------|
277
- | 触发点 | 当前焦点 Rem(光标位置) | 当前打开的页面 Rem |
278
- | SDK 入口 | `plugin.focus.getFocusedRem()` | `plugin.window.getFocusedPaneId()` → `getOpenPaneRemId()` |
302
+ | 触发点 | 当前焦点 Rem 或指定 `focusRemId` | 当前打开的页面 Rem |
303
+ | SDK 入口 | `focusRemId` ? `plugin.rem.findOne()` : `plugin.focus.getFocusedRem()` | `plugin.window.getFocusedPaneId()` → `getOpenPaneRemId()` |
279
304
  | 深度参数 | `ancestorLevels`(向上几层) | `depth`(向下几层) |
280
305
  | 展开策略 | 鱼眼(焦点完全展开,周围递减) | 均匀展开(全树同深度控制) |
281
306
  | 大纲头注释 | `<!-- path: ... -->` + `<!-- focus: Name (id) -->` | `<!-- page: Name -->` + `<!-- path: ... -->` |
@@ -345,7 +370,8 @@ focus 模式的核心特点是**渐进式展开**——离焦点越近,展开
345
370
  ## 注意事项
346
371
 
347
372
  - read-context **不缓存**结果——每次调用都重新从 SDK 获取最新数据
348
- - focus 模式需要用户在 RemNote 中有焦点 Rem(光标需在某个 Rem 上)
373
+ - focus 模式需要用户在 RemNote 中有焦点 Rem(光标需在某个 Rem 上),或通过 `focusRemId` 指定任意 Rem 作为鱼眼中心
374
+ - `focusRemId` 仅在 focus 模式下有效,page 模式下传入会报错
349
375
  - page 模式需要 RemNote 中有打开的页面
350
376
  - 输出使用 `createMinimalSerializableRem` 序列化,不包含 backText、practiceDirection 等详细信息
351
377
  - Powerup 过滤是硬编码的,无法通过参数关闭