@noob-stupid/dsh-plugin-console 0.5.87 → 0.5.93
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 +64 -4
- package/lib/client.js +1109 -78
- package/lib/server/domain/framework.js +7 -0
- package/lib/server/domain/plugin-annotations.js +569 -0
- package/lib/server/domain/update-batch.js +181 -0
- package/lib/server/routes/annotations.js +82 -0
- package/lib/server/routes/framework.js +137 -120
- package/lib/server/routes/index.js +12 -0
- package/lib/server/routes/update-batch.js +97 -0
- package/package.json +1 -1
|
@@ -19,6 +19,13 @@ function resolveDshBin() {
|
|
|
19
19
|
const requireLocal = createRequire(join(pluginRoot(), 'package.json'))
|
|
20
20
|
return join(dirname(requireLocal.resolve('@deepseek-ai/dsh/package.json')), 'lib', 'bin.js')
|
|
21
21
|
} catch {
|
|
22
|
+
// ★ 0.5.88:npx / 启动器拉起的实例,插件根里没有 @deepseek-ai/dsh,上面必然失败。
|
|
23
|
+
// 此时用**当前进程自己**的入口反推 —— process.argv[1] 就是拉起本进程的那个脚本。
|
|
24
|
+
// 只在它确实存在且是文件时采用(绝不凭空造一个路径),否则仍如实返回 null。
|
|
25
|
+
try {
|
|
26
|
+
const entry = typeof process.argv[1] === 'string' ? process.argv[1] : ''
|
|
27
|
+
if (entry !== '' && /[\\/]bin\.js$/u.test(entry) && existsSync(entry)) return entry
|
|
28
|
+
} catch {}
|
|
22
29
|
return null
|
|
23
30
|
}
|
|
24
31
|
}
|
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
// L1 · domain —— plugin-annotations.js(用户注解:备注 / 用户徽章 / 用户自建分组;issue #22 ②③)
|
|
2
|
+
// 分层分组:L1 · domain(tests/test-architecture-guard.mjs 断言边界;不认识 cordis ctx)
|
|
3
|
+
//
|
|
4
|
+
// ── 这个文件存在的原因 ────────────────────────────────────────────────────────
|
|
5
|
+
// issue #22 ②③:每个已装插件要能写一段**用户备注**("这是干什么的、为什么装它")、打自己的**徽章**,
|
|
6
|
+
// 并把插件归进**用户自建分组**。这三件事必须是**用户数据**,与框架/补丁/profile 清单完全无关 ——
|
|
7
|
+
// 所以落在一个独立文件里:`<DSH_HOME>/plugin-console/plugin-annotations.json`。
|
|
8
|
+
//
|
|
9
|
+
// ── 硬边界(每一条都有对应的测试)─────────────────────────────────────────────
|
|
10
|
+
// ① **绝不影响插件本身**:注解读不出来、写不进去、文件被手工改坏 —— 都不许让 /state、开关、
|
|
11
|
+
// 安装这些路径受一点影响。因此本模块不被 /state 引用,路由失败也只回自己的错误。
|
|
12
|
+
// ② **坏文件不覆盖**:文件存在但不是合法对象 ⇒ 读的时候**降级为空结构并报错**,写的时候**拒绝**
|
|
13
|
+
// (409),绝不用"我们的空结构"盖掉用户手写/被别的东西写坏的文件。空文件(0 字节)是另一回事:
|
|
14
|
+
// 那是"还没有内容",按空结构处理、写入时正常覆盖(并照常备份)。
|
|
15
|
+
// ③ **写盘四步**:备份 → 临时文件 + rename(原子)→ **读回核实预期事实** → 核实不过就回滚。
|
|
16
|
+
// "写了但没生效"是最坏的一种假绿(本仓 0.5.87 的"更新成功但版本没落住"就是这一族)。
|
|
17
|
+
// ④ **并发不丢更新**:所有写都过 infra/fsx.js 的 `queuedWrite`(同一个进程级队列),
|
|
18
|
+
// 于是"读-改-写"不会互相覆盖(10 个并发写 10 个包,10 条都必须在,测试 ③ 钉死)。
|
|
19
|
+
// ⑤ **只增不毁**:删分组只解除归属,**不动**插件的备注/徽章;删徽章/改备注都只是改这一条记录。
|
|
20
|
+
//
|
|
21
|
+
// ── 数据形状(version 1)──────────────────────────────────────────────────────
|
|
22
|
+
// {
|
|
23
|
+
// "version": 1,
|
|
24
|
+
// "updatedAt": 1700000000000,
|
|
25
|
+
// "plugins": {
|
|
26
|
+
// "<包名或 rowId>": { "note": "…", "badges": ["必装","记忆类"], "rowId": "dsh-memory", "updatedAt": 0 }
|
|
27
|
+
// },
|
|
28
|
+
// "groups": [
|
|
29
|
+
// { "id": "gxxx", "name": "记忆类", "packages": ["@openviking/dsh-memory-plugin"], "collapsed": false, "createdAt": 0 }
|
|
30
|
+
// ]
|
|
31
|
+
// }
|
|
32
|
+
// · `plugins` 键优先用**包名**(用户看到的就是包名),记录里另存 `rowId` 供"只给了 rowId"的调用命中;
|
|
33
|
+
// · `groups` 的**数组顺序就是展示顺序**(新建追加、撤销删除回到原位置);
|
|
34
|
+
// · 一个包**最多属于一个用户分组**(assign 到 B 会自动从 A 移出)—— 分组是"区块",一个插件同时出现在
|
|
35
|
+
// 两个区块里只会让人怀疑数据坏了。官方 family 区块(dsh-web 那类)优先级更高,由客户端处理:
|
|
36
|
+
// **属于官方 family 的插件不进入用户分组区块**(本模块不参与该判定,只存归属)。
|
|
37
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
38
|
+
import { dirname, join } from 'node:path'
|
|
39
|
+
import { queuedWrite } from '../infra/fsx.js'
|
|
40
|
+
import { dshHome } from '../infra/paths.js'
|
|
41
|
+
|
|
42
|
+
const ANNOTATIONS_FILE = 'plugin-annotations.json'
|
|
43
|
+
const ANNOTATIONS_VERSION = 1
|
|
44
|
+
const NOTE_MAX = 2000
|
|
45
|
+
const BADGE_MAX_COUNT = 12
|
|
46
|
+
const BADGE_MAX_LEN = 16
|
|
47
|
+
const GROUP_NAME_MAX = 24
|
|
48
|
+
const GROUP_MAX = 40
|
|
49
|
+
const PACKAGE_NAME_MAX = 214
|
|
50
|
+
/** 包名形态(允许子路径行,如 `@linxin666/dsh-web-all/plugin-manager`);用户注解只能挂在包上。 */
|
|
51
|
+
const PACKAGE_NAME_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(\/[A-Za-z0-9._-]+)*$/u
|
|
52
|
+
|
|
53
|
+
const messageOf = (error) => (error instanceof Error ? error.message : String(error))
|
|
54
|
+
|
|
55
|
+
/** 注解文件路径(`<DSH_HOME>/plugin-console/plugin-annotations.json`)。 */
|
|
56
|
+
function annotationsPath() {
|
|
57
|
+
return join(dshHome(), 'plugin-console', ANNOTATIONS_FILE)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 空结构(读不到 / 空文件 / 降级读取都用它;**永远返回新对象**,避免调用方改到共享状态)。 */
|
|
61
|
+
function emptyAnnotations() {
|
|
62
|
+
return { version: ANNOTATIONS_VERSION, updatedAt: 0, plugins: {}, groups: [] }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isPackageName(value) {
|
|
66
|
+
return typeof value === 'string' && value !== '' && value.length <= PACKAGE_NAME_MAX && PACKAGE_NAME_RE.test(value)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 徽章列表规范化(去空白、去重、上限);不合法 ⇒ ok:false + 可读原因。 */
|
|
70
|
+
function sanitizeBadges(raw) {
|
|
71
|
+
if (!Array.isArray(raw)) return { ok: false, error: 'badges 必须是字符串数组(清空请传 [])' }
|
|
72
|
+
const out = []
|
|
73
|
+
for (const item of raw) {
|
|
74
|
+
if (typeof item !== 'string') return { ok: false, error: '徽章必须是字符串' }
|
|
75
|
+
const text = item.trim()
|
|
76
|
+
if (text === '') continue
|
|
77
|
+
if (text.length > BADGE_MAX_LEN) return { ok: false, error: `徽章「${text.slice(0, 12)}…」太长(上限 ${BADGE_MAX_LEN} 字)` }
|
|
78
|
+
if (/[\u0000-\u001f\u007f]/u.test(text)) return { ok: false, error: '徽章里不能有控制字符' }
|
|
79
|
+
if (!out.includes(text)) out.push(text)
|
|
80
|
+
if (out.length > BADGE_MAX_COUNT) return { ok: false, error: `徽章最多 ${BADGE_MAX_COUNT} 个(收到 ${out.length} 个)` }
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, value: out }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 备注规范化(trim、限制长度);空串表示"清空这条备注"。 */
|
|
86
|
+
function sanitizeNote(raw) {
|
|
87
|
+
if (typeof raw !== 'string') return { ok: false, error: 'note 必须是字符串(清空请传空串)' }
|
|
88
|
+
const text = raw.replace(/\r\n/gu, '\n').trim()
|
|
89
|
+
if (text.length > NOTE_MAX) return { ok: false, error: `备注太长(${text.length} 字,上限 ${NOTE_MAX})` }
|
|
90
|
+
return { ok: true, value: text }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 分组名规范化。 */
|
|
94
|
+
function sanitizeGroupName(raw) {
|
|
95
|
+
if (typeof raw !== 'string') return { ok: false, error: 'name 必须是字符串' }
|
|
96
|
+
const text = raw.trim()
|
|
97
|
+
if (text === '') return { ok: false, error: '分组名不能为空' }
|
|
98
|
+
if (text.length > GROUP_NAME_MAX) return { ok: false, error: `分组名太长(${text.length} 字,上限 ${GROUP_NAME_MAX})` }
|
|
99
|
+
if (/[\u0000-\u001f\u007f]/u.test(text)) return { ok: false, error: '分组名里不能有控制字符' }
|
|
100
|
+
return { ok: true, value: text }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 把磁盘上的(可能被手工改过的)对象规范化成可信结构,并如实报告修了几处。 */
|
|
104
|
+
function normalizeAnnotations(raw) {
|
|
105
|
+
const repaired = []
|
|
106
|
+
const data = emptyAnnotations()
|
|
107
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { data, repaired: ['整体结构不是对象'] }
|
|
108
|
+
if (Number.isFinite(raw.updatedAt)) data.updatedAt = raw.updatedAt
|
|
109
|
+
// plugins
|
|
110
|
+
const plugins = raw.plugins !== null && typeof raw.plugins === 'object' && !Array.isArray(raw.plugins) ? raw.plugins : {}
|
|
111
|
+
if (raw.plugins !== undefined && plugins !== raw.plugins) repaired.push('plugins 不是对象,已忽略')
|
|
112
|
+
for (const [key, record] of Object.entries(plugins)) {
|
|
113
|
+
if (!isPackageName(key)) { repaired.push(`键不是合法包名:${String(key).slice(0, 40)}`); continue }
|
|
114
|
+
if (record === null || typeof record !== 'object' || Array.isArray(record)) { repaired.push(`记录不是对象:${key}`); continue }
|
|
115
|
+
const noteResult = typeof record.note === 'string' ? sanitizeNote(record.note) : { ok: false }
|
|
116
|
+
const badgesResult = Array.isArray(record.badges) ? sanitizeBadges(record.badges) : { ok: false }
|
|
117
|
+
const note = noteResult.ok === true ? noteResult.value : ''
|
|
118
|
+
const badges = badgesResult.ok === true ? badgesResult.value : []
|
|
119
|
+
if (typeof record.note === 'string' && !noteResult.ok) repaired.push(`备注不合法已丢弃:${key}`)
|
|
120
|
+
if (Array.isArray(record.badges) && !badgesResult.ok) repaired.push(`徽章不合法已丢弃:${key}`)
|
|
121
|
+
if (note === '' && badges.length === 0) continue
|
|
122
|
+
const out = {}
|
|
123
|
+
if (note !== '') out.note = note
|
|
124
|
+
if (badges.length > 0) out.badges = badges
|
|
125
|
+
if (typeof record.rowId === 'string' && record.rowId !== '') out.rowId = record.rowId
|
|
126
|
+
if (Number.isFinite(record.updatedAt)) out.updatedAt = record.updatedAt
|
|
127
|
+
data.plugins[key] = out
|
|
128
|
+
}
|
|
129
|
+
// groups
|
|
130
|
+
const groups = Array.isArray(raw.groups) ? raw.groups : []
|
|
131
|
+
if (raw.groups !== undefined && !Array.isArray(raw.groups)) repaired.push('groups 不是数组,已忽略')
|
|
132
|
+
const seenIds = new Set()
|
|
133
|
+
const seenNames = new Set()
|
|
134
|
+
for (const group of groups) {
|
|
135
|
+
if (group === null || typeof group !== 'object' || Array.isArray(group)) { repaired.push('分组记录不是对象'); continue }
|
|
136
|
+
const id = typeof group.id === 'string' ? group.id.trim() : ''
|
|
137
|
+
const nameResult = sanitizeGroupName(group.name)
|
|
138
|
+
if (id === '' || !nameResult.ok) { repaired.push('分组缺 id 或缺名字'); continue }
|
|
139
|
+
if (seenIds.has(id)) { repaired.push(`分组 id 重复:${id}`); continue }
|
|
140
|
+
if (seenNames.has(nameResult.value)) { repaired.push(`分组名重复:${nameResult.value}`); continue }
|
|
141
|
+
seenIds.add(id)
|
|
142
|
+
seenNames.add(nameResult.value)
|
|
143
|
+
const out = { id, name: nameResult.value, packages: [], collapsed: group.collapsed === true }
|
|
144
|
+
if (Array.isArray(group.packages)) {
|
|
145
|
+
for (const pkg of group.packages) {
|
|
146
|
+
if (!isPackageName(pkg)) { repaired.push(`分组 ${nameResult.value} 里的包名不合法:${String(pkg).slice(0, 40)}`); continue }
|
|
147
|
+
if (!out.packages.includes(pkg)) out.packages.push(pkg)
|
|
148
|
+
}
|
|
149
|
+
} else if (group.packages !== undefined) repaired.push(`分组 ${nameResult.value} 的 packages 不是数组`)
|
|
150
|
+
if (Number.isFinite(group.createdAt)) out.createdAt = group.createdAt
|
|
151
|
+
data.groups.push(out)
|
|
152
|
+
}
|
|
153
|
+
if (data.groups.length > GROUP_MAX) {
|
|
154
|
+
repaired.push(`分组超过上限 ${GROUP_MAX},多余的已被丢弃`)
|
|
155
|
+
data.groups = data.groups.slice(0, GROUP_MAX)
|
|
156
|
+
}
|
|
157
|
+
return { data, repaired }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 读取注解(**永不抛**):缺文件 / 空文件 / 坏文件都降级成空结构,并把原因如实带出。
|
|
162
|
+
* @param {string} [file] 显式路径(单测注入;生产不传 = annotationsPath())
|
|
163
|
+
* @returns {{ ok: boolean, exists: boolean, emptyFile: boolean, file: string,
|
|
164
|
+
* data: object, error: string|null, repaired: string[] }}
|
|
165
|
+
*/
|
|
166
|
+
function readAnnotations(file) {
|
|
167
|
+
const target = typeof file === 'string' && file !== '' ? file : annotationsPath()
|
|
168
|
+
const result = { ok: true, exists: false, emptyFile: false, file: target, data: emptyAnnotations(), error: null, repaired: [] }
|
|
169
|
+
if (!existsSync(target)) return result
|
|
170
|
+
result.exists = true
|
|
171
|
+
let text = ''
|
|
172
|
+
try {
|
|
173
|
+
text = readFileSync(target, 'utf8')
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return { ...result, ok: false, error: `读取注解文件失败:${messageOf(error)}` }
|
|
176
|
+
}
|
|
177
|
+
if (text.trim() === '') {
|
|
178
|
+
// 空文件 = "还没有内容":正常降级为空结构(写入时正常覆盖,但仍先备份)
|
|
179
|
+
return { ...result, emptyFile: true }
|
|
180
|
+
}
|
|
181
|
+
let parsed = null
|
|
182
|
+
try {
|
|
183
|
+
parsed = JSON.parse(text)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { ...result, ok: false, error: `注解文件不是合法 JSON(${messageOf(error)})—— 已按空结构降级读取;写入会被拒绝,以免覆盖你的文件` }
|
|
186
|
+
}
|
|
187
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
188
|
+
return { ...result, ok: false, error: `注解文件的顶层不是对象(${Array.isArray(parsed) ? '数组' : typeof parsed})—— 已按空结构降级读取;写入会被拒绝` }
|
|
189
|
+
}
|
|
190
|
+
const normalized = normalizeAnnotations(parsed)
|
|
191
|
+
return { ...result, data: normalized.data, repaired: normalized.repaired }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 键解析:优先包名精确命中,其次 rowId(旧记录只带 rowId 时也能命中),最后按包名新建。 */
|
|
195
|
+
function resolvePluginKey(data, { packageName, rowId } = {}) {
|
|
196
|
+
const name = typeof packageName === 'string' ? packageName.trim() : ''
|
|
197
|
+
const rid = typeof rowId === 'string' ? rowId.trim() : ''
|
|
198
|
+
if (name !== '' && Object.hasOwn(data.plugins, name)) return name
|
|
199
|
+
if (rid !== '') {
|
|
200
|
+
if (Object.hasOwn(data.plugins, rid)) return rid
|
|
201
|
+
for (const [key, record] of Object.entries(data.plugins)) {
|
|
202
|
+
if (record !== null && typeof record === 'object' && record.rowId === rid) return key
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (name !== '') return name
|
|
206
|
+
if (rid !== '') return rid
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function findGroupIndex(data, id) {
|
|
211
|
+
const wanted = typeof id === 'string' ? id.trim() : ''
|
|
212
|
+
return data.groups.findIndex((group) => group.id === wanted)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 记录里**用户可见的事实**(备注 / 徽章 / rowId)——比较"变了没有"和"读回是否如预期"都只认这三项。
|
|
217
|
+
* 为什么不直接比整个对象(本轮实测踩到的坑):
|
|
218
|
+
* · `updatedAt` 每次写都变 ⇒ 用它比会把"内容没变的重复提交"判成 changed(于是多写一次盘);
|
|
219
|
+
* · JSON.stringify 还依赖**键序**;读回来的是规范化后的固定键序(note/badges/rowId/updatedAt),
|
|
220
|
+
* 而写入时是"在原记录上追加" ⇒ 键序天然不同 ⇒ 读回核实会**误判成失败并回滚**
|
|
221
|
+
* (第一版实现就是这样:写徽章永远回滚,测试 ① 当场红)。
|
|
222
|
+
* 判据落在"事实"上,这两个坑一起消失。
|
|
223
|
+
*/
|
|
224
|
+
function pluginFactsOf(record) {
|
|
225
|
+
const src = record !== null && typeof record === 'object' ? record : {}
|
|
226
|
+
return {
|
|
227
|
+
note: typeof src.note === 'string' ? src.note : '',
|
|
228
|
+
badges: Array.isArray(src.badges) ? src.badges : [],
|
|
229
|
+
rowId: typeof src.rowId === 'string' ? src.rowId : '',
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function cloneData(data) {
|
|
234
|
+
return { version: data.version ?? ANNOTATIONS_VERSION, updatedAt: data.updatedAt ?? 0, plugins: { ...data.plugins }, groups: data.groups.map((group) => ({ ...group, packages: [...group.packages] })) }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function groupPayload(input) {
|
|
238
|
+
const raw = input?.group !== null && typeof input?.group === 'object' && !Array.isArray(input?.group) ? input.group : input
|
|
239
|
+
const id = typeof raw?.id === 'string' ? raw.id.trim() : ''
|
|
240
|
+
const nameResult = sanitizeGroupName(raw?.name)
|
|
241
|
+
if (id === '') return { ok: false, error: '缺少分组 id' }
|
|
242
|
+
if (!nameResult.ok) return { ok: false, error: nameResult.error }
|
|
243
|
+
const packages = []
|
|
244
|
+
for (const pkg of Array.isArray(raw?.packages) ? raw.packages : []) {
|
|
245
|
+
if (!isPackageName(pkg)) return { ok: false, error: `分组里的包名不合法:${String(pkg).slice(0, 40)}` }
|
|
246
|
+
if (!packages.includes(pkg)) packages.push(pkg)
|
|
247
|
+
}
|
|
248
|
+
const group = { id, name: nameResult.value, packages, collapsed: raw?.collapsed === true }
|
|
249
|
+
if (Number.isFinite(raw?.createdAt)) group.createdAt = raw.createdAt
|
|
250
|
+
return { ok: true, group }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 规划一次注解写入(**纯函数:不写盘、不读盘**)。所有动作都在这里校验,路由只做映射。
|
|
255
|
+
*
|
|
256
|
+
* action 一览:
|
|
257
|
+
* · note { packageName|rowId, note } note='' ⇒ 删除这条备注
|
|
258
|
+
* · badges { packageName|rowId, badges } badges=[] ⇒ 删除全部徽章
|
|
259
|
+
* · group-create { name } → meta.group
|
|
260
|
+
* · group-rename { id, name }
|
|
261
|
+
* · group-delete { id } → meta.deleted = { group, index }(供撤销)
|
|
262
|
+
* · group-restore { group } 用 meta.deleted.group 原样回填
|
|
263
|
+
* · group-assign { id, packageName, member=true } 一个包**可以同时在多个分组里**(重叠允许;assign 只加入,不移出)
|
|
264
|
+
* · group-collapse { id, collapsed }
|
|
265
|
+
*
|
|
266
|
+
* @param {object} data 规范化后的当前数据(readAnnotations().data)
|
|
267
|
+
* @param {object} input 请求体
|
|
268
|
+
* @returns {{ ok: boolean, status: number, error: string|null, changed: boolean,
|
|
269
|
+
* next: object|null, meta: object, verify: ((d: object) => boolean)|null }}
|
|
270
|
+
*/
|
|
271
|
+
function planAnnotationAction(data, input, options = {}) {
|
|
272
|
+
const now = typeof options.now === 'function' ? options.now() : Date.now()
|
|
273
|
+
const base = { ok: false, status: 400, error: null, changed: false, next: null, meta: {}, verify: null }
|
|
274
|
+
const action = typeof input?.action === 'string' ? input.action : ''
|
|
275
|
+
const current = data !== null && typeof data === 'object' && data.plugins !== undefined && Array.isArray(data.groups) ? data : emptyAnnotations()
|
|
276
|
+
const next = cloneData(current)
|
|
277
|
+
|
|
278
|
+
if (action === 'note' || action === 'badges') {
|
|
279
|
+
const key = resolvePluginKey(next, input)
|
|
280
|
+
if (key === null) return { ...base, error: '缺少 packageName(或 rowId)' }
|
|
281
|
+
if (!isPackageName(key)) return { ...base, error: `不是合法的包名:${String(key).slice(0, 40)}` }
|
|
282
|
+
const record = next.plugins[key] !== undefined ? { ...next.plugins[key] } : {}
|
|
283
|
+
const rowId = typeof input?.rowId === 'string' && input.rowId.trim() !== '' ? input.rowId.trim() : (record.rowId ?? null)
|
|
284
|
+
let metaExtra = {}
|
|
285
|
+
if (action === 'note') {
|
|
286
|
+
const noteResult = sanitizeNote(input?.note)
|
|
287
|
+
if (!noteResult.ok) return { ...base, error: noteResult.error }
|
|
288
|
+
if (noteResult.value === '') delete record.note
|
|
289
|
+
else record.note = noteResult.value
|
|
290
|
+
metaExtra = { note: noteResult.value }
|
|
291
|
+
} else {
|
|
292
|
+
const badgesResult = sanitizeBadges(input?.badges)
|
|
293
|
+
if (!badgesResult.ok) return { ...base, error: badgesResult.error }
|
|
294
|
+
if (badgesResult.value.length === 0) delete record.badges
|
|
295
|
+
else record.badges = badgesResult.value
|
|
296
|
+
metaExtra = { badges: badgesResult.value }
|
|
297
|
+
}
|
|
298
|
+
const hasNote = typeof record.note === 'string' && record.note !== ''
|
|
299
|
+
const hasBadges = Array.isArray(record.badges) && record.badges.length > 0
|
|
300
|
+
if (!hasNote && !hasBadges) delete next.plugins[key]
|
|
301
|
+
else next.plugins[key] = { ...record, ...(rowId === null ? {} : { rowId }), updatedAt: now }
|
|
302
|
+
const expected = pluginFactsOf(next.plugins[key] ?? null)
|
|
303
|
+
const changed = JSON.stringify(pluginFactsOf(current.plugins[key] ?? null)) !== JSON.stringify(expected)
|
|
304
|
+
return {
|
|
305
|
+
...base,
|
|
306
|
+
ok: true,
|
|
307
|
+
status: 200,
|
|
308
|
+
changed,
|
|
309
|
+
next: changed ? next : current,
|
|
310
|
+
meta: { key, ...metaExtra },
|
|
311
|
+
// 读回只认**事实**(见 pluginFactsOf 注释:整对象比较会因键序/时间戳误判)
|
|
312
|
+
verify: (read) => JSON.stringify(pluginFactsOf(read.plugins[key] ?? null)) === JSON.stringify(expected),
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (action === 'group-create') {
|
|
317
|
+
const nameResult = sanitizeGroupName(input?.name)
|
|
318
|
+
if (!nameResult.ok) return { ...base, error: nameResult.error }
|
|
319
|
+
if (next.groups.length >= GROUP_MAX) return { ...base, status: 409, error: `分组最多 ${GROUP_MAX} 个(当前 ${next.groups.length} 个)` }
|
|
320
|
+
if (next.groups.some((group) => group.name === nameResult.value)) {
|
|
321
|
+
return { ...base, status: 409, error: `已经有一个叫「${nameResult.value}」的分组了` }
|
|
322
|
+
}
|
|
323
|
+
const stamp = Number.isFinite(now) ? now : Date.now()
|
|
324
|
+
const group = { id: `g${stamp.toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`, name: nameResult.value, packages: [], collapsed: false, createdAt: stamp }
|
|
325
|
+
next.groups.push(group)
|
|
326
|
+
return {
|
|
327
|
+
...base,
|
|
328
|
+
ok: true,
|
|
329
|
+
status: 200,
|
|
330
|
+
changed: true,
|
|
331
|
+
next,
|
|
332
|
+
meta: { group },
|
|
333
|
+
verify: (read) => read.groups.some((g) => g.id === group.id && g.name === group.name),
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (action === 'group-rename') {
|
|
338
|
+
const index = findGroupIndex(next, input?.id)
|
|
339
|
+
if (index < 0) return { ...base, status: 404, error: `没有这个分组(id=${String(input?.id ?? '')})` }
|
|
340
|
+
const nameResult = sanitizeGroupName(input?.name)
|
|
341
|
+
if (!nameResult.ok) return { ...base, error: nameResult.error }
|
|
342
|
+
if (next.groups.some((group, i) => i !== index && group.name === nameResult.value)) {
|
|
343
|
+
return { ...base, status: 409, error: `已经有一个叫「${nameResult.value}」的分组了` }
|
|
344
|
+
}
|
|
345
|
+
const before = next.groups[index].name
|
|
346
|
+
next.groups[index] = { ...next.groups[index], name: nameResult.value }
|
|
347
|
+
const changed = before !== nameResult.value
|
|
348
|
+
const id = next.groups[index].id
|
|
349
|
+
return {
|
|
350
|
+
...base,
|
|
351
|
+
ok: true,
|
|
352
|
+
status: 200,
|
|
353
|
+
changed,
|
|
354
|
+
next: changed ? next : current,
|
|
355
|
+
meta: { id, from: before, to: nameResult.value },
|
|
356
|
+
verify: (read) => read.groups.some((g) => g.id === id && g.name === nameResult.value),
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (action === 'group-delete') {
|
|
361
|
+
const index = findGroupIndex(next, input?.id)
|
|
362
|
+
if (index < 0) return { ...base, status: 404, error: `没有这个分组(id=${String(input?.id ?? '')})` }
|
|
363
|
+
// ★ 只解除归属:插件记录(备注/徽章)一律不动,插件本身更不会被删。
|
|
364
|
+
const [deleted] = next.groups.splice(index, 1)
|
|
365
|
+
return {
|
|
366
|
+
...base,
|
|
367
|
+
ok: true,
|
|
368
|
+
status: 200,
|
|
369
|
+
changed: true,
|
|
370
|
+
next,
|
|
371
|
+
meta: { deleted: { group: deleted, index } },
|
|
372
|
+
verify: (read) => !read.groups.some((g) => g.id === deleted.id),
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (action === 'group-restore') {
|
|
377
|
+
const parsed = groupPayload(input)
|
|
378
|
+
if (!parsed.ok) return { ...base, error: parsed.error }
|
|
379
|
+
const group = parsed.group
|
|
380
|
+
if (next.groups.some((g) => g.id === group.id)) return { ...base, status: 409, error: `分组 id 已存在:${group.id}` }
|
|
381
|
+
if (next.groups.some((g) => g.name === group.name)) return { ...base, status: 409, error: `已经有一个叫「${group.name}」的分组了` }
|
|
382
|
+
if (next.groups.length >= GROUP_MAX) return { ...base, status: 409, error: `分组最多 ${GROUP_MAX} 个` }
|
|
383
|
+
const at = Number.isFinite(input?.index) ? Math.max(0, Math.min(next.groups.length, Math.floor(input.index))) : next.groups.length
|
|
384
|
+
next.groups.splice(at, 0, group)
|
|
385
|
+
// 一个包只能属于一个分组:回填时把同名包从别处移出
|
|
386
|
+
const moved = []
|
|
387
|
+
for (const other of next.groups) {
|
|
388
|
+
if (other.id === group.id) continue
|
|
389
|
+
for (const pkg of group.packages) {
|
|
390
|
+
if (other.packages.includes(pkg)) {
|
|
391
|
+
other.packages = other.packages.filter((x) => x !== pkg)
|
|
392
|
+
moved.push({ packageName: pkg, from: other.id })
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
...base,
|
|
398
|
+
ok: true,
|
|
399
|
+
status: 200,
|
|
400
|
+
changed: true,
|
|
401
|
+
next,
|
|
402
|
+
meta: { group, index: at, moved },
|
|
403
|
+
verify: (read) => read.groups.some((g) => g.id === group.id),
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (action === 'group-assign') {
|
|
408
|
+
const pkg = typeof input?.packageName === 'string' ? input.packageName.trim() : ''
|
|
409
|
+
if (!isPackageName(pkg)) return { ...base, error: `不是合法的包名:${String(input?.packageName ?? '').slice(0, 40)}` }
|
|
410
|
+
const member = input?.member !== false
|
|
411
|
+
if (!member) {
|
|
412
|
+
// 从分组移除(id 省略 = 从所有分组移除)。插件的备注/徽章不动。
|
|
413
|
+
const id = typeof input?.id === 'string' && input.id !== '' ? input.id.trim() : null
|
|
414
|
+
if (id !== null && findGroupIndex(next, id) < 0) return { ...base, status: 404, error: `没有这个分组(id=${id})` }
|
|
415
|
+
const removedFrom = []
|
|
416
|
+
for (const group of next.groups) {
|
|
417
|
+
if (id !== null && group.id !== id) continue
|
|
418
|
+
if (!group.packages.includes(pkg)) continue
|
|
419
|
+
group.packages = group.packages.filter((x) => x !== pkg)
|
|
420
|
+
removedFrom.push(group.id)
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
...base,
|
|
424
|
+
ok: true,
|
|
425
|
+
status: 200,
|
|
426
|
+
changed: removedFrom.length > 0,
|
|
427
|
+
next: removedFrom.length > 0 ? next : current,
|
|
428
|
+
meta: { packageName: pkg, removedFrom },
|
|
429
|
+
verify: (read) => read.groups.every((g) => !g.packages.includes(pkg)),
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const index = findGroupIndex(next, input?.id)
|
|
433
|
+
if (index < 0) return { ...base, status: 404, error: `没有这个分组(id=${String(input?.id ?? '')})` }
|
|
434
|
+
// ★ 用户 2026-10-27:**分组允许重叠** —— 一个插件可以同时属于 A 和 B。
|
|
435
|
+
// 旧实现会把它从别的分组里移出("一个包最多一个分组"),现在只做**加入**(幂等),不再动其它分组。
|
|
436
|
+
const already = next.groups[index].packages.includes(pkg)
|
|
437
|
+
if (!already) next.groups[index].packages.push(pkg)
|
|
438
|
+
const id = next.groups[index].id
|
|
439
|
+
return {
|
|
440
|
+
...base,
|
|
441
|
+
ok: true,
|
|
442
|
+
status: 200,
|
|
443
|
+
changed: !already,
|
|
444
|
+
next: already ? current : next,
|
|
445
|
+
meta: { packageName: pkg, id, movedFrom: [] },
|
|
446
|
+
verify: (read) => read.groups.some((g) => g.id === id && g.packages.includes(pkg)),
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (action === 'group-collapse') {
|
|
451
|
+
const index = findGroupIndex(next, input?.id)
|
|
452
|
+
if (index < 0) return { ...base, status: 404, error: `没有这个分组(id=${String(input?.id ?? '')})` }
|
|
453
|
+
const collapsed = input?.collapsed === true
|
|
454
|
+
const changed = next.groups[index].collapsed !== collapsed
|
|
455
|
+
next.groups[index] = { ...next.groups[index], collapsed }
|
|
456
|
+
const id = next.groups[index].id
|
|
457
|
+
return {
|
|
458
|
+
...base,
|
|
459
|
+
ok: true,
|
|
460
|
+
status: 200,
|
|
461
|
+
changed,
|
|
462
|
+
next: changed ? next : current,
|
|
463
|
+
meta: { id, collapsed },
|
|
464
|
+
verify: (read) => read.groups.some((g) => g.id === id && g.collapsed === collapsed),
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { ...base, error: `不认识的 action:${action === '' ? '(空)' : action}(可用:note / badges / group-create / group-rename / group-delete / group-restore / group-assign / group-collapse)` }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* 执行一次注解写入:排队 → 备份 → 原子写 → **读回核实** → 失败回滚。
|
|
473
|
+
* 绝不谎报成功;坏文件一律拒绝(409),绝不用空结构覆盖。
|
|
474
|
+
*
|
|
475
|
+
* @param {object} input 请求体(见 planAnnotationAction)
|
|
476
|
+
* @param {{ file?: string, now?: () => number, deps?: object }} [options]
|
|
477
|
+
*/
|
|
478
|
+
async function applyAnnotation(input, options = {}) {
|
|
479
|
+
const file = typeof options.file === 'string' && options.file !== '' ? options.file : annotationsPath()
|
|
480
|
+
return queuedWrite(() => applyAnnotationSync(input, { ...options, file }))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** 同步实现(`applyAnnotation` 在队列里调它;单测也可直接调,但仍应经 applyAnnotation 以覆盖并发语义)。 */
|
|
484
|
+
function applyAnnotationSync(input, options = {}) {
|
|
485
|
+
const file = typeof options.file === 'string' && options.file !== '' ? options.file : annotationsPath()
|
|
486
|
+
const now = typeof options.now === 'function' ? options.now : Date.now
|
|
487
|
+
const deps = options.deps !== null && typeof options.deps === 'object' ? options.deps : {}
|
|
488
|
+
const readFile = typeof deps.read === 'function' ? deps.read : readAnnotations
|
|
489
|
+
const writeRaw = typeof deps.writeFileSync === 'function' ? deps.writeFileSync : writeFileSync
|
|
490
|
+
const rename = typeof deps.renameSync === 'function' ? deps.renameSync : renameSync
|
|
491
|
+
const copy = typeof deps.copyFileSync === 'function' ? deps.copyFileSync : copyFileSync
|
|
492
|
+
const remove = typeof deps.rmSync === 'function' ? deps.rmSync : rmSync
|
|
493
|
+
const read = readFile(file)
|
|
494
|
+
if (read.ok !== true) {
|
|
495
|
+
// 坏文件:降级读出了空结构,但**写**必须拒绝(否则就是拿空结构盖掉用户的文件)
|
|
496
|
+
return { ok: false, status: 409, changed: false, error: read.error, file, data: read.data, meta: {}, backup: null }
|
|
497
|
+
}
|
|
498
|
+
const plan = planAnnotationAction(read.data, input, { now })
|
|
499
|
+
if (plan.ok !== true) return { ok: false, status: plan.status ?? 400, changed: false, error: plan.error, file, data: read.data, meta: plan.meta ?? {}, backup: null }
|
|
500
|
+
const payload = { ...plan.next, version: ANNOTATIONS_VERSION, updatedAt: now() }
|
|
501
|
+
if (plan.changed !== true) {
|
|
502
|
+
return { ok: true, status: 200, changed: false, error: null, file, data: read.data, meta: plan.meta ?? {}, backup: null }
|
|
503
|
+
}
|
|
504
|
+
const backup = `${file}.bak-${Date.now()}`
|
|
505
|
+
const stamp = Date.now()
|
|
506
|
+
const tmp = `${file}.tmp-${stamp}`
|
|
507
|
+
let hadFile = false
|
|
508
|
+
try {
|
|
509
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
510
|
+
hadFile = existsSync(file)
|
|
511
|
+
if (hadFile) copy(file, backup)
|
|
512
|
+
writeRaw(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
|
513
|
+
rename(tmp, file)
|
|
514
|
+
} catch (error) {
|
|
515
|
+
// 写失败 ⇒ 清掉临时文件、把备份放回去(有的话),如实报错
|
|
516
|
+
try { if (existsSync(tmp)) remove(tmp, { force: true }) } catch {}
|
|
517
|
+
try { if (hadFile && existsSync(backup)) copy(backup, file) } catch {}
|
|
518
|
+
return { ok: false, status: 500, changed: false, error: `写入注解文件失败:${messageOf(error)}`, file, data: read.data, meta: plan.meta ?? {}, backup: hadFile ? backup : null }
|
|
519
|
+
}
|
|
520
|
+
// ★ 读回核实:不是"文件改名成功"就算,要**读出预期事实**("写了但没生效"是最坏的假绿)
|
|
521
|
+
const verify = readFile(file)
|
|
522
|
+
const verified = verify.ok === true && typeof plan.verify === 'function' ? plan.verify(verify.data) === true : false
|
|
523
|
+
if (!verified) {
|
|
524
|
+
try { if (hadFile && existsSync(backup)) copy(backup, file) } catch {}
|
|
525
|
+
try { if (!hadFile && existsSync(file)) remove(file, { force: true }) } catch {}
|
|
526
|
+
return { ok: false, status: 500, changed: false, error: '写入后读回核实失败(文件内容与预期不符),已回滚到写入前的状态', file, data: read.data, meta: plan.meta ?? {}, backup: hadFile ? backup : null }
|
|
527
|
+
}
|
|
528
|
+
return { ok: true, status: 200, changed: true, error: null, file, data: verify.data, meta: plan.meta ?? {}, backup: hadFile ? backup : null }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** 面板用的摘要(不写盘):给"备注插件"面板与卡片渲染提供一次算好的统计。 */
|
|
532
|
+
function annotationsSummary(data) {
|
|
533
|
+
const src = data !== null && typeof data === 'object' ? data : emptyAnnotations()
|
|
534
|
+
const plugins = src.plugins !== null && typeof src.plugins === 'object' ? src.plugins : {}
|
|
535
|
+
const groups = Array.isArray(src.groups) ? src.groups : []
|
|
536
|
+
let notes = 0
|
|
537
|
+
let badges = 0
|
|
538
|
+
for (const record of Object.values(plugins)) {
|
|
539
|
+
if (record !== null && typeof record === 'object') {
|
|
540
|
+
if (typeof record.note === 'string' && record.note !== '') notes += 1
|
|
541
|
+
if (Array.isArray(record.badges)) badges += record.badges.length
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return { packages: Object.keys(plugins).length, notes, badges, groups: groups.length, members: groups.reduce((sum, g) => sum + (Array.isArray(g.packages) ? g.packages.length : 0), 0) }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export {
|
|
548
|
+
ANNOTATIONS_FILE,
|
|
549
|
+
ANNOTATIONS_VERSION,
|
|
550
|
+
NOTE_MAX,
|
|
551
|
+
BADGE_MAX_COUNT,
|
|
552
|
+
BADGE_MAX_LEN,
|
|
553
|
+
GROUP_NAME_MAX,
|
|
554
|
+
GROUP_MAX,
|
|
555
|
+
PACKAGE_NAME_RE,
|
|
556
|
+
annotationsPath,
|
|
557
|
+
emptyAnnotations,
|
|
558
|
+
isPackageName,
|
|
559
|
+
sanitizeBadges,
|
|
560
|
+
sanitizeNote,
|
|
561
|
+
sanitizeGroupName,
|
|
562
|
+
normalizeAnnotations,
|
|
563
|
+
readAnnotations,
|
|
564
|
+
resolvePluginKey,
|
|
565
|
+
planAnnotationAction,
|
|
566
|
+
applyAnnotation,
|
|
567
|
+
applyAnnotationSync,
|
|
568
|
+
annotationsSummary,
|
|
569
|
+
}
|