dsh-instruction-memory 1.1.0 → 1.2.0
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/CHANGELOG.md +107 -0
- package/README.md +171 -14
- package/lib/client.js +864 -2
- package/lib/index.js +1362 -74
- package/memory.schema.json +167 -0
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -8,14 +8,30 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Lifecycle: every side effect (prompt section, web route) belongs to this
|
|
10
10
|
* plugin's fiber, so unmounting the row removes all of them.
|
|
11
|
+
*
|
|
12
|
+
* Storage format v2 (F13 多情境配置集 / instruction sets): the file holds a
|
|
13
|
+
* named `sets` list plus one flat `entries`/`profiles` array where every item
|
|
14
|
+
* carries a `setId`; `activeSetId` names the one set that is injected. A v1
|
|
15
|
+
* file (no `sets` field) loads transparently — every item is folded into the
|
|
16
|
+
* single 默认 set — and is rewritten as v2 by the next save. Switching the
|
|
17
|
+
* active set is ALWAYS an explicit user command; nothing here ever derives a
|
|
18
|
+
* set from the process cwd (a real incident: a cwd-derived store location made
|
|
19
|
+
* memory appear to vanish when the user relaunched from another directory).
|
|
20
|
+
*
|
|
21
|
+
* Also in v2: timestamped history snapshots (F14) written into a `history/`
|
|
22
|
+
* subdirectory — deliberately NOT into the watched store directory entry that
|
|
23
|
+
* `fs.watch` listens on — a JSON Schema shipped for hand editing (F15), a raw
|
|
24
|
+
* source read/write pair for the settings page (F16), and an optional
|
|
25
|
+
* user-chosen backup directory (F18).
|
|
11
26
|
*/
|
|
12
27
|
|
|
13
|
-
import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
28
|
+
import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
14
29
|
import { watch } from 'node:fs'
|
|
15
30
|
import { randomUUID } from 'node:crypto'
|
|
16
31
|
import { isIP } from 'node:net'
|
|
17
|
-
import { homedir } from 'node:os'
|
|
18
|
-
import { basename, dirname, join } from 'node:path'
|
|
32
|
+
import { homedir, hostname } from 'node:os'
|
|
33
|
+
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
34
|
+
import { fileURLToPath } from 'node:url'
|
|
19
35
|
|
|
20
36
|
export const name = 'instruction-memory'
|
|
21
37
|
|
|
@@ -48,6 +64,34 @@ const STORE_FILE_NAME = 'memory.json'
|
|
|
48
64
|
/** Pre-release location, kept only so existing installs can migrate off it. */
|
|
49
65
|
const LEGACY_FILE_NAME = '.dsh-instruction-memory.json'
|
|
50
66
|
const SECTION_NAME = 'instruction-memory'
|
|
67
|
+
/**
|
|
68
|
+
* The store format this build writes.
|
|
69
|
+
*
|
|
70
|
+
* v2 introduced named instruction sets (F13). The bump is not cosmetic: an
|
|
71
|
+
* OLDER build of this plugin reads `version` and refuses anything that is not
|
|
72
|
+
* 1, so a downgrade meets an explicit "unsupported version" refusal with
|
|
73
|
+
* mutations paused instead of silently loading a v2 file as a v1 one (which
|
|
74
|
+
* would hide every set name and, on the next save, rewrite the file without
|
|
75
|
+
* them). v1 files keep loading here — every entry is folded into the single
|
|
76
|
+
* 默认 set — and the next save writes v2.
|
|
77
|
+
*/
|
|
78
|
+
const FORMAT_VERSION = 2
|
|
79
|
+
const SUPPORTED_VERSIONS = [1, 2]
|
|
80
|
+
/** The id/name of the set a fresh or migrated store gets. Stable on purpose. */
|
|
81
|
+
const DEFAULT_SET_ID = 'st-default'
|
|
82
|
+
const DEFAULT_SET_NAME = '默认'
|
|
83
|
+
const MAX_SETS = 20
|
|
84
|
+
const MAX_SET_NAME = 30
|
|
85
|
+
/** Rolling history snapshots (F14): how many are kept PER writing device. */
|
|
86
|
+
const MAX_HISTORY = 20
|
|
87
|
+
const HISTORY_DIR_NAME = 'history'
|
|
88
|
+
/** Name of the JSON Schema shipped with the package and copied next to the store. */
|
|
89
|
+
const SCHEMA_FILE_NAME = 'memory.schema.json'
|
|
90
|
+
/** The `$schema` value written into every store file (resolves next to it). */
|
|
91
|
+
const SCHEMA_REF = './' + SCHEMA_FILE_NAME
|
|
92
|
+
/** Upper bound for a user-chosen backup directory path. */
|
|
93
|
+
const MAX_BACKUP_DIR = 512
|
|
94
|
+
const BACKUP_FILE_SUFFIX = '.bak'
|
|
51
95
|
/**
|
|
52
96
|
* Placement of the injected instruction block among the system prompt's
|
|
53
97
|
* ordered sections. Sections are concatenated ascending; equal orders fall
|
|
@@ -128,7 +172,33 @@ const BATCH_OPS = ['set-enabled', 'set-priority', 'add-tags', 'remove-tags', 'de
|
|
|
128
172
|
* wrapper.
|
|
129
173
|
*/
|
|
130
174
|
export const MAX_BODY_CHARS = (MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + MAX_TAGS_PER_ENTRY * MAX_TAG_LENGTH + 200)
|
|
131
|
-
+ MAX_PROFILES * (MAX_PROFILE_CONTENT + MAX_PROFILE_NAME + 200)
|
|
175
|
+
+ MAX_PROFILES * (MAX_PROFILE_CONTENT + MAX_PROFILE_NAME + 200)
|
|
176
|
+
+ MAX_SETS * (MAX_SET_NAME + 200)) * 2 + 100_000
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Every numeric ceiling in one object, published on the snapshot and used by
|
|
180
|
+
* verify.mjs to pin `memory.schema.json` (the hand-edit JSON Schema) to the
|
|
181
|
+
* code. Two sources of truth that must never drift: a schema advertising a
|
|
182
|
+
* larger limit than the loader enforces would let a hand edit be silently
|
|
183
|
+
* truncated while the IDE reported it as valid.
|
|
184
|
+
*/
|
|
185
|
+
export const LIMITS = Object.freeze({
|
|
186
|
+
formatVersion: FORMAT_VERSION,
|
|
187
|
+
maxEntries: MAX_ENTRIES,
|
|
188
|
+
maxTitle: MAX_TITLE,
|
|
189
|
+
maxContent: MAX_CONTENT,
|
|
190
|
+
maxWhen: MAX_WHEN,
|
|
191
|
+
maxTagsPerEntry: MAX_TAGS_PER_ENTRY,
|
|
192
|
+
maxTagLength: MAX_TAG_LENGTH,
|
|
193
|
+
maxProfiles: MAX_PROFILES,
|
|
194
|
+
maxProfileName: MAX_PROFILE_NAME,
|
|
195
|
+
maxProfileContent: MAX_PROFILE_CONTENT,
|
|
196
|
+
maxSets: MAX_SETS,
|
|
197
|
+
maxSetName: MAX_SET_NAME,
|
|
198
|
+
minBudget: MIN_BUDGET,
|
|
199
|
+
maxBudget: MAX_BUDGET,
|
|
200
|
+
maxHistory: MAX_HISTORY,
|
|
201
|
+
})
|
|
132
202
|
|
|
133
203
|
/** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
|
|
134
204
|
const LOCK_STALE_MS = 10_000
|
|
@@ -174,6 +244,10 @@ function makeId() {
|
|
|
174
244
|
return 'im-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
|
|
175
245
|
}
|
|
176
246
|
|
|
247
|
+
function makeSetId() {
|
|
248
|
+
return 'st-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
|
|
249
|
+
}
|
|
250
|
+
|
|
177
251
|
function priorityLabel(value) {
|
|
178
252
|
if (value === 2) return '高'
|
|
179
253
|
if (value === 0) return '低'
|
|
@@ -222,10 +296,40 @@ export function sanitizeEntry(raw) {
|
|
|
222
296
|
priority: raw.priority === 2 || raw.priority === 0 ? raw.priority : 1,
|
|
223
297
|
enabled: raw.enabled !== false,
|
|
224
298
|
tags: sanitizeTags(raw.tags),
|
|
299
|
+
/**
|
|
300
|
+
* Which instruction set (F13) this entry belongs to. Preserved here — NOT
|
|
301
|
+
* re-derived — because both the loader and the importer need to honour a
|
|
302
|
+
* declared membership; the interactive save path overwrites it with the
|
|
303
|
+
* active set instead (an edit can never move an entry between sets).
|
|
304
|
+
* `normalizeData` guarantees a valid value on everything this plugin
|
|
305
|
+
* serves, so a blank/absent id on a hand-built object means "unattributed"
|
|
306
|
+
* (see inScope), not "no set".
|
|
307
|
+
*/
|
|
308
|
+
setId: typeof raw.setId === 'string' && raw.setId !== '' ? raw.setId.slice(0, 64) : '',
|
|
225
309
|
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
|
226
310
|
}
|
|
227
311
|
}
|
|
228
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Normalise one instruction set (F13).
|
|
315
|
+
*
|
|
316
|
+
* Shape: { id, name }. A set carries no content of its own — entries and
|
|
317
|
+
* user profiles point AT it through `setId` — so "switching the active set"
|
|
318
|
+
* never moves a byte of user text, which is what makes the operation safe to
|
|
319
|
+
* perform in one transaction. `id` is stable (it is what every item stores);
|
|
320
|
+
* `name` is a short single-line label. A blank name is rejected (null): unlike
|
|
321
|
+
* an entry, a set has nothing else to derive a label from.
|
|
322
|
+
*/
|
|
323
|
+
export function sanitizeSet(raw) {
|
|
324
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
325
|
+
const name = singleLine(raw.name, MAX_SET_NAME)
|
|
326
|
+
if (name === '') return null
|
|
327
|
+
return {
|
|
328
|
+
id: typeof raw.id === 'string' && raw.id !== '' ? raw.id.slice(0, 64) : makeSetId(),
|
|
329
|
+
name,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
229
333
|
/**
|
|
230
334
|
* Normalise one "用户形象" (a fact about the USER, not a role for the model).
|
|
231
335
|
*
|
|
@@ -246,10 +350,124 @@ export function sanitizeProfile(raw) {
|
|
|
246
350
|
name: name === '' ? singleLine(content, 20) : name,
|
|
247
351
|
content,
|
|
248
352
|
enabled: raw.enabled !== false,
|
|
353
|
+
/** Instruction set membership (F13); see the note in sanitizeEntry. */
|
|
354
|
+
setId: typeof raw.setId === 'string' && raw.setId !== '' ? raw.setId.slice(0, 64) : '',
|
|
249
355
|
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
|
250
356
|
}
|
|
251
357
|
}
|
|
252
358
|
|
|
359
|
+
/**
|
|
360
|
+
* The scope decision behind everything that renders or lists items: with a
|
|
361
|
+
* usable `sets` list plus a matching `activeSetId`, only the active set is in
|
|
362
|
+
* scope; without them the store is the legacy single-set shape and everything
|
|
363
|
+
* is in scope.
|
|
364
|
+
*
|
|
365
|
+
* Exported because THREE places must never disagree — the injection planner,
|
|
366
|
+
* the snapshot the settings page renders, and the commands that edit — and a
|
|
367
|
+
* missed filter here is exactly how a 某客户 set would leak into a 个人
|
|
368
|
+
* conversation. One predicate, used three times.
|
|
369
|
+
*
|
|
370
|
+
* The fallback when `activeSetId` names nothing (a hand-edited file): the
|
|
371
|
+
* FIRST set, which is also what normalizeData adopts. That keeps a pure
|
|
372
|
+
* consumer (planInjection called directly, as the tests do) in agreement with
|
|
373
|
+
* the loader instead of inventing a third behaviour.
|
|
374
|
+
*/
|
|
375
|
+
export function activeScope(data) {
|
|
376
|
+
if (data === null || typeof data !== 'object') return { scoped: false, activeId: '', ids: [] }
|
|
377
|
+
const sets = (Array.isArray(data.sets) ? data.sets : [])
|
|
378
|
+
.filter((set) => set !== null && typeof set === 'object' && typeof set.id === 'string' && set.id !== '')
|
|
379
|
+
if (sets.length === 0) return { scoped: false, activeId: '', ids: [] }
|
|
380
|
+
const wanted = typeof data.activeSetId === 'string' && data.activeSetId !== '' ? data.activeSetId : ''
|
|
381
|
+
const activeId = sets.some((set) => set.id === wanted) ? wanted : sets[0].id
|
|
382
|
+
return { scoped: true, activeId, ids: sets.map((set) => set.id) }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Whether one entry/profile takes part under a scope from activeScope().
|
|
387
|
+
* An item with no `setId` counts as belonging to the active set, mirroring
|
|
388
|
+
* the loader's stamping rule — an unattributed item must follow the user's
|
|
389
|
+
* current context rather than disappear from both the list and the prompt.
|
|
390
|
+
* The comparison is exact and case-sensitive: set ids are minted here.
|
|
391
|
+
*/
|
|
392
|
+
export function inScope(item, scope) {
|
|
393
|
+
if (scope === null || typeof scope !== 'object' || scope.scoped !== true) return true
|
|
394
|
+
const id = item !== null && typeof item === 'object' && typeof item.setId === 'string' ? item.setId : ''
|
|
395
|
+
return id === '' || id === scope.activeId
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* ------------------------------------------------------------------ *
|
|
399
|
+
* History snapshot naming (F14) — pure, so the format is pinnable
|
|
400
|
+
* ------------------------------------------------------------------ */
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A filesystem-safe UTC stamp: `20261015T072355123Z`. Fixed width, so plain
|
|
404
|
+
* lexicographic sorting on a filename is chronological sorting — which is what
|
|
405
|
+
* lets the history index sort without reading a single file.
|
|
406
|
+
*/
|
|
407
|
+
export function historyStamp(epoch) {
|
|
408
|
+
const date = new Date(epoch)
|
|
409
|
+
const pad = (value, width) => String(value).padStart(width, '0')
|
|
410
|
+
return pad(date.getUTCFullYear(), 4) + pad(date.getUTCMonth() + 1, 2) + pad(date.getUTCDate(), 2)
|
|
411
|
+
+ 'T' + pad(date.getUTCHours(), 2) + pad(date.getUTCMinutes(), 2) + pad(date.getUTCSeconds(), 2)
|
|
412
|
+
+ pad(date.getUTCMilliseconds(), 3) + 'Z'
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** This machine's short tag, used so two devices sharing one backup folder stay distinguishable. */
|
|
416
|
+
export function deviceTag(rawHostname) {
|
|
417
|
+
const source = typeof rawHostname === 'string' && rawHostname !== '' ? rawHostname : 'unknown'
|
|
418
|
+
const tag = source.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 16)
|
|
419
|
+
return tag === '' ? 'unknown' : tag
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* `20261015T072355123Z-r12-laptop.json` — stamp, revision, writing device.
|
|
424
|
+
* The revision is IN the name so a snapshot is identifiable in a plain file
|
|
425
|
+
* listing, and the device tag is sanitised to `[a-z0-9_]` so it can never be
|
|
426
|
+
* confused with the separators.
|
|
427
|
+
*/
|
|
428
|
+
export function historyFileName(epoch, rev, device) {
|
|
429
|
+
return historyStamp(epoch) + '-r' + (Number.isFinite(rev) ? rev : 0) + '-' + device + '.json'
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* What counts as one of OUR snapshots, and what we are therefore allowed to
|
|
434
|
+
* prune or delete. Anything else in the history directory — a hand-made file,
|
|
435
|
+
* another tool's backup, a cloud client's conflict copy — fails this pattern
|
|
436
|
+
* and is left strictly alone.
|
|
437
|
+
*/
|
|
438
|
+
export const HISTORY_FILE_PATTERN = /^\d{8}T\d{9}Z-r\d+-[a-z0-9_]+(?:\.json)$/
|
|
439
|
+
|
|
440
|
+
/** The device tag inside a snapshot name, or '' when the name is not ours. */
|
|
441
|
+
export function historyDeviceOf(name) {
|
|
442
|
+
if (typeof name !== 'string') return ''
|
|
443
|
+
const match = /-r\d+-([a-z0-9_]+)\.json$/.exec(name)
|
|
444
|
+
return match === null ? '' : match[1]
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The revision inside a snapshot name, or 0 when the name is not ours. */
|
|
448
|
+
export function historyRevOf(name) {
|
|
449
|
+
if (typeof name !== 'string') return 0
|
|
450
|
+
const match = /-r(\d+)-[a-z0-9_]+\.json$/.exec(name)
|
|
451
|
+
return match === null ? 0 : Number(match[1])
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The epoch (ms) from a snapshot name's UTC stamp, or 0 when it is not ours. */
|
|
455
|
+
export function historyEpochOf(name) {
|
|
456
|
+
if (typeof name !== 'string') return 0
|
|
457
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z-r/.exec(name)
|
|
458
|
+
if (match === null) return 0
|
|
459
|
+
return Date.UTC(
|
|
460
|
+
Number(match[1]), Number(match[2]) - 1, Number(match[3]),
|
|
461
|
+
Number(match[4]), Number(match[5]), Number(match[6]), Number(match[7]),
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Exported for the settings page: the stamp a manual export file carries. */
|
|
466
|
+
export function exportFileName(epoch) {
|
|
467
|
+
const stamp = historyStamp(epoch)
|
|
468
|
+
return 'dsh-instruction-memory-' + stamp.slice(0, 8) + '-' + stamp.slice(9, 15) + 'Z.json'
|
|
469
|
+
}
|
|
470
|
+
|
|
253
471
|
/**
|
|
254
472
|
* Which fields of a raw entry had to be cut to fit the stored limits.
|
|
255
473
|
*
|
|
@@ -282,6 +500,15 @@ export function truncatedProfileFields(raw) {
|
|
|
282
500
|
return cut
|
|
283
501
|
}
|
|
284
502
|
|
|
503
|
+
/** Same disclosure, for an instruction set's name. */
|
|
504
|
+
export function truncatedSetFields(raw) {
|
|
505
|
+
if (raw === null || typeof raw !== 'object') return []
|
|
506
|
+
const unlimited = Number.MAX_SAFE_INTEGER
|
|
507
|
+
return typeof raw.name === 'string' && singleLine(raw.name, unlimited) !== singleLine(raw.name, MAX_SET_NAME)
|
|
508
|
+
? ['配置集名称']
|
|
509
|
+
: []
|
|
510
|
+
}
|
|
511
|
+
|
|
285
512
|
/** JSON.stringify keeps quotes/type visible; the String() tail is for values JSON cannot encode. */
|
|
286
513
|
const describeValue = (value) => {
|
|
287
514
|
const text = JSON.stringify(value)
|
|
@@ -300,24 +527,42 @@ const describeValue = (value) => {
|
|
|
300
527
|
* snapshot (another window, another process) can be refused instead of
|
|
301
528
|
* silently overwriting it. Missing (pre-rev files) reads as 0.
|
|
302
529
|
*
|
|
303
|
-
*
|
|
304
|
-
* untouched
|
|
305
|
-
* adopting a misread would let the next save
|
|
306
|
-
* shape. A MISSING version still loads as v1 —
|
|
307
|
-
* 1 since the first release, so absence is an
|
|
308
|
-
* foreign format.
|
|
530
|
+
* Format versions: 1 and 2 both load; anything else numeric sets
|
|
531
|
+
* `rejectedVersion` and leaves the data untouched — a future format's fields
|
|
532
|
+
* cannot be best-effort parsed, and adopting a misread would let the next save
|
|
533
|
+
* rewrite the file in the wrong shape. A MISSING version still loads as v1 —
|
|
534
|
+
* the field has been written as 1 since the first release, so absence is an
|
|
535
|
+
* old/hand-made file, not a foreign format.
|
|
536
|
+
*
|
|
537
|
+
* v1 → v2 (F13) is fully automatic and never loses an item: with no usable
|
|
538
|
+
* `sets` list the store gets one 默认 set, and every entry/profile is stamped
|
|
539
|
+
* with its id. Items already naming a set that does not exist are re-attributed
|
|
540
|
+
* to the active set and COUNTED (`reattributed`), because that is a hand edit
|
|
541
|
+
* this loader had to overrule and the user must be told.
|
|
309
542
|
*
|
|
310
543
|
* `optionFixes` lists options the file carried but the loader had to correct
|
|
311
|
-
* (a non-boolean `enabled`, an out-of-range or non-numeric `budgetChars
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
544
|
+
* (a non-boolean `enabled`, an out-of-range or non-numeric `budgetChars`, a
|
|
545
|
+
* non-string/over-long `backupDir`, an `activeSetId` naming no set). Each is a
|
|
546
|
+
* human-readable "field 原值 → 修正值 (原因)" string, surfaced the same way as
|
|
547
|
+
* `dropped`: the next save persists the corrected values, so the user must be
|
|
548
|
+
* told their hand edit did not survive verbatim.
|
|
315
549
|
*/
|
|
316
550
|
function normalizeData(raw) {
|
|
317
|
-
const data = {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
551
|
+
const data = {
|
|
552
|
+
version: FORMAT_VERSION,
|
|
553
|
+
rev: 0,
|
|
554
|
+
enabled: true,
|
|
555
|
+
budgetChars: DEFAULT_BUDGET,
|
|
556
|
+
backupDir: '',
|
|
557
|
+
activeSetId: DEFAULT_SET_ID,
|
|
558
|
+
sets: [],
|
|
559
|
+
entries: [],
|
|
560
|
+
profiles: [],
|
|
561
|
+
}
|
|
562
|
+
const loaded = { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], formatNote: null }
|
|
563
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return loaded
|
|
564
|
+
if (typeof raw.version === 'number' && !SUPPORTED_VERSIONS.includes(raw.version)) {
|
|
565
|
+
return { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], rejectedVersion: raw.version }
|
|
321
566
|
}
|
|
322
567
|
if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
|
|
323
568
|
data.rev = Math.round(raw.rev)
|
|
@@ -325,7 +570,7 @@ function normalizeData(raw) {
|
|
|
325
570
|
// Hand-edited options the loader has to correct are disclosed, not silently
|
|
326
571
|
// adopted: the next save would otherwise rewrite the file with the fixed
|
|
327
572
|
// values and the user would never learn their edit had been ignored.
|
|
328
|
-
const optionFixes =
|
|
573
|
+
const optionFixes = loaded.optionFixes
|
|
329
574
|
if (typeof raw.enabled === 'boolean') {
|
|
330
575
|
data.enabled = raw.enabled
|
|
331
576
|
} else if ('enabled' in raw) {
|
|
@@ -340,21 +585,87 @@ function normalizeData(raw) {
|
|
|
340
585
|
}
|
|
341
586
|
data.budgetChars = clamped
|
|
342
587
|
}
|
|
588
|
+
// Custom backup location (F18). A path is machine-local data, so anything
|
|
589
|
+
// unusable is dropped rather than guessed at — with a disclosure, because a
|
|
590
|
+
// silently ignored path would look like "backups point there" while they do
|
|
591
|
+
// not.
|
|
592
|
+
if (typeof raw.backupDir === 'string') {
|
|
593
|
+
const wanted = raw.backupDir.trim()
|
|
594
|
+
if (wanted.length > MAX_BACKUP_DIR) {
|
|
595
|
+
optionFixes.push('backupDir 超过 ' + MAX_BACKUP_DIR + ' 字符上限 → 已清空')
|
|
596
|
+
} else {
|
|
597
|
+
data.backupDir = wanted
|
|
598
|
+
}
|
|
599
|
+
} else if ('backupDir' in raw && raw.backupDir !== null && raw.backupDir !== '') {
|
|
600
|
+
optionFixes.push('backupDir ' + describeValue(raw.backupDir) + ' → ""(需要字符串路径)')
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Instruction sets (F13). A missing/blank list means a v1 file (or a hand
|
|
604
|
+
// edit that removed it): synthesise the single 默认 set and record the fact,
|
|
605
|
+
// so the panel can say what happened instead of appearing to have invented a
|
|
606
|
+
// set out of nowhere.
|
|
607
|
+
const rawSets = Array.isArray(raw.sets) ? raw.sets : []
|
|
608
|
+
const seenSetIds = new Set()
|
|
609
|
+
let synthesized = false
|
|
610
|
+
for (const candidate of rawSets) {
|
|
611
|
+
const set = sanitizeSet(candidate)
|
|
612
|
+
if (set === null || data.sets.length >= MAX_SETS) {
|
|
613
|
+
loaded.droppedSets += 1
|
|
614
|
+
continue
|
|
615
|
+
}
|
|
616
|
+
if (seenSetIds.has(set.id)) set.id = makeSetId()
|
|
617
|
+
seenSetIds.add(set.id)
|
|
618
|
+
data.sets.push(set)
|
|
619
|
+
}
|
|
620
|
+
if (data.sets.length === 0) {
|
|
621
|
+
data.sets.push({ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME })
|
|
622
|
+
synthesized = true
|
|
623
|
+
}
|
|
624
|
+
const rawActive = typeof raw.activeSetId === 'string' ? raw.activeSetId.trim() : ''
|
|
625
|
+
if (rawActive !== '' && data.sets.some((set) => set.id === rawActive)) {
|
|
626
|
+
data.activeSetId = rawActive
|
|
627
|
+
} else {
|
|
628
|
+
data.activeSetId = data.sets[0].id
|
|
629
|
+
if (rawActive !== '') {
|
|
630
|
+
optionFixes.push('activeSetId ' + describeValue(raw.activeSetId) + ' → ' + data.activeSetId + '(没有该配置集)')
|
|
631
|
+
} else if ('activeSetId' in raw && raw.activeSetId !== null) {
|
|
632
|
+
optionFixes.push('activeSetId ' + describeValue(raw.activeSetId) + ' → ' + data.activeSetId + '(需要配置集 id 字符串)')
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const knownSet = (id) => data.sets.some((set) => set.id === id)
|
|
636
|
+
// A silent load is the normal path on a current-format file; everything else
|
|
637
|
+
// is stated outright, because both branches change what the NEXT save writes.
|
|
638
|
+
const declaredVersion = typeof raw.version === 'number' ? raw.version : null
|
|
639
|
+
if (synthesized) {
|
|
640
|
+
loaded.formatNote = '存储文件未包含配置集信息(' + (declaredVersion !== null ? '格式版本 ' + declaredVersion : '未声明格式版本')
|
|
641
|
+
+ '),其中的条目与形象已归入「' + DEFAULT_SET_NAME + '」配置集;下次保存时文件将写为格式版本 ' + FORMAT_VERSION
|
|
642
|
+
} else if (declaredVersion !== FORMAT_VERSION) {
|
|
643
|
+
loaded.formatNote = declaredVersion === null
|
|
644
|
+
? '存储文件未声明格式版本,已按格式版本 ' + FORMAT_VERSION + ' 载入;下次保存时文件将补写版本号'
|
|
645
|
+
: '存储文件为旧版格式(版本 ' + declaredVersion + '),已按格式版本 ' + FORMAT_VERSION + ' 载入;下次保存时文件将写为版本 ' + FORMAT_VERSION
|
|
646
|
+
}
|
|
647
|
+
|
|
343
648
|
const list = Array.isArray(raw.entries) ? raw.entries : []
|
|
344
649
|
const seen = new Set()
|
|
345
|
-
let dropped = 0
|
|
346
650
|
for (const candidate of list) {
|
|
347
651
|
const entry = sanitizeEntry(candidate)
|
|
348
652
|
if (entry === null) {
|
|
349
|
-
dropped += 1
|
|
653
|
+
loaded.dropped += 1
|
|
350
654
|
continue
|
|
351
655
|
}
|
|
352
656
|
if (data.entries.length >= MAX_ENTRIES) {
|
|
353
|
-
dropped += 1
|
|
657
|
+
loaded.dropped += 1
|
|
354
658
|
continue
|
|
355
659
|
}
|
|
356
660
|
if (seen.has(entry.id)) entry.id = makeId()
|
|
357
661
|
seen.add(entry.id)
|
|
662
|
+
if (entry.setId === '' || !knownSet(entry.setId)) {
|
|
663
|
+
// A blank setId is simply an unfiled item (v1 file, hand-added entry) and
|
|
664
|
+
// files silently; a NON-blank id naming a set that does not exist is a
|
|
665
|
+
// broken reference the loader overrules, and that is counted.
|
|
666
|
+
if (entry.setId !== '') loaded.reattributed += 1
|
|
667
|
+
entry.setId = data.activeSetId
|
|
668
|
+
}
|
|
358
669
|
data.entries.push(entry)
|
|
359
670
|
}
|
|
360
671
|
// User profiles ("用户形象") follow the same tolerant rules: a missing
|
|
@@ -363,18 +674,21 @@ function normalizeData(raw) {
|
|
|
363
674
|
// silently (the next save rewrites the file without them).
|
|
364
675
|
const profileList = Array.isArray(raw.profiles) ? raw.profiles : []
|
|
365
676
|
const seenProfileIds = new Set()
|
|
366
|
-
let droppedProfiles = 0
|
|
367
677
|
for (const candidate of profileList) {
|
|
368
678
|
const profile = sanitizeProfile(candidate)
|
|
369
679
|
if (profile === null || data.profiles.length >= MAX_PROFILES) {
|
|
370
|
-
droppedProfiles += 1
|
|
680
|
+
loaded.droppedProfiles += 1
|
|
371
681
|
continue
|
|
372
682
|
}
|
|
373
683
|
if (seenProfileIds.has(profile.id)) profile.id = makeId()
|
|
374
684
|
seenProfileIds.add(profile.id)
|
|
685
|
+
if (profile.setId === '' || !knownSet(profile.setId)) {
|
|
686
|
+
if (profile.setId !== '') loaded.reattributed += 1
|
|
687
|
+
profile.setId = data.activeSetId
|
|
688
|
+
}
|
|
375
689
|
data.profiles.push(profile)
|
|
376
690
|
}
|
|
377
|
-
return
|
|
691
|
+
return loaded
|
|
378
692
|
}
|
|
379
693
|
|
|
380
694
|
/**
|
|
@@ -388,10 +702,13 @@ function normalizeData(raw) {
|
|
|
388
702
|
*/
|
|
389
703
|
function cloneData(data) {
|
|
390
704
|
return {
|
|
391
|
-
version:
|
|
705
|
+
version: FORMAT_VERSION,
|
|
392
706
|
rev: data.rev || 0,
|
|
393
707
|
enabled: data.enabled === true,
|
|
394
708
|
budgetChars: data.budgetChars,
|
|
709
|
+
backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
|
|
710
|
+
activeSetId: data.activeSetId,
|
|
711
|
+
sets: (Array.isArray(data.sets) ? data.sets : []).map((set) => ({ ...set })),
|
|
395
712
|
entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
|
|
396
713
|
profiles: (Array.isArray(data.profiles) ? data.profiles : []).map((profile) => ({ ...profile })),
|
|
397
714
|
}
|
|
@@ -407,6 +724,16 @@ const droppedProfilesMessage = (count) =>
|
|
|
407
724
|
? '存储文件中有 ' + count + ' 个用户形象未能载入(名称与内容同时为空或超过 ' + MAX_PROFILES + ' 个上限);下次保存时它们将从文件中移除'
|
|
408
725
|
: null
|
|
409
726
|
|
|
727
|
+
const droppedSetsMessage = (count) =>
|
|
728
|
+
count > 0
|
|
729
|
+
? '存储文件中有 ' + count + ' 个配置集未能载入(名称为空或超过 ' + MAX_SETS + ' 个上限);下次保存时它们将从文件中移除,其条目会归入当前配置集'
|
|
730
|
+
: null
|
|
731
|
+
|
|
732
|
+
const reattributedMessage = (count) =>
|
|
733
|
+
count > 0
|
|
734
|
+
? '存储文件中有 ' + count + ' 项(条目或形象)指向不存在的配置集,已归入当前配置集'
|
|
735
|
+
: null
|
|
736
|
+
|
|
410
737
|
/**
|
|
411
738
|
* The load-time disclosures for one normalizeData result: dropped entries and
|
|
412
739
|
* corrected hand-edited options, joined with a full-width semicolon.
|
|
@@ -415,10 +742,15 @@ const droppedProfilesMessage = (count) =>
|
|
|
415
742
|
*/
|
|
416
743
|
const loadWarnings = (loaded) => {
|
|
417
744
|
const parts = []
|
|
745
|
+
if (typeof loaded.formatNote === 'string' && loaded.formatNote !== '') parts.push(loaded.formatNote)
|
|
418
746
|
const dropped = droppedEntriesMessage(loaded.dropped)
|
|
419
747
|
if (dropped !== null) parts.push(dropped)
|
|
420
748
|
const droppedProfiles = droppedProfilesMessage(loaded.droppedProfiles || 0)
|
|
421
749
|
if (droppedProfiles !== null) parts.push(droppedProfiles)
|
|
750
|
+
const droppedSets = droppedSetsMessage(loaded.droppedSets || 0)
|
|
751
|
+
if (droppedSets !== null) parts.push(droppedSets)
|
|
752
|
+
const reattributed = reattributedMessage(loaded.reattributed || 0)
|
|
753
|
+
if (reattributed !== null) parts.push(reattributed)
|
|
422
754
|
if (Array.isArray(loaded.optionFixes) && loaded.optionFixes.length > 0) {
|
|
423
755
|
parts.push('存储文件中的以下选项值无效,已按规则修正:' + loaded.optionFixes.join(';') + ';下次保存时修正值将写入文件')
|
|
424
756
|
}
|
|
@@ -820,8 +1152,20 @@ export function planInjection(data) {
|
|
|
820
1152
|
profiles: [], profileChars: 0, entryChars: 0,
|
|
821
1153
|
})
|
|
822
1154
|
if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
|
|
823
|
-
|
|
824
|
-
|
|
1155
|
+
/**
|
|
1156
|
+
* The set filter (F13) is applied HERE, at the one place both the rendered
|
|
1157
|
+
* text and the budget decision are produced, so no caller can inject an
|
|
1158
|
+
* inactive set by accident. A store with no `sets` at all is the legacy
|
|
1159
|
+
* single-set shape and is injected whole (see activeScope).
|
|
1160
|
+
*/
|
|
1161
|
+
const scope = activeScope(data)
|
|
1162
|
+
const scoped = {
|
|
1163
|
+
...data,
|
|
1164
|
+
entries: (Array.isArray(data.entries) ? data.entries : []).filter((entry) => inScope(entry, scope)),
|
|
1165
|
+
profiles: (Array.isArray(data.profiles) ? data.profiles : []).filter((profile) => inScope(profile, scope)),
|
|
1166
|
+
}
|
|
1167
|
+
const persona = buildProfilesBlock(scoped.profiles)
|
|
1168
|
+
const entryPlan = planEntries(scoped, budget)
|
|
825
1169
|
const parts = []
|
|
826
1170
|
if (persona.text !== '') parts.push(persona.text)
|
|
827
1171
|
if (entryPlan.text !== '') parts.push(entryPlan.text)
|
|
@@ -854,13 +1198,29 @@ export function apply(ctx) {
|
|
|
854
1198
|
const systemPrompt = ctx.systemPrompt
|
|
855
1199
|
|
|
856
1200
|
const state = {
|
|
857
|
-
data: {
|
|
1201
|
+
data: {
|
|
1202
|
+
version: FORMAT_VERSION,
|
|
1203
|
+
rev: 0,
|
|
1204
|
+
enabled: true,
|
|
1205
|
+
budgetChars: DEFAULT_BUDGET,
|
|
1206
|
+
backupDir: '',
|
|
1207
|
+
activeSetId: DEFAULT_SET_ID,
|
|
1208
|
+
sets: [{ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME }],
|
|
1209
|
+
entries: [],
|
|
1210
|
+
profiles: [],
|
|
1211
|
+
},
|
|
858
1212
|
storagePath: null,
|
|
859
1213
|
storageDisplay: null,
|
|
860
1214
|
storageError: null,
|
|
861
1215
|
storageWarning: null,
|
|
862
1216
|
/** A failed rolling-backup refresh, shown only when no load warning wins. */
|
|
863
1217
|
backupError: null,
|
|
1218
|
+
/**
|
|
1219
|
+
* A failed history snapshot (F14). Non-fatal by design — the store itself
|
|
1220
|
+
* was written — but shown, because "history is being kept" is a promise the
|
|
1221
|
+
* panel makes on this feature's behalf.
|
|
1222
|
+
*/
|
|
1223
|
+
historyError: null,
|
|
864
1224
|
/** Which branch of the load actually produced the current data (diagnostics). */
|
|
865
1225
|
loadedFrom: null,
|
|
866
1226
|
savedAt: null,
|
|
@@ -876,6 +1236,8 @@ export function apply(ctx) {
|
|
|
876
1236
|
sectionError: null,
|
|
877
1237
|
/** Whether a store file is actually present on disk (diagnostics + the panel). */
|
|
878
1238
|
storeExists: false,
|
|
1239
|
+
/** How many history snapshots the current directory holds (diagnostics). */
|
|
1240
|
+
historyCount: 0,
|
|
879
1241
|
}
|
|
880
1242
|
let sectionDispose = null
|
|
881
1243
|
|
|
@@ -1165,7 +1527,47 @@ export function apply(ctx) {
|
|
|
1165
1527
|
|
|
1166
1528
|
const storePath = resolveStorePath()
|
|
1167
1529
|
|
|
1168
|
-
|
|
1530
|
+
/**
|
|
1531
|
+
* The exact object written to memory.json — and the one an export carries.
|
|
1532
|
+
*
|
|
1533
|
+
* `$schema` comes first on purpose: opening the file in an editor then offers
|
|
1534
|
+
* validation and field completion against the copy of memory.schema.json the
|
|
1535
|
+
* host keeps next to the store (see ensureSchemaFile). The loader ignores the
|
|
1536
|
+
* key, so it costs nothing to anyone who never opens the file.
|
|
1537
|
+
*/
|
|
1538
|
+
const persistableData = () => ({
|
|
1539
|
+
$schema: SCHEMA_REF,
|
|
1540
|
+
version: FORMAT_VERSION,
|
|
1541
|
+
rev: state.data.rev || 0,
|
|
1542
|
+
enabled: state.data.enabled === true,
|
|
1543
|
+
budgetChars: clampBudget(state.data.budgetChars),
|
|
1544
|
+
backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
|
|
1545
|
+
activeSetId: state.data.activeSetId,
|
|
1546
|
+
sets: (Array.isArray(state.data.sets) ? state.data.sets : []).map((set) => ({ ...set })),
|
|
1547
|
+
entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
|
|
1548
|
+
profiles: (Array.isArray(state.data.profiles) ? state.data.profiles : []).map((profile) => ({ ...profile })),
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
const serialize = () => JSON.stringify(persistableData(), null, 2) + '\n'
|
|
1552
|
+
|
|
1553
|
+
/* ---------------- backup location + history snapshots (F14 / F18) ---------------- */
|
|
1554
|
+
|
|
1555
|
+
const backupDirOf = () => (typeof state.data.backupDir === 'string' ? state.data.backupDir : '')
|
|
1556
|
+
/** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
|
|
1557
|
+
const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
|
|
1558
|
+
/** The user-facing copy under a custom backup location, or null (F18). */
|
|
1559
|
+
const customBackupPath = () => (backupDirOf() !== '' ? join(backupDirOf(), basename(storePath) + BACKUP_FILE_SUFFIX) : null)
|
|
1560
|
+
/**
|
|
1561
|
+
* History snapshots live in a `history/` SUBDIRECTORY of the backup location
|
|
1562
|
+
* (default: the store directory). Two reasons for the subdirectory:
|
|
1563
|
+
* 1. the external-change watcher listens on the store directory and must
|
|
1564
|
+
* never see a snapshot as a change to memory.json — a subdirectory is
|
|
1565
|
+
* not reported by a non-recursive watch, and the filename filter drops
|
|
1566
|
+
* anything that is not memory.json anyway;
|
|
1567
|
+
* 2. a custom (e.g. cloud-synced) backup directory stays tidy: one known
|
|
1568
|
+
* folder holds the history, nothing else is touched.
|
|
1569
|
+
*/
|
|
1570
|
+
const historyDirPath = () => join(backupDirOf() !== '' ? backupDirOf() : dirname(storePath), HISTORY_DIR_NAME)
|
|
1169
1571
|
|
|
1170
1572
|
/**
|
|
1171
1573
|
* Cross-process write lock: a `memory.json.lock` marker created exclusively
|
|
@@ -1234,6 +1636,151 @@ export function apply(ctx) {
|
|
|
1234
1636
|
}
|
|
1235
1637
|
}
|
|
1236
1638
|
|
|
1639
|
+
/* ---------------- custom backup location (F18) ---------------- */
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* Can this directory actually hold backups right now? Setting a path is a
|
|
1643
|
+
* promise ("my backups live there"), so it is verified at the moment it is
|
|
1644
|
+
* set — exists, is a directory, and accepts a write — instead of being
|
|
1645
|
+
* discovered to be wrong by the first save that silently failed to copy.
|
|
1646
|
+
*
|
|
1647
|
+
* @returns null when usable, otherwise a human-readable reason.
|
|
1648
|
+
*/
|
|
1649
|
+
const probeBackupDir = async (dir) => {
|
|
1650
|
+
try {
|
|
1651
|
+
const info = await stat(dir)
|
|
1652
|
+
if (!info.isDirectory()) return '该路径不是目录'
|
|
1653
|
+
} catch {
|
|
1654
|
+
return '目录不存在或不可访问'
|
|
1655
|
+
}
|
|
1656
|
+
const probe = join(dir, '.dsh-instruction-memory-probe-' + randomUUID())
|
|
1657
|
+
try {
|
|
1658
|
+
await writeFile(probe, 'probe', 'utf8')
|
|
1659
|
+
await unlink(probe).catch(() => {})
|
|
1660
|
+
return null
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
return '目录不可写:' + describeError(err)
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/* ---------------- history snapshots and the schema copy (both best-effort) ---------------- */
|
|
1667
|
+
|
|
1668
|
+
/** This machine's tag inside snapshot filenames; see deviceTag(). */
|
|
1669
|
+
const DEVICE = deviceTag((() => {
|
|
1670
|
+
try {
|
|
1671
|
+
return hostname()
|
|
1672
|
+
} catch {
|
|
1673
|
+
return ''
|
|
1674
|
+
}
|
|
1675
|
+
})())
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* The bytes of the most recent snapshot, so two writes that produce the same
|
|
1679
|
+
* file (a no-op-ish save, a repeat restore) do not each mint a "version".
|
|
1680
|
+
*/
|
|
1681
|
+
let lastHistoryText = null
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* Enforce the per-device retention limit. Grouping by the device tag inside
|
|
1685
|
+
* the filename is deliberate: two machines sharing one backup folder (the
|
|
1686
|
+
* documented F18 setup) each keep MAX_HISTORY of their OWN snapshots, so one
|
|
1687
|
+
* device can never prune the other's history away. Files that do not match
|
|
1688
|
+
* our naming pattern are never touched.
|
|
1689
|
+
*/
|
|
1690
|
+
const pruneHistory = async (dir) => {
|
|
1691
|
+
const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
|
|
1692
|
+
const byDevice = new Map()
|
|
1693
|
+
for (const name of names) {
|
|
1694
|
+
const key = historyDeviceOf(name)
|
|
1695
|
+
const bucket = byDevice.get(key)
|
|
1696
|
+
if (bucket === undefined) byDevice.set(key, [name])
|
|
1697
|
+
else bucket.push(name)
|
|
1698
|
+
}
|
|
1699
|
+
let removed = 0
|
|
1700
|
+
let kept = 0
|
|
1701
|
+
for (const bucket of byDevice.values()) {
|
|
1702
|
+
// Fixed-width UTC stamp first => lexicographic order is chronological.
|
|
1703
|
+
bucket.sort()
|
|
1704
|
+
const excess = bucket.length - MAX_HISTORY
|
|
1705
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
1706
|
+
if (index < excess) {
|
|
1707
|
+
await unlink(join(dir, bucket[index])).catch(() => {})
|
|
1708
|
+
removed += 1
|
|
1709
|
+
} else {
|
|
1710
|
+
kept += 1
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return { removed, kept }
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Record the just-written store as a new history snapshot (F14).
|
|
1719
|
+
*
|
|
1720
|
+
* Written AFTER the atomic replace, so a snapshot always equals a state that
|
|
1721
|
+
* really was committed; a failure here is disclosed and never fails the save,
|
|
1722
|
+
* because the store itself is already safely on disk.
|
|
1723
|
+
*/
|
|
1724
|
+
const writeHistorySnapshot = async (text) => {
|
|
1725
|
+
if (typeof text !== 'string' || text === '' || text === lastHistoryText) return
|
|
1726
|
+
try {
|
|
1727
|
+
const dir = historyDirPath()
|
|
1728
|
+
await mkdir(dir, { recursive: true })
|
|
1729
|
+
const epoch = Date.now()
|
|
1730
|
+
let written = false
|
|
1731
|
+
// `wx` never overwrites: same-millisecond saves (two rapid bulk actions)
|
|
1732
|
+
// bump the stamp by a millisecond instead of losing one of the two.
|
|
1733
|
+
for (let attempt = 0; attempt < 5 && !written; attempt += 1) {
|
|
1734
|
+
try {
|
|
1735
|
+
await writeFile(join(dir, historyFileName(epoch + attempt, state.data.rev || 0, DEVICE)), text, { encoding: 'utf8', flag: 'wx' })
|
|
1736
|
+
written = true
|
|
1737
|
+
} catch (err) {
|
|
1738
|
+
if (err.code !== 'EEXIST') throw err
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
if (!written) throw new Error('快照文件名重复')
|
|
1742
|
+
lastHistoryText = text
|
|
1743
|
+
const { kept } = await pruneHistory(dir)
|
|
1744
|
+
state.historyCount = kept
|
|
1745
|
+
state.historyError = null
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
state.historyError = '版本历史快照未能写入(' + describeError(err) + ');本次保存本身已完成'
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* Keep a copy of the shipped JSON Schema next to the store, so the `$schema`
|
|
1753
|
+
* reference inside memory.json resolves for a hand editor who has no idea
|
|
1754
|
+
* where the package lives. Content-compared: one small read on a save, a
|
|
1755
|
+
* write only when the shipped schema actually changed. Never throws — the
|
|
1756
|
+
* schema is a convenience, not a precondition for holding memory.
|
|
1757
|
+
*/
|
|
1758
|
+
const ensureSchemaFile = async () => {
|
|
1759
|
+
try {
|
|
1760
|
+
const source = await readFile(fileURLToPath(new URL('../' + SCHEMA_FILE_NAME, import.meta.url)), 'utf8')
|
|
1761
|
+
const target = join(dirname(storePath), SCHEMA_FILE_NAME)
|
|
1762
|
+
const existing = await readFile(target, 'utf8').catch(() => null)
|
|
1763
|
+
if (existing !== source) await writeFile(target, source, 'utf8')
|
|
1764
|
+
} catch {
|
|
1765
|
+
// Best effort by design.
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Recount the snapshot directory for the panel's diagnostics. Best effort:
|
|
1771
|
+
* an unreadable directory simply leaves the count as it was, because the
|
|
1772
|
+
* history list (the actual UI, which reports its own errors) is what the
|
|
1773
|
+
* user acts on.
|
|
1774
|
+
*/
|
|
1775
|
+
const refreshHistoryCount = async () => {
|
|
1776
|
+
try {
|
|
1777
|
+
const names = await readdir(historyDirPath())
|
|
1778
|
+
state.historyCount = names.filter((name) => HISTORY_FILE_PATTERN.test(name)).length
|
|
1779
|
+
} catch {
|
|
1780
|
+
// No directory yet (nothing written) — count stays 0.
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1237
1784
|
const writeToDisk = async () => {
|
|
1238
1785
|
// Atomic replace (write a sibling temp file, then rename) with a rolling
|
|
1239
1786
|
// one-version backup: a crash mid-write must never leave a truncated
|
|
@@ -1249,7 +1796,6 @@ export function apply(ctx) {
|
|
|
1249
1796
|
// the primary in place until the atomic replace succeeds, so a failure at
|
|
1250
1797
|
// any step leaves the previous store readable at its own path.
|
|
1251
1798
|
const tmpPath = storePath + '.tmp'
|
|
1252
|
-
const bakPath = storePath + '.bak'
|
|
1253
1799
|
let lock = null
|
|
1254
1800
|
let previousRev = null
|
|
1255
1801
|
try {
|
|
@@ -1264,8 +1810,12 @@ export function apply(ctx) {
|
|
|
1264
1810
|
state.data.rev = previousRev + 1
|
|
1265
1811
|
const text = serialize()
|
|
1266
1812
|
await writeFile(tmpPath, text, 'utf8')
|
|
1813
|
+
// Both rolling copies stand for the PREVIOUS good version, so both are
|
|
1814
|
+
// taken before the atomic replace, and neither may ever be renamed into
|
|
1815
|
+
// place (see the long note above).
|
|
1816
|
+
let hadPrevious = true
|
|
1267
1817
|
try {
|
|
1268
|
-
await copyFile(storePath,
|
|
1818
|
+
await copyFile(storePath, localBackupPath())
|
|
1269
1819
|
state.backupError = null
|
|
1270
1820
|
} catch (err) {
|
|
1271
1821
|
// ENOENT is simply the very first save: there is no previous version
|
|
@@ -1273,9 +1823,23 @@ export function apply(ctx) {
|
|
|
1273
1823
|
// happen — non-fatal for this save, because the primary is still intact
|
|
1274
1824
|
// and the replace below is the real gate, but it must be disclosed
|
|
1275
1825
|
// rather than swallowed.
|
|
1826
|
+
hadPrevious = err.code !== 'ENOENT'
|
|
1276
1827
|
state.backupError = err.code === 'ENOENT'
|
|
1277
1828
|
? null
|
|
1278
|
-
: '上一版未能保留为
|
|
1829
|
+
: '上一版未能保留为 ' + localBackupPath() + ':' + describeError(err)
|
|
1830
|
+
}
|
|
1831
|
+
// The user-facing copy under a custom backup location (F18). The LOCAL
|
|
1832
|
+
// copy above stays the one crash recovery reads, so a failure here
|
|
1833
|
+
// degrades the extra copy only — never the plugin's own safety net.
|
|
1834
|
+
const customBak = customBackupPath()
|
|
1835
|
+
if (customBak !== null && hadPrevious) {
|
|
1836
|
+
try {
|
|
1837
|
+
await copyFile(storePath, customBak)
|
|
1838
|
+
} catch (err) {
|
|
1839
|
+
if (state.backupError === null) {
|
|
1840
|
+
state.backupError = '备份位置未能保留上一版(' + customBak + '):' + describeError(err)
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1279
1843
|
}
|
|
1280
1844
|
try {
|
|
1281
1845
|
await rename(tmpPath, storePath)
|
|
@@ -1294,8 +1858,15 @@ export function apply(ctx) {
|
|
|
1294
1858
|
// be (re)armed after a first run that had nothing to watch.
|
|
1295
1859
|
startWatching()
|
|
1296
1860
|
state.savedAt = Date.now()
|
|
1861
|
+
// Best-effort side files, each guarded inside itself: a failure in either
|
|
1862
|
+
// must never be mistaken for "the save failed" — the save already
|
|
1863
|
+
// committed, and the store is on disk whatever happens next.
|
|
1864
|
+
await writeHistorySnapshot(text)
|
|
1865
|
+
await ensureSchemaFile()
|
|
1297
1866
|
logDebug('store written: ' + storePath + ' bytes=' + text.length
|
|
1298
1867
|
+ ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
|
|
1868
|
+
+ ' sets=' + state.data.sets.length + ' activeSet=' + state.data.activeSetId
|
|
1869
|
+
+ ' history=' + state.historyCount
|
|
1299
1870
|
+ (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
|
|
1300
1871
|
return true
|
|
1301
1872
|
} catch (err) {
|
|
@@ -1335,14 +1906,21 @@ export function apply(ctx) {
|
|
|
1335
1906
|
/**
|
|
1336
1907
|
* A store declaring an unsupported format version: adopt nothing, write
|
|
1337
1908
|
* nothing, and say why. The one thing that must NEVER happen in this
|
|
1338
|
-
* state is a save — it would rewrite the file
|
|
1339
|
-
* the newer format meant (the mutating commands refuse
|
|
1340
|
-
* versionRejected is set).
|
|
1909
|
+
* state is a save — it would rewrite the file in this build's format and
|
|
1910
|
+
* destroy whatever the newer format meant (the mutating commands refuse
|
|
1911
|
+
* while versionRejected is set).
|
|
1912
|
+
*
|
|
1913
|
+
* This is also the guard that makes a DOWNGRADE safe: a file written by a
|
|
1914
|
+
* newer plugin (e.g. version 2 from an F13-aware build) opened by an older
|
|
1915
|
+
* one is refused here, with the data left untouched, instead of being
|
|
1916
|
+
* half-read and then rewritten without the fields the old build cannot
|
|
1917
|
+
* see.
|
|
1341
1918
|
*/
|
|
1342
1919
|
const rejectVersion = (version, source) => {
|
|
1343
1920
|
state.versionRejected = true
|
|
1344
1921
|
state.rejectedVersion = version
|
|
1345
|
-
state.storageError = '存储文件声明为格式版本 ' + version + '
|
|
1922
|
+
state.storageError = '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
|
|
1923
|
+
+ ';已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回受支持的版本后点「重新载入」'
|
|
1346
1924
|
state.loadedFrom = source + ' (unsupported version ' + version + ')'
|
|
1347
1925
|
state.storeExists = true
|
|
1348
1926
|
}
|
|
@@ -1360,6 +1938,9 @@ export function apply(ctx) {
|
|
|
1360
1938
|
state.storageWarning = loadWarnings(loaded)
|
|
1361
1939
|
state.loadedFrom = 'memory.json'
|
|
1362
1940
|
state.storeExists = true
|
|
1941
|
+
// A real file is now in memory, so the "materialise on first need"
|
|
1942
|
+
// flag is spent — see ensureStoreMaterialized.
|
|
1943
|
+
needsMaterialize = false
|
|
1363
1944
|
}
|
|
1364
1945
|
state.storageError = null
|
|
1365
1946
|
return false
|
|
@@ -1487,6 +2068,20 @@ export function apply(ctx) {
|
|
|
1487
2068
|
*/
|
|
1488
2069
|
const ensureStoreMaterialized = async () => {
|
|
1489
2070
|
if (!needsMaterialize) return false
|
|
2071
|
+
/**
|
|
2072
|
+
* A store may have APPEARED since boot — a hand-copied memory.json, a
|
|
2073
|
+
* backup restored by another tool, a second process. Materialising the
|
|
2074
|
+
* empty in-memory store over it would destroy exactly what the user just
|
|
2075
|
+
* put there, so the disk is re-checked first and an existing file is
|
|
2076
|
+
* adopted instead. (The gap is wide enough to matter: the panel's first
|
|
2077
|
+
* `state` call can come minutes after boot.)
|
|
2078
|
+
*/
|
|
2079
|
+
const appeared = await readStoreFile(storePath).catch(() => null)
|
|
2080
|
+
if (appeared !== null) {
|
|
2081
|
+
const wrote = await readFromDisk()
|
|
2082
|
+
syncSection()
|
|
2083
|
+
return wrote
|
|
2084
|
+
}
|
|
1490
2085
|
return writeToDisk()
|
|
1491
2086
|
}
|
|
1492
2087
|
|
|
@@ -1506,9 +2101,10 @@ export function apply(ctx) {
|
|
|
1506
2101
|
.catch((err) => {
|
|
1507
2102
|
state.storageError = '读取失败:' + describeError(err)
|
|
1508
2103
|
})
|
|
1509
|
-
.then(() => {
|
|
2104
|
+
.then(async () => {
|
|
1510
2105
|
bootSettled = true
|
|
1511
2106
|
syncSection()
|
|
2107
|
+
await refreshHistoryCount()
|
|
1512
2108
|
// The one always-on line: it answers "is memory mounted, where does it
|
|
1513
2109
|
// live, and how much is being injected?" without any further digging.
|
|
1514
2110
|
// 措辞很重要:`section=` 说的是"这个段落已就绪、若被装配会产出多少字符",
|
|
@@ -1519,6 +2115,7 @@ export function apply(ctx) {
|
|
|
1519
2115
|
+ ' from=' + String(state.loadedFrom)
|
|
1520
2116
|
+ ' onDisk=' + (state.storeExists ? 'yes' : 'no')
|
|
1521
2117
|
+ ' entries=' + state.data.entries.length
|
|
2118
|
+
+ ' sets=' + state.data.sets.length + ' activeSet=' + state.data.activeSetId
|
|
1522
2119
|
+ ' enabled=' + (state.data.enabled === true)
|
|
1523
2120
|
+ ' section=' + (chars > 0 ? chars + ' chars (pending assembly)' : 'nothing to inject')
|
|
1524
2121
|
+ ' pulls=' + sectionPulls
|
|
@@ -1545,17 +2142,58 @@ export function apply(ctx) {
|
|
|
1545
2142
|
|
|
1546
2143
|
/* ---------------- snapshots ---------------- */
|
|
1547
2144
|
|
|
2145
|
+
/**
|
|
2146
|
+
* The snapshot the settings page renders (and the SSE frames carry).
|
|
2147
|
+
*
|
|
2148
|
+
* `data.entries`/`data.profiles` publish ONLY the active set (F13): the panel
|
|
2149
|
+
* lists and edits exactly what is being injected, so a 某客户 set can never
|
|
2150
|
+
* appear in the 个人 panel — the isolation guarantee the feature exists for.
|
|
2151
|
+
* The set list, with per-set counts and the active flag, travels in `sets`,
|
|
2152
|
+
* and the raw arrays are in `data.sets`/`data.activeSetId` for the switcher.
|
|
2153
|
+
* Anything needing the WHOLE store (export, source mode) goes through
|
|
2154
|
+
* persistableData(), which is also what the file holds.
|
|
2155
|
+
*/
|
|
1548
2156
|
const snapshot = () => {
|
|
1549
2157
|
const plan = sectionPlan()
|
|
1550
2158
|
const preview = plan.text
|
|
2159
|
+
const scope = activeScope(state.data)
|
|
2160
|
+
const belongsTo = (item, id) => {
|
|
2161
|
+
const own = item !== null && typeof item === 'object' && typeof item.setId === 'string' && item.setId !== ''
|
|
2162
|
+
? item.setId
|
|
2163
|
+
: state.data.activeSetId
|
|
2164
|
+
return own === id
|
|
2165
|
+
}
|
|
2166
|
+
const activeEntries = state.data.entries.filter((entry) => inScope(entry, scope))
|
|
2167
|
+
const activeProfiles = state.data.profiles.filter((profile) => inScope(profile, scope))
|
|
1551
2168
|
return {
|
|
1552
2169
|
data: {
|
|
1553
|
-
version:
|
|
2170
|
+
version: FORMAT_VERSION,
|
|
1554
2171
|
rev: state.data.rev || 0,
|
|
1555
2172
|
enabled: state.data.enabled === true,
|
|
1556
2173
|
budgetChars: clampBudget(state.data.budgetChars),
|
|
1557
|
-
|
|
1558
|
-
|
|
2174
|
+
backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
|
|
2175
|
+
activeSetId: state.data.activeSetId,
|
|
2176
|
+
sets: state.data.sets.map((set) => ({ id: set.id, name: set.name })),
|
|
2177
|
+
entries: activeEntries.map((entry) => ({ ...entry })),
|
|
2178
|
+
profiles: activeProfiles.map((profile) => ({ ...profile })),
|
|
2179
|
+
},
|
|
2180
|
+
/**
|
|
2181
|
+
* Instruction sets (F13): the switcher's data source. `total` counts the
|
|
2182
|
+
* WHOLE store (all sets), which is what the 200-entry cap is measured
|
|
2183
|
+
* against — the panel says "共 N 条" per active set but the cap message
|
|
2184
|
+
* must quote the store-wide number.
|
|
2185
|
+
*/
|
|
2186
|
+
sets: {
|
|
2187
|
+
activeId: state.data.activeSetId,
|
|
2188
|
+
scoped: scope.scoped,
|
|
2189
|
+
total: { entries: state.data.entries.length, profiles: state.data.profiles.length },
|
|
2190
|
+
list: state.data.sets.map((set) => ({
|
|
2191
|
+
id: set.id,
|
|
2192
|
+
name: set.name,
|
|
2193
|
+
active: set.id === state.data.activeSetId,
|
|
2194
|
+
entries: state.data.entries.filter((entry) => belongsTo(entry, set.id)).length,
|
|
2195
|
+
profiles: state.data.profiles.filter((profile) => belongsTo(profile, set.id)).length,
|
|
2196
|
+
})),
|
|
1559
2197
|
},
|
|
1560
2198
|
storage: {
|
|
1561
2199
|
path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
|
|
@@ -1565,6 +2203,16 @@ export function apply(ctx) {
|
|
|
1565
2203
|
migratedFrom: state.migratedFrom,
|
|
1566
2204
|
/** false until the store file has actually been written (see readFromDisk step 3). */
|
|
1567
2205
|
onDisk: state.storeExists,
|
|
2206
|
+
/** Backup location (F18): '' means "next to the store file". */
|
|
2207
|
+
backupDir: backupDirOf(),
|
|
2208
|
+
isDefaultBackupDir: backupDirOf() === '',
|
|
2209
|
+
/** Where the rolling pre-write copy actually sits (diagnostics). */
|
|
2210
|
+
backupPath: localBackupPath(),
|
|
2211
|
+
/** Version history (F14). */
|
|
2212
|
+
historyDir: historyDirPath(),
|
|
2213
|
+
historyCount: state.historyCount,
|
|
2214
|
+
historyError: state.historyError,
|
|
2215
|
+
schemaFile: join(dirname(storePath), SCHEMA_FILE_NAME),
|
|
1568
2216
|
},
|
|
1569
2217
|
injection: {
|
|
1570
2218
|
registered: state.sectionRegistered,
|
|
@@ -1611,6 +2259,10 @@ export function apply(ctx) {
|
|
|
1611
2259
|
maxProfiles: MAX_PROFILES,
|
|
1612
2260
|
maxProfileName: MAX_PROFILE_NAME,
|
|
1613
2261
|
maxProfileContent: MAX_PROFILE_CONTENT,
|
|
2262
|
+
maxSets: MAX_SETS,
|
|
2263
|
+
maxSetName: MAX_SET_NAME,
|
|
2264
|
+
maxHistory: MAX_HISTORY,
|
|
2265
|
+
maxBackupDir: MAX_BACKUP_DIR,
|
|
1614
2266
|
maxBodyChars: MAX_BODY_CHARS,
|
|
1615
2267
|
},
|
|
1616
2268
|
}
|
|
@@ -1649,17 +2301,70 @@ export function apply(ctx) {
|
|
|
1649
2301
|
|
|
1650
2302
|
/**
|
|
1651
2303
|
* Refusal while the store on disk declares an unsupported format version:
|
|
1652
|
-
* a save here would rewrite
|
|
1653
|
-
* format meant. The file stays untouched until the user upgrades
|
|
1654
|
-
* or restores a
|
|
2304
|
+
* a save here would rewrite it in this build's format and destroy whatever
|
|
2305
|
+
* the newer format meant. The file stays untouched until the user upgrades
|
|
2306
|
+
* the plugin or restores a supported-version file and hits 重新载入.
|
|
1655
2307
|
*/
|
|
1656
2308
|
const versionRefusal = () => ({
|
|
1657
2309
|
ok: false,
|
|
1658
2310
|
saved: false,
|
|
1659
|
-
message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本
|
|
2311
|
+
message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本 ' + FORMAT_VERSION
|
|
2312
|
+
+ ' 覆盖它,可能损坏数据。请先升级插件,或手动将文件改回受支持的版本(' + SUPPORTED_VERSIONS.join(' / ') + ')后点「重新载入」',
|
|
1660
2313
|
snapshot: snapshot(),
|
|
1661
2314
|
})
|
|
1662
2315
|
|
|
2316
|
+
/**
|
|
2317
|
+
* Adopt a whole parsed store — the shared core of 源码模式 (F16) and the
|
|
2318
|
+
* 整体替换 restore (F14). Both hand the user the SAME safety story as every
|
|
2319
|
+
* other entry point: only a v1/v2 store object is accepted (a stray `{}` or
|
|
2320
|
+
* an unrelated JSON file must never be able to wipe the store), the format
|
|
2321
|
+
* version gate applies, and every correction normalizeData makes is
|
|
2322
|
+
* reported.
|
|
2323
|
+
*
|
|
2324
|
+
* `backupDir` is deliberately NOT adopted: the backup location is
|
|
2325
|
+
* machine-local state, so restoring a file — from another machine, or from
|
|
2326
|
+
* before the path existed — must not silently redirect where backups go.
|
|
2327
|
+
*/
|
|
2328
|
+
const adoptParsedStore = (parsed) => {
|
|
2329
|
+
const looksLikeStore = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
2330
|
+
&& ['version', 'rev', 'enabled', 'budgetChars', 'backupDir', 'activeSetId', 'sets', 'entries', 'profiles']
|
|
2331
|
+
.some((key) => key in parsed)
|
|
2332
|
+
if (!looksLikeStore) {
|
|
2333
|
+
return {
|
|
2334
|
+
error: '内容不像本插件的存储文件:顶层需要是对象,且至少包含 entries / profiles / sets / version 之一。'
|
|
2335
|
+
+ '若要清空记忆,请使用界面上的删除操作,而不是保存一个空对象',
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
const loaded = normalizeData(parsed)
|
|
2339
|
+
if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
|
|
2340
|
+
loaded.data.backupDir = backupDirOf()
|
|
2341
|
+
return { loaded }
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
/** Per-field truncation disclosure for an adopted raw store (mirrors import). */
|
|
2345
|
+
const truncationNotes = (parsed) => {
|
|
2346
|
+
const lists = [
|
|
2347
|
+
[parsed.entries, truncatedFields],
|
|
2348
|
+
[parsed.profiles, truncatedProfileFields],
|
|
2349
|
+
[parsed.sets, truncatedSetFields],
|
|
2350
|
+
]
|
|
2351
|
+
let items = 0
|
|
2352
|
+
const fields = new Set()
|
|
2353
|
+
for (const [list, fieldsOf] of lists) {
|
|
2354
|
+
for (const raw of (Array.isArray(list) ? list : [])) {
|
|
2355
|
+
const cut = fieldsOf(raw)
|
|
2356
|
+
if (cut.length > 0) {
|
|
2357
|
+
items += 1
|
|
2358
|
+
for (const name of cut) fields.add(name)
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
return items > 0 ? '有 ' + items + ' 项超过长度上限,' + [...fields].join('、') + '已被截断' : null
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
const describeStoreShape = (data) =>
|
|
2366
|
+
data.entries.length + ' 条指令 / ' + data.profiles.length + ' 个用户形象 / ' + data.sets.length + ' 个配置集'
|
|
2367
|
+
|
|
1663
2368
|
/**
|
|
1664
2369
|
* Apply an in-memory mutation, persist it, and UNDO it when the write fails.
|
|
1665
2370
|
*
|
|
@@ -1707,11 +2412,33 @@ export function apply(ctx) {
|
|
|
1707
2412
|
const entry = sanitizeEntry(raw)
|
|
1708
2413
|
if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
|
|
1709
2414
|
entry.updatedAt = Date.now()
|
|
2415
|
+
/**
|
|
2416
|
+
* The active set is authoritative for the interactive save path: the panel
|
|
2417
|
+
* edits what is being injected, and an edit must never be able to move an
|
|
2418
|
+
* item into another set (or drag one in). Moving content between sets is a
|
|
2419
|
+
* deliberate act — create the entry where it belongs, or use 复制配置集.
|
|
2420
|
+
*/
|
|
2421
|
+
entry.setId = state.data.activeSetId
|
|
2422
|
+
/**
|
|
2423
|
+
* An id that already belongs to ANOTHER set is not this command's to
|
|
2424
|
+
* replace (entries are addressed by id alone): honouring it would move the
|
|
2425
|
+
* other set's entry into this one and overwrite its text — a cross-set
|
|
2426
|
+
* leak in the one direction the whole feature exists to prevent. Mint a
|
|
2427
|
+
* fresh id instead, and say so, because the caller's id silently changed.
|
|
2428
|
+
*/
|
|
2429
|
+
const foreignId = state.data.entries.some((candidate) => candidate.id === entry.id
|
|
2430
|
+
&& candidate.setId !== state.data.activeSetId)
|
|
2431
|
+
if (foreignId) entry.id = makeId()
|
|
1710
2432
|
// Resolved before the mutation so a refusal can return a response instead
|
|
1711
2433
|
// of being swallowed as an early exit inside it.
|
|
1712
2434
|
const replacesExisting = state.data.entries.some((candidate) => candidate.id === entry.id)
|
|
1713
2435
|
if (!replacesExisting && state.data.entries.length >= MAX_ENTRIES) {
|
|
1714
|
-
return {
|
|
2436
|
+
return {
|
|
2437
|
+
ok: false,
|
|
2438
|
+
saved: false,
|
|
2439
|
+
message: '最多保存 ' + MAX_ENTRIES + ' 条指令(全部配置集合计,当前 ' + state.data.entries.length + ' 条)',
|
|
2440
|
+
snapshot: snapshot(),
|
|
2441
|
+
}
|
|
1715
2442
|
}
|
|
1716
2443
|
const cut = truncatedFields(raw)
|
|
1717
2444
|
const saved = await commit(() => {
|
|
@@ -1720,10 +2447,14 @@ export function apply(ctx) {
|
|
|
1720
2447
|
if (index >= 0) list[index] = entry
|
|
1721
2448
|
else list.push(entry)
|
|
1722
2449
|
})
|
|
1723
|
-
const
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
2450
|
+
const notes = []
|
|
2451
|
+
if (cut.length > 0) {
|
|
2452
|
+
notes.push(cut.join('、') + '超过长度上限(标题 ' + MAX_TITLE + ' / 内容 ' + MAX_CONTENT + ' / 适用场景 ' + MAX_WHEN + ' 字符),超出部分已被截断')
|
|
2453
|
+
}
|
|
2454
|
+
if (foreignId) {
|
|
2455
|
+
notes.push('提交的 id 属于其他配置集,已改用新 id 在当前配置集新建(其他配置集的内容未被改动)')
|
|
2456
|
+
}
|
|
2457
|
+
return settle(notes.length > 0 ? '已保存;' + notes.join(';') : null, saved)
|
|
1727
2458
|
})
|
|
1728
2459
|
|
|
1729
2460
|
const deleteEntry = (id, baseRev) => enqueue(async () => {
|
|
@@ -1733,7 +2464,13 @@ export function apply(ctx) {
|
|
|
1733
2464
|
// disk, bump rev, or push an echo frame — otherwise two panels deleting
|
|
1734
2465
|
// the same entry one after another would manufacture a pointless write
|
|
1735
2466
|
// (and an external process watching the file would wake for nothing).
|
|
1736
|
-
|
|
2467
|
+
//
|
|
2468
|
+
// Scoped to the ACTIVE set: the panel only ever lists those ids, so an id
|
|
2469
|
+
// from another set is not "an entry that is not there" by accident — it is
|
|
2470
|
+
// a request this command must not honour (deleting across sets from a
|
|
2471
|
+
// settings page that claims to show one set would be a data-loss bug).
|
|
2472
|
+
const scope = activeScope(state.data)
|
|
2473
|
+
if (!state.data.entries.some((entry) => entry.id === id && inScope(entry, scope))) {
|
|
1737
2474
|
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
1738
2475
|
}
|
|
1739
2476
|
const saved = await commit(() => {
|
|
@@ -1745,15 +2482,48 @@ export function apply(ctx) {
|
|
|
1745
2482
|
const setOptions = (patch, baseRev) => enqueue(async () => {
|
|
1746
2483
|
if (state.versionRejected) return versionRefusal()
|
|
1747
2484
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
2485
|
+
// The custom backup location (F18) is validated BEFORE anything is queued
|
|
2486
|
+
// into the store: an unusable path must be refused with a reason, not
|
|
2487
|
+
// accepted and then discovered as a failed copy on the next save.
|
|
2488
|
+
if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
|
|
2489
|
+
const wanted = patch.backupDir.trim()
|
|
2490
|
+
if (wanted !== '') {
|
|
2491
|
+
if (wanted.length > MAX_BACKUP_DIR) {
|
|
2492
|
+
return { ok: false, saved: false, message: '备份位置路径过长(上限 ' + MAX_BACKUP_DIR + ' 字符)', snapshot: snapshot() }
|
|
2493
|
+
}
|
|
2494
|
+
if (!isAbsolute(wanted)) {
|
|
2495
|
+
return {
|
|
2496
|
+
ok: false,
|
|
2497
|
+
saved: false,
|
|
2498
|
+
message: '备份位置需要绝对路径(例如 D:\\云盘\\dsh-memory 或 /home/me/cloud/dsh-memory);留空表示与存储文件同目录',
|
|
2499
|
+
snapshot: snapshot(),
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
const problem = await probeBackupDir(wanted)
|
|
2503
|
+
if (problem !== null) {
|
|
2504
|
+
return { ok: false, saved: false, message: '备份位置不可用(' + problem + '):' + wanted, snapshot: snapshot() }
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
1748
2508
|
const saved = await commit(() => {
|
|
1749
2509
|
if (patch !== null && typeof patch === 'object') {
|
|
1750
2510
|
if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
|
|
1751
2511
|
if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
|
|
1752
2512
|
state.data.budgetChars = clampBudget(patch.budgetChars)
|
|
1753
2513
|
}
|
|
2514
|
+
if (typeof patch.backupDir === 'string') state.data.backupDir = patch.backupDir.trim()
|
|
1754
2515
|
}
|
|
1755
2516
|
})
|
|
1756
|
-
|
|
2517
|
+
// A changed backup location moves where history snapshots and manual
|
|
2518
|
+
// exports go, so it is reported rather than left to be discovered.
|
|
2519
|
+
let note = null
|
|
2520
|
+
if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
|
|
2521
|
+
const wanted = patch.backupDir.trim()
|
|
2522
|
+
note = wanted === ''
|
|
2523
|
+
? '已恢复默认备份位置(版本历史与导出与存储文件同目录)'
|
|
2524
|
+
: '备份位置已更新:' + wanted + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)'
|
|
2525
|
+
}
|
|
2526
|
+
return settle(note, saved)
|
|
1757
2527
|
})
|
|
1758
2528
|
|
|
1759
2529
|
/**
|
|
@@ -1787,7 +2557,11 @@ export function apply(ctx) {
|
|
|
1787
2557
|
}
|
|
1788
2558
|
|
|
1789
2559
|
const list = state.data.entries
|
|
1790
|
-
|
|
2560
|
+
// Scoped to the active set, exactly like the single-entry commands: the
|
|
2561
|
+
// panel's selection can only ever contain active-set ids, so an id from
|
|
2562
|
+
// another set is reported as "not found" rather than acted on.
|
|
2563
|
+
const scope = activeScope(state.data)
|
|
2564
|
+
const targets = list.filter((entry) => uniqueIds.includes(entry.id) && inScope(entry, scope))
|
|
1791
2565
|
const missing = uniqueIds.length - targets.length
|
|
1792
2566
|
const missingNote = missing > 0
|
|
1793
2567
|
? '(另有 ' + missing + ' 条未找到,可能已被其他窗口或进程修改)'
|
|
@@ -1882,6 +2656,13 @@ export function apply(ctx) {
|
|
|
1882
2656
|
const profile = sanitizeProfile(raw)
|
|
1883
2657
|
if (profile === null) return { ok: false, saved: false, message: '形象名称与内容不能同时为空', snapshot: snapshot() }
|
|
1884
2658
|
profile.updatedAt = Date.now()
|
|
2659
|
+
/** Same rule as entries: the interactive path files into the active set. */
|
|
2660
|
+
profile.setId = state.data.activeSetId
|
|
2661
|
+
// ...and an id owned by another set is re-minted rather than replaced (see
|
|
2662
|
+
// the note in saveEntry — same cross-set hazard, same guard).
|
|
2663
|
+
const foreignProfileId = state.data.profiles.some((candidate) => candidate.id === profile.id
|
|
2664
|
+
&& candidate.setId !== state.data.activeSetId)
|
|
2665
|
+
if (foreignProfileId) profile.id = makeId()
|
|
1885
2666
|
const replacesExisting = state.data.profiles.some((candidate) => candidate.id === profile.id)
|
|
1886
2667
|
if (!replacesExisting && state.data.profiles.length >= MAX_PROFILES) {
|
|
1887
2668
|
return { ok: false, saved: false, message: '最多保存 ' + MAX_PROFILES + ' 个用户形象', snapshot: snapshot() }
|
|
@@ -1902,8 +2683,10 @@ export function apply(ctx) {
|
|
|
1902
2683
|
const deleteProfile = (id, baseRev) => enqueue(async () => {
|
|
1903
2684
|
if (state.versionRejected) return versionRefusal()
|
|
1904
2685
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
1905
|
-
// Mirror deleteEntry: an absent id changes nothing and must not touch disk
|
|
1906
|
-
|
|
2686
|
+
// Mirror deleteEntry: an absent id changes nothing and must not touch disk,
|
|
2687
|
+
// and an id belonging to a different set is not this panel's to delete.
|
|
2688
|
+
const scope = activeScope(state.data)
|
|
2689
|
+
if (!state.data.profiles.some((profile) => profile.id === id && inScope(profile, scope))) {
|
|
1907
2690
|
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
1908
2691
|
}
|
|
1909
2692
|
const saved = await commit(() => {
|
|
@@ -1912,6 +2695,269 @@ export function apply(ctx) {
|
|
|
1912
2695
|
return settle(null, saved)
|
|
1913
2696
|
})
|
|
1914
2697
|
|
|
2698
|
+
/* ---------------- instruction sets (F13) ---------------- */
|
|
2699
|
+
|
|
2700
|
+
/**
|
|
2701
|
+
* Create or rename an instruction set.
|
|
2702
|
+
*
|
|
2703
|
+
* A blank `set.id` is a CREATE (the Host mints the id), the same convention
|
|
2704
|
+
* entry edits use, and a supplied id is a RENAME. Creating does NOT switch:
|
|
2705
|
+
* switching is what changes what gets injected, and it stays a separate,
|
|
2706
|
+
* explicit click (the response says so). Nothing in this file ever derives a
|
|
2707
|
+
* set from the process cwd or from an open folder — see the README note on
|
|
2708
|
+
* the incident that rule comes from.
|
|
2709
|
+
*/
|
|
2710
|
+
const saveSet = (raw, baseRev) => enqueue(async () => {
|
|
2711
|
+
if (state.versionRejected) return versionRefusal()
|
|
2712
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
2713
|
+
const wanted = sanitizeSet(raw)
|
|
2714
|
+
if (wanted === null) {
|
|
2715
|
+
return { ok: false, saved: false, message: '配置集名称不能为空(单行,最多 ' + MAX_SET_NAME + ' 字符)', snapshot: snapshot() }
|
|
2716
|
+
}
|
|
2717
|
+
const index = state.data.sets.findIndex((set) => set.id === wanted.id)
|
|
2718
|
+
if (state.data.sets.some((set, at) => at !== index && set.name === wanted.name)) {
|
|
2719
|
+
return { ok: false, saved: false, message: '已存在同名配置集「' + wanted.name + '」', snapshot: snapshot() }
|
|
2720
|
+
}
|
|
2721
|
+
if (index < 0) {
|
|
2722
|
+
if (state.data.sets.length >= MAX_SETS) {
|
|
2723
|
+
return { ok: false, saved: false, message: '最多创建 ' + MAX_SETS + ' 个配置集', snapshot: snapshot() }
|
|
2724
|
+
}
|
|
2725
|
+
const cut = truncatedSetFields(raw)
|
|
2726
|
+
const saved = await commit(() => {
|
|
2727
|
+
state.data.sets.push({ id: wanted.id, name: wanted.name })
|
|
2728
|
+
})
|
|
2729
|
+
const note = '已创建配置集「' + wanted.name + '」'
|
|
2730
|
+
+ (cut.length > 0 ? ';名称超过 ' + MAX_SET_NAME + ' 字符上限,超出部分已被截断' : '')
|
|
2731
|
+
+ ';它是空的,点击「切换」后才会注入它'
|
|
2732
|
+
return settle(note, saved)
|
|
2733
|
+
}
|
|
2734
|
+
if (state.data.sets[index].name === wanted.name) {
|
|
2735
|
+
return { ok: true, saved: false, message: '名称未变化', snapshot: snapshot() }
|
|
2736
|
+
}
|
|
2737
|
+
const cut = truncatedSetFields(raw)
|
|
2738
|
+
const saved = await commit(() => {
|
|
2739
|
+
state.data.sets[index].name = wanted.name
|
|
2740
|
+
})
|
|
2741
|
+
return settle('已重命名为「' + wanted.name + '」' + (cut.length > 0 ? '(名称已按长度上限截断)' : ''), saved)
|
|
2742
|
+
})
|
|
2743
|
+
|
|
2744
|
+
/**
|
|
2745
|
+
* Delete a set together with everything filed under it. Refused for the
|
|
2746
|
+
* ACTIVE set: deleting what is currently injected would change the prompt as
|
|
2747
|
+
* a side effect of a management action, so the user switches first and the
|
|
2748
|
+
* deletion becomes a separate, visible step.
|
|
2749
|
+
*/
|
|
2750
|
+
const deleteSet = (id, baseRev) => enqueue(async () => {
|
|
2751
|
+
if (state.versionRejected) return versionRefusal()
|
|
2752
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
2753
|
+
const index = state.data.sets.findIndex((set) => set.id === id)
|
|
2754
|
+
if (index < 0) {
|
|
2755
|
+
return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
|
|
2756
|
+
}
|
|
2757
|
+
if (state.data.sets.length <= 1) {
|
|
2758
|
+
return { ok: false, saved: false, message: '至少要保留一个配置集', snapshot: snapshot() }
|
|
2759
|
+
}
|
|
2760
|
+
if (id === state.data.activeSetId) {
|
|
2761
|
+
return { ok: false, saved: false, message: '不能删除当前激活的配置集;请先切换到其他配置集', snapshot: snapshot() }
|
|
2762
|
+
}
|
|
2763
|
+
const doomedEntries = state.data.entries.filter((entry) => entry.setId === id).length
|
|
2764
|
+
const doomedProfiles = state.data.profiles.filter((profile) => profile.setId === id).length
|
|
2765
|
+
const saved = await commit(() => {
|
|
2766
|
+
state.data.sets.splice(index, 1)
|
|
2767
|
+
state.data.entries = state.data.entries.filter((entry) => entry.setId !== id)
|
|
2768
|
+
state.data.profiles = state.data.profiles.filter((profile) => profile.setId !== id)
|
|
2769
|
+
})
|
|
2770
|
+
return settle('已删除配置集,并移除其中的 ' + doomedEntries + ' 条指令、' + doomedProfiles + ' 个用户形象', saved)
|
|
2771
|
+
})
|
|
2772
|
+
|
|
2773
|
+
/**
|
|
2774
|
+
* Make one set the active one — the single command that changes what is
|
|
2775
|
+
* injected. It writes (the choice survives a restart) and therefore bumps
|
|
2776
|
+
* `rev`, so every other open panel follows along.
|
|
2777
|
+
*/
|
|
2778
|
+
const setActiveSet = (id, baseRev) => enqueue(async () => {
|
|
2779
|
+
if (state.versionRejected) return versionRefusal()
|
|
2780
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
2781
|
+
const target = state.data.sets.find((set) => set.id === id)
|
|
2782
|
+
if (target === undefined) {
|
|
2783
|
+
return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
|
|
2784
|
+
}
|
|
2785
|
+
if (id === state.data.activeSetId) {
|
|
2786
|
+
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
2787
|
+
}
|
|
2788
|
+
const saved = await commit(() => {
|
|
2789
|
+
state.data.activeSetId = id
|
|
2790
|
+
})
|
|
2791
|
+
return settle('已切换到配置集「' + target.name + '」,注入内容已随之更新', saved)
|
|
2792
|
+
})
|
|
2793
|
+
|
|
2794
|
+
/**
|
|
2795
|
+
* Copy a set (its entries and profiles, with fresh ids) into a new one — the
|
|
2796
|
+
* "start from a template" flow for per-client isolation. The copy is NOT
|
|
2797
|
+
* activated: it is a new, untested configuration until the user switches.
|
|
2798
|
+
*/
|
|
2799
|
+
const duplicateSet = (id, name, baseRev) => enqueue(async () => {
|
|
2800
|
+
if (state.versionRejected) return versionRefusal()
|
|
2801
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
2802
|
+
const source = state.data.sets.find((set) => set.id === id)
|
|
2803
|
+
if (source === undefined) {
|
|
2804
|
+
return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
|
|
2805
|
+
}
|
|
2806
|
+
if (state.data.sets.length >= MAX_SETS) {
|
|
2807
|
+
return { ok: false, saved: false, message: '最多 ' + MAX_SETS + ' 个配置集', snapshot: snapshot() }
|
|
2808
|
+
}
|
|
2809
|
+
const wanted = singleLine(typeof name === 'string' ? name : '', MAX_SET_NAME)
|
|
2810
|
+
|| singleLine(source.name + ' 副本', MAX_SET_NAME)
|
|
2811
|
+
if (state.data.sets.some((set) => set.name === wanted)) {
|
|
2812
|
+
return { ok: false, saved: false, message: '已存在同名配置集「' + wanted + '」', snapshot: snapshot() }
|
|
2813
|
+
}
|
|
2814
|
+
const sourceEntries = state.data.entries.filter((entry) => entry.setId === id)
|
|
2815
|
+
if (state.data.entries.length + sourceEntries.length > MAX_ENTRIES) {
|
|
2816
|
+
return {
|
|
2817
|
+
ok: false,
|
|
2818
|
+
saved: false,
|
|
2819
|
+
message: '复制后条目总数会超过 ' + MAX_ENTRIES + ' 条上限(当前 ' + state.data.entries.length
|
|
2820
|
+
+ ' 条,需复制 ' + sourceEntries.length + ' 条)',
|
|
2821
|
+
snapshot: snapshot(),
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
const sourceProfiles = state.data.profiles.filter((profile) => profile.setId === id)
|
|
2825
|
+
if (state.data.profiles.length + sourceProfiles.length > MAX_PROFILES) {
|
|
2826
|
+
return { ok: false, saved: false, message: '复制后用户形象总数会超过 ' + MAX_PROFILES + ' 个上限', snapshot: snapshot() }
|
|
2827
|
+
}
|
|
2828
|
+
const newId = makeSetId()
|
|
2829
|
+
const stamp = Date.now()
|
|
2830
|
+
const entries = sourceEntries.map((entry) => ({ ...entry, id: makeId(), setId: newId, updatedAt: stamp }))
|
|
2831
|
+
const profiles = sourceProfiles.map((profile) => ({ ...profile, id: makeId(), setId: newId, updatedAt: stamp }))
|
|
2832
|
+
const saved = await commit(() => {
|
|
2833
|
+
state.data.sets.push({ id: newId, name: wanted })
|
|
2834
|
+
state.data.entries.push(...entries)
|
|
2835
|
+
state.data.profiles.push(...profiles)
|
|
2836
|
+
})
|
|
2837
|
+
return settle('已复制为「' + wanted + '」(' + entries.length + ' 条指令、' + profiles.length + ' 个用户形象)'
|
|
2838
|
+
+ ';点击「切换」后才会注入它', saved)
|
|
2839
|
+
})
|
|
2840
|
+
|
|
2841
|
+
/* ---------------- version history (F14) ---------------- */
|
|
2842
|
+
|
|
2843
|
+
/**
|
|
2844
|
+
* The snapshot index, newest first. Names carry the timestamp, revision and
|
|
2845
|
+
* device, so nothing here needs to read a file to sort; counts are read from
|
|
2846
|
+
* the small ones only, so a maximal store cannot make listing expensive.
|
|
2847
|
+
*/
|
|
2848
|
+
const HISTORY_INDEX_MAX_BYTES = 1048576
|
|
2849
|
+
const historyList = () => enqueue(async () => {
|
|
2850
|
+
const dir = historyDirPath()
|
|
2851
|
+
let names = []
|
|
2852
|
+
try {
|
|
2853
|
+
names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
|
|
2854
|
+
} catch (err) {
|
|
2855
|
+
// A missing directory is not an error: nothing has been written yet.
|
|
2856
|
+
if (err === null || err === undefined || err.code !== 'ENOENT') {
|
|
2857
|
+
return { ok: false, saved: false, message: '无法读取版本历史目录:' + describeError(err), snapshot: snapshot(), history: { dir, items: [] } }
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
const items = []
|
|
2861
|
+
for (const name of names) {
|
|
2862
|
+
const full = join(dir, name)
|
|
2863
|
+
let info = null
|
|
2864
|
+
try {
|
|
2865
|
+
info = await stat(full)
|
|
2866
|
+
} catch {
|
|
2867
|
+
continue
|
|
2868
|
+
}
|
|
2869
|
+
const item = {
|
|
2870
|
+
name,
|
|
2871
|
+
epoch: historyEpochOf(name),
|
|
2872
|
+
rev: historyRevOf(name),
|
|
2873
|
+
device: historyDeviceOf(name),
|
|
2874
|
+
foreign: historyDeviceOf(name) !== DEVICE,
|
|
2875
|
+
bytes: info.size,
|
|
2876
|
+
entries: null,
|
|
2877
|
+
profiles: null,
|
|
2878
|
+
sets: null,
|
|
2879
|
+
}
|
|
2880
|
+
if (info.size <= HISTORY_INDEX_MAX_BYTES) {
|
|
2881
|
+
try {
|
|
2882
|
+
const parsed = JSON.parse(await readFile(full, 'utf8'))
|
|
2883
|
+
item.entries = Array.isArray(parsed.entries) ? parsed.entries.length : 0
|
|
2884
|
+
item.profiles = Array.isArray(parsed.profiles) ? parsed.profiles.length : 0
|
|
2885
|
+
item.sets = Array.isArray(parsed.sets) ? parsed.sets.length : 1
|
|
2886
|
+
} catch {
|
|
2887
|
+
// An unreadable snapshot still lists; restore will report the detail.
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
items.push(item)
|
|
2891
|
+
}
|
|
2892
|
+
items.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
|
|
2893
|
+
state.historyCount = items.length
|
|
2894
|
+
return { ok: true, saved: false, message: null, snapshot: snapshot(), history: { dir, items } }
|
|
2895
|
+
})
|
|
2896
|
+
|
|
2897
|
+
/**
|
|
2898
|
+
* Roll the store back to one snapshot. The snapshot becomes the new CURRENT
|
|
2899
|
+
* state and is written as a new revision (rev only ever moves forward, so a
|
|
2900
|
+
* client holding an older rev is refused rather than silently reverted) —
|
|
2901
|
+
* and, like every other write, it leaves its own snapshot behind, so the
|
|
2902
|
+
* rollback itself can be undone from the history list.
|
|
2903
|
+
*/
|
|
2904
|
+
const historyRestore = (name, baseRev) => enqueue(async () => {
|
|
2905
|
+
if (state.versionRejected) return versionRefusal()
|
|
2906
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
2907
|
+
if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
|
|
2908
|
+
return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
|
|
2909
|
+
}
|
|
2910
|
+
let parsed = null
|
|
2911
|
+
try {
|
|
2912
|
+
parsed = JSON.parse(await readFile(join(historyDirPath(), name), 'utf8'))
|
|
2913
|
+
} catch (err) {
|
|
2914
|
+
return { ok: false, saved: false, message: '无法读取该历史快照:' + describeError(err), snapshot: snapshot() }
|
|
2915
|
+
}
|
|
2916
|
+
const adopted = adoptParsedStore(parsed)
|
|
2917
|
+
if (adopted.error !== undefined) {
|
|
2918
|
+
return { ok: false, saved: false, message: '该历史快照不是有效的存储文件:' + adopted.error, snapshot: snapshot() }
|
|
2919
|
+
}
|
|
2920
|
+
if (adopted.rejectedVersion !== undefined) {
|
|
2921
|
+
return {
|
|
2922
|
+
ok: false,
|
|
2923
|
+
saved: false,
|
|
2924
|
+
message: '该历史快照为格式版本 ' + adopted.rejectedVersion + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
|
|
2925
|
+
+ ';已拒绝回滚,当前内容未被修改',
|
|
2926
|
+
snapshot: snapshot(),
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
const { loaded } = adopted
|
|
2930
|
+
const notes = []
|
|
2931
|
+
const warnings = loadWarnings(loaded)
|
|
2932
|
+
if (warnings !== null) notes.push(warnings)
|
|
2933
|
+
const saved = await commit(() => {
|
|
2934
|
+
loaded.data.rev = state.data.rev || 0
|
|
2935
|
+
loaded.data.backupDir = backupDirOf()
|
|
2936
|
+
state.data = loaded.data
|
|
2937
|
+
})
|
|
2938
|
+
const when = new Date(historyEpochOf(name)).toLocaleString()
|
|
2939
|
+
const head = '已回滚到 ' + when + ' 的快照(快照 rev ' + historyRevOf(name) + ',本次写为新 rev ' + (state.data.rev || 0) + '):'
|
|
2940
|
+
+ describeStoreShape(state.data)
|
|
2941
|
+
return settle([head, ...notes].join(';'), saved)
|
|
2942
|
+
})
|
|
2943
|
+
|
|
2944
|
+
/**
|
|
2945
|
+
* Delete one snapshot. `saved` stays false: the STORE was not written, which
|
|
2946
|
+
* is exactly what the field means everywhere else.
|
|
2947
|
+
*/
|
|
2948
|
+
const historyDelete = (name) => enqueue(async () => {
|
|
2949
|
+
if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
|
|
2950
|
+
return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
|
|
2951
|
+
}
|
|
2952
|
+
try {
|
|
2953
|
+
await unlink(join(historyDirPath(), name))
|
|
2954
|
+
} catch (err) {
|
|
2955
|
+
return { ok: false, saved: false, message: '删除历史快照失败:' + describeError(err), snapshot: snapshot() }
|
|
2956
|
+
}
|
|
2957
|
+
await refreshHistoryCount()
|
|
2958
|
+
return { ok: true, saved: false, message: '已删除该历史快照', snapshot: snapshot() }
|
|
2959
|
+
})
|
|
2960
|
+
|
|
1915
2961
|
const reload = () => enqueue(async () => {
|
|
1916
2962
|
// `saved` is only true when the load itself wrote to disk (the
|
|
1917
2963
|
// backup-promotion or legacy-migration branch); a plain re-read claiming
|
|
@@ -1920,6 +2966,18 @@ export function apply(ctx) {
|
|
|
1920
2966
|
syncSection()
|
|
1921
2967
|
return { ok: state.storageError === null, saved: wrote === true, message: state.storageError, snapshot: snapshot() }
|
|
1922
2968
|
})
|
|
2969
|
+
/**
|
|
2970
|
+
* An export is a BACKUP, so it carries the whole store — every set, with
|
|
2971
|
+
* each item's `setId` — not just the active one (F13). `version` is the
|
|
2972
|
+
* envelope's own version; `data.version` is the store format inside it.
|
|
2973
|
+
*/
|
|
2974
|
+
const exportEnvelope = () => ({
|
|
2975
|
+
app: 'dsh-instruction-memory',
|
|
2976
|
+
version: FORMAT_VERSION,
|
|
2977
|
+
exportedAt: new Date().toISOString(),
|
|
2978
|
+
data: persistableData(),
|
|
2979
|
+
})
|
|
2980
|
+
|
|
1923
2981
|
const exportData = () => enqueue(async () => ({
|
|
1924
2982
|
ok: true,
|
|
1925
2983
|
// An export reads the store and never writes it; a truthful `saved:false`
|
|
@@ -1928,10 +2986,135 @@ export function apply(ctx) {
|
|
|
1928
2986
|
message: null,
|
|
1929
2987
|
// A self-describing envelope: re-importable by this plugin, and readable
|
|
1930
2988
|
// as plain JSON by anything else.
|
|
1931
|
-
export:
|
|
2989
|
+
export: exportEnvelope(),
|
|
1932
2990
|
snapshot: snapshot(),
|
|
1933
2991
|
}))
|
|
1934
2992
|
|
|
2993
|
+
/**
|
|
2994
|
+
* Write an export straight into the backup directory (F18) — the "land the
|
|
2995
|
+
* backup in my cloud-synced folder" button. The default directory is the
|
|
2996
|
+
* store directory, so this works before a custom location is configured too.
|
|
2997
|
+
*/
|
|
2998
|
+
const exportToDir = () => enqueue(async () => {
|
|
2999
|
+
const dir = backupDirOf() !== '' ? backupDirOf() : dirname(storePath)
|
|
3000
|
+
const target = join(dir, exportFileName(Date.now()))
|
|
3001
|
+
try {
|
|
3002
|
+
await mkdir(dir, { recursive: true })
|
|
3003
|
+
await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', 'utf8')
|
|
3004
|
+
} catch (err) {
|
|
3005
|
+
return { ok: false, saved: false, message: '写入备份目录失败:' + describeError(err), snapshot: snapshot() }
|
|
3006
|
+
}
|
|
3007
|
+
return { ok: true, saved: false, message: '已写入 ' + target, snapshot: snapshot() }
|
|
3008
|
+
})
|
|
3009
|
+
|
|
3010
|
+
/**
|
|
3011
|
+
* The raw store text for 源码模式 (F16): exactly the bytes a save would write.
|
|
3012
|
+
* Editing it is therefore equivalent to editing memory.json — but it goes
|
|
3013
|
+
* through the same loader, version gate, disclosures and rev check as every
|
|
3014
|
+
* other write instead of being trusted because it came from the UI.
|
|
3015
|
+
*/
|
|
3016
|
+
const readSource = () => enqueue(async () => {
|
|
3017
|
+
/**
|
|
3018
|
+
* While the file on disk declares an unsupported version, the in-memory
|
|
3019
|
+
* state is a placeholder (nothing was adopted), so handing it out as "the
|
|
3020
|
+
* source" would show an EMPTY store and invite the user to save it over
|
|
3021
|
+
* their real file. The documented way out of that state is to fix the file
|
|
3022
|
+
* (or upgrade the plugin) and hit 重新载入 — so say that instead.
|
|
3023
|
+
*/
|
|
3024
|
+
if (state.versionRejected) {
|
|
3025
|
+
return {
|
|
3026
|
+
ok: false,
|
|
3027
|
+
saved: false,
|
|
3028
|
+
message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',源码模式已暂停:请先用编辑器修改 '
|
|
3029
|
+
+ storePath + '(或升级插件)后点「重新载入」',
|
|
3030
|
+
snapshot: snapshot(),
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
// Opening source mode is a deliberate user action, so it is also allowed to
|
|
3034
|
+
// materialise a first-run store (same rule as the `state` method).
|
|
3035
|
+
const materialized = await ensureStoreMaterialized()
|
|
3036
|
+
return { ok: true, saved: materialized === true, message: null, source: serialize(), snapshot: snapshot() }
|
|
3037
|
+
})
|
|
3038
|
+
|
|
3039
|
+
/**
|
|
3040
|
+
* The shared core of 源码保存 (F16) and 整体替换恢复 (F14): adopt a whole
|
|
3041
|
+
* parsed store and commit it in one transaction, reporting every correction
|
|
3042
|
+
* the loader had to make. Callers add their own opening line.
|
|
3043
|
+
*/
|
|
3044
|
+
const commitAdoptedStore = async (parsed, head) => {
|
|
3045
|
+
const adopted = adoptParsedStore(parsed)
|
|
3046
|
+
if (adopted.error !== undefined) {
|
|
3047
|
+
return { ok: false, saved: false, message: adopted.error, snapshot: snapshot() }
|
|
3048
|
+
}
|
|
3049
|
+
if (adopted.rejectedVersion !== undefined) {
|
|
3050
|
+
return {
|
|
3051
|
+
ok: false,
|
|
3052
|
+
saved: false,
|
|
3053
|
+
message: '内容声明为格式版本 ' + adopted.rejectedVersion + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
|
|
3054
|
+
+ ';已拒绝保存,当前内容未被修改',
|
|
3055
|
+
snapshot: snapshot(),
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
const { loaded } = adopted
|
|
3059
|
+
const notes = []
|
|
3060
|
+
const warnings = loadWarnings(loaded)
|
|
3061
|
+
if (warnings !== null) notes.push(warnings)
|
|
3062
|
+
const truncation = truncationNotes(parsed)
|
|
3063
|
+
if (truncation !== null) notes.push(truncation)
|
|
3064
|
+
const saved = await commit(() => {
|
|
3065
|
+
// rev only ever moves forward: a restore is a NEW revision of the store,
|
|
3066
|
+
// so a client holding an older one is refused rather than silently
|
|
3067
|
+
// reverted (and `baseRev` conflict handling stays meaningful).
|
|
3068
|
+
loaded.data.rev = state.data.rev || 0
|
|
3069
|
+
loaded.data.backupDir = backupDirOf()
|
|
3070
|
+
state.data = loaded.data
|
|
3071
|
+
})
|
|
3072
|
+
return settle([head + describeStoreShape(state.data), ...notes].join(';'), saved)
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
const writeSource = (text, baseRev) => enqueue(async () => {
|
|
3076
|
+
if (state.versionRejected) return versionRefusal()
|
|
3077
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3078
|
+
if (typeof text !== 'string') {
|
|
3079
|
+
return { ok: false, saved: false, message: '源码必须是 JSON 文本', snapshot: snapshot() }
|
|
3080
|
+
}
|
|
3081
|
+
if (text.length > MAX_BODY_CHARS) {
|
|
3082
|
+
return {
|
|
3083
|
+
ok: false,
|
|
3084
|
+
saved: false,
|
|
3085
|
+
message: '源码过长:' + text.length + ' 字符,上限 ' + MAX_BODY_CHARS + ' 字符',
|
|
3086
|
+
snapshot: snapshot(),
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
let parsed = null
|
|
3090
|
+
try {
|
|
3091
|
+
parsed = JSON.parse(text)
|
|
3092
|
+
} catch (err) {
|
|
3093
|
+
return { ok: false, saved: false, message: '源码不是有效的 JSON:' + describeError(err), snapshot: snapshot() }
|
|
3094
|
+
}
|
|
3095
|
+
return commitAdoptedStore(parsed, '已按源码保存:')
|
|
3096
|
+
})
|
|
3097
|
+
|
|
3098
|
+
/**
|
|
3099
|
+
* Replace the whole store from a backup file — the "用备份整体替换" door
|
|
3100
|
+
* (F14). Import merges by design and can never restore an older whole-store
|
|
3101
|
+
* state; this is that missing path, and it is explicit: the panel confirms
|
|
3102
|
+
* first, and nothing is unrecoverable (the replaced content stays in
|
|
3103
|
+
* memory.json.bak and, whenever history is writable, in the version list).
|
|
3104
|
+
*/
|
|
3105
|
+
const restoreData = (payload, baseRev) => enqueue(async () => {
|
|
3106
|
+
if (state.versionRejected) return versionRefusal()
|
|
3107
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3108
|
+
if (payload === null || typeof payload !== 'object') {
|
|
3109
|
+
return { ok: false, saved: false, message: '恢复内容不是有效的 JSON 对象', snapshot: snapshot() }
|
|
3110
|
+
}
|
|
3111
|
+
// Accept our own export envelope as well as a bare store object.
|
|
3112
|
+
const inner = payload.data !== null && typeof payload.data === 'object' && !Array.isArray(payload.data)
|
|
3113
|
+
? payload.data
|
|
3114
|
+
: payload
|
|
3115
|
+
return commitAdoptedStore(inner, '已用备份整体替换当前记忆:')
|
|
3116
|
+
})
|
|
3117
|
+
|
|
1935
3118
|
const importData = (payload, baseRev) => enqueue(async () => {
|
|
1936
3119
|
if (state.versionRejected) return versionRefusal()
|
|
1937
3120
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
@@ -1945,26 +3128,56 @@ export function apply(ctx) {
|
|
|
1945
3128
|
: (Array.isArray(inner.entries) ? inner.entries : null)
|
|
1946
3129
|
const rawProfiles = Array.isArray(payload.profiles) ? payload.profiles
|
|
1947
3130
|
: (Array.isArray(inner.profiles) ? inner.profiles : null)
|
|
3131
|
+
// Our own export also carries the set list, which is what lets an import
|
|
3132
|
+
// recreate the sets its items belong to instead of dumping everything into
|
|
3133
|
+
// the active one.
|
|
3134
|
+
const rawSets = Array.isArray(payload.sets) ? payload.sets
|
|
3135
|
+
: (Array.isArray(inner.sets) ? inner.sets : null)
|
|
1948
3136
|
if ((rawEntries === null || rawEntries.length === 0)
|
|
1949
3137
|
&& (rawProfiles === null || rawProfiles.length === 0)) {
|
|
1950
3138
|
return { ok: false, saved: false, message: '导入文件中没有找到条目或用户形象', snapshot: snapshot() }
|
|
1951
3139
|
}
|
|
3140
|
+
/**
|
|
3141
|
+
* Set mapping (F13). An imported item is filed under the set it names when
|
|
3142
|
+
* that set already exists here, or when the payload DECLARES it (our own
|
|
3143
|
+
* export carries the sets array) and there is room to create it. Anything
|
|
3144
|
+
* else lands in the active set, and how many did so is disclosed — the
|
|
3145
|
+
* alternative, dropping items whose set is unknown, would be data loss.
|
|
3146
|
+
*/
|
|
3147
|
+
const localSetIds = new Set(state.data.sets.map((set) => set.id))
|
|
3148
|
+
const declaredSets = []
|
|
3149
|
+
const declaredIds = new Set()
|
|
3150
|
+
for (const candidate of (Array.isArray(rawSets) ? rawSets : [])) {
|
|
3151
|
+
const set = sanitizeSet(candidate)
|
|
3152
|
+
if (set === null || localSetIds.has(set.id) || declaredIds.has(set.id)) continue
|
|
3153
|
+
declaredIds.add(set.id)
|
|
3154
|
+
declaredSets.push(set)
|
|
3155
|
+
}
|
|
3156
|
+
const activeId = state.data.activeSetId
|
|
1952
3157
|
// Merge, never replace: importing must not be able to wipe what is here.
|
|
1953
|
-
// Duplicates (same id, or same title+content
|
|
1954
|
-
// skipped and counted, not silently
|
|
3158
|
+
// Duplicates (same id within the same set, or same title+content within the
|
|
3159
|
+
// same set) and invalid items are skipped and counted, not silently
|
|
3160
|
+
// dropped. De-duplicating per set is what makes re-importing idempotent
|
|
3161
|
+
// while still allowing the same instruction to exist in two sets.
|
|
1955
3162
|
//
|
|
1956
3163
|
// The whole plan is computed BEFORE the store is touched, so the merge and
|
|
1957
3164
|
// its counters cannot disagree with what actually gets appended.
|
|
1958
3165
|
const existing = state.data.entries
|
|
1959
|
-
const
|
|
1960
|
-
const
|
|
3166
|
+
const bodyKey = (setId, title, content) => setId + '\u0000' + title + '\u0000' + content
|
|
3167
|
+
const seenIds = new Set(existing.map((entry) => entry.setId + '\u0000' + entry.id))
|
|
3168
|
+
const seenBodies = new Set(existing.map((entry) => bodyKey(entry.setId, entry.title, entry.content)))
|
|
1961
3169
|
const accepted = []
|
|
1962
3170
|
const cutFields = new Set()
|
|
1963
3171
|
let truncatedCount = 0
|
|
1964
3172
|
let skipped = 0
|
|
1965
3173
|
for (const candidate of (rawEntries || [])) {
|
|
1966
3174
|
const entry = sanitizeEntry(candidate)
|
|
1967
|
-
if (entry === null
|
|
3175
|
+
if (entry === null) {
|
|
3176
|
+
skipped += 1
|
|
3177
|
+
continue
|
|
3178
|
+
}
|
|
3179
|
+
const wanted = entry.setId !== '' ? entry.setId : activeId
|
|
3180
|
+
if (seenIds.has(wanted + '\u0000' + entry.id) || seenBodies.has(bodyKey(wanted, entry.title, entry.content))) {
|
|
1968
3181
|
skipped += 1
|
|
1969
3182
|
continue
|
|
1970
3183
|
}
|
|
@@ -1982,25 +3195,29 @@ export function apply(ctx) {
|
|
|
1982
3195
|
for (const field of cut) cutFields.add(field)
|
|
1983
3196
|
}
|
|
1984
3197
|
entry.updatedAt = Date.now()
|
|
1985
|
-
accepted.push(entry)
|
|
1986
|
-
seenIds.add(entry.id)
|
|
1987
|
-
seenBodies.add(entry.title
|
|
3198
|
+
accepted.push({ entry, wanted })
|
|
3199
|
+
seenIds.add(wanted + '\u0000' + entry.id)
|
|
3200
|
+
seenBodies.add(bodyKey(wanted, entry.title, entry.content))
|
|
1988
3201
|
}
|
|
1989
3202
|
// Persona profiles merge under the same rules (same id, or same
|
|
1990
3203
|
// name+content) in the SAME transaction, so a mixed import never lands
|
|
1991
3204
|
// half-way.
|
|
1992
3205
|
const existingProfiles = state.data.profiles
|
|
1993
|
-
const seenProfileIds = new Set(existingProfiles.map((profile) => profile.id))
|
|
1994
|
-
const seenProfileBodies = new Set(existingProfiles.map((profile) => profile.name + '\u0000' + profile.content))
|
|
3206
|
+
const seenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
|
|
3207
|
+
const seenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
|
|
1995
3208
|
const acceptedProfiles = []
|
|
1996
3209
|
const cutProfileFields = new Set()
|
|
1997
3210
|
let truncatedProfileCount = 0
|
|
1998
3211
|
let skippedProfiles = 0
|
|
1999
3212
|
for (const candidate of (rawProfiles || [])) {
|
|
2000
3213
|
const profile = sanitizeProfile(candidate)
|
|
2001
|
-
if (profile === null
|
|
2002
|
-
|
|
2003
|
-
|
|
3214
|
+
if (profile === null) {
|
|
3215
|
+
skippedProfiles += 1
|
|
3216
|
+
continue
|
|
3217
|
+
}
|
|
3218
|
+
const wanted = profile.setId !== '' ? profile.setId : activeId
|
|
3219
|
+
if (seenProfileIds.has(wanted + '\u0000' + profile.id)
|
|
3220
|
+
|| seenProfileBodies.has(wanted + '\u0000' + profile.name + '\u0000' + profile.content)) {
|
|
2004
3221
|
skippedProfiles += 1
|
|
2005
3222
|
continue
|
|
2006
3223
|
}
|
|
@@ -2014,9 +3231,9 @@ export function apply(ctx) {
|
|
|
2014
3231
|
for (const field of cut) cutProfileFields.add(field)
|
|
2015
3232
|
}
|
|
2016
3233
|
profile.updatedAt = Date.now()
|
|
2017
|
-
acceptedProfiles.push(profile)
|
|
2018
|
-
seenProfileIds.add(profile.id)
|
|
2019
|
-
seenProfileBodies.add(profile.name + '\u0000' + profile.content)
|
|
3234
|
+
acceptedProfiles.push({ profile, wanted })
|
|
3235
|
+
seenProfileIds.add(wanted + '\u0000' + profile.id)
|
|
3236
|
+
seenProfileBodies.add(wanted + '\u0000' + profile.name + '\u0000' + profile.content)
|
|
2020
3237
|
}
|
|
2021
3238
|
if (accepted.length === 0 && acceptedProfiles.length === 0) {
|
|
2022
3239
|
// Nothing was written to disk, so claiming saved:true would be a lie the
|
|
@@ -2028,11 +3245,45 @@ export function apply(ctx) {
|
|
|
2028
3245
|
snapshot: snapshot(),
|
|
2029
3246
|
}
|
|
2030
3247
|
}
|
|
3248
|
+
// Create only the declared sets that the accepted items actually reference,
|
|
3249
|
+
// in declaration order, while there is room.
|
|
3250
|
+
const wantedIds = []
|
|
3251
|
+
for (const item of [...accepted, ...acceptedProfiles]) {
|
|
3252
|
+
if (!localSetIds.has(item.wanted) && !wantedIds.includes(item.wanted)) wantedIds.push(item.wanted)
|
|
3253
|
+
}
|
|
3254
|
+
const createdSets = []
|
|
3255
|
+
const createdIds = new Set()
|
|
3256
|
+
for (const id of wantedIds) {
|
|
3257
|
+
if (state.data.sets.length + createdSets.length >= MAX_SETS) break
|
|
3258
|
+
const declared = declaredSets.find((set) => set.id === id)
|
|
3259
|
+
if (declared === undefined) continue
|
|
3260
|
+
createdSets.push({ id: declared.id, name: declared.name })
|
|
3261
|
+
createdIds.add(declared.id)
|
|
3262
|
+
}
|
|
3263
|
+
let fellBack = 0
|
|
3264
|
+
const finalIdOf = (wanted) => {
|
|
3265
|
+
if (localSetIds.has(wanted) || createdIds.has(wanted)) return wanted
|
|
3266
|
+
fellBack += 1
|
|
3267
|
+
return activeId
|
|
3268
|
+
}
|
|
2031
3269
|
const saved = await commit(() => {
|
|
2032
|
-
state.data.
|
|
2033
|
-
|
|
3270
|
+
state.data.sets.push(...createdSets)
|
|
3271
|
+
for (const item of accepted) {
|
|
3272
|
+
item.entry.setId = finalIdOf(item.wanted)
|
|
3273
|
+
state.data.entries.push(item.entry)
|
|
3274
|
+
}
|
|
3275
|
+
for (const item of acceptedProfiles) {
|
|
3276
|
+
item.profile.setId = finalIdOf(item.wanted)
|
|
3277
|
+
state.data.profiles.push(item.profile)
|
|
3278
|
+
}
|
|
2034
3279
|
})
|
|
2035
3280
|
const parts = []
|
|
3281
|
+
if (createdSets.length > 0) {
|
|
3282
|
+
parts.push('新建配置集 ' + createdSets.length + ' 个(' + createdSets.map((set) => '「' + set.name + '」').join('、') + ')')
|
|
3283
|
+
}
|
|
3284
|
+
if (fellBack > 0) {
|
|
3285
|
+
parts.push('有 ' + fellBack + ' 项因原配置集不存在或已达 ' + MAX_SETS + ' 个上限,已归入当前配置集')
|
|
3286
|
+
}
|
|
2036
3287
|
if (accepted.length > 0) {
|
|
2037
3288
|
const truncated = truncatedCount > 0
|
|
2038
3289
|
? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
|
|
@@ -2078,6 +3329,43 @@ export function apply(ctx) {
|
|
|
2078
3329
|
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
2079
3330
|
case 'set-options':
|
|
2080
3331
|
return setOptions(args, args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3332
|
+
case 'save-set':
|
|
3333
|
+
return saveSet(
|
|
3334
|
+
args !== null && typeof args === 'object' ? args.set : null,
|
|
3335
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3336
|
+
case 'delete-set':
|
|
3337
|
+
return deleteSet(
|
|
3338
|
+
args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
|
|
3339
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3340
|
+
case 'set-active-set':
|
|
3341
|
+
return setActiveSet(
|
|
3342
|
+
args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
|
|
3343
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3344
|
+
case 'duplicate-set':
|
|
3345
|
+
return duplicateSet(
|
|
3346
|
+
args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
|
|
3347
|
+
args !== null && typeof args === 'object' ? args.name : undefined,
|
|
3348
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3349
|
+
case 'history-list':
|
|
3350
|
+
return historyList()
|
|
3351
|
+
case 'history-restore':
|
|
3352
|
+
return historyRestore(
|
|
3353
|
+
args !== null && typeof args === 'object' && typeof args.name === 'string' ? args.name : '',
|
|
3354
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3355
|
+
case 'history-delete':
|
|
3356
|
+
return historyDelete(args !== null && typeof args === 'object' && typeof args.name === 'string' ? args.name : '')
|
|
3357
|
+
case 'read-source':
|
|
3358
|
+
return readSource()
|
|
3359
|
+
case 'write-source':
|
|
3360
|
+
return writeSource(
|
|
3361
|
+
args !== null && typeof args === 'object' ? args.text : null,
|
|
3362
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3363
|
+
case 'restore-data':
|
|
3364
|
+
return restoreData(
|
|
3365
|
+
args !== null && typeof args === 'object' ? args.payload : null,
|
|
3366
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3367
|
+
case 'export-to-dir':
|
|
3368
|
+
return exportToDir()
|
|
2081
3369
|
case 'reload':
|
|
2082
3370
|
return reload()
|
|
2083
3371
|
case 'export-data':
|