remnote-bridge 0.1.5 → 0.1.7

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.
@@ -45,10 +45,36 @@ newStr:
45
45
  | \\\` → \\\` | \\\`问题 → 答案\\\` | 创建 forward 闪卡(单行) |
46
46
  | \\\` ← \\\` | \\\`问题 ← 答案\\\` | 创建 backward 闪卡(单行) |
47
47
  | \\\` ↔ \\\` | \\\`问题 ↔ 答案\\\` | 创建 both 闪卡(单行) |
48
+ | \\\` ↓ \\\` | \\\`问题 ↓ 答案\\\` | 创建 forward 多行闪卡(有 backText) |
49
+ | \\\` ↑ \\\` | \\\`问题 ↑ 答案\\\` | 创建 backward 多行闪卡(有 backText) |
50
+ | \\\` ↕ \\\` | \\\`问题 ↕ 答案\\\` | 创建 both 多行闪卡(有 backText) |
48
51
  | \\\` ↓\\\` | \\\`问题 ↓\\\` | 创建 forward 多行闪卡(子节点为答案) |
49
52
  | \\\` ↑\\\` | \\\`问题 ↑\\\` | 创建 backward 多行闪卡 |
50
53
  | \\\` ↕\\\` | \\\`问题 ↕\\\` | 创建 both 多行闪卡 |
51
54
 
55
+ #### 新增行的元数据注释
56
+
57
+ 新增行可以在行尾添加 HTML 注释来指定元数据属性(注意:没有 remId,以 \\\`type:\\\`/\\\`doc\\\`/\\\`tag:\\\` 开头):
58
+
59
+ \\\`\\\`\\\`
60
+ 新概念 <!--type:concept-->
61
+ 新描述 <!--type:descriptor-->
62
+ 新文档 <!--doc-->
63
+ 带标签 <!--tag:TagName(tagRemId)-->
64
+ 组合使用 <!--type:concept doc tag:Physics(abc123)-->
65
+ \\\`\\\`\\\`
66
+
67
+ | 标记 | 效果 |
68
+ |------|------|
69
+ | \\\`type:concept\\\` | 设置 Rem 类型为 Concept |
70
+ | \\\`type:descriptor\\\` | 设置 Rem 类型为 Descriptor |
71
+ | \\\`doc\\\` | 将 Rem 标记为 Document |
72
+ | \\\`tag:Name(remId)\\\` | 添加指定 remId 的 Tag(可多个) |
73
+
74
+ 与已有行行尾标记的区别:
75
+ - 已有行:\\\`文本 <!--remId type:concept doc-->\\\`(以 remId 开头)
76
+ - 新增行:\\\`文本 <!--type:concept doc-->\\\`(无 remId,直接以属性开头)
77
+
52
78
  #### 嵌套新增
53
79
 
54
80
  新增行下面可以再嵌套新增行,通过缩进表示父子关系:
@@ -63,6 +89,10 @@ newStr:
63
89
 
64
90
  嵌套新增行的父 ID 通过内部占位标记管理,创建顺序保证从浅到深。
65
91
 
92
+ #### ⚠️ 插入位置
93
+
94
+ 新行**不能**插在一个有子节点的 Rem 和它的 children 之间,否则 children 会被新行"劫持",触发 \\\`children_captured\\\` 错误。新行必须插在目标层级所有兄弟的**末尾**。
95
+
66
96
  ### 2. 删除行
67
97
 
68
98
  从 newStr 中移除带 remId 的行。**必须同时删除该行的所有可见子行**,否则报 orphan_detected 错误。
@@ -123,6 +153,7 @@ newStr:
123
153
  | 删除行但保留子节点 | \\\`orphan_detected\\\` | Cannot delete {id} because it has children that were not removed. | 同时删除所有子行 |
124
154
  | 删除/修改省略占位符 | \\\`elided_modified\\\` | Cannot delete or modify elided region directly. | 用更大的 depth/maxSiblings 重新 read_tree 展开 |
125
155
  | 缩进跳级 | \\\`indent_skip\\\` | 缩进跳级:行 ... 的缩进级别为 N,但找不到上一级的父节点。 | 检查缩进是否正确(每级 2 空格) |
156
+ | 新行劫持已有子节点 | \\\`children_captured\\\` | New line "..." accidentally captured existing children (...). | 把新行插到兄弟末尾,不要插在父 Rem 和 children 之间 |
126
157
 
127
158
  ---
128
159
 
@@ -194,4 +225,33 @@ newStr:
194
225
  \\\`\\\`\\\`
195
226
 
196
227
  结果:创建一个多行 forward 闪卡,问题为"什么是线性回归?",两个子行自动成为答案(isCardItem=true)。
228
+
229
+ ## 示例 4:创建 Portal
230
+
231
+ Portal 新增行使用 \\\`<!--portal refs:id1,id2-->\\\` 语法(无文本内容,纯参数行):
232
+
233
+ \\\`\\\`\\\`
234
+ oldStr:
235
+ 子节点 A <!--idA-->
236
+
237
+ newStr:
238
+ <!--portal refs:refId1,refId2-->
239
+ 子节点 A <!--idA-->
240
+ \\\`\\\`\\\`
241
+
242
+ 结果:创建一个 Portal,引用 refId1 和 refId2 两个 Rem。
243
+
244
+ 空 Portal(无引用):
245
+
246
+ \\\`\\\`\\\`
247
+ <!--portal-->
248
+ \\\`\\\`\\\`
249
+
250
+ Portal 新增行与已有 Portal 行的区别:
251
+ - 已有 Portal:\\\`<!--remId type:portal refs:id1,id2-->\\\`(有 remId)
252
+ - 新增 Portal:\\\`<!--portal refs:id1,id2-->\\\`(无 remId,以 \\\`portal\\\` 关键字开头)
253
+
254
+ 删除 Portal 与删除普通行相同——从 newStr 中移除该 Portal 行即可。
255
+
256
+ **注意**:修改已有 Portal 的引用列表(增删引用的 Rem)请使用 \\\`edit_rem\\\`,通过 str_replace 修改简化 JSON 中的 \\\`portalDirectlyIncludedRem\\\` 数组。
197
257
  `;
@@ -1,7 +1,7 @@
1
1
  export const ERROR_REFERENCE_CONTENT = `
2
2
  # Error Reference
3
3
 
4
- 所有 CLI 命令错误的完整参考,按类别分组。
4
+ 所有工具错误的完整参考,按类别分组。
5
5
 
6
6
  ---
7
7
 
@@ -54,6 +54,8 @@ export const ERROR_REFERENCE_CONTENT = `
54
54
  | Failed to update field '{field}': ... | SDK setter 调用失败 | 检查字段值是否在允许范围内 |
55
55
  | Field '{fieldName}' is read-only and was ignored | 修改了只读字段 | **警告**(非阻断),该字段不可修改 |
56
56
  | Setting 'todoStatus' without 'isTodo: true' may have no effect | todoStatus 非 null 但 isTodo=false | 先将 isTodo 设为 true |
57
+ | old_str not found in the simplified Portal JSON of rem {remId} | Portal 编辑时 oldStr 在 9 字段简化 JSON 中不匹配 | 检查 oldStr 是否匹配 Portal 简化 JSON 格式(9 字段:id、type、portalType、portalDirectlyIncludedRem、parent、positionAmongstSiblings、children、createdAt、updatedAt) |
58
+ | old_str matches {N} locations in Portal rem {remId}. Make old_str more specific to match exactly once. | Portal 编辑时 oldStr 在简化 JSON 中匹配多处 | 扩大 oldStr 范围以唯一定位 |
57
59
 
58
60
  ---
59
61
 
@@ -67,6 +69,7 @@ export const ERROR_REFERENCE_CONTENT = `
67
69
  | Cannot delete {id} because it has children that were not removed. | \\\`orphan_detected\\\` | 删除了父行但保留了子行 | 必须同时删除所有子行 |
68
70
  | Cannot delete or modify elided region directly. | \\\`elided_modified\\\` | 删除或修改了省略占位符 | 用更大的 depth/maxSiblings 重新 \\\`read_tree\\\` 展开 |
69
71
  | 缩进跳级:行 ... 的缩进级别为 N,但找不到上一级的父节点。 | \\\`indent_skip\\\` | 新增行的缩进不正确 | 检查缩进(每级 2 空格) |
72
+ | New line "..." accidentally captured existing children (...). Insert the new line after the last child, not between a parent Rem and its children. | \\\`children_captured\\\` | 新增行插在了一个有子节点的 Rem 和它的 children 之间,劫持了已有子节点 | 把新行插到目标层级所有兄弟的**末尾**,不要插在父 Rem 紧后面 |
70
73
 
71
74
  ---
72
75
 
@@ -106,6 +109,7 @@ export const ERROR_REFERENCE_CONTENT = `
106
109
 
107
110
  ├─ "old_str not found"
108
111
  │ ├─ 检查空格、换行、引号是否精确匹配
112
+ │ ├─ Portal Rem?检查是否匹配 9 字段简化 JSON(非完整 JSON)
109
113
  │ └─ 重新 read 确认当前内容
110
114
 
111
115
  ├─ "old_str matches N locations"
@@ -117,6 +121,9 @@ export const ERROR_REFERENCE_CONTENT = `
117
121
  ├─ "Content modification not allowed"
118
122
  │ └─ 改用 edit_rem 修改内容
119
123
 
124
+ ├─ "children_captured"
125
+ │ └─ 把新行插到所有兄弟末尾,不要插在父 Rem 和 children 之间
126
+
120
127
  ├─ "orphan_detected"
121
128
  │ └─ 同时删除父行的所有子行
122
129
 
@@ -86,7 +86,35 @@ read_tree / read_globe / read_context 的输出核心是 Markdown 大纲文本
86
86
 
87
87
  ---
88
88
 
89
- ## 6. 完整示例
89
+ ## 6. edit-tree 新增行的特殊格式
90
+
91
+ ### 新增 Portal 行
92
+
93
+ 在 edit-tree 的 newStr 中,用以下格式新增 Portal:
94
+
95
+ \\\`\\\`\\\`
96
+ <!--portal refs:id1,id2--> 新增 Portal 并引用 id1、id2
97
+ <!--portal--> 新增空 Portal(无引用)
98
+ \\\`\\\`\\\`
99
+
100
+ 与已有 Portal 行(\\\`<!--remId type:portal refs:id1,id2-->\\\`)不同,新增 Portal 行没有 remId,以 \\\`<!--portal\\\` 开头。
101
+
102
+ ### 新增行的 metadata-only 注释
103
+
104
+ 新增行(无 remId)可在行尾用 HTML 注释指定类型、文档属性和标签,格式为仅包含元数据标记(不含 remId):
105
+
106
+ \\\`\\\`\\\`
107
+ 新概念 <!--type:concept-->
108
+ 新文档页 <!--type:concept doc-->
109
+ 带标签 <!--type:descriptor tag:数学(tag01)-->
110
+ 多标记组合 <!--type:concept doc tag:基础(tag02) tag:数学(tag01)-->
111
+ \\\`\\\`\\\`
112
+
113
+ 支持的标记:\\\`type:concept\\\`、\\\`type:descriptor\\\`、\\\`doc\\\`、\\\`tag:Name(id)\\\`(可多个)。
114
+
115
+ ---
116
+
117
+ ## 7. 完整示例
90
118
 
91
119
  \\\`\\\`\\\`markdown
92
120
  # 数据结构 <!--kLr type:concept doc top-->
@@ -10,6 +10,7 @@ RemObject 是本项目对 RemNote Rem 的标准化表示,包含 51 个字段
10
10
  | 标记 | 含义 | 数量 | 输出条件 |
11
11
  |:-----|:-----|:-----|:---------|
12
12
  | RW | 可读可写 | 20 | 默认输出 |
13
+ | Portal-W | Portal 专用可写 | 1 | 默认输出(Portal 简化模式) |
13
14
  | R | 只读 | 14 | 默认输出 |
14
15
  | R-F | 只读低频 | 17 | 仅 \\\`--full\\\` 输出 |
15
16
 
@@ -78,7 +79,7 @@ RemObject 是本项目对 RemNote Rem 的标准化表示,包含 51 个字段
78
79
  | 字段 | 类型 | 权限 | 说明 |
79
80
  |------|------|:----:|------|
80
81
  | \\\`portalType\\\` | \\\`PortalType \\| null\\\` | R | Portal 子类型。仅 type=portal 时有值 |
81
- | \\\`portalDirectlyIncludedRem\\\` | \\\`string[]\\\` | R | Portal 直接包含的 Rem ID |
82
+ | \\\`portalDirectlyIncludedRem\\\` | \\\`string[]\\\` | Portal-W | Portal 直接包含的 Rem ID。仅 type=portal 时可通过 edit-rem 修改(Diff 机制:addToPortal/removeFromPortal) |
82
83
 
83
84
  ## 属性类型
84
85
 
@@ -155,7 +156,7 @@ RemObject 是本项目对 RemNote Rem 的标准化表示,包含 51 个字段
155
156
 
156
157
  ## 可编辑字段约束表
157
158
 
158
- 以下为 20 个可编辑字段(RW)及其写入约束:
159
+ 以下为 21 个可编辑字段(RW + Portal-W)及其写入约束:
159
160
 
160
161
  | 字段 | SDK setter | 值类型 | 约束 / 特殊处理 |
161
162
  |------|-----------|--------|-----------------|
@@ -179,6 +180,7 @@ RemObject 是本项目对 RemNote Rem 的标准化表示,包含 51 个字段
179
180
  | \\\`tags\\\` | \\\`rem.addTag()\\\` / \\\`rem.removeTag()\\\` | string[] | **Diff 机制**:对比当前 vs 目标,增删差异项 |
180
181
  | \\\`sources\\\` | \\\`rem.addSource()\\\` / \\\`rem.removeSource()\\\` | string[] | **Diff 机制**:对比当前 vs 目标,增删差异项 |
181
182
  | \\\`positionAmongstSiblings\\\` | \\\`rem.setParent(parent, position)\\\` | number \\| null | 与 \\\`parent\\\` 联动 |
183
+ | \\\`portalDirectlyIncludedRem\\\` | \\\`rem.addToPortal()\\\` / \\\`rem.removeFromPortal()\\\` | string[] | **Portal-W Diff 机制**:仅 type=portal 时可修改。对比当前 vs 目标数组,逐项增删 |
182
184
 
183
185
  ### parent + positionAmongstSiblings 联动
184
186
 
@@ -213,13 +215,13 @@ for id in currentSet:
213
215
 
214
216
  ## 只读字段列表
215
217
 
216
- 以下 31 个字段在 str_replace 中被修改时,**只产生警告,不执行写入**:
218
+ 以下 30 个字段在 str_replace 中被修改时,**只产生警告,不执行写入**:
217
219
 
218
220
  \\\`\\\`\\\`
219
221
  id,
220
222
  children,
221
223
  isTable,
222
- portalType, portalDirectlyIncludedRem,
224
+ portalType,
223
225
  propertyType,
224
226
  aliases,
225
227
  remsBeingReferenced, deepRemsBeingReferenced, remsReferencingThis,
@@ -1,11 +1,11 @@
1
1
  export const SEPARATOR_FLASHCARD_CONTENT = `
2
2
  # Separator & Flashcard Reference
3
3
 
4
- **重要**:分隔符(\\\`::\\\`、\\\`;;\\\`、\\\`>>\\\` 等)是 RemNote 编辑器的输入语法,**CLI 不使用分隔符**。通过 CLI 创建/修改闪卡,操作的是 \\\`type\\\`、\\\`backText\\\`、\\\`practiceDirection\\\` 字段和大纲箭头(\\\`→←↔↓↑↕\\\`)。本参考表用于理解用户意图——当用户提到分隔符时,映射到对应的 CLI 操作。
4
+ **重要**:分隔符(\\\`::\\\`、\\\`;;\\\`、\\\`>>\\\` 等)是 RemNote 编辑器的输入语法,**工具端不使用分隔符**。创建/修改闪卡,操作的是 \\\`type\\\`、\\\`backText\\\`、\\\`practiceDirection\\\` 字段和大纲箭头(\\\`→←↔↓↑↕\\\`)。本参考表用于理解用户意图——当用户提到分隔符时,映射到对应的工具操作。
5
5
 
6
6
  ---
7
7
 
8
- ## 1. 用户意图映射:编辑器分隔符 → CLI 操作
8
+ ## 1. 用户意图映射:编辑器分隔符 → 工具操作
9
9
 
10
10
  当用户提到以下分隔符时,对应的 Rem 属性为:
11
11
 
@@ -24,7 +24,7 @@ export const SEPARATOR_FLASHCARD_CONTENT = `
24
24
 
25
25
  ---
26
26
 
27
- ## 2. CLI 操作方式:大纲箭头
27
+ ## 2. 操作方式:大纲箭头
28
28
 
29
29
  在 Markdown 大纲(read_tree / edit_tree)中,practiceDirection 编码为 Unicode 箭头:
30
30
 
@@ -66,7 +66,7 @@ RemNote 推荐的知识结构化方法:
66
66
  - **Concept**(type:concept):需要理解的核心概念——"X 是什么?"
67
67
  - **Descriptor**(type:descriptor):概念的属性/描述——"X 的 Y 是什么?"
68
68
 
69
- 在 CLI 大纲中的表现(注意:用箭头和元数据标记,不用分隔符):
69
+ 在大纲中的表现(注意:用箭头和元数据标记,不用分隔符):
70
70
 
71
71
  \\\`\\\`\\\`
72
72
  线性回归 ↔ 最基本的回归模型 <!--id1 type:concept-->
@@ -76,7 +76,7 @@ RemNote 推荐的知识结构化方法:
76
76
 
77
77
  ---
78
78
 
79
- ## 5. 通过 CLI 创建闪卡
79
+ ## 5. 创建闪卡
80
80
 
81
81
  ### 使用 edit_tree 新增行
82
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remnote-bridge",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "RemNote 自动化桥接工具集:CLI + MCP Server + Plugin",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,9 @@ import { deleteRem } from '../services/delete-rem';
19
19
  import { moveRem } from '../services/move-rem';
20
20
  import { reorderChildren } from '../services/reorder-children';
21
21
  import { search } from '../services/search';
22
+ import { createPortal } from '../services/create-portal';
23
+ import { addToPortal } from '../services/add-to-portal';
24
+ import { removeFromPortal } from '../services/remove-from-portal';
22
25
 
23
26
  /**
24
27
  * 创建消息路由处理器
@@ -50,6 +53,14 @@ export function createMessageRouter(plugin: ReactRNPlugin): (request: BridgeRequ
50
53
  case 'search':
51
54
  return search(plugin, request.payload as { query: string; numResults?: number });
52
55
 
56
+ // Portal 操作
57
+ case 'create_portal':
58
+ return createPortal(plugin, request.payload as { parentId: string; position?: number });
59
+ case 'add_to_portal':
60
+ return addToPortal(plugin, request.payload as { portalId: string; remId: string });
61
+ case 'remove_from_portal':
62
+ return removeFromPortal(plugin, request.payload as { portalId: string; remId: string });
63
+
53
64
  default:
54
65
  throw new Error(`未实现的 action: ${request.action}`);
55
66
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * add-to-portal service — 向 Portal 添加引用
3
+ *
4
+ * 同态命名:add_to_portal (action) → add-to-portal.ts (文件) → addToPortal (函数)
5
+ *
6
+ * 注意调用方向:addToPortal() 是在被引用的 Rem 上调用,参数是 Portal Rem。
7
+ */
8
+
9
+ import type { ReactRNPlugin } from '@remnote/plugin-sdk';
10
+
11
+ export interface AddToPortalPayload {
12
+ /** Portal Rem ID */
13
+ portalId: string;
14
+ /** 要添加到 Portal 的 Rem ID */
15
+ remId: string;
16
+ }
17
+
18
+ /**
19
+ * 将指定 Rem 添加到 Portal 的引用列表。
20
+ *
21
+ * @throws Error — Portal 不存在、Rem 不存在
22
+ */
23
+ export async function addToPortal(
24
+ plugin: ReactRNPlugin,
25
+ payload: AddToPortalPayload,
26
+ ): Promise<void> {
27
+ const { portalId, remId } = payload;
28
+
29
+ const portal = await plugin.rem.findOne(portalId);
30
+ if (!portal) {
31
+ throw new Error(`Portal not found: ${portalId}`);
32
+ }
33
+
34
+ const rem = await plugin.rem.findOne(remId);
35
+ if (!rem) {
36
+ throw new Error(`Rem not found: ${remId}`);
37
+ }
38
+
39
+ await rem.addToPortal(portal);
40
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * create-portal service — 创建 Portal Rem
3
+ *
4
+ * 同态命名:create_portal (action) → create-portal.ts (文件) → createPortal (函数)
5
+ *
6
+ * Portal 只能通过 plugin.rem.createPortal() 创建,不能通过 setType() 将已有 Rem 转为 Portal。
7
+ */
8
+
9
+ import type { ReactRNPlugin } from '@remnote/plugin-sdk';
10
+
11
+ export interface CreatePortalPayload {
12
+ /** 父节点 Rem ID */
13
+ parentId: string;
14
+ /** 在兄弟中的位置(0-based),可选 */
15
+ position?: number;
16
+ }
17
+
18
+ export interface CreatePortalResult {
19
+ remId: string;
20
+ }
21
+
22
+ /**
23
+ * 创建空 Portal 并设置父节点。
24
+ *
25
+ * @throws Error — 创建失败、父节点不存在
26
+ */
27
+ export async function createPortal(
28
+ plugin: ReactRNPlugin,
29
+ payload: CreatePortalPayload,
30
+ ): Promise<CreatePortalResult> {
31
+ const { parentId, position } = payload;
32
+
33
+ // 创建空 Portal
34
+ const portal = await plugin.rem.createPortal();
35
+ if (!portal) {
36
+ throw new Error('Failed to create portal');
37
+ }
38
+
39
+ // 设置父节点和位置
40
+ const parent = await plugin.rem.findOne(parentId);
41
+ if (!parent) {
42
+ throw new Error(`Parent Rem not found: ${parentId}`);
43
+ }
44
+ await portal.setParent(parent, position);
45
+
46
+ return { remId: portal._id };
47
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * remove-from-portal service — 从 Portal 移除引用
3
+ *
4
+ * 同态命名:remove_from_portal (action) → remove-from-portal.ts (文件) → removeFromPortal (函数)
5
+ *
6
+ * 注意调用方向:removeFromPortal() 是在被引用的 Rem 上调用,参数是 Portal Rem。
7
+ */
8
+
9
+ import type { ReactRNPlugin } from '@remnote/plugin-sdk';
10
+
11
+ export interface RemoveFromPortalPayload {
12
+ /** Portal Rem ID */
13
+ portalId: string;
14
+ /** 要从 Portal 移除的 Rem ID */
15
+ remId: string;
16
+ }
17
+
18
+ /**
19
+ * 将指定 Rem 从 Portal 的引用列表中移除。
20
+ *
21
+ * @throws Error — Portal 不存在、Rem 不存在
22
+ */
23
+ export async function removeFromPortal(
24
+ plugin: ReactRNPlugin,
25
+ payload: RemoveFromPortalPayload,
26
+ ): Promise<void> {
27
+ const { portalId, remId } = payload;
28
+
29
+ const portal = await plugin.rem.findOne(portalId);
30
+ if (!portal) {
31
+ throw new Error(`Portal not found: ${portalId}`);
32
+ }
33
+
34
+ const rem = await plugin.rem.findOne(remId);
35
+ if (!rem) {
36
+ throw new Error(`Rem not found: ${remId}`);
37
+ }
38
+
39
+ await rem.removeFromPortal(portal);
40
+ }
@@ -194,6 +194,11 @@ async function applyField(
194
194
  await applySourcesDiff(rem, value as string[]);
195
195
  break;
196
196
 
197
+ // Portal 引用(diff based)
198
+ case 'portalDirectlyIncludedRem':
199
+ await applyPortalRefsDiff(rem, plugin, value as string[]);
200
+ break;
201
+
197
202
  // Powerup 操作
198
203
  case 'addPowerup':
199
204
  await rem.addPowerup(value as string);
@@ -242,6 +247,40 @@ async function applySourcesDiff(rem: Rem, targetIds: string[]): Promise<void> {
242
247
  }
243
248
  }
244
249
 
250
+ /**
251
+ * portalDirectlyIncludedRem diff: 对比当前和目标,通过 addToPortal/removeFromPortal 增删。
252
+ * 注意调用方向:addToPortal/removeFromPortal 是在被引用 Rem 上调用,参数是 Portal Rem。
253
+ */
254
+ async function applyPortalRefsDiff(portalRem: Rem, plugin: ReactRNPlugin, targetIds: string[]): Promise<void> {
255
+ // 防御:非 Portal Rem 不可修改此字段
256
+ // 使用 rem.type === 6(portal 类型的枚举值),而非 getPortalType()
257
+ // 因为 getPortalType() 可能对新创建的 Portal 返回 undefined
258
+ if ((portalRem as unknown as { type: number }).type !== 6) {
259
+ throw new Error('portalDirectlyIncludedRem can only be modified on Portal Rem');
260
+ }
261
+
262
+ const currentRefs = await portalRem.getPortalDirectlyIncludedRem();
263
+ const currentIds = new Set(currentRefs.map((r: Rem) => r._id));
264
+ const targetSet = new Set(targetIds);
265
+
266
+ // 添加缺少的
267
+ for (const id of targetIds) {
268
+ if (!currentIds.has(id)) {
269
+ const remToAdd = await plugin.rem.findOne(id);
270
+ if (!remToAdd) throw new Error(`Rem not found: ${id}`);
271
+ await remToAdd.addToPortal(portalRem);
272
+ }
273
+ }
274
+ // 删除多余的
275
+ for (const id of currentIds) {
276
+ if (!targetSet.has(id as string)) {
277
+ const remToRemove = await plugin.rem.findOne(id as string);
278
+ if (!remToRemove) throw new Error(`Rem not found: ${id}`);
279
+ await remToRemove.removeFromPortal(portalRem);
280
+ }
281
+ }
282
+ }
283
+
245
284
  /** 字符串类型值 → SDK SetRemType 枚举数值。Portal 不可通过 setType() 设置。 */
246
285
  function remTypeStringToEnum(type: string): 1 | 2 | 'DEFAULT_TYPE' {
247
286
  switch (type) {
@@ -21,13 +21,16 @@
21
21
  * 以下每个 [RW] 字段的注释标注了其底层是 Powerup 机制还是纯字段修改
22
22
  *
23
23
  * 读写标注:
24
- * - [RW] = 可读可写(SDK 有对应的 setter/add/remove 方法)
25
- * - [R] = 只读,默认输出(SDK 仅有 getter)
26
- * - [R-F] = 只读,仅 --full 模式输出(低频 / 细粒度 / 可由其他字段推导)
24
+ * - [RW] = 可读可写(SDK 有对应的 setter/add/remove 方法)
25
+ * - [Portal-W] = Portal 专用可写(仅 Portal Rem 可通过 addToPortal/removeFromPortal 修改)
26
+ * - [R] = 只读,默认输出(SDK 仅有 getter)
27
+ * - [R-F] = 只读,仅 --full 模式输出(低频 / 细粒度 / 可由其他字段推导)
27
28
  *
28
29
  * 输出模式(CLI --full 选项):
29
30
  * - 默认模式:输出 [RW] + [R] 字段(34 个),覆盖 Agent 常用场景
30
31
  * - --full 模式:额外输出 [R-F] 字段(+17 个),用于调试或深度分析
32
+ * - Portal 模式:type === 'portal' 时自动输出 9 个关键字段(id、type、portalType、
33
+ * portalDirectlyIncludedRem、parent、positionAmongstSiblings、children、createdAt、updatedAt)
31
34
  *
32
35
  * 实测标注(2026-03-03 在 RemNote UI 中逐字段截图观察):
33
36
  * - ✅ 已实测 = 在真实 RemNote 环境中创建独立 Rem 并截图观察视觉行为
@@ -254,7 +257,7 @@ export interface RemObject {
254
257
 
255
258
  /** [R] Portal 子类型。仅 type === 'portal' 时有值。SDK: getPortalType() */
256
259
  portalType: PortalType | null;
257
- /** [R] Portal 直接包含的 Rem ID 数组。SDK: getPortalDirectlyIncludedRem() */
260
+ /** [Portal-W] Portal 直接包含的 Rem ID 数组。SDK: getPortalDirectlyIncludedRem() / addToPortal() / removeFromPortal()。写入时使用 diff 机制。仅 type === 'portal' 时可修改 */
258
261
  portalDirectlyIncludedRem: string[];
259
262
 
260
263
  // ══════════════════════════════════════════════════════════
@@ -42,7 +42,7 @@ RemNote 中所有内容的基本单元都是 **Rem**。文档、文件夹、闪
42
42
 
43
43
  两个独立维度:
44
44
 
45
- - **type**(闪卡语义):`concept`(加粗)、`descriptor`(正常字重)、`default`(普通)、`portal`(只读)
45
+ - **type**(闪卡语义):`concept`(加粗)、`descriptor`(正常字重)、`default`(普通)、`portal`(嵌入引用容器)
46
46
  - **isDocument**(页面语义):与 type 完全独立
47
47
 
48
48
  ### CDF 框架(Concept-Descriptor Framework)
@@ -88,7 +88,17 @@ RemNote 推荐的知识结构化方法:
88
88
  | **Tag** | `##` | Rem 的 tags 数组 | read-rem 的 `tags` 字段 |
89
89
  | **Portal** | `((` | 嵌入实时视图(**编辑同步**) | read-tree 标记 `type:portal refs:id1,id2` |
90
90
 
91
- Portal 的编辑同步意味着修改一处会影响另一处。Portal 引用的 Rem 不会被 read-tree 自动展开,需要对 refs 中的 ID 单独 read-tree。
91
+ Portal 的编辑同步意味着修改一处会影响另一处。Portal 引用的 Rem 不会被 read-tree 自动展开,需要对 refs 中的 ID 单独 read-tree。Portal 引用列表可通过 `edit-rem` 修改(str_replace 简化 JSON 中的 `portalDirectlyIncludedRem` 数组)。
92
+
93
+ #### Portal 操作速查
94
+
95
+ | 操作 | 命令 | 方式 |
96
+ |:-----|:-----|:-----|
97
+ | 创建 Portal | `edit-tree` | 新增行 `<!--portal refs:id1,id2-->` |
98
+ | 删除 Portal | `edit-tree` | 从大纲中移除 Portal 行(与删除普通行相同) |
99
+ | 修改引用列表(增删引用的 Rem) | `edit-rem` | str_replace 简化 JSON 中的 `portalDirectlyIncludedRem` 数组 |
100
+ | 移动 Portal(换父节点/位置) | `edit-tree` | 与移动普通行相同 |
101
+ | 读取 Portal | `read-rem` | 自动输出 9 字段简化 JSON |
92
102
 
93
103
  ### RichText 格式
94
104
 
@@ -423,6 +433,30 @@ read-tree / read-globe / read-context 输出 Markdown 大纲,edit-tree 基于
423
433
  `代码块`
424
434
  ```
425
435
 
436
+ ### 新增行指定类型/属性(metadata-only 注释)
437
+
438
+ 新增行可在行尾添加 HTML 注释来指定 type、isDocument、tag,格式中不含 remId:
439
+
440
+ ```markdown
441
+ 新概念 <!--type:concept-->
442
+ 新文档页 <!--type:concept doc-->
443
+ 带标签的描述 <!--type:descriptor tag:数学(tag01)-->
444
+ 多标记组合 <!--type:concept doc tag:基础(tag02) tag:数学(tag01)-->
445
+ ```
446
+
447
+ 支持的标记:`type:concept`、`type:descriptor`、`doc`、`tag:Name(id)`(可多个,空格分隔)。
448
+
449
+ ### 新增 Portal
450
+
451
+ 在 newStr 中用特殊格式创建 Portal:
452
+
453
+ ```markdown
454
+ <!--portal refs:id1,id2--> 创建 Portal 并引用 id1、id2
455
+ <!--portal--> 创建空 Portal
456
+ ```
457
+
458
+ 注意:与已有 Portal 行(`<!--remId type:portal refs:id1,id2-->`)不同,新增 Portal 以 `<!--portal` 开头,无 remId。Portal 不能通过 `edit-rem` 设 `type: "portal"` 创建,只能用此格式或 SDK `createPortal()`。
459
+
426
460
  ### 嵌套新增(一次创建父+子结构)
427
461
 
428
462
  新增行下面可以再嵌套新增行,缩进表示父子关系:
@@ -474,14 +508,14 @@ oldStr: "\"text\": [\n \"Hello\"\n ]"
474
508
  newStr: "\"text\": [\n \"World\"\n ]"
475
509
  ```
476
510
 
477
- ### 20 个可编辑字段
511
+ ### 21 个可编辑字段
478
512
 
479
513
  ```
480
514
  text, backText, type, isDocument, parent,
481
515
  fontSize, highlightColor,
482
516
  isTodo, todoStatus, isCode, isQuote, isListItem, isCardItem, isSlot, isProperty,
483
517
  enablePractice, practiceDirection,
484
- tags, sources, positionAmongstSiblings
518
+ tags, sources, positionAmongstSiblings, portalDirectlyIncludedRem
485
519
  ```
486
520
 
487
521
  ### 特殊字段处理规则
@@ -495,6 +529,7 @@ tags, sources, positionAmongstSiblings
495
529
  | `type` | 不可设为 `portal`(只能通过 SDK `createPortal()` 创建) |
496
530
  | `parent` + `positionAmongstSiblings` | 共享同一 SDK 调用 `setParent(parentId, position)`,**应在同一次 str_replace 中同时修改** |
497
531
  | `tags` / `sources` | **Diff 机制**:对比当前 vs 目标数组,逐项 add/remove。必须列出完整目标数组,缺少的会被删除 |
532
+ | `portalDirectlyIncludedRem` | Portal 专用可写。**Diff 机制**:对比当前 vs 目标数组,逐项 addToPortal/removeFromPortal。仅 type=portal 时可修改。edit-rem 对 Portal 使用 9 字段简化 JSON |
498
533
 
499
534
  ### 常用只读字段(修改只产生警告,不生效)
500
535
 
@@ -508,6 +543,7 @@ aliases, descendants, siblingRem, isTable, portalType, propertyType
508
543
  | 模式 | 字段数 | 用法 |
509
544
  |:-----|:-------|:-----|
510
545
  | 默认 | 34(RW + R) | 常用场景 |
546
+ | Portal 简化 | 9(id, type, portalType, portalDirectlyIncludedRem, parent, positionAmongstSiblings, children, createdAt, updatedAt) | type=portal 时自动切换,`--full` 和 `--fields` 可覆盖 |
511
547
  | `--full` | 51(含低频 R-F) | 需要 Powerup 标识、时间戳等 |
512
548
  | `--fields` | 自选 + id | 精确获取特定字段 |
513
549
 
@@ -531,6 +567,8 @@ aliases, descendants, siblingRem, isTable, portalType, propertyType
531
567
  | folded_delete | 删了有隐藏子节点的行 | 用更大 depth 重新 read-tree |
532
568
  | elided_modified | 删/改了省略占位符 | 用更大 depth/maxSiblings 重新 read-tree |
533
569
  | indent_skip | 缩进跳级(如 0→4 空格) | 每级 2 空格,不可跳级 |
570
+ | children_captured | 新行劫持已有子节点 | 把新行插到兄弟末尾 |
571
+ | old_str not found in simplified Portal JSON | Portal 编辑时 oldStr 不匹配简化 JSON | 检查 Portal 简化 JSON 格式 |
534
572
  | Rem not found | remId 无效或已删除 | 用 search 重新定位 |
535
573
 
536
574
  ---
@@ -106,7 +106,7 @@ remnote-bridge --json connect
106
106
  {
107
107
  "ok": false,
108
108
  "command": "connect",
109
- "error": "守护进程启动超时(10 秒)",
109
+ "error": "守护进程启动超时(60 秒)",
110
110
  "timestamp": "2026-03-06T10:00:00.000Z"
111
111
  }
112
112
  ```
@@ -125,19 +125,27 @@ remnote-bridge --json connect
125
125
  3. daemon 内部按顺序启动:
126
126
  ├─ WS Server(必须成功,否则 daemon 退出)
127
127
  ├─ ConfigServer(非关键,失败不阻塞)
128
- └─ webpack-dev-server(必须成功,否则 daemon 退出)
128
+ └─ webpack-dev-server(含依赖自动安装 + 崩溃重试)
129
129
 
130
130
  4. daemon 写入 PID 文件
131
131
 
132
132
  5. daemon 通过 IPC 发送 ready 信号给父进程
133
133
 
134
134
  6. 父进程(CLI)收到 ready → 输出结果 → 退出
135
- ├─ 10 秒内未收到 → 超时失败
135
+ ├─ 60 秒内未收到 → 超时失败
136
136
  └─ 收到 error → 启动失败
137
137
  ```
138
138
 
139
139
  ---
140
140
 
141
+ ## Windows 注意事项
142
+
143
+ - **首次 connect 较慢**:daemon 启动时会自动安装 remnote-plugin 的依赖(约 600+ 个包),在 Windows 上可能需要 30-60 秒。connect 命令的超时为 60 秒
144
+ - **依赖自动修复**:如果 webpack-dev-server 因依赖损坏而崩溃,daemon 会自动执行清洁重装(删除 node_modules + package-lock.json 后重新安装)并重试,最多重试 2 次,无需手动干预
145
+ - **端口残留**:多次 connect 失败后可能出现端口被占用(`EADDRINUSE`),先执行 `remnote-bridge disconnect`,如仍有残留可通过 `netstat -ano | findstr 3002` 定位 PID 后 `taskkill /F /PID <pid>` 强制终止
146
+
147
+ ---
148
+
141
149
  ## 超时机制
142
150
 
143
151
  daemon 启动后开始计时,默认 **30 分钟无 CLI 交互**自动关闭(执行优雅 shutdown)。每次收到 CLI 请求时重置计时器。