project-node-memory 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +204 -2
- package/README_EN.md +201 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1621 -0
- package/lib/engine/commands.js +84 -0
- package/lib/engine/diag.js +220 -0
- package/lib/engine/mdlib.js +283 -0
- package/lib/engine/parse.js +67 -0
- package/lib/engine/registry.js +241 -0
- package/lib/engine/rename.js +203 -0
- package/lib/engine/render.js +138 -0
- package/lib/engine/scaffold.js +123 -0
- package/lib/engine/search.js +138 -0
- package/lib/engine/write.js +140 -0
- package/lib/host/prompt.js +111 -0
- package/lib/host/routes.js +304 -0
- package/lib/host/runtime.js +108 -0
- package/lib/host/skill.js +160 -0
- package/lib/host/tools.js +475 -0
- package/lib/index.js +60 -0
- package/package.json +62 -4
- package/skills/project-node-memory-guide/SKILL.md +59 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry —— 库定位(工作区优先,注册表兜底)。
|
|
3
|
+
*
|
|
4
|
+
* 三种来源,优先级从高到低:
|
|
5
|
+
* 1. 工具参数 `lib`(技能目录或 .nodekb 路径)——受工作区/已注册路径白名单约束
|
|
6
|
+
* 2. config.libraries 里显式声明的 { 名字: 技能目录 }
|
|
7
|
+
* 3. 注册表 JSON(`{"libraries":{"名字":{"path":"技能目录"}}}`,与 Python 版 kb_registry.json 同格式)
|
|
8
|
+
* 4. 工作区自带 `.nodekb`(名字固定为 workspace)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_KB = 'workspace'
|
|
16
|
+
|
|
17
|
+
function isDir(p) {
|
|
18
|
+
try {
|
|
19
|
+
return fs.statSync(p).isDirectory()
|
|
20
|
+
} catch {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isFile(p) {
|
|
26
|
+
try {
|
|
27
|
+
return fs.statSync(p).isFile()
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** child 是否位于 parent 之内(含相等) */
|
|
34
|
+
export function isInside(child, parent) {
|
|
35
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child))
|
|
36
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function defaultRegistryPaths(workspace) {
|
|
40
|
+
const out = [path.join(os.homedir(), '.dsh', 'nodekb', 'registry.json')]
|
|
41
|
+
for (const p of [path.join(workspace, 'kb_registry.json'), path.join(workspace, '.nodekb', 'kb_registry.json')]) {
|
|
42
|
+
if (isFile(p)) out.push(p)
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readRegistryFile(file) {
|
|
48
|
+
try {
|
|
49
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
50
|
+
const libs = data && typeof data === 'object' ? data.libraries : null
|
|
51
|
+
if (!libs || typeof libs !== 'object') return {}
|
|
52
|
+
const out = {}
|
|
53
|
+
for (const [name, info] of Object.entries(libs)) {
|
|
54
|
+
if (info && typeof info.path === 'string' && info.path) out[name] = { path: info.path, registry: file }
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
} catch {
|
|
58
|
+
return {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 合并多个注册表(先出现的优先) */
|
|
63
|
+
export function loadRegistries(files, inline) {
|
|
64
|
+
const merged = new Map()
|
|
65
|
+
for (const [name, dir] of Object.entries(inline || {})) {
|
|
66
|
+
if (typeof dir === 'string' && dir) merged.set(name, { path: dir, registry: 'config' })
|
|
67
|
+
}
|
|
68
|
+
for (const f of files || []) {
|
|
69
|
+
for (const [name, entry] of Object.entries(readRegistryFile(f))) {
|
|
70
|
+
if (!merged.has(name)) merged.set(name, entry)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return merged
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function workspaceBase(workspace) {
|
|
77
|
+
const nodekb = path.join(workspace, '.nodekb')
|
|
78
|
+
return isDir(nodekb) ? { name: DEFAULT_KB, base: workspace, nodekb, origin: 'workspace' } : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeReaddir(dir) {
|
|
82
|
+
try {
|
|
83
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
84
|
+
} catch {
|
|
85
|
+
return []
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 在工作区里找库(有界 BFS,默认下钻 2 层)。
|
|
91
|
+
* 约定位置优先,但 <root>/packages/<包>/.nodekb 这种单仓多包也能发现。
|
|
92
|
+
* 跳过 node_modules/.git/dist/build 与点目录;budget 是 readdir 次数上限,
|
|
93
|
+
* 面板每次加载都会调,不能把整棵树走一遍。
|
|
94
|
+
*/
|
|
95
|
+
const SKIP_DIR = new Set(['node_modules', 'dist', 'build', 'out', 'target', 'coverage', '.git'])
|
|
96
|
+
|
|
97
|
+
export function discoverLibraries(root, { depth = 2, budget = 300, limit = 60 } = {}) {
|
|
98
|
+
const ws = path.resolve(root || '.')
|
|
99
|
+
const out = []
|
|
100
|
+
const seen = new Set()
|
|
101
|
+
const push = (dir, rel, origin) => {
|
|
102
|
+
const nodekb = path.join(dir, '.nodekb')
|
|
103
|
+
if (!isDir(nodekb) || seen.has(nodekb)) return
|
|
104
|
+
seen.add(nodekb)
|
|
105
|
+
const parts = rel ? rel.split(/[\\/]/) : []
|
|
106
|
+
const isSkill = parts.length >= 2 && parts[parts.length - 2] === 'skills'
|
|
107
|
+
const name = !parts.length ? DEFAULT_KB : isSkill ? parts[parts.length - 1] : parts.join('/')
|
|
108
|
+
out.push({ name, path: dir, nodekb, exists: true, origin })
|
|
109
|
+
}
|
|
110
|
+
push(ws, '', 'workspace')
|
|
111
|
+
// 技能自带的知识库是约定位置,单独直查(不进 BFS,因为 .dsh 是点目录)
|
|
112
|
+
const scanSkills = (base, prefix) => {
|
|
113
|
+
for (const e of safeReaddir(path.join(base, '.dsh', 'skills'))) {
|
|
114
|
+
if (!e.isDirectory() || e.name.startsWith('.')) continue
|
|
115
|
+
push(path.join(base, '.dsh', 'skills', e.name), (prefix ? prefix + '/' : '') + '.dsh/skills/' + e.name, 'discovered')
|
|
116
|
+
if (out.length >= limit) return
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
scanSkills(ws, '')
|
|
120
|
+
const queue = [{ dir: ws, rel: '', level: 0 }]
|
|
121
|
+
let spent = 0
|
|
122
|
+
while (queue.length && spent < budget && out.length < limit) {
|
|
123
|
+
const cur = queue.shift()
|
|
124
|
+
if (cur.level >= depth) continue
|
|
125
|
+
let entries
|
|
126
|
+
spent += 1
|
|
127
|
+
try {
|
|
128
|
+
entries = fs.readdirSync(cur.dir, { withFileTypes: true })
|
|
129
|
+
} catch {
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
for (const e of entries) {
|
|
133
|
+
if (out.length >= limit || spent >= budget) break
|
|
134
|
+
if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIR.has(e.name)) continue
|
|
135
|
+
const rel = cur.rel ? cur.rel + '/' + e.name : e.name
|
|
136
|
+
const dir = path.join(cur.dir, e.name)
|
|
137
|
+
push(dir, rel, 'discovered')
|
|
138
|
+
if (cur.level === 0) scanSkills(dir, e.name)
|
|
139
|
+
queue.push({ dir, rel, level: cur.level + 1 })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 定位库根(返回 .nodekb 目录)。
|
|
147
|
+
* @throws 带可行动提示的错误
|
|
148
|
+
*/
|
|
149
|
+
export function resolveLibrary({ kb, lib, workspace, config } = {}) {
|
|
150
|
+
const ws = path.resolve(workspace || process.cwd())
|
|
151
|
+
const registries = loadRegistries(
|
|
152
|
+
config && config.registries ? config.registries : defaultRegistryPaths(ws),
|
|
153
|
+
config && config.libraries,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// 1) 显式路径
|
|
157
|
+
if (lib) {
|
|
158
|
+
const p = path.resolve(ws, lib)
|
|
159
|
+
const nodekb = path.basename(p) === '.nodekb' ? p : path.join(p, '.nodekb')
|
|
160
|
+
if (!isDir(nodekb)) throw new Error(`库目录不存在: ${nodekb}(你传入: ${lib})`)
|
|
161
|
+
const allowed = [ws, ...[...registries.values()].map((e) => e.path)]
|
|
162
|
+
if (!allowed.some((root) => isInside(nodekb, root))) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`拒绝访问工作区外的库: ${nodekb}\n` +
|
|
165
|
+
`允许范围 = 工作区 ${ws} + 已注册库。需要长期使用请在插件配置 config.libraries 里登记。`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
return { name: path.basename(nodekb), base: path.dirname(nodekb), nodekb, origin: 'lib' }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2) 名字
|
|
172
|
+
const name = kb && String(kb).trim()
|
|
173
|
+
if (name) {
|
|
174
|
+
const entry = registries.get(name)
|
|
175
|
+
if (entry) {
|
|
176
|
+
const nodekb = path.join(entry.path, '.nodekb')
|
|
177
|
+
if (!isDir(nodekb)) throw new Error(`库目录不存在: ${nodekb}(注册表 ${name} 指向 ${entry.path})`)
|
|
178
|
+
return { name, base: entry.path, nodekb, origin: entry.registry }
|
|
179
|
+
}
|
|
180
|
+
const discovered = discoverLibraries(ws).find((r) => r.name === name)
|
|
181
|
+
if (discovered) {
|
|
182
|
+
return { name, base: path.dirname(discovered.nodekb), nodekb: discovered.nodekb, origin: discovered.origin }
|
|
183
|
+
}
|
|
184
|
+
if (name === DEFAULT_KB) {
|
|
185
|
+
const wsLib = workspaceBase(ws)
|
|
186
|
+
if (wsLib) return wsLib
|
|
187
|
+
}
|
|
188
|
+
const names = [...registries.keys(), ...discoverLibraries(ws).map((r) => r.name)]
|
|
189
|
+
throw new Error(`未注册的库: ${name}(可用: ${names.join(', ') || '无'};或用 lib 参数给绝对路径)`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 3) 无参:工作区自带 → 工作区里唯一发现 → 唯一的注册表库
|
|
193
|
+
const wsLib = workspaceBase(ws)
|
|
194
|
+
if (wsLib) return wsLib
|
|
195
|
+
const found = discoverLibraries(ws)
|
|
196
|
+
if (found.length === 1) {
|
|
197
|
+
return { name: found[0].name, base: path.dirname(found[0].nodekb), nodekb: found[0].nodekb, origin: found[0].origin }
|
|
198
|
+
}
|
|
199
|
+
if (found.length > 1) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`未指定库(工作区根没有 .nodekb,但发现 ${found.length} 个):${found.map((r) => r.name).join('、')}\n` +
|
|
202
|
+
`用 kb 参数选一个,或把库放到工作区根的 .nodekb。`,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
if (registries.size === 1) {
|
|
206
|
+
const [[only, entry]] = [...registries.entries()]
|
|
207
|
+
const nodekb = path.join(entry.path, '.nodekb')
|
|
208
|
+
if (isDir(nodekb)) return { name: only, base: entry.path, nodekb, origin: entry.registry }
|
|
209
|
+
}
|
|
210
|
+
if (registries.size > 1) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`未指定库(工作区没有 .nodekb)。已注册: ${[...registries.keys()].join(', ')};用 kb 参数选一个。`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
throw new Error(
|
|
216
|
+
`没有可用库:工作区 ${ws} 下没有 .nodekb,注册表里也没有库。\n` +
|
|
217
|
+
`在项目里建 .nodekb/ 并放 md 文件即可(folder/文件/## 标题都会自动变成节点);` +
|
|
218
|
+
`技能自带的知识库放在 .dsh/skills/<技能名>/.nodekb/ 也会被自动发现。`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 列出可用库(不解析内容,避免一次拖动整库) */
|
|
223
|
+
export function listLibraries({ workspace, config } = {}) {
|
|
224
|
+
const ws = path.resolve(workspace || process.cwd())
|
|
225
|
+
const registries = loadRegistries(
|
|
226
|
+
config && config.registries ? config.registries : defaultRegistryPaths(ws),
|
|
227
|
+
config && config.libraries,
|
|
228
|
+
)
|
|
229
|
+
const rows = []
|
|
230
|
+
const seen = new Set()
|
|
231
|
+
for (const r of discoverLibraries(ws)) {
|
|
232
|
+
rows.push({ name: r.name, path: path.dirname(r.nodekb), exists: true, origin: r.origin })
|
|
233
|
+
seen.add(r.nodekb)
|
|
234
|
+
}
|
|
235
|
+
for (const [name, entry] of registries) {
|
|
236
|
+
const nodekb = path.join(entry.path, '.nodekb')
|
|
237
|
+
if (seen.has(nodekb)) continue
|
|
238
|
+
rows.push({ name, path: entry.path, exists: isDir(nodekb), origin: entry.registry })
|
|
239
|
+
}
|
|
240
|
+
return rows
|
|
241
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rename —— 改文件节点名并把 [[TID]] 引用一起搬走。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:nodekb 的 TID 由「文件夹 TID + 文件名 slug」派生,
|
|
5
|
+
* 所以重命名一个 md 会让它的 TID 变掉,散落各处的 `[[doc:03-x]]` 立刻变成悬空引用。
|
|
6
|
+
* 手工改名的代价是事后跑 kb_diag 再逐个 grep,所以把「改名 + 重写引用」做成一个原子动作。
|
|
7
|
+
*
|
|
8
|
+
* 三条纪律(抄 dsh-tiddlywiki 的教训):
|
|
9
|
+
* 1. 写前重读:算好新内容后再 stat 一次,mtime/size 变了就跳过并报出来,绝不覆盖别人刚改的东西。
|
|
10
|
+
* 2. 围栏内的 `[[...]]` 不重写(parser 也不把它当引用,改了反而破坏示例代码)。
|
|
11
|
+
* 3. 只改 TID 这一层:`[[old]]` 与 `[[old:1.2]]` 都搬,`[[oldx]]` 这种前缀相同的别动。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { slugify } from './parse.js'
|
|
17
|
+
import { ROOT_TID } from './mdlib.js'
|
|
18
|
+
|
|
19
|
+
const FENCE = '```'
|
|
20
|
+
|
|
21
|
+
/** 把一段文本里的 oldTid 引用改写成 newTid(含 `oldTid:…` 子节点引用)。 */
|
|
22
|
+
export function rewriteRefsInText(text, oldTid, newTid) {
|
|
23
|
+
const lines = String(text).split('\n')
|
|
24
|
+
const out = []
|
|
25
|
+
let count = 0
|
|
26
|
+
let fenced = false
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (line.trim().startsWith(FENCE)) {
|
|
29
|
+
fenced = !fenced
|
|
30
|
+
out.push(line)
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (fenced) {
|
|
34
|
+
out.push(line)
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
out.push(
|
|
38
|
+
line.replace(/\[\[([^[\]]+)\]\]/g, (whole, inner) => {
|
|
39
|
+
if (inner === oldTid) {
|
|
40
|
+
count += 1
|
|
41
|
+
return `[[${newTid}]]`
|
|
42
|
+
}
|
|
43
|
+
if (inner.startsWith(oldTid + ':')) {
|
|
44
|
+
count += 1
|
|
45
|
+
return `[[${newTid}${inner.slice(oldTid.length)}]]`
|
|
46
|
+
}
|
|
47
|
+
return whole
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
return { text: out.join('\n'), count }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function absOf(lib, node) {
|
|
55
|
+
// source 是「绝对路径#L1-L22」(相对路径只在库根被移走时才可能出现)
|
|
56
|
+
const raw = String(node.source || '').split('#')[0]
|
|
57
|
+
if (!raw) return ''
|
|
58
|
+
return path.isAbsolute(raw) ? path.normalize(raw) : path.join(lib.nodekb, raw)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function relOf(lib, abs) {
|
|
62
|
+
return path.relative(lib.nodekb, abs).split(path.sep).join('/')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 收集库里所有 md(跳过点目录,与 parse_library 一致) */
|
|
66
|
+
function allMarkdown(nodekb) {
|
|
67
|
+
const out = []
|
|
68
|
+
const walk = (d) => {
|
|
69
|
+
let entries
|
|
70
|
+
try {
|
|
71
|
+
entries = fs.readdirSync(d, { withFileTypes: true })
|
|
72
|
+
} catch {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
if (e.name.startsWith('.')) continue
|
|
77
|
+
const p = path.join(d, e.name)
|
|
78
|
+
if (e.isDirectory()) walk(p)
|
|
79
|
+
else if (e.name.endsWith('.md')) out.push(p)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
walk(nodekb)
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 重命名一个文件节点,并把它引用的 TID 搬过去。
|
|
88
|
+
* @param {{nodekb:string, nodes:Map}} lib openLibrary 的结果
|
|
89
|
+
* @param {string} target 文件节点 TID,或相对路径 `doc/03-x.md`
|
|
90
|
+
* @param {string} newName 新文件名(.md 可省略)
|
|
91
|
+
* @param {{dryRun?:boolean}} [opts]
|
|
92
|
+
*/
|
|
93
|
+
export function renameFileNode(lib, target, newName, opts = {}) {
|
|
94
|
+
const nodes = lib.nodes
|
|
95
|
+
const wanted = String(target || '').trim()
|
|
96
|
+
if (!wanted) throw new Error('target 不能为空')
|
|
97
|
+
|
|
98
|
+
let node = nodes.get(wanted)
|
|
99
|
+
if (!node) {
|
|
100
|
+
// 允许直接给相对路径
|
|
101
|
+
const rel = wanted.split(path.sep).join('/')
|
|
102
|
+
node = [...nodes.values()].find((n) => n.kind === 'file' && String(n.source).split('#')[0] === rel)
|
|
103
|
+
}
|
|
104
|
+
if (!node) throw new Error(`未找到节点: ${wanted}(可先 kb_read --toc 看 TID 清单)`)
|
|
105
|
+
if (node.kind !== 'file') {
|
|
106
|
+
throw new Error(`只能重命名文件节点;${node.tid} 是 ${node.kind} 节点(目录改名请直接改文件夹)`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const from = absOf(lib, node)
|
|
110
|
+
if (!fs.existsSync(from)) throw new Error(`源文件不存在: ${relOf(lib, from)}`)
|
|
111
|
+
|
|
112
|
+
let name = String(newName || '').trim()
|
|
113
|
+
if (!name) throw new Error('new_name 不能为空')
|
|
114
|
+
if (!name.endsWith('.md')) name += '.md'
|
|
115
|
+
const to = path.join(path.dirname(from), name)
|
|
116
|
+
if (path.resolve(to) === path.resolve(from)) throw new Error('新旧文件名相同,没有要改的')
|
|
117
|
+
if (fs.existsSync(to)) throw new Error(`目标已存在: ${relOf(lib, to)}`)
|
|
118
|
+
|
|
119
|
+
const stem = name.replace(/\.md$/, '')
|
|
120
|
+
const parent = node.parent_tid || ROOT_TID
|
|
121
|
+
const newTid = parent === ROOT_TID ? slugify(stem) : `${parent}:${slugify(stem)}`
|
|
122
|
+
if (newTid === node.tid) {
|
|
123
|
+
throw new Error(`新文件名 slug 化后与旧 TID 相同(${newTid}),引用无需改动;只改大小写等请直接改文件`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const report = {
|
|
127
|
+
from: relOf(lib, from),
|
|
128
|
+
to: relOf(lib, to),
|
|
129
|
+
tid: node.tid,
|
|
130
|
+
new_tid: newTid,
|
|
131
|
+
dry_run: Boolean(opts.dryRun),
|
|
132
|
+
rewritten: [],
|
|
133
|
+
skipped: [],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 先算出要改哪些文件(读 → 重写),最后才动文件系统
|
|
137
|
+
const edits = []
|
|
138
|
+
for (const file of allMarkdown(lib.nodekb)) {
|
|
139
|
+
let text
|
|
140
|
+
let st1
|
|
141
|
+
try {
|
|
142
|
+
st1 = fs.statSync(file)
|
|
143
|
+
text = fs.readFileSync(file, 'utf8')
|
|
144
|
+
} catch (e) {
|
|
145
|
+
report.skipped.push({ path: relOf(lib, file), why: `读失败: ${e.message}` })
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (!text.includes(oldRefPrefix(node.tid))) continue
|
|
149
|
+
const { text: next, count } = rewriteRefsInText(text, node.tid, newTid)
|
|
150
|
+
if (!count) continue
|
|
151
|
+
edits.push({ file, st1, next, count })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (opts.dryRun) {
|
|
155
|
+
for (const e of edits) report.rewritten.push({ path: relOf(lib, e.file), count: e.count })
|
|
156
|
+
return report
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fs.renameSync(from, to)
|
|
160
|
+
report.renamed = true
|
|
161
|
+
|
|
162
|
+
for (const e of edits) {
|
|
163
|
+
const target2 = e.file === from ? to : e.file
|
|
164
|
+
try {
|
|
165
|
+
const st2 = fs.statSync(target2)
|
|
166
|
+
if (st2.mtimeMs !== e.st1.mtimeMs || st2.size !== e.st1.size) {
|
|
167
|
+
report.skipped.push({ path: relOf(lib, target2), why: '读后被改动,未覆盖' })
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
fs.writeFileSync(target2, e.next, 'utf8')
|
|
171
|
+
report.rewritten.push({ path: relOf(lib, target2), count: e.count })
|
|
172
|
+
} catch (err) {
|
|
173
|
+
report.skipped.push({ path: relOf(lib, target2), why: err.message })
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return report
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function oldRefPrefix(tid) {
|
|
181
|
+
return `[[${tid}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 人读的回执 */
|
|
185
|
+
export function formatRename(report) {
|
|
186
|
+
const lines = []
|
|
187
|
+
const head = report.dry_run ? '(演练,未落盘)' : ''
|
|
188
|
+
lines.push(`${head}已重命名「${report.from}」→「${report.to}」`)
|
|
189
|
+
lines.push(`TID: ${report.tid} → ${report.new_tid}`)
|
|
190
|
+
if (report.rewritten.length) {
|
|
191
|
+
const total = report.rewritten.reduce((s, r) => s + r.count, 0)
|
|
192
|
+
lines.push(`更新了 ${total} 处引用(${report.rewritten.length} 个文件)`)
|
|
193
|
+
for (const r of report.rewritten.slice(0, 10)) lines.push(` - ${r.path}: ${r.count}`)
|
|
194
|
+
if (report.rewritten.length > 10) lines.push(` …另有 ${report.rewritten.length - 10} 个文件`)
|
|
195
|
+
} else {
|
|
196
|
+
lines.push('没有需要改写的引用')
|
|
197
|
+
}
|
|
198
|
+
if (report.skipped.length) {
|
|
199
|
+
lines.push(`跳过 ${report.skipped.length} 个文件(未覆盖):`)
|
|
200
|
+
for (const s of report.skipped.slice(0, 10)) lines.push(` - ${s.path}: ${s.why}`)
|
|
201
|
+
}
|
|
202
|
+
return lines.join('\n')
|
|
203
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render —— 基于内存节点模型的渲染层(md 即库,无持久化状态)。
|
|
3
|
+
*
|
|
4
|
+
* - render_node / render_subtree:[[TID]] 就地填充(transclusion 展开)
|
|
5
|
+
* - toc_lines:目录视图(tid · 标题 · 字符数,不渲染正文)
|
|
6
|
+
* - node_info:节点元信息(含 source 行号)
|
|
7
|
+
* - 环检测:展开路径重复出现同一 TID 抛 TransclusionError;悬空引用保留 [[TID]] 原文
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { refRegex } from './parse.js'
|
|
11
|
+
|
|
12
|
+
export class TransclusionError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message)
|
|
15
|
+
this.name = 'TransclusionError'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function children(nodes, tid) {
|
|
20
|
+
const out = []
|
|
21
|
+
for (const n of nodes.values()) if (n.parent_tid === tid) out.push(n)
|
|
22
|
+
out.sort((a, b) => (a.sort - b.sort) || (a.tid < b.tid ? -1 : a.tid > b.tid ? 1 : 0))
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cycle_msg(seen, target, nodes) {
|
|
27
|
+
let msg = `循环引用: ${[...seen, target].join(' -> ')}`
|
|
28
|
+
const node = nodes.get(target)
|
|
29
|
+
if (node && node.source) msg += `(重复节点源: ${node.source})`
|
|
30
|
+
return msg
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 容器节点(文件夹/文件)转译:整支子树投影(`## 标题` + 内容 + 子节递归) */
|
|
34
|
+
function project_container(nodes, tid, seen) {
|
|
35
|
+
const node = nodes.get(tid)
|
|
36
|
+
const parts = []
|
|
37
|
+
const title = node.title || tid
|
|
38
|
+
if (title !== tid) parts.push('## ' + title) // 显式节点(title===tid)内联时不输出标题,避免列表内噪音
|
|
39
|
+
parts.push(expand(node.content, nodes, seen))
|
|
40
|
+
for (const child of children(nodes, tid)) {
|
|
41
|
+
parts.push(project_container(nodes, child.tid, [...seen, child.tid]))
|
|
42
|
+
}
|
|
43
|
+
return parts.join('\n\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function expand(text, nodes, seen) {
|
|
47
|
+
const out = []
|
|
48
|
+
let pos = 0
|
|
49
|
+
for (const m of String(text).matchAll(refRegex())) {
|
|
50
|
+
out.push(text.slice(pos, m.index))
|
|
51
|
+
const target = m[1]
|
|
52
|
+
if (seen.includes(target)) throw new TransclusionError(cycle_msg(seen, target, nodes))
|
|
53
|
+
const nxt = [...seen, target]
|
|
54
|
+
const node = nodes.get(target)
|
|
55
|
+
if (node) {
|
|
56
|
+
// 仅 文件夹/文件 容器展开整支子树;显式 <node> 原子引用只展开自身(避免同文件兄弟被连带展开)
|
|
57
|
+
if (node.kind === 'folder' || node.kind === 'file') out.push(project_container(nodes, target, nxt))
|
|
58
|
+
else out.push(expand(node.content, nodes, nxt))
|
|
59
|
+
} else {
|
|
60
|
+
out.push(m[0]) // 悬空:保留原文,diag 报告
|
|
61
|
+
}
|
|
62
|
+
pos = m.index + m[0].length
|
|
63
|
+
}
|
|
64
|
+
out.push(text.slice(pos))
|
|
65
|
+
return out.join('')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 渲染单节点内容(不含标题行) */
|
|
69
|
+
export function render_node(nodes, tid) {
|
|
70
|
+
const node = nodes.get(tid)
|
|
71
|
+
if (!node) throw new Error(`节点不存在: ${tid}`)
|
|
72
|
+
return expand(node.content, nodes, [tid])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 渲染整支:递归子树为带标题层级的 md */
|
|
76
|
+
export function render_subtree(nodes, tid, level = 1) {
|
|
77
|
+
const node = nodes.get(tid)
|
|
78
|
+
if (!node) throw new Error(`节点不存在: ${tid}`)
|
|
79
|
+
const title = node.title || tid
|
|
80
|
+
const head = '#'.repeat(level) + ' ' + title
|
|
81
|
+
const parts = [head, expand(node.content, nodes, [tid])]
|
|
82
|
+
for (const child of children(nodes, tid)) parts.push(render_subtree(nodes, child.tid, level + 1))
|
|
83
|
+
return parts.join('\n\n')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 目录视图:每节点一行(tid · 标题 · 字符数) */
|
|
87
|
+
export function toc_lines(nodes, tid, level = 0) {
|
|
88
|
+
const node = nodes.get(tid)
|
|
89
|
+
if (!node) return [`未找到节点: ${tid}`]
|
|
90
|
+
const lines = [`${' '.repeat(level)}- ${node.tid} · ${node.title || '(无标题)'} · ${node.content.length} 字符`]
|
|
91
|
+
for (const child of children(nodes, tid)) lines.push(...toc_lines(nodes, child.tid, level + 1))
|
|
92
|
+
return lines
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 目录树(给 HTML 面板用,与 toc_lines 同源) */
|
|
96
|
+
export function toc_tree(nodes, tid, depth = 0) {
|
|
97
|
+
const node = nodes.get(tid)
|
|
98
|
+
if (!node) return null
|
|
99
|
+
return {
|
|
100
|
+
tid: node.tid,
|
|
101
|
+
title: node.title || '',
|
|
102
|
+
type: node.type,
|
|
103
|
+
kind: node.kind,
|
|
104
|
+
positional: !!node.positional,
|
|
105
|
+
chars: node.content.length,
|
|
106
|
+
source: node.source,
|
|
107
|
+
children: children(nodes, tid).map((c) => toc_tree(nodes, c.tid, depth + 1)).filter(Boolean),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function node_info(nodes, tid) {
|
|
112
|
+
const node = nodes.get(tid)
|
|
113
|
+
if (!node) throw new Error(`节点不存在: ${tid}`)
|
|
114
|
+
// 字段与 Python 版严格一致(kind/positional 由调用方按需补,避免 raw 输出漂移)
|
|
115
|
+
return {
|
|
116
|
+
tid: node.tid,
|
|
117
|
+
type: node.type,
|
|
118
|
+
title: node.title,
|
|
119
|
+
parent_tid: node.parent_tid,
|
|
120
|
+
sort: node.sort,
|
|
121
|
+
source: node.source,
|
|
122
|
+
content: node.content,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 路径解析(三级):精确 TID → 段边界前缀唯一 → 裸前缀(模糊)唯一 */
|
|
127
|
+
export function resolve_path(nodes, p) {
|
|
128
|
+
if (nodes.has(p)) return p
|
|
129
|
+
const keys = [...nodes.keys()]
|
|
130
|
+
let hits = keys.filter((t) => t === p || t.startsWith(p + ':')).sort()
|
|
131
|
+
if (hits.length === 1) return hits[0]
|
|
132
|
+
if (!hits.length) {
|
|
133
|
+
// 段边界无命中 → 裸前缀模糊(fact:mod → fact:modid / fact:modid-places / fact:modid-trap)
|
|
134
|
+
hits = keys.filter((t) => t.startsWith(p)).sort()
|
|
135
|
+
if (hits.length === 1) return hits[0]
|
|
136
|
+
}
|
|
137
|
+
return null
|
|
138
|
+
}
|