dsh-sessions-manager 3.6.2 → 3.7.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/README.en.md +22 -11
- package/README.md +24 -13
- package/lib/client.js +989 -31
- package/lib/client.js.map +2 -2
- package/lib/index.js +921 -151
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +816 -33
- package/src/client/logic.js +285 -0
- package/src/empty-scan-index.js +118 -0
- package/src/index.js +340 -79
- package/src/lineage.js +18 -14
- package/src/move-notices.js +129 -0
- package/src/saved-filters.js +208 -0
- package/src/tag-index.js +308 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// move-notices.js — 排队移动「后台终局」的持久通知(3.7.0 T2)。
|
|
2
|
+
//
|
|
3
|
+
// 背景(UX 黑洞):排队移动发起时有诚实的「已排队」提示,但**后台完成/最终放弃**
|
|
4
|
+
// 原先只 console.warn——用户合上页面就永远不知道结果。本模块把每次终局落成一条
|
|
5
|
+
// 独立通知,经 sidebar-state 带出、client 弹出、ack 后清除。
|
|
6
|
+
//
|
|
7
|
+
// 为什么是独立文件而不是 pending-moves.json 加字段:排队的两个终局(成功 remove、
|
|
8
|
+
// 放弃 bumpAttempts→dropped)都会把条目从 items 里删掉——挂在条目上的字段与载体
|
|
9
|
+
// 同生共死,恰恰在两个目标场景里都观察不到。独立文件另外两点好处:pending-moves
|
|
10
|
+
// 的 items 形状与 schemaVersion 保持 1 不动(旧 host 读新文件不可见,回滚零影响);
|
|
11
|
+
// 通知的生命周期(服务端保留到 ack + 7 天 TTL + 20 条截断)与队列互不干扰。
|
|
12
|
+
//
|
|
13
|
+
// 投递语义:**留在服务端直到 client ack**——pop 式投递会被「toast 没弹用户就关了
|
|
14
|
+
// 页面」直接丢通知。同浏览器多 tab 的即时去重靠 client 的 localStorage 已见集合,
|
|
15
|
+
// ack 只是尽力清理(ack 失败无害,TTL 兜底)。
|
|
16
|
+
//
|
|
17
|
+
// 写入沿用本仓库索引的既定纪律:schemaVersion + 串行 mutation + 原子写(tmp →
|
|
18
|
+
// rename)+ 0600 + 防御式归一化(坏数据逐条丢弃,坏文件当空集合)。
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
|
|
24
|
+
const SCHEMA_VERSION = 1
|
|
25
|
+
// env 在 create 时读取而非模块加载时冻结(测试动态导入的既定模式;生产单实例两可)。
|
|
26
|
+
export const MAX_NOTICES = 20
|
|
27
|
+
export const NOTICE_MAX_AGE_MS = 7 * 24 * 3600 * 1000
|
|
28
|
+
const KINDS = new Set(['moved', 'abandoned'])
|
|
29
|
+
|
|
30
|
+
function isSafeId(value) {
|
|
31
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 200
|
|
32
|
+
}
|
|
33
|
+
function str(v, max) {
|
|
34
|
+
return typeof v === 'string' && v ? v.slice(0, max) : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeNotice(raw) {
|
|
38
|
+
if (!raw || typeof raw !== 'object') return null
|
|
39
|
+
if (!KINDS.has(raw.kind) || !isSafeId(raw.sessionId)) return null
|
|
40
|
+
const at = Number.isFinite(raw.at) && raw.at > 0 ? Math.floor(raw.at) : 0
|
|
41
|
+
if (!at) return null
|
|
42
|
+
const sessionId = String(raw.sessionId)
|
|
43
|
+
return {
|
|
44
|
+
id: str(raw.id, 400) || `${sessionId}:${raw.kind}:${at}`,
|
|
45
|
+
kind: raw.kind,
|
|
46
|
+
sessionId,
|
|
47
|
+
targetPath: str(raw.targetPath, 500),
|
|
48
|
+
attempts: Number.isSafeInteger(raw.attempts) && raw.attempts >= 0 ? raw.attempts : null,
|
|
49
|
+
reason: str(raw.reason, 300),
|
|
50
|
+
at,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeStore(raw) {
|
|
55
|
+
const source = raw && Array.isArray(raw.items) ? raw.items : []
|
|
56
|
+
const seen = new Set()
|
|
57
|
+
const items = []
|
|
58
|
+
for (const entry of source) {
|
|
59
|
+
const n = normalizeNotice(entry)
|
|
60
|
+
if (!n || seen.has(n.id)) continue
|
|
61
|
+
seen.add(n.id)
|
|
62
|
+
items.push(n)
|
|
63
|
+
}
|
|
64
|
+
items.sort((a, b) => a.at - b.at)
|
|
65
|
+
const trimmed = items.length > MAX_NOTICES ? items.slice(items.length - MAX_NOTICES) : items
|
|
66
|
+
return { schemaVersion: SCHEMA_VERSION, items: trimmed }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {object} [options]
|
|
71
|
+
* @param {string} [options.dir] 通知文件所在目录(测试注入临时目录)
|
|
72
|
+
*/
|
|
73
|
+
export function createMoveNoticeStore(options = {}) {
|
|
74
|
+
const dir = options.dir
|
|
75
|
+
|| process.env.DSH_SESSIONS_MANAGER_PENDING_DIR
|
|
76
|
+
|| process.env.DSH_SESSIONS_MANAGER_STAR_DIR
|
|
77
|
+
|| join(homedir(), '.dsh', 'sessions-manager')
|
|
78
|
+
const indexPath = options.indexPath || join(dir, 'move-notices.json')
|
|
79
|
+
let mutation = Promise.resolve()
|
|
80
|
+
|
|
81
|
+
function read() {
|
|
82
|
+
try {
|
|
83
|
+
return normalizeStore(JSON.parse(readFileSync(indexPath, 'utf8')))
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return normalizeStore(null)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function write(store) {
|
|
90
|
+
await mkdir(dir, { recursive: true })
|
|
91
|
+
const tmp = join(dir, `.move-notices-${process.pid}-${Date.now()}.tmp`)
|
|
92
|
+
await writeFile(tmp, JSON.stringify(normalizeStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
93
|
+
await rename(tmp, indexPath)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mutate(mutator) {
|
|
97
|
+
const operation = mutation.then(async () => {
|
|
98
|
+
const store = read()
|
|
99
|
+
const result = await mutator(store)
|
|
100
|
+
await write(store)
|
|
101
|
+
return result
|
|
102
|
+
})
|
|
103
|
+
mutation = operation.catch(() => {})
|
|
104
|
+
return operation
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
indexPath,
|
|
109
|
+
// 终局落一条通知;同 id(会话+类型+时刻)幂等去重。
|
|
110
|
+
append: (notice) => mutate((store) => {
|
|
111
|
+
const n = normalizeNotice(notice)
|
|
112
|
+
if (!n) return null
|
|
113
|
+
if (store.items.some((x) => x.id === n.id)) return n
|
|
114
|
+
store.items.push(n)
|
|
115
|
+
return n
|
|
116
|
+
}),
|
|
117
|
+
// 只读列出未过期、未 ack 的通知(按时间升序)。过期项在下次写入时随归一化清掉。
|
|
118
|
+
async list() {
|
|
119
|
+
const now = Date.now()
|
|
120
|
+
return read().items.filter((n) => now - n.at <= NOTICE_MAX_AGE_MS)
|
|
121
|
+
},
|
|
122
|
+
ack: (ids) => mutate((store) => {
|
|
123
|
+
const drop = new Set((Array.isArray(ids) ? ids : []).filter((v) => typeof v === 'string' && v.length <= 400).map(String))
|
|
124
|
+
const before = store.items.length
|
|
125
|
+
store.items = store.items.filter((n) => !drop.has(n.id))
|
|
126
|
+
return before - store.items.length
|
|
127
|
+
}),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Durable "saved filters" store (schema v1).
|
|
2
|
+
//
|
|
3
|
+
// Same discipline as src/star-index.js / src/tag-index.js: version field,
|
|
4
|
+
// per-entry defensive normalize, atomic write (tmp + rename at 0o600) and a
|
|
5
|
+
// single chained mutation queue. Filter payloads are OPAQUE JSON: the host
|
|
6
|
+
// never interprets them (the UI round-trips its own chip state); we only
|
|
7
|
+
// bound the serialized size so a corrupt entry can never bloat the state
|
|
8
|
+
// file. Lives in the plugin state directory next to tags.json.
|
|
9
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { randomBytes } from 'node:crypto'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
// v1 is the first saved-filters schema. Unlike tags.json (which starts at v4
|
|
16
|
+
// to dodge star's v3) this document lives in its own file and can begin
|
|
17
|
+
// counting from 1.
|
|
18
|
+
export const FILTER_SCHEMA_VERSION = 1
|
|
19
|
+
|
|
20
|
+
// Caps: ≤20 saved filters, ≤40 code points per name (Array.from counting),
|
|
21
|
+
// ≤2048 characters of serialized JSON per payload.
|
|
22
|
+
export const MAX_FILTERS = 20
|
|
23
|
+
export const MAX_FILTER_NAME = 40
|
|
24
|
+
export const MAX_FILTER_JSON = 2048
|
|
25
|
+
|
|
26
|
+
// Same state directory (and same env override) as the star / tag indexes.
|
|
27
|
+
const DEFAULT_FILTER_DIR = join(homedir(), '.dsh', 'sessions-manager')
|
|
28
|
+
|
|
29
|
+
const FILTER_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
30
|
+
|
|
31
|
+
function randomSuffix() {
|
|
32
|
+
const bytes = randomBytes(8)
|
|
33
|
+
let out = ''
|
|
34
|
+
for (const b of bytes) out += FILTER_ID_ALPHABET[b % FILTER_ID_ALPHABET.length]
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isSafeFilterId(value) {
|
|
39
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 64 && !/[\\/\0]/.test(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function filterError(message, status, code) {
|
|
43
|
+
const error = new Error(message)
|
|
44
|
+
error.status = status
|
|
45
|
+
if (code) error.code = code
|
|
46
|
+
throw error
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeFilterName(value) {
|
|
50
|
+
if (typeof value !== 'string') return null
|
|
51
|
+
const name = value.trim()
|
|
52
|
+
if (!name || /[\\/\0]/.test(name)) return null
|
|
53
|
+
if (Array.from(name).length > MAX_FILTER_NAME) return null
|
|
54
|
+
return name
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function nameKey(name) {
|
|
58
|
+
return name.toLocaleLowerCase()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Serialize an opaque payload, or null when it cannot be stored (not JSON at
|
|
62
|
+
// all — e.g. undefined/functions — or beyond the size cap).
|
|
63
|
+
function normalizeFiltersPayload(value) {
|
|
64
|
+
let serialized
|
|
65
|
+
try {
|
|
66
|
+
serialized = JSON.stringify(value)
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
if (typeof serialized !== 'string' || serialized.length > MAX_FILTER_JSON) return null
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(serialized)
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Coerce anything on disk (or nothing at all) into a valid v1 store.
|
|
80
|
+
* A dirty entry (bad id/name, unserializable or oversize payload, duplicate
|
|
81
|
+
* id / casefold-duplicate name) is dropped whole, never partially repaired.
|
|
82
|
+
*/
|
|
83
|
+
export function normalizeFilterStore(raw) {
|
|
84
|
+
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null
|
|
85
|
+
const items = []
|
|
86
|
+
const ids = new Set()
|
|
87
|
+
const names = new Set()
|
|
88
|
+
for (const item of (source && Array.isArray(source.items) ? source.items : [])) {
|
|
89
|
+
if (!item || typeof item !== 'object') continue
|
|
90
|
+
if (!isSafeFilterId(item.id) || ids.has(item.id)) continue
|
|
91
|
+
const name = normalizeFilterName(item.name)
|
|
92
|
+
if (name === null) continue
|
|
93
|
+
const key = nameKey(name)
|
|
94
|
+
if (names.has(key)) continue
|
|
95
|
+
const filters = normalizeFiltersPayload(item.filters)
|
|
96
|
+
if (filters === null) continue
|
|
97
|
+
ids.add(item.id)
|
|
98
|
+
names.add(key)
|
|
99
|
+
const createdAt = Number.isFinite(Number(item.createdAt)) ? Number(item.createdAt) : 0
|
|
100
|
+
items.push({ id: item.id, name, filters, createdAt })
|
|
101
|
+
if (items.length >= MAX_FILTERS) break
|
|
102
|
+
}
|
|
103
|
+
return { schemaVersion: FILTER_SCHEMA_VERSION, items }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Open the saved-filters store.
|
|
108
|
+
* @param {object} [options]
|
|
109
|
+
* @param {string} [options.dir] - Directory holding the store (tests inject a temp dir).
|
|
110
|
+
* @param {string} [options.indexPath] - Full store path, overriding `dir`.
|
|
111
|
+
*/
|
|
112
|
+
export function createSavedFilters(options = {}) {
|
|
113
|
+
const dir = options.dir || process.env.DSH_SESSIONS_MANAGER_STAR_DIR || DEFAULT_FILTER_DIR
|
|
114
|
+
const indexPath = options.indexPath || join(dir, 'saved-filters.json')
|
|
115
|
+
let mutation = Promise.resolve()
|
|
116
|
+
|
|
117
|
+
async function read() {
|
|
118
|
+
try {
|
|
119
|
+
return normalizeFilterStore(JSON.parse(readFileSync(indexPath, 'utf8')))
|
|
120
|
+
} catch {
|
|
121
|
+
return normalizeFilterStore(null)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function write(store) {
|
|
126
|
+
await mkdir(dir, { recursive: true })
|
|
127
|
+
const tmp = join(dir, `.saved-filters-${process.pid}-${Date.now()}.tmp`)
|
|
128
|
+
await writeFile(tmp, JSON.stringify(normalizeFilterStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
129
|
+
await rename(tmp, indexPath)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Serialize read-modify-write cycles (same chain semantics as the star index).
|
|
133
|
+
function mutate(mutator) {
|
|
134
|
+
const operation = mutation.then(async () => {
|
|
135
|
+
const store = await read()
|
|
136
|
+
const result = await mutator(store)
|
|
137
|
+
await write(store)
|
|
138
|
+
return result
|
|
139
|
+
})
|
|
140
|
+
mutation = operation.catch(() => {})
|
|
141
|
+
return operation
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function newFilterId(store) {
|
|
145
|
+
for (let i = 0; i < 16; i++) {
|
|
146
|
+
const id = `f_${randomSuffix()}`
|
|
147
|
+
if (!store.items.some((t) => t.id === id)) return id
|
|
148
|
+
}
|
|
149
|
+
return filterError('无法生成筛选 id(随机碰撞异常)', 500, 'DSM_FILTER_ID_COLLISION')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Save a named filter. `filters` is stored verbatim (opaque to the host).
|
|
154
|
+
* @param {string} rawName
|
|
155
|
+
* @param {*} filters
|
|
156
|
+
* @returns {Promise<{id, name, filters, createdAt}>}
|
|
157
|
+
* @throws 400 DSM_FILTER_NAME_INVALID | 400 DSM_FILTER_INVALID
|
|
158
|
+
* | 400 DSM_FILTER_TOO_LARGE | 409 DSM_FILTER_EXISTS | 409 DSM_FILTER_LIMIT
|
|
159
|
+
*/
|
|
160
|
+
async function save(rawName, filters) {
|
|
161
|
+
const name = normalizeFilterName(rawName)
|
|
162
|
+
if (name === null) filterError(`筛选名无效(非空、不含斜杠、不超过 ${MAX_FILTER_NAME} 个字符)`, 400, 'DSM_FILTER_NAME_INVALID')
|
|
163
|
+
// Bound the opaque payload before it can ever reach disk, with honest
|
|
164
|
+
// codes for "not JSON at all" vs. "oversize".
|
|
165
|
+
let serialized
|
|
166
|
+
try {
|
|
167
|
+
serialized = JSON.stringify(filters)
|
|
168
|
+
} catch {
|
|
169
|
+
serialized = undefined
|
|
170
|
+
}
|
|
171
|
+
if (typeof serialized !== 'string') filterError('筛选条件必须是可 JSON 序列化的数据', 400, 'DSM_FILTER_INVALID')
|
|
172
|
+
if (serialized === 'null') filterError('筛选条件不能为 null', 400, 'DSM_FILTER_INVALID')
|
|
173
|
+
if (serialized.length > MAX_FILTER_JSON) filterError(`筛选条件过大(序列化后最多 ${MAX_FILTER_JSON} 字符)`, 400, 'DSM_FILTER_TOO_LARGE')
|
|
174
|
+
const payload = JSON.parse(serialized)
|
|
175
|
+
const key = nameKey(name)
|
|
176
|
+
return mutate((store) => {
|
|
177
|
+
if (store.items.some((t) => nameKey(t.name) === key)) filterError('同名筛选已存在', 409, 'DSM_FILTER_EXISTS')
|
|
178
|
+
if (store.items.length >= MAX_FILTERS) filterError(`保存的筛选已达上限(${MAX_FILTERS})`, 409, 'DSM_FILTER_LIMIT')
|
|
179
|
+
const item = { id: newFilterId(store), name, filters: payload, createdAt: Date.now() }
|
|
180
|
+
store.items.push(item)
|
|
181
|
+
return item
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Delete saved filters by id (unknown ids are ignored).
|
|
187
|
+
* @param {string[]} ids
|
|
188
|
+
* @returns {Promise<number>} how many items were actually removed.
|
|
189
|
+
*/
|
|
190
|
+
function remove(ids) {
|
|
191
|
+
const wanted = new Set((Array.isArray(ids) ? ids : []).filter(isSafeFilterId).map(String))
|
|
192
|
+
return mutate((store) => {
|
|
193
|
+
const before = store.items.length
|
|
194
|
+
store.items = store.items.filter((t) => !wanted.has(t.id))
|
|
195
|
+
return before - store.items.length
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @returns {Promise<Array<{id, name, filters, createdAt}>>}
|
|
201
|
+
*/
|
|
202
|
+
async function list() {
|
|
203
|
+
const store = await read()
|
|
204
|
+
return store.items
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { read, write, mutate, list, save, remove, indexPath, dir }
|
|
208
|
+
}
|
package/src/tag-index.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Durable "session tags" index (schema v4).
|
|
2
|
+
//
|
|
3
|
+
// Deliberately mirrors the star index in src/star-index.js: version field,
|
|
4
|
+
// per-entry defensive normalize (dirty data is dropped, never repaired by
|
|
5
|
+
// guessing), atomic write (tmp + rename at 0o600) and a single chained
|
|
6
|
+
// mutation queue so two concurrent requests can never clobber each other.
|
|
7
|
+
// Tags are pure user metadata: create/rename/merge/remove rewrite THIS
|
|
8
|
+
// document only and never touch a session (the 3.7.0 red line, plan §1.3).
|
|
9
|
+
// Extracted from the host bundle so it can be unit-tested directly — pass
|
|
10
|
+
// `dir` to point the index at a temp directory.
|
|
11
|
+
import { mkdir, rename as fsRename, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { randomBytes } from 'node:crypto'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
// v4 is the first tag schema; it starts at 4 so it can never be confused with
|
|
18
|
+
// the recycle bin's v1/v2 or the star index's v3 documents even if a file is
|
|
19
|
+
// copied between them.
|
|
20
|
+
export const TAG_SCHEMA_VERSION = 4
|
|
21
|
+
|
|
22
|
+
// Caps (plan §1.3): ≤200 tag definitions globally, ≤10 tags per session,
|
|
23
|
+
// ≤24 code points per name. Array.from counting means one Chinese character
|
|
24
|
+
// (or one surrogate pair) counts as exactly 1, so the limit is honest for CJK.
|
|
25
|
+
export const MAX_TAGS = 200
|
|
26
|
+
export const MAX_TAGS_PER_SESSION = 10
|
|
27
|
+
export const MAX_TAG_NAME = 24
|
|
28
|
+
|
|
29
|
+
// Same state directory (and same env override) as the star index: all
|
|
30
|
+
// plugin-owned user marks live together in ~/.dsh/sessions-manager.
|
|
31
|
+
const DEFAULT_TAG_DIR = join(homedir(), '.dsh', 'sessions-manager')
|
|
32
|
+
|
|
33
|
+
// Random short ids: 't_' + 8 lowercase alphanumeric chars. Ids are minted
|
|
34
|
+
// once and never reused (rename only rewrites the name), so assignments can
|
|
35
|
+
// reference them safely. A collision (36^-8) is simply re-rolled.
|
|
36
|
+
const TAG_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
37
|
+
|
|
38
|
+
function randomSuffix() {
|
|
39
|
+
const bytes = randomBytes(8)
|
|
40
|
+
let out = ''
|
|
41
|
+
for (const b of bytes) out += TAG_ID_ALPHABET[b % TAG_ID_ALPHABET.length]
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isSafeSessionId(value) {
|
|
46
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\\/\0]/.test(value) && value !== '.' && value !== '..'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isSafeTagId(value) {
|
|
50
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 64 && !/[\\/\0]/.test(value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tagError(message, status, code) {
|
|
54
|
+
const error = new Error(message)
|
|
55
|
+
error.status = status
|
|
56
|
+
if (code) error.code = code
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Trim + shape-check a user-supplied tag name. Returns the cleaned name or
|
|
61
|
+
// null; the caller turns null into a 400. Slashes and NUL are rejected in
|
|
62
|
+
// line with this plugin's path-safety discipline for anything persisted.
|
|
63
|
+
export function normalizeTagName(value) {
|
|
64
|
+
if (typeof value !== 'string') return null
|
|
65
|
+
const name = value.trim()
|
|
66
|
+
if (!name || /[\\/\0]/.test(name)) return null
|
|
67
|
+
if (Array.from(name).length > MAX_TAG_NAME) return null
|
|
68
|
+
return name
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Casefold key used for the global name-uniqueness rule.
|
|
72
|
+
function nameKey(name) {
|
|
73
|
+
return name.toLocaleLowerCase()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Coerce anything on disk (or nothing at all) into a valid v4 store.
|
|
78
|
+
* Every entry is defended individually: a bad tag entry or a dangling
|
|
79
|
+
* assignment (a tagId no longer defined) is dropped silently.
|
|
80
|
+
*/
|
|
81
|
+
export function normalizeTagStore(raw) {
|
|
82
|
+
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null
|
|
83
|
+
const tags = []
|
|
84
|
+
const ids = new Set()
|
|
85
|
+
const names = new Set()
|
|
86
|
+
for (const item of (source && Array.isArray(source.tags) ? source.tags : [])) {
|
|
87
|
+
if (!item || typeof item !== 'object') continue
|
|
88
|
+
// Strings only: coercing a number into an id would let junk into the
|
|
89
|
+
// index and mask a caller bug (same reasoning as normalizeStarStore).
|
|
90
|
+
if (!isSafeTagId(item.id) || ids.has(item.id)) continue
|
|
91
|
+
const name = normalizeTagName(item.name)
|
|
92
|
+
if (name === null) continue
|
|
93
|
+
const key = nameKey(name)
|
|
94
|
+
if (names.has(key)) continue
|
|
95
|
+
ids.add(item.id)
|
|
96
|
+
names.add(key)
|
|
97
|
+
const createdAt = Number.isFinite(Number(item.createdAt)) ? Number(item.createdAt) : 0
|
|
98
|
+
tags.push({ id: item.id, name, createdAt })
|
|
99
|
+
if (tags.length >= MAX_TAGS) break
|
|
100
|
+
}
|
|
101
|
+
const assignments = {}
|
|
102
|
+
const rawAssignments = source && source.assignments && typeof source.assignments === 'object' && !Array.isArray(source.assignments)
|
|
103
|
+
? source.assignments
|
|
104
|
+
: null
|
|
105
|
+
if (rawAssignments) {
|
|
106
|
+
for (const [sid, value] of Object.entries(rawAssignments)) {
|
|
107
|
+
if (!isSafeSessionId(sid) || !Array.isArray(value)) continue
|
|
108
|
+
const kept = []
|
|
109
|
+
const seen = new Set()
|
|
110
|
+
for (const tagId of value) {
|
|
111
|
+
// Unknown ids are dangling (tag deleted meanwhile, or a foreign file
|
|
112
|
+
// was copied in): drop, never keep a ghost the UI could not render.
|
|
113
|
+
if (!ids.has(tagId) || seen.has(tagId)) continue
|
|
114
|
+
seen.add(tagId)
|
|
115
|
+
kept.push(tagId)
|
|
116
|
+
if (kept.length >= MAX_TAGS_PER_SESSION) break
|
|
117
|
+
}
|
|
118
|
+
if (kept.length) assignments[sid] = kept
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { schemaVersion: TAG_SCHEMA_VERSION, tags, assignments }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Open the tag index.
|
|
126
|
+
* @param {object} [options]
|
|
127
|
+
* @param {string} [options.dir] - Directory holding the index (tests inject a temp dir).
|
|
128
|
+
* @param {string} [options.indexPath] - Full index path, overriding `dir`.
|
|
129
|
+
*/
|
|
130
|
+
export function createTagIndex(options = {}) {
|
|
131
|
+
const dir = options.dir || process.env.DSH_SESSIONS_MANAGER_STAR_DIR || DEFAULT_TAG_DIR
|
|
132
|
+
const indexPath = options.indexPath || join(dir, 'tags.json')
|
|
133
|
+
let mutation = Promise.resolve()
|
|
134
|
+
|
|
135
|
+
async function read() {
|
|
136
|
+
try {
|
|
137
|
+
return normalizeTagStore(JSON.parse(readFileSync(indexPath, 'utf8')))
|
|
138
|
+
} catch {
|
|
139
|
+
return normalizeTagStore(null)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function write(store) {
|
|
144
|
+
await mkdir(dir, { recursive: true })
|
|
145
|
+
const tmp = join(dir, `.tags-${process.pid}-${Date.now()}.tmp`)
|
|
146
|
+
await writeFile(tmp, JSON.stringify(normalizeTagStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
147
|
+
await fsRename(tmp, indexPath)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Serialize read-modify-write cycles: every mutator sees the store as left by
|
|
151
|
+
// the previous one, and a rejected mutator still keeps the chain alive (and
|
|
152
|
+
// writes nothing — the checks throw before `write` is reached).
|
|
153
|
+
function mutate(mutator) {
|
|
154
|
+
const operation = mutation.then(async () => {
|
|
155
|
+
const store = await read()
|
|
156
|
+
const result = await mutator(store)
|
|
157
|
+
await write(store)
|
|
158
|
+
return result
|
|
159
|
+
})
|
|
160
|
+
mutation = operation.catch(() => {})
|
|
161
|
+
return operation
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function newTagId(store) {
|
|
165
|
+
for (let i = 0; i < 16; i++) {
|
|
166
|
+
const id = `t_${randomSuffix()}`
|
|
167
|
+
if (!store.tags.some((t) => t.id === id)) return id
|
|
168
|
+
}
|
|
169
|
+
// Astronomically unreachable; honest failure beats a duplicate id.
|
|
170
|
+
return tagError('无法生成标签 id(随机碰撞异常)', 500, 'DSM_TAG_ID_COLLISION')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Define a new tag.
|
|
175
|
+
* @param {string} rawName
|
|
176
|
+
* @returns {Promise<{id: string, name: string, createdAt: number}>}
|
|
177
|
+
* @throws 400 DSM_TAG_NAME_INVALID | 409 DSM_TAG_EXISTS | 409 DSM_TAG_LIMIT
|
|
178
|
+
*/
|
|
179
|
+
async function create(rawName) {
|
|
180
|
+
const name = normalizeTagName(rawName)
|
|
181
|
+
if (name === null) tagError(`标签名无效(非空、不含斜杠、不超过 ${MAX_TAG_NAME} 个字符)`, 400, 'DSM_TAG_NAME_INVALID')
|
|
182
|
+
const key = nameKey(name)
|
|
183
|
+
return mutate((store) => {
|
|
184
|
+
if (store.tags.some((t) => nameKey(t.name) === key)) tagError('同名标签已存在', 409, 'DSM_TAG_EXISTS')
|
|
185
|
+
if (store.tags.length >= MAX_TAGS) tagError(`标签总数已达上限(${MAX_TAGS})`, 409, 'DSM_TAG_LIMIT')
|
|
186
|
+
const tag = { id: newTagId(store), name, createdAt: Date.now() }
|
|
187
|
+
store.tags.push(tag)
|
|
188
|
+
return tag
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Rename a tag. Ids never change, so assignments survive a rename untouched.
|
|
194
|
+
* A rename that only differs in case is legal (it's not a "duplicate" of
|
|
195
|
+
* another tag); a collision with a DIFFERENT tag is rejected.
|
|
196
|
+
* (Note: this method name is why the atomic rename above is `fsRename`.)
|
|
197
|
+
* @throws 400 | 404 DSM_TAG_NOT_FOUND | 409 DSM_TAG_EXISTS
|
|
198
|
+
*/
|
|
199
|
+
async function rename(id, rawName) {
|
|
200
|
+
const name = normalizeTagName(rawName)
|
|
201
|
+
if (name === null) tagError(`标签名无效(非空、不含斜杠、不超过 ${MAX_TAG_NAME} 个字符)`, 400, 'DSM_TAG_NAME_INVALID')
|
|
202
|
+
const key = nameKey(name)
|
|
203
|
+
return mutate((store) => {
|
|
204
|
+
const tag = store.tags.find((t) => t.id === id)
|
|
205
|
+
if (!tag) tagError('标签不存在', 404, 'DSM_TAG_NOT_FOUND')
|
|
206
|
+
if (store.tags.some((t) => t.id !== tag.id && nameKey(t.name) === key)) tagError('同名标签已存在', 409, 'DSM_TAG_EXISTS')
|
|
207
|
+
tag.name = name
|
|
208
|
+
return tag
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Fold the source tag into the target tag: every session carrying the
|
|
214
|
+
* source also carries the target (deduped; the per-session cap truncates an
|
|
215
|
+
* overflow rather than failing a half-applied merge — the UI must never be
|
|
216
|
+
* stuck with a source it cannot remove). The source definition and all of
|
|
217
|
+
* its assignments are then dropped. Sessions themselves are never touched.
|
|
218
|
+
* @throws 400 DSM_TAG_INVALID | 404 DSM_TAG_NOT_FOUND
|
|
219
|
+
*/
|
|
220
|
+
async function merge(fromId, toId) {
|
|
221
|
+
if (!isSafeTagId(fromId) || !isSafeTagId(toId) || fromId === toId) {
|
|
222
|
+
tagError('合并的源/目标标签无效或相同', 400, 'DSM_TAG_INVALID')
|
|
223
|
+
}
|
|
224
|
+
return mutate((store) => {
|
|
225
|
+
if (!store.tags.some((t) => t.id === fromId) || !store.tags.some((t) => t.id === toId)) {
|
|
226
|
+
tagError('标签不存在', 404, 'DSM_TAG_NOT_FOUND')
|
|
227
|
+
}
|
|
228
|
+
for (const [sid, list] of Object.entries(store.assignments)) {
|
|
229
|
+
if (!list.includes(fromId)) continue
|
|
230
|
+
const merged = [...new Set(list.filter((x) => x !== fromId).concat(toId))]
|
|
231
|
+
store.assignments[sid] = merged.slice(0, MAX_TAGS_PER_SESSION)
|
|
232
|
+
}
|
|
233
|
+
store.tags = store.tags.filter((t) => t.id !== fromId)
|
|
234
|
+
return { merged: true }
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Drop a tag definition and every assignment to it. Idempotent: an unknown
|
|
240
|
+
* id is a no-op success (the UI may race itself). NEVER touches a session.
|
|
241
|
+
*/
|
|
242
|
+
function removeTag(id) {
|
|
243
|
+
return mutate((store) => {
|
|
244
|
+
if (isSafeTagId(id)) {
|
|
245
|
+
store.tags = store.tags.filter((t) => t.id !== id)
|
|
246
|
+
for (const [sid, list] of Object.entries(store.assignments)) {
|
|
247
|
+
const kept = list.filter((x) => x !== id)
|
|
248
|
+
if (kept.length) store.assignments[sid] = kept
|
|
249
|
+
else delete store.assignments[sid]
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { removed: true }
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Replace the full tag set of one session (the UI always sends the complete
|
|
258
|
+
* list; "assign" and "unassign" are just set-with-one-more / set-minus-one).
|
|
259
|
+
* @param {string} sessionId
|
|
260
|
+
* @param {string[]} tagIds
|
|
261
|
+
* @returns {Promise<object>} the full assignments map after the change.
|
|
262
|
+
* @throws 400 DSM_TAG_SESSION_INVALID | 400 DSM_TAG_IDS_INVALID
|
|
263
|
+
* | 400 DSM_TAG_UNKNOWN | 409 DSM_TAG_LIMIT
|
|
264
|
+
*/
|
|
265
|
+
async function setTags(sessionId, tagIds) {
|
|
266
|
+
if (!isSafeSessionId(sessionId)) tagError('无效的 sessionId', 400, 'DSM_TAG_SESSION_INVALID')
|
|
267
|
+
if (!Array.isArray(tagIds)) tagError('tagIds 必须是数组', 400, 'DSM_TAG_IDS_INVALID')
|
|
268
|
+
const seen = new Set()
|
|
269
|
+
const wanted = []
|
|
270
|
+
for (const v of tagIds) {
|
|
271
|
+
// Strings only (same reasoning as the star index): a number in the list
|
|
272
|
+
// is a caller bug, not data to coerce.
|
|
273
|
+
if (!isSafeTagId(v)) tagError('无效的标签 id', 400, 'DSM_TAG_UNKNOWN')
|
|
274
|
+
if (seen.has(v)) continue
|
|
275
|
+
seen.add(v)
|
|
276
|
+
wanted.push(v)
|
|
277
|
+
}
|
|
278
|
+
if (wanted.length > MAX_TAGS_PER_SESSION) tagError(`单个会话最多 ${MAX_TAGS_PER_SESSION} 个标签`, 409, 'DSM_TAG_LIMIT')
|
|
279
|
+
return mutate((store) => {
|
|
280
|
+
const known = new Set(store.tags.map((t) => t.id))
|
|
281
|
+
const unknown = wanted.filter((id) => !known.has(id))
|
|
282
|
+
if (unknown.length) tagError(`未知的标签 id:${unknown.join('、')}`, 400, 'DSM_TAG_UNKNOWN')
|
|
283
|
+
if (wanted.length) store.assignments[sessionId] = [...wanted]
|
|
284
|
+
else delete store.assignments[sessionId]
|
|
285
|
+
return store.assignments
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Drop assignments once their session is gone (purged / deleted), otherwise
|
|
290
|
+
// the index would grow forever with ids that can never be listed again.
|
|
291
|
+
function removeIds(ids) {
|
|
292
|
+
const wanted = (Array.isArray(ids) ? ids : []).filter(isSafeSessionId)
|
|
293
|
+
return mutate((store) => {
|
|
294
|
+
for (const sid of wanted) delete store.assignments[sid]
|
|
295
|
+
return store.assignments
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @returns {Promise<{tags: Array<{id,name,createdAt}>, assignments: object}>}
|
|
301
|
+
*/
|
|
302
|
+
async function list() {
|
|
303
|
+
const store = await read()
|
|
304
|
+
return { tags: store.tags, assignments: store.assignments }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { read, write, mutate, list, create, rename, merge, removeTag, setTags, removeIds, indexPath, dir }
|
|
308
|
+
}
|