dsh-instruction-memory 1.1.0 → 1.3.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 +168 -0
- package/README.md +208 -14
- package/lib/client.js +1267 -10
- package/lib/index.js +1645 -90
- package/memory.schema.json +173 -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,40 @@ 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
|
+
/**
|
|
88
|
+
* F7: the longest single pause. Doubles as the setTimeout guard — 7 days is
|
|
89
|
+
* far below the 2^31-1 ms (≈24.8 days) timer ceiling, so the delay never
|
|
90
|
+
* overflows Node's timer range.
|
|
91
|
+
*/
|
|
92
|
+
const MAX_MUTE_MS = 7 * 24 * 60 * 60 * 1000
|
|
93
|
+
const HISTORY_DIR_NAME = 'history'
|
|
94
|
+
/** Name of the JSON Schema shipped with the package and copied next to the store. */
|
|
95
|
+
const SCHEMA_FILE_NAME = 'memory.schema.json'
|
|
96
|
+
/** The `$schema` value written into every store file (resolves next to it). */
|
|
97
|
+
const SCHEMA_REF = './' + SCHEMA_FILE_NAME
|
|
98
|
+
/** Upper bound for a user-chosen backup directory path. */
|
|
99
|
+
const MAX_BACKUP_DIR = 512
|
|
100
|
+
const BACKUP_FILE_SUFFIX = '.bak'
|
|
51
101
|
/**
|
|
52
102
|
* Placement of the injected instruction block among the system prompt's
|
|
53
103
|
* ordered sections. Sections are concatenated ascending; equal orders fall
|
|
@@ -128,7 +178,33 @@ const BATCH_OPS = ['set-enabled', 'set-priority', 'add-tags', 'remove-tags', 'de
|
|
|
128
178
|
* wrapper.
|
|
129
179
|
*/
|
|
130
180
|
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)
|
|
181
|
+
+ MAX_PROFILES * (MAX_PROFILE_CONTENT + MAX_PROFILE_NAME + 200)
|
|
182
|
+
+ MAX_SETS * (MAX_SET_NAME + 200)) * 2 + 100_000
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Every numeric ceiling in one object, published on the snapshot and used by
|
|
186
|
+
* verify.mjs to pin `memory.schema.json` (the hand-edit JSON Schema) to the
|
|
187
|
+
* code. Two sources of truth that must never drift: a schema advertising a
|
|
188
|
+
* larger limit than the loader enforces would let a hand edit be silently
|
|
189
|
+
* truncated while the IDE reported it as valid.
|
|
190
|
+
*/
|
|
191
|
+
export const LIMITS = Object.freeze({
|
|
192
|
+
formatVersion: FORMAT_VERSION,
|
|
193
|
+
maxEntries: MAX_ENTRIES,
|
|
194
|
+
maxTitle: MAX_TITLE,
|
|
195
|
+
maxContent: MAX_CONTENT,
|
|
196
|
+
maxWhen: MAX_WHEN,
|
|
197
|
+
maxTagsPerEntry: MAX_TAGS_PER_ENTRY,
|
|
198
|
+
maxTagLength: MAX_TAG_LENGTH,
|
|
199
|
+
maxProfiles: MAX_PROFILES,
|
|
200
|
+
maxProfileName: MAX_PROFILE_NAME,
|
|
201
|
+
maxProfileContent: MAX_PROFILE_CONTENT,
|
|
202
|
+
maxSets: MAX_SETS,
|
|
203
|
+
maxSetName: MAX_SET_NAME,
|
|
204
|
+
minBudget: MIN_BUDGET,
|
|
205
|
+
maxBudget: MAX_BUDGET,
|
|
206
|
+
maxHistory: MAX_HISTORY,
|
|
207
|
+
})
|
|
132
208
|
|
|
133
209
|
/** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
|
|
134
210
|
const LOCK_STALE_MS = 10_000
|
|
@@ -174,6 +250,10 @@ function makeId() {
|
|
|
174
250
|
return 'im-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
|
|
175
251
|
}
|
|
176
252
|
|
|
253
|
+
function makeSetId() {
|
|
254
|
+
return 'st-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
|
|
255
|
+
}
|
|
256
|
+
|
|
177
257
|
function priorityLabel(value) {
|
|
178
258
|
if (value === 2) return '高'
|
|
179
259
|
if (value === 0) return '低'
|
|
@@ -222,10 +302,40 @@ export function sanitizeEntry(raw) {
|
|
|
222
302
|
priority: raw.priority === 2 || raw.priority === 0 ? raw.priority : 1,
|
|
223
303
|
enabled: raw.enabled !== false,
|
|
224
304
|
tags: sanitizeTags(raw.tags),
|
|
305
|
+
/**
|
|
306
|
+
* Which instruction set (F13) this entry belongs to. Preserved here — NOT
|
|
307
|
+
* re-derived — because both the loader and the importer need to honour a
|
|
308
|
+
* declared membership; the interactive save path overwrites it with the
|
|
309
|
+
* active set instead (an edit can never move an entry between sets).
|
|
310
|
+
* `normalizeData` guarantees a valid value on everything this plugin
|
|
311
|
+
* serves, so a blank/absent id on a hand-built object means "unattributed"
|
|
312
|
+
* (see inScope), not "no set".
|
|
313
|
+
*/
|
|
314
|
+
setId: typeof raw.setId === 'string' && raw.setId !== '' ? raw.setId.slice(0, 64) : '',
|
|
225
315
|
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
|
226
316
|
}
|
|
227
317
|
}
|
|
228
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Normalise one instruction set (F13).
|
|
321
|
+
*
|
|
322
|
+
* Shape: { id, name }. A set carries no content of its own — entries and
|
|
323
|
+
* user profiles point AT it through `setId` — so "switching the active set"
|
|
324
|
+
* never moves a byte of user text, which is what makes the operation safe to
|
|
325
|
+
* perform in one transaction. `id` is stable (it is what every item stores);
|
|
326
|
+
* `name` is a short single-line label. A blank name is rejected (null): unlike
|
|
327
|
+
* an entry, a set has nothing else to derive a label from.
|
|
328
|
+
*/
|
|
329
|
+
export function sanitizeSet(raw) {
|
|
330
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
331
|
+
const name = singleLine(raw.name, MAX_SET_NAME)
|
|
332
|
+
if (name === '') return null
|
|
333
|
+
return {
|
|
334
|
+
id: typeof raw.id === 'string' && raw.id !== '' ? raw.id.slice(0, 64) : makeSetId(),
|
|
335
|
+
name,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
229
339
|
/**
|
|
230
340
|
* Normalise one "用户形象" (a fact about the USER, not a role for the model).
|
|
231
341
|
*
|
|
@@ -246,10 +356,124 @@ export function sanitizeProfile(raw) {
|
|
|
246
356
|
name: name === '' ? singleLine(content, 20) : name,
|
|
247
357
|
content,
|
|
248
358
|
enabled: raw.enabled !== false,
|
|
359
|
+
/** Instruction set membership (F13); see the note in sanitizeEntry. */
|
|
360
|
+
setId: typeof raw.setId === 'string' && raw.setId !== '' ? raw.setId.slice(0, 64) : '',
|
|
249
361
|
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
|
250
362
|
}
|
|
251
363
|
}
|
|
252
364
|
|
|
365
|
+
/**
|
|
366
|
+
* The scope decision behind everything that renders or lists items: with a
|
|
367
|
+
* usable `sets` list plus a matching `activeSetId`, only the active set is in
|
|
368
|
+
* scope; without them the store is the legacy single-set shape and everything
|
|
369
|
+
* is in scope.
|
|
370
|
+
*
|
|
371
|
+
* Exported because THREE places must never disagree — the injection planner,
|
|
372
|
+
* the snapshot the settings page renders, and the commands that edit — and a
|
|
373
|
+
* missed filter here is exactly how a 某客户 set would leak into a 个人
|
|
374
|
+
* conversation. One predicate, used three times.
|
|
375
|
+
*
|
|
376
|
+
* The fallback when `activeSetId` names nothing (a hand-edited file): the
|
|
377
|
+
* FIRST set, which is also what normalizeData adopts. That keeps a pure
|
|
378
|
+
* consumer (planInjection called directly, as the tests do) in agreement with
|
|
379
|
+
* the loader instead of inventing a third behaviour.
|
|
380
|
+
*/
|
|
381
|
+
export function activeScope(data) {
|
|
382
|
+
if (data === null || typeof data !== 'object') return { scoped: false, activeId: '', ids: [] }
|
|
383
|
+
const sets = (Array.isArray(data.sets) ? data.sets : [])
|
|
384
|
+
.filter((set) => set !== null && typeof set === 'object' && typeof set.id === 'string' && set.id !== '')
|
|
385
|
+
if (sets.length === 0) return { scoped: false, activeId: '', ids: [] }
|
|
386
|
+
const wanted = typeof data.activeSetId === 'string' && data.activeSetId !== '' ? data.activeSetId : ''
|
|
387
|
+
const activeId = sets.some((set) => set.id === wanted) ? wanted : sets[0].id
|
|
388
|
+
return { scoped: true, activeId, ids: sets.map((set) => set.id) }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Whether one entry/profile takes part under a scope from activeScope().
|
|
393
|
+
* An item with no `setId` counts as belonging to the active set, mirroring
|
|
394
|
+
* the loader's stamping rule — an unattributed item must follow the user's
|
|
395
|
+
* current context rather than disappear from both the list and the prompt.
|
|
396
|
+
* The comparison is exact and case-sensitive: set ids are minted here.
|
|
397
|
+
*/
|
|
398
|
+
export function inScope(item, scope) {
|
|
399
|
+
if (scope === null || typeof scope !== 'object' || scope.scoped !== true) return true
|
|
400
|
+
const id = item !== null && typeof item === 'object' && typeof item.setId === 'string' ? item.setId : ''
|
|
401
|
+
return id === '' || id === scope.activeId
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* ------------------------------------------------------------------ *
|
|
405
|
+
* History snapshot naming (F14) — pure, so the format is pinnable
|
|
406
|
+
* ------------------------------------------------------------------ */
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A filesystem-safe UTC stamp: `20261015T072355123Z`. Fixed width, so plain
|
|
410
|
+
* lexicographic sorting on a filename is chronological sorting — which is what
|
|
411
|
+
* lets the history index sort without reading a single file.
|
|
412
|
+
*/
|
|
413
|
+
export function historyStamp(epoch) {
|
|
414
|
+
const date = new Date(epoch)
|
|
415
|
+
const pad = (value, width) => String(value).padStart(width, '0')
|
|
416
|
+
return pad(date.getUTCFullYear(), 4) + pad(date.getUTCMonth() + 1, 2) + pad(date.getUTCDate(), 2)
|
|
417
|
+
+ 'T' + pad(date.getUTCHours(), 2) + pad(date.getUTCMinutes(), 2) + pad(date.getUTCSeconds(), 2)
|
|
418
|
+
+ pad(date.getUTCMilliseconds(), 3) + 'Z'
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** This machine's short tag, used so two devices sharing one backup folder stay distinguishable. */
|
|
422
|
+
export function deviceTag(rawHostname) {
|
|
423
|
+
const source = typeof rawHostname === 'string' && rawHostname !== '' ? rawHostname : 'unknown'
|
|
424
|
+
const tag = source.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 16)
|
|
425
|
+
return tag === '' ? 'unknown' : tag
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* `20261015T072355123Z-r12-laptop.json` — stamp, revision, writing device.
|
|
430
|
+
* The revision is IN the name so a snapshot is identifiable in a plain file
|
|
431
|
+
* listing, and the device tag is sanitised to `[a-z0-9_]` so it can never be
|
|
432
|
+
* confused with the separators.
|
|
433
|
+
*/
|
|
434
|
+
export function historyFileName(epoch, rev, device) {
|
|
435
|
+
return historyStamp(epoch) + '-r' + (Number.isFinite(rev) ? rev : 0) + '-' + device + '.json'
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* What counts as one of OUR snapshots, and what we are therefore allowed to
|
|
440
|
+
* prune or delete. Anything else in the history directory — a hand-made file,
|
|
441
|
+
* another tool's backup, a cloud client's conflict copy — fails this pattern
|
|
442
|
+
* and is left strictly alone.
|
|
443
|
+
*/
|
|
444
|
+
export const HISTORY_FILE_PATTERN = /^\d{8}T\d{9}Z-r\d+-[a-z0-9_]+(?:\.json)$/
|
|
445
|
+
|
|
446
|
+
/** The device tag inside a snapshot name, or '' when the name is not ours. */
|
|
447
|
+
export function historyDeviceOf(name) {
|
|
448
|
+
if (typeof name !== 'string') return ''
|
|
449
|
+
const match = /-r\d+-([a-z0-9_]+)\.json$/.exec(name)
|
|
450
|
+
return match === null ? '' : match[1]
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The revision inside a snapshot name, or 0 when the name is not ours. */
|
|
454
|
+
export function historyRevOf(name) {
|
|
455
|
+
if (typeof name !== 'string') return 0
|
|
456
|
+
const match = /-r(\d+)-[a-z0-9_]+\.json$/.exec(name)
|
|
457
|
+
return match === null ? 0 : Number(match[1])
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The epoch (ms) from a snapshot name's UTC stamp, or 0 when it is not ours. */
|
|
461
|
+
export function historyEpochOf(name) {
|
|
462
|
+
if (typeof name !== 'string') return 0
|
|
463
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z-r/.exec(name)
|
|
464
|
+
if (match === null) return 0
|
|
465
|
+
return Date.UTC(
|
|
466
|
+
Number(match[1]), Number(match[2]) - 1, Number(match[3]),
|
|
467
|
+
Number(match[4]), Number(match[5]), Number(match[6]), Number(match[7]),
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Exported for the settings page: the stamp a manual export file carries. */
|
|
472
|
+
export function exportFileName(epoch) {
|
|
473
|
+
const stamp = historyStamp(epoch)
|
|
474
|
+
return 'dsh-instruction-memory-' + stamp.slice(0, 8) + '-' + stamp.slice(9, 15) + 'Z.json'
|
|
475
|
+
}
|
|
476
|
+
|
|
253
477
|
/**
|
|
254
478
|
* Which fields of a raw entry had to be cut to fit the stored limits.
|
|
255
479
|
*
|
|
@@ -282,6 +506,15 @@ export function truncatedProfileFields(raw) {
|
|
|
282
506
|
return cut
|
|
283
507
|
}
|
|
284
508
|
|
|
509
|
+
/** Same disclosure, for an instruction set's name. */
|
|
510
|
+
export function truncatedSetFields(raw) {
|
|
511
|
+
if (raw === null || typeof raw !== 'object') return []
|
|
512
|
+
const unlimited = Number.MAX_SAFE_INTEGER
|
|
513
|
+
return typeof raw.name === 'string' && singleLine(raw.name, unlimited) !== singleLine(raw.name, MAX_SET_NAME)
|
|
514
|
+
? ['配置集名称']
|
|
515
|
+
: []
|
|
516
|
+
}
|
|
517
|
+
|
|
285
518
|
/** JSON.stringify keeps quotes/type visible; the String() tail is for values JSON cannot encode. */
|
|
286
519
|
const describeValue = (value) => {
|
|
287
520
|
const text = JSON.stringify(value)
|
|
@@ -300,24 +533,43 @@ const describeValue = (value) => {
|
|
|
300
533
|
* snapshot (another window, another process) can be refused instead of
|
|
301
534
|
* silently overwriting it. Missing (pre-rev files) reads as 0.
|
|
302
535
|
*
|
|
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.
|
|
536
|
+
* Format versions: 1 and 2 both load; anything else numeric sets
|
|
537
|
+
* `rejectedVersion` and leaves the data untouched — a future format's fields
|
|
538
|
+
* cannot be best-effort parsed, and adopting a misread would let the next save
|
|
539
|
+
* rewrite the file in the wrong shape. A MISSING version still loads as v1 —
|
|
540
|
+
* the field has been written as 1 since the first release, so absence is an
|
|
541
|
+
* old/hand-made file, not a foreign format.
|
|
542
|
+
*
|
|
543
|
+
* v1 → v2 (F13) is fully automatic and never loses an item: with no usable
|
|
544
|
+
* `sets` list the store gets one 默认 set, and every entry/profile is stamped
|
|
545
|
+
* with its id. Items already naming a set that does not exist are re-attributed
|
|
546
|
+
* to the active set and COUNTED (`reattributed`), because that is a hand edit
|
|
547
|
+
* this loader had to overrule and the user must be told.
|
|
309
548
|
*
|
|
310
549
|
* `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
|
-
*
|
|
550
|
+
* (a non-boolean `enabled`, an out-of-range or non-numeric `budgetChars`, a
|
|
551
|
+
* non-string/over-long `backupDir`, an `activeSetId` naming no set). Each is a
|
|
552
|
+
* human-readable "field 原值 → 修正值 (原因)" string, surfaced the same way as
|
|
553
|
+
* `dropped`: the next save persists the corrected values, so the user must be
|
|
554
|
+
* told their hand edit did not survive verbatim.
|
|
315
555
|
*/
|
|
316
556
|
function normalizeData(raw) {
|
|
317
|
-
const data = {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
557
|
+
const data = {
|
|
558
|
+
version: FORMAT_VERSION,
|
|
559
|
+
rev: 0,
|
|
560
|
+
enabled: true,
|
|
561
|
+
disabledUntil: 0,
|
|
562
|
+
budgetChars: DEFAULT_BUDGET,
|
|
563
|
+
backupDir: '',
|
|
564
|
+
activeSetId: DEFAULT_SET_ID,
|
|
565
|
+
sets: [],
|
|
566
|
+
entries: [],
|
|
567
|
+
profiles: [],
|
|
568
|
+
}
|
|
569
|
+
const loaded = { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], formatNote: null }
|
|
570
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return loaded
|
|
571
|
+
if (typeof raw.version === 'number' && !SUPPORTED_VERSIONS.includes(raw.version)) {
|
|
572
|
+
return { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], rejectedVersion: raw.version }
|
|
321
573
|
}
|
|
322
574
|
if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
|
|
323
575
|
data.rev = Math.round(raw.rev)
|
|
@@ -325,7 +577,7 @@ function normalizeData(raw) {
|
|
|
325
577
|
// Hand-edited options the loader has to correct are disclosed, not silently
|
|
326
578
|
// adopted: the next save would otherwise rewrite the file with the fixed
|
|
327
579
|
// values and the user would never learn their edit had been ignored.
|
|
328
|
-
const optionFixes =
|
|
580
|
+
const optionFixes = loaded.optionFixes
|
|
329
581
|
if (typeof raw.enabled === 'boolean') {
|
|
330
582
|
data.enabled = raw.enabled
|
|
331
583
|
} else if ('enabled' in raw) {
|
|
@@ -340,21 +592,99 @@ function normalizeData(raw) {
|
|
|
340
592
|
}
|
|
341
593
|
data.budgetChars = clamped
|
|
342
594
|
}
|
|
595
|
+
// Custom backup location (F18). A path is machine-local data, so anything
|
|
596
|
+
// unusable is dropped rather than guessed at — with a disclosure, because a
|
|
597
|
+
// silently ignored path would look like "backups point there" while they do
|
|
598
|
+
// not.
|
|
599
|
+
if (typeof raw.backupDir === 'string') {
|
|
600
|
+
const wanted = raw.backupDir.trim()
|
|
601
|
+
if (wanted.length > MAX_BACKUP_DIR) {
|
|
602
|
+
optionFixes.push('backupDir 超过 ' + MAX_BACKUP_DIR + ' 字符上限 → 已清空')
|
|
603
|
+
} else {
|
|
604
|
+
data.backupDir = wanted
|
|
605
|
+
}
|
|
606
|
+
} else if ('backupDir' in raw && raw.backupDir !== null && raw.backupDir !== '') {
|
|
607
|
+
optionFixes.push('backupDir ' + describeValue(raw.backupDir) + ' → ""(需要字符串路径)')
|
|
608
|
+
}
|
|
609
|
+
// F7 temporary mute. The field is OPTIONAL (the format stays v2): missing
|
|
610
|
+
// means "not paused" and is adopted silently, exactly like a v1 file without
|
|
611
|
+
// sets. Present but not a usable instant is reset to 0 with a disclosure,
|
|
612
|
+
// like every other hand-edited option.
|
|
613
|
+
if ('disabledUntil' in raw) {
|
|
614
|
+
const muteValue = raw.disabledUntil
|
|
615
|
+
if (typeof muteValue === 'number' && Number.isFinite(muteValue) && muteValue >= 0) {
|
|
616
|
+
data.disabledUntil = Math.round(muteValue)
|
|
617
|
+
} else {
|
|
618
|
+
optionFixes.push('disabledUntil ' + describeValue(muteValue) + ' → 0(需要 ≥ 0 的 epoch 毫秒数,0 表示未暂停)')
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Instruction sets (F13). A missing/blank list means a v1 file (or a hand
|
|
623
|
+
// edit that removed it): synthesise the single 默认 set and record the fact,
|
|
624
|
+
// so the panel can say what happened instead of appearing to have invented a
|
|
625
|
+
// set out of nowhere.
|
|
626
|
+
const rawSets = Array.isArray(raw.sets) ? raw.sets : []
|
|
627
|
+
const seenSetIds = new Set()
|
|
628
|
+
let synthesized = false
|
|
629
|
+
for (const candidate of rawSets) {
|
|
630
|
+
const set = sanitizeSet(candidate)
|
|
631
|
+
if (set === null || data.sets.length >= MAX_SETS) {
|
|
632
|
+
loaded.droppedSets += 1
|
|
633
|
+
continue
|
|
634
|
+
}
|
|
635
|
+
if (seenSetIds.has(set.id)) set.id = makeSetId()
|
|
636
|
+
seenSetIds.add(set.id)
|
|
637
|
+
data.sets.push(set)
|
|
638
|
+
}
|
|
639
|
+
if (data.sets.length === 0) {
|
|
640
|
+
data.sets.push({ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME })
|
|
641
|
+
synthesized = true
|
|
642
|
+
}
|
|
643
|
+
const rawActive = typeof raw.activeSetId === 'string' ? raw.activeSetId.trim() : ''
|
|
644
|
+
if (rawActive !== '' && data.sets.some((set) => set.id === rawActive)) {
|
|
645
|
+
data.activeSetId = rawActive
|
|
646
|
+
} else {
|
|
647
|
+
data.activeSetId = data.sets[0].id
|
|
648
|
+
if (rawActive !== '') {
|
|
649
|
+
optionFixes.push('activeSetId ' + describeValue(raw.activeSetId) + ' → ' + data.activeSetId + '(没有该配置集)')
|
|
650
|
+
} else if ('activeSetId' in raw && raw.activeSetId !== null) {
|
|
651
|
+
optionFixes.push('activeSetId ' + describeValue(raw.activeSetId) + ' → ' + data.activeSetId + '(需要配置集 id 字符串)')
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const knownSet = (id) => data.sets.some((set) => set.id === id)
|
|
655
|
+
// A silent load is the normal path on a current-format file; everything else
|
|
656
|
+
// is stated outright, because both branches change what the NEXT save writes.
|
|
657
|
+
const declaredVersion = typeof raw.version === 'number' ? raw.version : null
|
|
658
|
+
if (synthesized) {
|
|
659
|
+
loaded.formatNote = '存储文件未包含配置集信息(' + (declaredVersion !== null ? '格式版本 ' + declaredVersion : '未声明格式版本')
|
|
660
|
+
+ '),其中的条目与形象已归入「' + DEFAULT_SET_NAME + '」配置集;下次保存时文件将写为格式版本 ' + FORMAT_VERSION
|
|
661
|
+
} else if (declaredVersion !== FORMAT_VERSION) {
|
|
662
|
+
loaded.formatNote = declaredVersion === null
|
|
663
|
+
? '存储文件未声明格式版本,已按格式版本 ' + FORMAT_VERSION + ' 载入;下次保存时文件将补写版本号'
|
|
664
|
+
: '存储文件为旧版格式(版本 ' + declaredVersion + '),已按格式版本 ' + FORMAT_VERSION + ' 载入;下次保存时文件将写为版本 ' + FORMAT_VERSION
|
|
665
|
+
}
|
|
666
|
+
|
|
343
667
|
const list = Array.isArray(raw.entries) ? raw.entries : []
|
|
344
668
|
const seen = new Set()
|
|
345
|
-
let dropped = 0
|
|
346
669
|
for (const candidate of list) {
|
|
347
670
|
const entry = sanitizeEntry(candidate)
|
|
348
671
|
if (entry === null) {
|
|
349
|
-
dropped += 1
|
|
672
|
+
loaded.dropped += 1
|
|
350
673
|
continue
|
|
351
674
|
}
|
|
352
675
|
if (data.entries.length >= MAX_ENTRIES) {
|
|
353
|
-
dropped += 1
|
|
676
|
+
loaded.dropped += 1
|
|
354
677
|
continue
|
|
355
678
|
}
|
|
356
679
|
if (seen.has(entry.id)) entry.id = makeId()
|
|
357
680
|
seen.add(entry.id)
|
|
681
|
+
if (entry.setId === '' || !knownSet(entry.setId)) {
|
|
682
|
+
// A blank setId is simply an unfiled item (v1 file, hand-added entry) and
|
|
683
|
+
// files silently; a NON-blank id naming a set that does not exist is a
|
|
684
|
+
// broken reference the loader overrules, and that is counted.
|
|
685
|
+
if (entry.setId !== '') loaded.reattributed += 1
|
|
686
|
+
entry.setId = data.activeSetId
|
|
687
|
+
}
|
|
358
688
|
data.entries.push(entry)
|
|
359
689
|
}
|
|
360
690
|
// User profiles ("用户形象") follow the same tolerant rules: a missing
|
|
@@ -363,18 +693,21 @@ function normalizeData(raw) {
|
|
|
363
693
|
// silently (the next save rewrites the file without them).
|
|
364
694
|
const profileList = Array.isArray(raw.profiles) ? raw.profiles : []
|
|
365
695
|
const seenProfileIds = new Set()
|
|
366
|
-
let droppedProfiles = 0
|
|
367
696
|
for (const candidate of profileList) {
|
|
368
697
|
const profile = sanitizeProfile(candidate)
|
|
369
698
|
if (profile === null || data.profiles.length >= MAX_PROFILES) {
|
|
370
|
-
droppedProfiles += 1
|
|
699
|
+
loaded.droppedProfiles += 1
|
|
371
700
|
continue
|
|
372
701
|
}
|
|
373
702
|
if (seenProfileIds.has(profile.id)) profile.id = makeId()
|
|
374
703
|
seenProfileIds.add(profile.id)
|
|
704
|
+
if (profile.setId === '' || !knownSet(profile.setId)) {
|
|
705
|
+
if (profile.setId !== '') loaded.reattributed += 1
|
|
706
|
+
profile.setId = data.activeSetId
|
|
707
|
+
}
|
|
375
708
|
data.profiles.push(profile)
|
|
376
709
|
}
|
|
377
|
-
return
|
|
710
|
+
return loaded
|
|
378
711
|
}
|
|
379
712
|
|
|
380
713
|
/**
|
|
@@ -388,10 +721,16 @@ function normalizeData(raw) {
|
|
|
388
721
|
*/
|
|
389
722
|
function cloneData(data) {
|
|
390
723
|
return {
|
|
391
|
-
version:
|
|
724
|
+
version: FORMAT_VERSION,
|
|
392
725
|
rev: data.rev || 0,
|
|
393
726
|
enabled: data.enabled === true,
|
|
727
|
+
disabledUntil: typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil) && data.disabledUntil > 0
|
|
728
|
+
? data.disabledUntil
|
|
729
|
+
: 0,
|
|
394
730
|
budgetChars: data.budgetChars,
|
|
731
|
+
backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
|
|
732
|
+
activeSetId: data.activeSetId,
|
|
733
|
+
sets: (Array.isArray(data.sets) ? data.sets : []).map((set) => ({ ...set })),
|
|
395
734
|
entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
|
|
396
735
|
profiles: (Array.isArray(data.profiles) ? data.profiles : []).map((profile) => ({ ...profile })),
|
|
397
736
|
}
|
|
@@ -407,6 +746,16 @@ const droppedProfilesMessage = (count) =>
|
|
|
407
746
|
? '存储文件中有 ' + count + ' 个用户形象未能载入(名称与内容同时为空或超过 ' + MAX_PROFILES + ' 个上限);下次保存时它们将从文件中移除'
|
|
408
747
|
: null
|
|
409
748
|
|
|
749
|
+
const droppedSetsMessage = (count) =>
|
|
750
|
+
count > 0
|
|
751
|
+
? '存储文件中有 ' + count + ' 个配置集未能载入(名称为空或超过 ' + MAX_SETS + ' 个上限);下次保存时它们将从文件中移除,其条目会归入当前配置集'
|
|
752
|
+
: null
|
|
753
|
+
|
|
754
|
+
const reattributedMessage = (count) =>
|
|
755
|
+
count > 0
|
|
756
|
+
? '存储文件中有 ' + count + ' 项(条目或形象)指向不存在的配置集,已归入当前配置集'
|
|
757
|
+
: null
|
|
758
|
+
|
|
410
759
|
/**
|
|
411
760
|
* The load-time disclosures for one normalizeData result: dropped entries and
|
|
412
761
|
* corrected hand-edited options, joined with a full-width semicolon.
|
|
@@ -415,10 +764,15 @@ const droppedProfilesMessage = (count) =>
|
|
|
415
764
|
*/
|
|
416
765
|
const loadWarnings = (loaded) => {
|
|
417
766
|
const parts = []
|
|
767
|
+
if (typeof loaded.formatNote === 'string' && loaded.formatNote !== '') parts.push(loaded.formatNote)
|
|
418
768
|
const dropped = droppedEntriesMessage(loaded.dropped)
|
|
419
769
|
if (dropped !== null) parts.push(dropped)
|
|
420
770
|
const droppedProfiles = droppedProfilesMessage(loaded.droppedProfiles || 0)
|
|
421
771
|
if (droppedProfiles !== null) parts.push(droppedProfiles)
|
|
772
|
+
const droppedSets = droppedSetsMessage(loaded.droppedSets || 0)
|
|
773
|
+
if (droppedSets !== null) parts.push(droppedSets)
|
|
774
|
+
const reattributed = reattributedMessage(loaded.reattributed || 0)
|
|
775
|
+
if (reattributed !== null) parts.push(reattributed)
|
|
422
776
|
if (Array.isArray(loaded.optionFixes) && loaded.optionFixes.length > 0) {
|
|
423
777
|
parts.push('存储文件中的以下选项值无效,已按规则修正:' + loaded.optionFixes.join(';') + ';下次保存时修正值将写入文件')
|
|
424
778
|
}
|
|
@@ -813,15 +1167,58 @@ function planEntries(data, budget) {
|
|
|
813
1167
|
* - a profile-only store (no instructions) still injects, and the master
|
|
814
1168
|
* `enabled` switch suppresses both halves as before.
|
|
815
1169
|
*/
|
|
816
|
-
|
|
1170
|
+
/**
|
|
1171
|
+
* F7 temporary mute: a store-level pause with an expiry. `disabledUntil` is an
|
|
1172
|
+
* epoch-ms instant; while it lies in the future (and the master switch is on)
|
|
1173
|
+
* the store is muted. The wall clock is only the DEFAULT: both this and the
|
|
1174
|
+
* render entry points take `now`, so a fixed clock reproduces the same bytes
|
|
1175
|
+
* and tests pin the behaviour without waiting.
|
|
1176
|
+
*/
|
|
1177
|
+
export function isMuted(data, now = Date.now()) {
|
|
1178
|
+
return data !== null && typeof data === 'object' && data.enabled === true
|
|
1179
|
+
&& typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
|
|
1180
|
+
&& data.disabledUntil > now
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* The lapsed-mute decision the host timer acts on: a mute whose instant has
|
|
1185
|
+
* passed needs the section re-registered (and a snapshot pushed), but writes
|
|
1186
|
+
* NOTHING — expiry is a state change, not a save; the store file and its rev
|
|
1187
|
+
* stay untouched, and the stale value is simply ignored until the next real
|
|
1188
|
+
* write rewrites the file without it.
|
|
1189
|
+
*/
|
|
1190
|
+
export function muteExpired(data, now = Date.now()) {
|
|
1191
|
+
return data !== null && typeof data === 'object' && data.enabled === true
|
|
1192
|
+
&& typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
|
|
1193
|
+
&& data.disabledUntil > 0 && data.disabledUntil <= now
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export function planInjection(data, now = Date.now()) {
|
|
817
1197
|
const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
|
|
818
1198
|
const blank = () => ({
|
|
819
1199
|
text: '', budget, activeCount: 0, included: [], omitted: [],
|
|
820
1200
|
profiles: [], profileChars: 0, entryChars: 0,
|
|
821
1201
|
})
|
|
822
1202
|
if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
|
|
823
|
-
|
|
824
|
-
|
|
1203
|
+
// F7 temporary mute: while the pause is live the plan is EXACTLY the
|
|
1204
|
+
// enabled:false plan — same fields, same empty text — so the prompt section
|
|
1205
|
+
// disappears and the prompt is byte-identical to a deployment without this
|
|
1206
|
+
// plugin.
|
|
1207
|
+
if (isMuted(data, now)) return blank()
|
|
1208
|
+
/**
|
|
1209
|
+
* The set filter (F13) is applied HERE, at the one place both the rendered
|
|
1210
|
+
* text and the budget decision are produced, so no caller can inject an
|
|
1211
|
+
* inactive set by accident. A store with no `sets` at all is the legacy
|
|
1212
|
+
* single-set shape and is injected whole (see activeScope).
|
|
1213
|
+
*/
|
|
1214
|
+
const scope = activeScope(data)
|
|
1215
|
+
const scoped = {
|
|
1216
|
+
...data,
|
|
1217
|
+
entries: (Array.isArray(data.entries) ? data.entries : []).filter((entry) => inScope(entry, scope)),
|
|
1218
|
+
profiles: (Array.isArray(data.profiles) ? data.profiles : []).filter((profile) => inScope(profile, scope)),
|
|
1219
|
+
}
|
|
1220
|
+
const persona = buildProfilesBlock(scoped.profiles)
|
|
1221
|
+
const entryPlan = planEntries(scoped, budget)
|
|
825
1222
|
const parts = []
|
|
826
1223
|
if (persona.text !== '') parts.push(persona.text)
|
|
827
1224
|
if (entryPlan.text !== '') parts.push(entryPlan.text)
|
|
@@ -842,8 +1239,8 @@ export function planInjection(data) {
|
|
|
842
1239
|
* decision process lives in one place, and callers that only need the text
|
|
843
1240
|
* (the prompt section) keep this byte-stable entry point.
|
|
844
1241
|
*/
|
|
845
|
-
export function buildBlock(data) {
|
|
846
|
-
return planInjection(data).text
|
|
1242
|
+
export function buildBlock(data, now = Date.now()) {
|
|
1243
|
+
return planInjection(data, now).text
|
|
847
1244
|
}
|
|
848
1245
|
|
|
849
1246
|
/* ------------------------------------------------------------------ *
|
|
@@ -854,13 +1251,29 @@ export function apply(ctx) {
|
|
|
854
1251
|
const systemPrompt = ctx.systemPrompt
|
|
855
1252
|
|
|
856
1253
|
const state = {
|
|
857
|
-
data: {
|
|
1254
|
+
data: {
|
|
1255
|
+
version: FORMAT_VERSION,
|
|
1256
|
+
rev: 0,
|
|
1257
|
+
enabled: true,
|
|
1258
|
+
budgetChars: DEFAULT_BUDGET,
|
|
1259
|
+
backupDir: '',
|
|
1260
|
+
activeSetId: DEFAULT_SET_ID,
|
|
1261
|
+
sets: [{ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME }],
|
|
1262
|
+
entries: [],
|
|
1263
|
+
profiles: [],
|
|
1264
|
+
},
|
|
858
1265
|
storagePath: null,
|
|
859
1266
|
storageDisplay: null,
|
|
860
1267
|
storageError: null,
|
|
861
1268
|
storageWarning: null,
|
|
862
1269
|
/** A failed rolling-backup refresh, shown only when no load warning wins. */
|
|
863
1270
|
backupError: null,
|
|
1271
|
+
/**
|
|
1272
|
+
* A failed history snapshot (F14). Non-fatal by design — the store itself
|
|
1273
|
+
* was written — but shown, because "history is being kept" is a promise the
|
|
1274
|
+
* panel makes on this feature's behalf.
|
|
1275
|
+
*/
|
|
1276
|
+
historyError: null,
|
|
864
1277
|
/** Which branch of the load actually produced the current data (diagnostics). */
|
|
865
1278
|
loadedFrom: null,
|
|
866
1279
|
savedAt: null,
|
|
@@ -876,8 +1289,30 @@ export function apply(ctx) {
|
|
|
876
1289
|
sectionError: null,
|
|
877
1290
|
/** Whether a store file is actually present on disk (diagnostics + the panel). */
|
|
878
1291
|
storeExists: false,
|
|
1292
|
+
/** How many history snapshots the current directory holds (diagnostics). */
|
|
1293
|
+
historyCount: 0,
|
|
1294
|
+
/**
|
|
1295
|
+
* The address the settings route is actually bound to (F12 security panel).
|
|
1296
|
+
* `''` until a web server publishes; the panel turns it into a warning when
|
|
1297
|
+
* the bind is not loopback.
|
|
1298
|
+
*/
|
|
1299
|
+
boundHost: '',
|
|
879
1300
|
}
|
|
880
1301
|
let sectionDispose = null
|
|
1302
|
+
/**
|
|
1303
|
+
* Which Host values this route accepts. Set when the route is registered,
|
|
1304
|
+
* from the address the web server is actually bound to (see the long note on
|
|
1305
|
+
* requestOriginVerdict). Until then the route does not exist at all, so the
|
|
1306
|
+
* default only matters if something registers it out of band. Declared next
|
|
1307
|
+
* to `state` so the snapshot can publish it at any time without a
|
|
1308
|
+
* temporal-dead-zone hazard (F12 publishes it as `security.hostGuarded`).
|
|
1309
|
+
*/
|
|
1310
|
+
let guardHost = true
|
|
1311
|
+
/**
|
|
1312
|
+
* F7: the pending mute-expiry timer, or null. Re-armed by scheduleMuteTimer
|
|
1313
|
+
* (from syncSection) and cleared in the effect cleanup.
|
|
1314
|
+
*/
|
|
1315
|
+
let muteTimer = null
|
|
881
1316
|
|
|
882
1317
|
/**
|
|
883
1318
|
* A first run deliberately writes NOTHING (see readFromDisk step 3). This flag
|
|
@@ -958,7 +1393,7 @@ export function apply(ctx) {
|
|
|
958
1393
|
|
|
959
1394
|
const sectionText = () => {
|
|
960
1395
|
try {
|
|
961
|
-
return buildBlock(state.data)
|
|
1396
|
+
return buildBlock(state.data, Date.now())
|
|
962
1397
|
} catch (err) {
|
|
963
1398
|
console.error('[instruction-memory] render failed:', err)
|
|
964
1399
|
return ''
|
|
@@ -974,7 +1409,7 @@ export function apply(ctx) {
|
|
|
974
1409
|
*/
|
|
975
1410
|
const sectionPlan = () => {
|
|
976
1411
|
try {
|
|
977
|
-
return planInjection(state.data)
|
|
1412
|
+
return planInjection(state.data, Date.now())
|
|
978
1413
|
} catch (err) {
|
|
979
1414
|
console.error('[instruction-memory] render failed:', err)
|
|
980
1415
|
return {
|
|
@@ -999,6 +1434,37 @@ export function apply(ctx) {
|
|
|
999
1434
|
return sectionText()
|
|
1000
1435
|
}
|
|
1001
1436
|
|
|
1437
|
+
/**
|
|
1438
|
+
* F7: keep one host-side timer pointed at the mute's expiry. Expiry must be
|
|
1439
|
+
* automatic — no user action, no write: when the instant passes, the section
|
|
1440
|
+
* re-registers (or stays dropped) and open panels get a fresh snapshot,
|
|
1441
|
+
* while the store file and its rev stay untouched. syncSection re-arms it
|
|
1442
|
+
* on every state change (boot, commit, external reload), and re-arming
|
|
1443
|
+
* clears the previous timer first, so there is never more than one.
|
|
1444
|
+
*/
|
|
1445
|
+
const scheduleMuteTimer = () => {
|
|
1446
|
+
if (muteTimer !== null) {
|
|
1447
|
+
clearTimeout(muteTimer)
|
|
1448
|
+
muteTimer = null
|
|
1449
|
+
}
|
|
1450
|
+
if (!isMuted(state.data)) return
|
|
1451
|
+
const delay = Math.min(state.data.disabledUntil - Date.now(), MAX_MUTE_MS)
|
|
1452
|
+
muteTimer = setTimeout(() => {
|
|
1453
|
+
muteTimer = null
|
|
1454
|
+
// Clock skew or an early fire: if the mute is still live, re-arm instead
|
|
1455
|
+
// of acting; the sync below happens only on a real expiry.
|
|
1456
|
+
if (isMuted(state.data)) {
|
|
1457
|
+
scheduleMuteTimer()
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
syncSection()
|
|
1461
|
+
pushSnapshot()
|
|
1462
|
+
logDebug('mute expired: section re-synced without a write')
|
|
1463
|
+
}, Math.max(1, delay))
|
|
1464
|
+
// A 7-day timer must not hold a shutting-down process open.
|
|
1465
|
+
if (typeof muteTimer.unref === 'function') muteTimer.unref()
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1002
1468
|
/**
|
|
1003
1469
|
* Keep the registered section exactly in step with the stored data: present
|
|
1004
1470
|
* when something must be injected, absent otherwise. An empty memory must
|
|
@@ -1018,9 +1484,7 @@ export function apply(ctx) {
|
|
|
1018
1484
|
state.sectionError = '注入注册失败:' + describeError(err)
|
|
1019
1485
|
console.error('[instruction-memory] section registration failed:', err)
|
|
1020
1486
|
}
|
|
1021
|
-
|
|
1022
|
-
}
|
|
1023
|
-
if (!wanted) {
|
|
1487
|
+
} else if (!wanted) {
|
|
1024
1488
|
if (sectionDispose !== null) {
|
|
1025
1489
|
const dispose = sectionDispose
|
|
1026
1490
|
sectionDispose = null
|
|
@@ -1034,6 +1498,9 @@ export function apply(ctx) {
|
|
|
1034
1498
|
state.sectionRegistered = false
|
|
1035
1499
|
state.sectionError = null
|
|
1036
1500
|
}
|
|
1501
|
+
// F7: whatever this sync just published, the mute timer must point at
|
|
1502
|
+
// exactly this state (and re-arming clears the previous timer first).
|
|
1503
|
+
scheduleMuteTimer()
|
|
1037
1504
|
}
|
|
1038
1505
|
|
|
1039
1506
|
ctx.effect(() => () => {
|
|
@@ -1046,6 +1513,10 @@ export function apply(ctx) {
|
|
|
1046
1513
|
console.error('[instruction-memory] cleanup failed:', err)
|
|
1047
1514
|
}
|
|
1048
1515
|
}
|
|
1516
|
+
if (muteTimer !== null) {
|
|
1517
|
+
clearTimeout(muteTimer)
|
|
1518
|
+
muteTimer = null
|
|
1519
|
+
}
|
|
1049
1520
|
stopWatching()
|
|
1050
1521
|
disposeStreams()
|
|
1051
1522
|
})
|
|
@@ -1165,7 +1636,50 @@ export function apply(ctx) {
|
|
|
1165
1636
|
|
|
1166
1637
|
const storePath = resolveStorePath()
|
|
1167
1638
|
|
|
1168
|
-
|
|
1639
|
+
/**
|
|
1640
|
+
* The exact object written to memory.json — and the one an export carries.
|
|
1641
|
+
*
|
|
1642
|
+
* `$schema` comes first on purpose: opening the file in an editor then offers
|
|
1643
|
+
* validation and field completion against the copy of memory.schema.json the
|
|
1644
|
+
* host keeps next to the store (see ensureSchemaFile). The loader ignores the
|
|
1645
|
+
* key, so it costs nothing to anyone who never opens the file.
|
|
1646
|
+
*/
|
|
1647
|
+
const persistableData = () => ({
|
|
1648
|
+
$schema: SCHEMA_REF,
|
|
1649
|
+
version: FORMAT_VERSION,
|
|
1650
|
+
rev: state.data.rev || 0,
|
|
1651
|
+
enabled: state.data.enabled === true,
|
|
1652
|
+
disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
|
|
1653
|
+
? state.data.disabledUntil
|
|
1654
|
+
: 0,
|
|
1655
|
+
budgetChars: clampBudget(state.data.budgetChars),
|
|
1656
|
+
backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
|
|
1657
|
+
activeSetId: state.data.activeSetId,
|
|
1658
|
+
sets: (Array.isArray(state.data.sets) ? state.data.sets : []).map((set) => ({ ...set })),
|
|
1659
|
+
entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
|
|
1660
|
+
profiles: (Array.isArray(state.data.profiles) ? state.data.profiles : []).map((profile) => ({ ...profile })),
|
|
1661
|
+
})
|
|
1662
|
+
|
|
1663
|
+
const serialize = () => JSON.stringify(persistableData(), null, 2) + '\n'
|
|
1664
|
+
|
|
1665
|
+
/* ---------------- backup location + history snapshots (F14 / F18) ---------------- */
|
|
1666
|
+
|
|
1667
|
+
const backupDirOf = () => (typeof state.data.backupDir === 'string' ? state.data.backupDir : '')
|
|
1668
|
+
/** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
|
|
1669
|
+
const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
|
|
1670
|
+
/** The user-facing copy under a custom backup location, or null (F18). */
|
|
1671
|
+
const customBackupPath = () => (backupDirOf() !== '' ? join(backupDirOf(), basename(storePath) + BACKUP_FILE_SUFFIX) : null)
|
|
1672
|
+
/**
|
|
1673
|
+
* History snapshots live in a `history/` SUBDIRECTORY of the backup location
|
|
1674
|
+
* (default: the store directory). Two reasons for the subdirectory:
|
|
1675
|
+
* 1. the external-change watcher listens on the store directory and must
|
|
1676
|
+
* never see a snapshot as a change to memory.json — a subdirectory is
|
|
1677
|
+
* not reported by a non-recursive watch, and the filename filter drops
|
|
1678
|
+
* anything that is not memory.json anyway;
|
|
1679
|
+
* 2. a custom (e.g. cloud-synced) backup directory stays tidy: one known
|
|
1680
|
+
* folder holds the history, nothing else is touched.
|
|
1681
|
+
*/
|
|
1682
|
+
const historyDirPath = () => join(backupDirOf() !== '' ? backupDirOf() : dirname(storePath), HISTORY_DIR_NAME)
|
|
1169
1683
|
|
|
1170
1684
|
/**
|
|
1171
1685
|
* Cross-process write lock: a `memory.json.lock` marker created exclusively
|
|
@@ -1234,6 +1748,151 @@ export function apply(ctx) {
|
|
|
1234
1748
|
}
|
|
1235
1749
|
}
|
|
1236
1750
|
|
|
1751
|
+
/* ---------------- custom backup location (F18) ---------------- */
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* Can this directory actually hold backups right now? Setting a path is a
|
|
1755
|
+
* promise ("my backups live there"), so it is verified at the moment it is
|
|
1756
|
+
* set — exists, is a directory, and accepts a write — instead of being
|
|
1757
|
+
* discovered to be wrong by the first save that silently failed to copy.
|
|
1758
|
+
*
|
|
1759
|
+
* @returns null when usable, otherwise a human-readable reason.
|
|
1760
|
+
*/
|
|
1761
|
+
const probeBackupDir = async (dir) => {
|
|
1762
|
+
try {
|
|
1763
|
+
const info = await stat(dir)
|
|
1764
|
+
if (!info.isDirectory()) return '该路径不是目录'
|
|
1765
|
+
} catch {
|
|
1766
|
+
return '目录不存在或不可访问'
|
|
1767
|
+
}
|
|
1768
|
+
const probe = join(dir, '.dsh-instruction-memory-probe-' + randomUUID())
|
|
1769
|
+
try {
|
|
1770
|
+
await writeFile(probe, 'probe', 'utf8')
|
|
1771
|
+
await unlink(probe).catch(() => {})
|
|
1772
|
+
return null
|
|
1773
|
+
} catch (err) {
|
|
1774
|
+
return '目录不可写:' + describeError(err)
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/* ---------------- history snapshots and the schema copy (both best-effort) ---------------- */
|
|
1779
|
+
|
|
1780
|
+
/** This machine's tag inside snapshot filenames; see deviceTag(). */
|
|
1781
|
+
const DEVICE = deviceTag((() => {
|
|
1782
|
+
try {
|
|
1783
|
+
return hostname()
|
|
1784
|
+
} catch {
|
|
1785
|
+
return ''
|
|
1786
|
+
}
|
|
1787
|
+
})())
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* The bytes of the most recent snapshot, so two writes that produce the same
|
|
1791
|
+
* file (a no-op-ish save, a repeat restore) do not each mint a "version".
|
|
1792
|
+
*/
|
|
1793
|
+
let lastHistoryText = null
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Enforce the per-device retention limit. Grouping by the device tag inside
|
|
1797
|
+
* the filename is deliberate: two machines sharing one backup folder (the
|
|
1798
|
+
* documented F18 setup) each keep MAX_HISTORY of their OWN snapshots, so one
|
|
1799
|
+
* device can never prune the other's history away. Files that do not match
|
|
1800
|
+
* our naming pattern are never touched.
|
|
1801
|
+
*/
|
|
1802
|
+
const pruneHistory = async (dir) => {
|
|
1803
|
+
const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
|
|
1804
|
+
const byDevice = new Map()
|
|
1805
|
+
for (const name of names) {
|
|
1806
|
+
const key = historyDeviceOf(name)
|
|
1807
|
+
const bucket = byDevice.get(key)
|
|
1808
|
+
if (bucket === undefined) byDevice.set(key, [name])
|
|
1809
|
+
else bucket.push(name)
|
|
1810
|
+
}
|
|
1811
|
+
let removed = 0
|
|
1812
|
+
let kept = 0
|
|
1813
|
+
for (const bucket of byDevice.values()) {
|
|
1814
|
+
// Fixed-width UTC stamp first => lexicographic order is chronological.
|
|
1815
|
+
bucket.sort()
|
|
1816
|
+
const excess = bucket.length - MAX_HISTORY
|
|
1817
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
1818
|
+
if (index < excess) {
|
|
1819
|
+
await unlink(join(dir, bucket[index])).catch(() => {})
|
|
1820
|
+
removed += 1
|
|
1821
|
+
} else {
|
|
1822
|
+
kept += 1
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
return { removed, kept }
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Record the just-written store as a new history snapshot (F14).
|
|
1831
|
+
*
|
|
1832
|
+
* Written AFTER the atomic replace, so a snapshot always equals a state that
|
|
1833
|
+
* really was committed; a failure here is disclosed and never fails the save,
|
|
1834
|
+
* because the store itself is already safely on disk.
|
|
1835
|
+
*/
|
|
1836
|
+
const writeHistorySnapshot = async (text) => {
|
|
1837
|
+
if (typeof text !== 'string' || text === '' || text === lastHistoryText) return
|
|
1838
|
+
try {
|
|
1839
|
+
const dir = historyDirPath()
|
|
1840
|
+
await mkdir(dir, { recursive: true })
|
|
1841
|
+
const epoch = Date.now()
|
|
1842
|
+
let written = false
|
|
1843
|
+
// `wx` never overwrites: same-millisecond saves (two rapid bulk actions)
|
|
1844
|
+
// bump the stamp by a millisecond instead of losing one of the two.
|
|
1845
|
+
for (let attempt = 0; attempt < 5 && !written; attempt += 1) {
|
|
1846
|
+
try {
|
|
1847
|
+
await writeFile(join(dir, historyFileName(epoch + attempt, state.data.rev || 0, DEVICE)), text, { encoding: 'utf8', flag: 'wx' })
|
|
1848
|
+
written = true
|
|
1849
|
+
} catch (err) {
|
|
1850
|
+
if (err.code !== 'EEXIST') throw err
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
if (!written) throw new Error('快照文件名重复')
|
|
1854
|
+
lastHistoryText = text
|
|
1855
|
+
const { kept } = await pruneHistory(dir)
|
|
1856
|
+
state.historyCount = kept
|
|
1857
|
+
state.historyError = null
|
|
1858
|
+
} catch (err) {
|
|
1859
|
+
state.historyError = '版本历史快照未能写入(' + describeError(err) + ');本次保存本身已完成'
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* Keep a copy of the shipped JSON Schema next to the store, so the `$schema`
|
|
1865
|
+
* reference inside memory.json resolves for a hand editor who has no idea
|
|
1866
|
+
* where the package lives. Content-compared: one small read on a save, a
|
|
1867
|
+
* write only when the shipped schema actually changed. Never throws — the
|
|
1868
|
+
* schema is a convenience, not a precondition for holding memory.
|
|
1869
|
+
*/
|
|
1870
|
+
const ensureSchemaFile = async () => {
|
|
1871
|
+
try {
|
|
1872
|
+
const source = await readFile(fileURLToPath(new URL('../' + SCHEMA_FILE_NAME, import.meta.url)), 'utf8')
|
|
1873
|
+
const target = join(dirname(storePath), SCHEMA_FILE_NAME)
|
|
1874
|
+
const existing = await readFile(target, 'utf8').catch(() => null)
|
|
1875
|
+
if (existing !== source) await writeFile(target, source, 'utf8')
|
|
1876
|
+
} catch {
|
|
1877
|
+
// Best effort by design.
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
/**
|
|
1882
|
+
* Recount the snapshot directory for the panel's diagnostics. Best effort:
|
|
1883
|
+
* an unreadable directory simply leaves the count as it was, because the
|
|
1884
|
+
* history list (the actual UI, which reports its own errors) is what the
|
|
1885
|
+
* user acts on.
|
|
1886
|
+
*/
|
|
1887
|
+
const refreshHistoryCount = async () => {
|
|
1888
|
+
try {
|
|
1889
|
+
const names = await readdir(historyDirPath())
|
|
1890
|
+
state.historyCount = names.filter((name) => HISTORY_FILE_PATTERN.test(name)).length
|
|
1891
|
+
} catch {
|
|
1892
|
+
// No directory yet (nothing written) — count stays 0.
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1237
1896
|
const writeToDisk = async () => {
|
|
1238
1897
|
// Atomic replace (write a sibling temp file, then rename) with a rolling
|
|
1239
1898
|
// one-version backup: a crash mid-write must never leave a truncated
|
|
@@ -1249,7 +1908,6 @@ export function apply(ctx) {
|
|
|
1249
1908
|
// the primary in place until the atomic replace succeeds, so a failure at
|
|
1250
1909
|
// any step leaves the previous store readable at its own path.
|
|
1251
1910
|
const tmpPath = storePath + '.tmp'
|
|
1252
|
-
const bakPath = storePath + '.bak'
|
|
1253
1911
|
let lock = null
|
|
1254
1912
|
let previousRev = null
|
|
1255
1913
|
try {
|
|
@@ -1264,8 +1922,12 @@ export function apply(ctx) {
|
|
|
1264
1922
|
state.data.rev = previousRev + 1
|
|
1265
1923
|
const text = serialize()
|
|
1266
1924
|
await writeFile(tmpPath, text, 'utf8')
|
|
1925
|
+
// Both rolling copies stand for the PREVIOUS good version, so both are
|
|
1926
|
+
// taken before the atomic replace, and neither may ever be renamed into
|
|
1927
|
+
// place (see the long note above).
|
|
1928
|
+
let hadPrevious = true
|
|
1267
1929
|
try {
|
|
1268
|
-
await copyFile(storePath,
|
|
1930
|
+
await copyFile(storePath, localBackupPath())
|
|
1269
1931
|
state.backupError = null
|
|
1270
1932
|
} catch (err) {
|
|
1271
1933
|
// ENOENT is simply the very first save: there is no previous version
|
|
@@ -1273,9 +1935,23 @@ export function apply(ctx) {
|
|
|
1273
1935
|
// happen — non-fatal for this save, because the primary is still intact
|
|
1274
1936
|
// and the replace below is the real gate, but it must be disclosed
|
|
1275
1937
|
// rather than swallowed.
|
|
1938
|
+
hadPrevious = err.code !== 'ENOENT'
|
|
1276
1939
|
state.backupError = err.code === 'ENOENT'
|
|
1277
1940
|
? null
|
|
1278
|
-
: '上一版未能保留为
|
|
1941
|
+
: '上一版未能保留为 ' + localBackupPath() + ':' + describeError(err)
|
|
1942
|
+
}
|
|
1943
|
+
// The user-facing copy under a custom backup location (F18). The LOCAL
|
|
1944
|
+
// copy above stays the one crash recovery reads, so a failure here
|
|
1945
|
+
// degrades the extra copy only — never the plugin's own safety net.
|
|
1946
|
+
const customBak = customBackupPath()
|
|
1947
|
+
if (customBak !== null && hadPrevious) {
|
|
1948
|
+
try {
|
|
1949
|
+
await copyFile(storePath, customBak)
|
|
1950
|
+
} catch (err) {
|
|
1951
|
+
if (state.backupError === null) {
|
|
1952
|
+
state.backupError = '备份位置未能保留上一版(' + customBak + '):' + describeError(err)
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1279
1955
|
}
|
|
1280
1956
|
try {
|
|
1281
1957
|
await rename(tmpPath, storePath)
|
|
@@ -1294,8 +1970,15 @@ export function apply(ctx) {
|
|
|
1294
1970
|
// be (re)armed after a first run that had nothing to watch.
|
|
1295
1971
|
startWatching()
|
|
1296
1972
|
state.savedAt = Date.now()
|
|
1973
|
+
// Best-effort side files, each guarded inside itself: a failure in either
|
|
1974
|
+
// must never be mistaken for "the save failed" — the save already
|
|
1975
|
+
// committed, and the store is on disk whatever happens next.
|
|
1976
|
+
await writeHistorySnapshot(text)
|
|
1977
|
+
await ensureSchemaFile()
|
|
1297
1978
|
logDebug('store written: ' + storePath + ' bytes=' + text.length
|
|
1298
1979
|
+ ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
|
|
1980
|
+
+ ' sets=' + state.data.sets.length + ' activeSet=' + state.data.activeSetId
|
|
1981
|
+
+ ' history=' + state.historyCount
|
|
1299
1982
|
+ (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
|
|
1300
1983
|
return true
|
|
1301
1984
|
} catch (err) {
|
|
@@ -1335,14 +2018,21 @@ export function apply(ctx) {
|
|
|
1335
2018
|
/**
|
|
1336
2019
|
* A store declaring an unsupported format version: adopt nothing, write
|
|
1337
2020
|
* 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).
|
|
2021
|
+
* state is a save — it would rewrite the file in this build's format and
|
|
2022
|
+
* destroy whatever the newer format meant (the mutating commands refuse
|
|
2023
|
+
* while versionRejected is set).
|
|
2024
|
+
*
|
|
2025
|
+
* This is also the guard that makes a DOWNGRADE safe: a file written by a
|
|
2026
|
+
* newer plugin (e.g. version 2 from an F13-aware build) opened by an older
|
|
2027
|
+
* one is refused here, with the data left untouched, instead of being
|
|
2028
|
+
* half-read and then rewritten without the fields the old build cannot
|
|
2029
|
+
* see.
|
|
1341
2030
|
*/
|
|
1342
2031
|
const rejectVersion = (version, source) => {
|
|
1343
2032
|
state.versionRejected = true
|
|
1344
2033
|
state.rejectedVersion = version
|
|
1345
|
-
state.storageError = '存储文件声明为格式版本 ' + version + '
|
|
2034
|
+
state.storageError = '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
|
|
2035
|
+
+ ';已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回受支持的版本后点「重新载入」'
|
|
1346
2036
|
state.loadedFrom = source + ' (unsupported version ' + version + ')'
|
|
1347
2037
|
state.storeExists = true
|
|
1348
2038
|
}
|
|
@@ -1360,6 +2050,9 @@ export function apply(ctx) {
|
|
|
1360
2050
|
state.storageWarning = loadWarnings(loaded)
|
|
1361
2051
|
state.loadedFrom = 'memory.json'
|
|
1362
2052
|
state.storeExists = true
|
|
2053
|
+
// A real file is now in memory, so the "materialise on first need"
|
|
2054
|
+
// flag is spent — see ensureStoreMaterialized.
|
|
2055
|
+
needsMaterialize = false
|
|
1363
2056
|
}
|
|
1364
2057
|
state.storageError = null
|
|
1365
2058
|
return false
|
|
@@ -1487,6 +2180,35 @@ export function apply(ctx) {
|
|
|
1487
2180
|
*/
|
|
1488
2181
|
const ensureStoreMaterialized = async () => {
|
|
1489
2182
|
if (!needsMaterialize) return false
|
|
2183
|
+
/**
|
|
2184
|
+
* A store may have APPEARED since boot — a hand-copied memory.json, a
|
|
2185
|
+
* backup restored by another tool, a second process. Three cases, and the
|
|
2186
|
+
* difference between the last two is the whole point:
|
|
2187
|
+
* - nothing there: materialise the empty store (the documented first run);
|
|
2188
|
+
* - READABLE: adopt it instead of overwriting whatever the user just put
|
|
2189
|
+
* there (the gap is wide — the panel's first `state` call can come
|
|
2190
|
+
* minutes after boot);
|
|
2191
|
+
* - present but UNREADABLE: write nothing at all and let the loader's own
|
|
2192
|
+
* disclosure say why. Overwriting would destroy the very file a user
|
|
2193
|
+
* could still repair by hand, and it would report `ok:true` while doing
|
|
2194
|
+
* it — the silent data loss this plugin must never commit.
|
|
2195
|
+
*/
|
|
2196
|
+
let appeared = null
|
|
2197
|
+
let unreadable = false
|
|
2198
|
+
try {
|
|
2199
|
+
appeared = await readStoreFile(storePath)
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
// ENOENT is the FIRST RUN itself — nothing to adopt, so materialise.
|
|
2202
|
+
// Anything else (EACCES, EISDIR, a parse failure …) means a file IS
|
|
2203
|
+
// there and could not be adopted; readFromDisk() below reports exactly
|
|
2204
|
+
// what it was, and nothing is written.
|
|
2205
|
+
unreadable = !(err !== null && typeof err === 'object' && err.code === 'ENOENT')
|
|
2206
|
+
}
|
|
2207
|
+
if (unreadable || appeared !== null) {
|
|
2208
|
+
const wrote = await readFromDisk()
|
|
2209
|
+
syncSection()
|
|
2210
|
+
return wrote
|
|
2211
|
+
}
|
|
1490
2212
|
return writeToDisk()
|
|
1491
2213
|
}
|
|
1492
2214
|
|
|
@@ -1506,9 +2228,10 @@ export function apply(ctx) {
|
|
|
1506
2228
|
.catch((err) => {
|
|
1507
2229
|
state.storageError = '读取失败:' + describeError(err)
|
|
1508
2230
|
})
|
|
1509
|
-
.then(() => {
|
|
2231
|
+
.then(async () => {
|
|
1510
2232
|
bootSettled = true
|
|
1511
2233
|
syncSection()
|
|
2234
|
+
await refreshHistoryCount()
|
|
1512
2235
|
// The one always-on line: it answers "is memory mounted, where does it
|
|
1513
2236
|
// live, and how much is being injected?" without any further digging.
|
|
1514
2237
|
// 措辞很重要:`section=` 说的是"这个段落已就绪、若被装配会产出多少字符",
|
|
@@ -1519,6 +2242,7 @@ export function apply(ctx) {
|
|
|
1519
2242
|
+ ' from=' + String(state.loadedFrom)
|
|
1520
2243
|
+ ' onDisk=' + (state.storeExists ? 'yes' : 'no')
|
|
1521
2244
|
+ ' entries=' + state.data.entries.length
|
|
2245
|
+
+ ' sets=' + state.data.sets.length + ' activeSet=' + state.data.activeSetId
|
|
1522
2246
|
+ ' enabled=' + (state.data.enabled === true)
|
|
1523
2247
|
+ ' section=' + (chars > 0 ? chars + ' chars (pending assembly)' : 'nothing to inject')
|
|
1524
2248
|
+ ' pulls=' + sectionPulls
|
|
@@ -1545,17 +2269,83 @@ export function apply(ctx) {
|
|
|
1545
2269
|
|
|
1546
2270
|
/* ---------------- snapshots ---------------- */
|
|
1547
2271
|
|
|
2272
|
+
/**
|
|
2273
|
+
* The snapshot the settings page renders (and the SSE frames carry).
|
|
2274
|
+
*
|
|
2275
|
+
* `data.entries`/`data.profiles` publish ONLY the active set (F13): the panel
|
|
2276
|
+
* lists and edits exactly what is being injected, so a 某客户 set can never
|
|
2277
|
+
* appear in the 个人 panel — the isolation guarantee the feature exists for.
|
|
2278
|
+
* The set list, with per-set counts and the active flag, travels in `sets`,
|
|
2279
|
+
* and the raw arrays are in `data.sets`/`data.activeSetId` for the switcher.
|
|
2280
|
+
* Anything needing the WHOLE store (export, source mode) goes through
|
|
2281
|
+
* persistableData(), which is also what the file holds.
|
|
2282
|
+
*/
|
|
1548
2283
|
const snapshot = () => {
|
|
1549
2284
|
const plan = sectionPlan()
|
|
1550
2285
|
const preview = plan.text
|
|
2286
|
+
const scope = activeScope(state.data)
|
|
2287
|
+
const belongsTo = (item, id) => {
|
|
2288
|
+
const own = item !== null && typeof item === 'object' && typeof item.setId === 'string' && item.setId !== ''
|
|
2289
|
+
? item.setId
|
|
2290
|
+
: state.data.activeSetId
|
|
2291
|
+
return own === id
|
|
2292
|
+
}
|
|
2293
|
+
const activeEntries = state.data.entries.filter((entry) => inScope(entry, scope))
|
|
2294
|
+
const activeProfiles = state.data.profiles.filter((profile) => inScope(profile, scope))
|
|
1551
2295
|
return {
|
|
1552
2296
|
data: {
|
|
1553
|
-
version:
|
|
2297
|
+
version: FORMAT_VERSION,
|
|
1554
2298
|
rev: state.data.rev || 0,
|
|
1555
2299
|
enabled: state.data.enabled === true,
|
|
2300
|
+
disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
|
|
2301
|
+
? state.data.disabledUntil
|
|
2302
|
+
: 0,
|
|
1556
2303
|
budgetChars: clampBudget(state.data.budgetChars),
|
|
1557
|
-
|
|
1558
|
-
|
|
2304
|
+
backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
|
|
2305
|
+
activeSetId: state.data.activeSetId,
|
|
2306
|
+
sets: state.data.sets.map((set) => ({ id: set.id, name: set.name })),
|
|
2307
|
+
entries: activeEntries.map((entry) => ({ ...entry })),
|
|
2308
|
+
profiles: activeProfiles.map((profile) => ({ ...profile })),
|
|
2309
|
+
},
|
|
2310
|
+
/**
|
|
2311
|
+
* Instruction sets (F13): the switcher's data source. `total` counts the
|
|
2312
|
+
* WHOLE store (all sets), which is what the 200-entry cap is measured
|
|
2313
|
+
* against — the panel says "共 N 条" per active set but the cap message
|
|
2314
|
+
* must quote the store-wide number.
|
|
2315
|
+
*/
|
|
2316
|
+
sets: {
|
|
2317
|
+
activeId: state.data.activeSetId,
|
|
2318
|
+
scoped: scope.scoped,
|
|
2319
|
+
total: { entries: state.data.entries.length, profiles: state.data.profiles.length },
|
|
2320
|
+
list: state.data.sets.map((set) => ({
|
|
2321
|
+
id: set.id,
|
|
2322
|
+
name: set.name,
|
|
2323
|
+
active: set.id === state.data.activeSetId,
|
|
2324
|
+
entries: state.data.entries.filter((entry) => belongsTo(entry, set.id)).length,
|
|
2325
|
+
profiles: state.data.profiles.filter((profile) => belongsTo(profile, set.id)).length,
|
|
2326
|
+
})),
|
|
2327
|
+
},
|
|
2328
|
+
/**
|
|
2329
|
+
* F12: the one security signal the panel was missing. `hostGuarded` is
|
|
2330
|
+
* the SAME flag the route enforces on every request — false means the
|
|
2331
|
+
* Host allowlist is OFF because the server is bound beyond loopback,
|
|
2332
|
+
* i.e. the page is reachable without authentication from the local
|
|
2333
|
+
* network. `boundHost` says which address produced that decision so the
|
|
2334
|
+
* warning can name it instead of guessing.
|
|
2335
|
+
*/
|
|
2336
|
+
security: {
|
|
2337
|
+
hostGuarded: guardHost === true,
|
|
2338
|
+
boundHost: state.boundHost,
|
|
2339
|
+
},
|
|
2340
|
+
/**
|
|
2341
|
+
* F12: load-side diagnostics that used to be log-only. `loadedFrom` says
|
|
2342
|
+
* which branch of the boot load produced the current data (fresh store,
|
|
2343
|
+
* adopted file, recovered .bak, …); `rev` mirrors data.rev so a support
|
|
2344
|
+
* dump pairs it with the storage fields without another round trip.
|
|
2345
|
+
*/
|
|
2346
|
+
diagnostics: {
|
|
2347
|
+
loadedFrom: state.loadedFrom,
|
|
2348
|
+
rev: state.data.rev || 0,
|
|
1559
2349
|
},
|
|
1560
2350
|
storage: {
|
|
1561
2351
|
path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
|
|
@@ -1565,11 +2355,27 @@ export function apply(ctx) {
|
|
|
1565
2355
|
migratedFrom: state.migratedFrom,
|
|
1566
2356
|
/** false until the store file has actually been written (see readFromDisk step 3). */
|
|
1567
2357
|
onDisk: state.storeExists,
|
|
2358
|
+
/** Backup location (F18): '' means "next to the store file". */
|
|
2359
|
+
backupDir: backupDirOf(),
|
|
2360
|
+
isDefaultBackupDir: backupDirOf() === '',
|
|
2361
|
+
/** Where the rolling pre-write copy actually sits (diagnostics). */
|
|
2362
|
+
backupPath: localBackupPath(),
|
|
2363
|
+
/** Version history (F14). */
|
|
2364
|
+
historyDir: historyDirPath(),
|
|
2365
|
+
historyCount: state.historyCount,
|
|
2366
|
+
historyError: state.historyError,
|
|
2367
|
+
schemaFile: join(dirname(storePath), SCHEMA_FILE_NAME),
|
|
1568
2368
|
},
|
|
1569
2369
|
injection: {
|
|
1570
2370
|
registered: state.sectionRegistered,
|
|
1571
2371
|
available: true,
|
|
1572
2372
|
error: state.sectionError,
|
|
2373
|
+
/**
|
|
2374
|
+
* F7: the pause is decided by the HOST clock (isMuted on the live
|
|
2375
|
+
* store) and published as a derived flag; the client only renders a
|
|
2376
|
+
* countdown from data.disabledUntil and never decides by itself.
|
|
2377
|
+
*/
|
|
2378
|
+
muted: isMuted(state.data, Date.now()),
|
|
1573
2379
|
chars: preview.length,
|
|
1574
2380
|
/**
|
|
1575
2381
|
* The budget decision behind `preview`, for the settings page's
|
|
@@ -1611,6 +2417,10 @@ export function apply(ctx) {
|
|
|
1611
2417
|
maxProfiles: MAX_PROFILES,
|
|
1612
2418
|
maxProfileName: MAX_PROFILE_NAME,
|
|
1613
2419
|
maxProfileContent: MAX_PROFILE_CONTENT,
|
|
2420
|
+
maxSets: MAX_SETS,
|
|
2421
|
+
maxSetName: MAX_SET_NAME,
|
|
2422
|
+
maxHistory: MAX_HISTORY,
|
|
2423
|
+
maxBackupDir: MAX_BACKUP_DIR,
|
|
1614
2424
|
maxBodyChars: MAX_BODY_CHARS,
|
|
1615
2425
|
},
|
|
1616
2426
|
}
|
|
@@ -1649,17 +2459,70 @@ export function apply(ctx) {
|
|
|
1649
2459
|
|
|
1650
2460
|
/**
|
|
1651
2461
|
* 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
|
|
2462
|
+
* a save here would rewrite it in this build's format and destroy whatever
|
|
2463
|
+
* the newer format meant. The file stays untouched until the user upgrades
|
|
2464
|
+
* the plugin or restores a supported-version file and hits 重新载入.
|
|
1655
2465
|
*/
|
|
1656
2466
|
const versionRefusal = () => ({
|
|
1657
2467
|
ok: false,
|
|
1658
2468
|
saved: false,
|
|
1659
|
-
message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本
|
|
2469
|
+
message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本 ' + FORMAT_VERSION
|
|
2470
|
+
+ ' 覆盖它,可能损坏数据。请先升级插件,或手动将文件改回受支持的版本(' + SUPPORTED_VERSIONS.join(' / ') + ')后点「重新载入」',
|
|
1660
2471
|
snapshot: snapshot(),
|
|
1661
2472
|
})
|
|
1662
2473
|
|
|
2474
|
+
/**
|
|
2475
|
+
* Adopt a whole parsed store — the shared core of 源码模式 (F16) and the
|
|
2476
|
+
* 整体替换 restore (F14). Both hand the user the SAME safety story as every
|
|
2477
|
+
* other entry point: only a v1/v2 store object is accepted (a stray `{}` or
|
|
2478
|
+
* an unrelated JSON file must never be able to wipe the store), the format
|
|
2479
|
+
* version gate applies, and every correction normalizeData makes is
|
|
2480
|
+
* reported.
|
|
2481
|
+
*
|
|
2482
|
+
* `backupDir` is deliberately NOT adopted: the backup location is
|
|
2483
|
+
* machine-local state, so restoring a file — from another machine, or from
|
|
2484
|
+
* before the path existed — must not silently redirect where backups go.
|
|
2485
|
+
*/
|
|
2486
|
+
const adoptParsedStore = (parsed) => {
|
|
2487
|
+
const looksLikeStore = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
2488
|
+
&& ['version', 'rev', 'enabled', 'budgetChars', 'backupDir', 'activeSetId', 'sets', 'entries', 'profiles']
|
|
2489
|
+
.some((key) => key in parsed)
|
|
2490
|
+
if (!looksLikeStore) {
|
|
2491
|
+
return {
|
|
2492
|
+
error: '内容不像本插件的存储文件:顶层需要是对象,且至少包含 entries / profiles / sets / version 之一。'
|
|
2493
|
+
+ '若要清空记忆,请使用界面上的删除操作,而不是保存一个空对象',
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
const loaded = normalizeData(parsed)
|
|
2497
|
+
if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
|
|
2498
|
+
loaded.data.backupDir = backupDirOf()
|
|
2499
|
+
return { loaded }
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
/** Per-field truncation disclosure for an adopted raw store (mirrors import). */
|
|
2503
|
+
const truncationNotes = (parsed) => {
|
|
2504
|
+
const lists = [
|
|
2505
|
+
[parsed.entries, truncatedFields],
|
|
2506
|
+
[parsed.profiles, truncatedProfileFields],
|
|
2507
|
+
[parsed.sets, truncatedSetFields],
|
|
2508
|
+
]
|
|
2509
|
+
let items = 0
|
|
2510
|
+
const fields = new Set()
|
|
2511
|
+
for (const [list, fieldsOf] of lists) {
|
|
2512
|
+
for (const raw of (Array.isArray(list) ? list : [])) {
|
|
2513
|
+
const cut = fieldsOf(raw)
|
|
2514
|
+
if (cut.length > 0) {
|
|
2515
|
+
items += 1
|
|
2516
|
+
for (const name of cut) fields.add(name)
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
return items > 0 ? '有 ' + items + ' 项超过长度上限,' + [...fields].join('、') + '已被截断' : null
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
const describeStoreShape = (data) =>
|
|
2524
|
+
data.entries.length + ' 条指令 / ' + data.profiles.length + ' 个用户形象 / ' + data.sets.length + ' 个配置集'
|
|
2525
|
+
|
|
1663
2526
|
/**
|
|
1664
2527
|
* Apply an in-memory mutation, persist it, and UNDO it when the write fails.
|
|
1665
2528
|
*
|
|
@@ -1707,11 +2570,33 @@ export function apply(ctx) {
|
|
|
1707
2570
|
const entry = sanitizeEntry(raw)
|
|
1708
2571
|
if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
|
|
1709
2572
|
entry.updatedAt = Date.now()
|
|
2573
|
+
/**
|
|
2574
|
+
* The active set is authoritative for the interactive save path: the panel
|
|
2575
|
+
* edits what is being injected, and an edit must never be able to move an
|
|
2576
|
+
* item into another set (or drag one in). Moving content between sets is a
|
|
2577
|
+
* deliberate act — create the entry where it belongs, or use 复制配置集.
|
|
2578
|
+
*/
|
|
2579
|
+
entry.setId = state.data.activeSetId
|
|
2580
|
+
/**
|
|
2581
|
+
* An id that already belongs to ANOTHER set is not this command's to
|
|
2582
|
+
* replace (entries are addressed by id alone): honouring it would move the
|
|
2583
|
+
* other set's entry into this one and overwrite its text — a cross-set
|
|
2584
|
+
* leak in the one direction the whole feature exists to prevent. Mint a
|
|
2585
|
+
* fresh id instead, and say so, because the caller's id silently changed.
|
|
2586
|
+
*/
|
|
2587
|
+
const foreignId = state.data.entries.some((candidate) => candidate.id === entry.id
|
|
2588
|
+
&& candidate.setId !== state.data.activeSetId)
|
|
2589
|
+
if (foreignId) entry.id = makeId()
|
|
1710
2590
|
// Resolved before the mutation so a refusal can return a response instead
|
|
1711
2591
|
// of being swallowed as an early exit inside it.
|
|
1712
2592
|
const replacesExisting = state.data.entries.some((candidate) => candidate.id === entry.id)
|
|
1713
2593
|
if (!replacesExisting && state.data.entries.length >= MAX_ENTRIES) {
|
|
1714
|
-
return {
|
|
2594
|
+
return {
|
|
2595
|
+
ok: false,
|
|
2596
|
+
saved: false,
|
|
2597
|
+
message: '最多保存 ' + MAX_ENTRIES + ' 条指令(全部配置集合计,当前 ' + state.data.entries.length + ' 条)',
|
|
2598
|
+
snapshot: snapshot(),
|
|
2599
|
+
}
|
|
1715
2600
|
}
|
|
1716
2601
|
const cut = truncatedFields(raw)
|
|
1717
2602
|
const saved = await commit(() => {
|
|
@@ -1720,10 +2605,14 @@ export function apply(ctx) {
|
|
|
1720
2605
|
if (index >= 0) list[index] = entry
|
|
1721
2606
|
else list.push(entry)
|
|
1722
2607
|
})
|
|
1723
|
-
const
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
2608
|
+
const notes = []
|
|
2609
|
+
if (cut.length > 0) {
|
|
2610
|
+
notes.push(cut.join('、') + '超过长度上限(标题 ' + MAX_TITLE + ' / 内容 ' + MAX_CONTENT + ' / 适用场景 ' + MAX_WHEN + ' 字符),超出部分已被截断')
|
|
2611
|
+
}
|
|
2612
|
+
if (foreignId) {
|
|
2613
|
+
notes.push('提交的 id 属于其他配置集,已改用新 id 在当前配置集新建(其他配置集的内容未被改动)')
|
|
2614
|
+
}
|
|
2615
|
+
return settle(notes.length > 0 ? '已保存;' + notes.join(';') : null, saved)
|
|
1727
2616
|
})
|
|
1728
2617
|
|
|
1729
2618
|
const deleteEntry = (id, baseRev) => enqueue(async () => {
|
|
@@ -1733,7 +2622,13 @@ export function apply(ctx) {
|
|
|
1733
2622
|
// disk, bump rev, or push an echo frame — otherwise two panels deleting
|
|
1734
2623
|
// the same entry one after another would manufacture a pointless write
|
|
1735
2624
|
// (and an external process watching the file would wake for nothing).
|
|
1736
|
-
|
|
2625
|
+
//
|
|
2626
|
+
// Scoped to the ACTIVE set: the panel only ever lists those ids, so an id
|
|
2627
|
+
// from another set is not "an entry that is not there" by accident — it is
|
|
2628
|
+
// a request this command must not honour (deleting across sets from a
|
|
2629
|
+
// settings page that claims to show one set would be a data-loss bug).
|
|
2630
|
+
const scope = activeScope(state.data)
|
|
2631
|
+
if (!state.data.entries.some((entry) => entry.id === id && inScope(entry, scope))) {
|
|
1737
2632
|
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
1738
2633
|
}
|
|
1739
2634
|
const saved = await commit(() => {
|
|
@@ -1745,15 +2640,95 @@ export function apply(ctx) {
|
|
|
1745
2640
|
const setOptions = (patch, baseRev) => enqueue(async () => {
|
|
1746
2641
|
if (state.versionRejected) return versionRefusal()
|
|
1747
2642
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
2643
|
+
// The custom backup location (F18) is validated BEFORE anything is queued
|
|
2644
|
+
// into the store: an unusable path must be refused with a reason, not
|
|
2645
|
+
// accepted and then discovered as a failed copy on the next save.
|
|
2646
|
+
if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
|
|
2647
|
+
const wanted = patch.backupDir.trim()
|
|
2648
|
+
if (wanted !== '') {
|
|
2649
|
+
if (wanted.length > MAX_BACKUP_DIR) {
|
|
2650
|
+
return { ok: false, saved: false, message: '备份位置路径过长(上限 ' + MAX_BACKUP_DIR + ' 字符)', snapshot: snapshot() }
|
|
2651
|
+
}
|
|
2652
|
+
if (!isAbsolute(wanted)) {
|
|
2653
|
+
return {
|
|
2654
|
+
ok: false,
|
|
2655
|
+
saved: false,
|
|
2656
|
+
message: '备份位置需要绝对路径(例如 D:\\云盘\\dsh-memory 或 /home/me/cloud/dsh-memory);留空表示与存储文件同目录',
|
|
2657
|
+
snapshot: snapshot(),
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
const problem = await probeBackupDir(wanted)
|
|
2661
|
+
if (problem !== null) {
|
|
2662
|
+
return { ok: false, saved: false, message: '备份位置不可用(' + problem + '):' + wanted, snapshot: snapshot() }
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
// F7 temporary mute: validated BEFORE the transaction so a bad value is
|
|
2667
|
+
// refused with a reason instead of being silently normalized. 0 means
|
|
2668
|
+
// "resume now"; a past instant means the same thing; anything beyond the
|
|
2669
|
+
// 7-day ceiling is clamped (and disclosed) rather than refused — a pause
|
|
2670
|
+
// is a de-escalation, refusing it would be user-hostile.
|
|
2671
|
+
let muteTarget = null
|
|
2672
|
+
let muteClamped = false
|
|
2673
|
+
if (patch !== null && typeof patch === 'object' && 'disabledUntil' in patch) {
|
|
2674
|
+
const rawMute = patch.disabledUntil
|
|
2675
|
+
if (typeof rawMute !== 'number' || !Number.isFinite(rawMute) || rawMute < 0) {
|
|
2676
|
+
return {
|
|
2677
|
+
ok: false,
|
|
2678
|
+
saved: false,
|
|
2679
|
+
message: 'disabledUntil 需要 0(立即恢复)或未来的 epoch 毫秒数',
|
|
2680
|
+
snapshot: snapshot(),
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
let wanted = Math.round(rawMute)
|
|
2684
|
+
if (wanted <= Date.now()) {
|
|
2685
|
+
wanted = 0
|
|
2686
|
+
} else if (wanted > Date.now() + MAX_MUTE_MS) {
|
|
2687
|
+
wanted = Date.now() + MAX_MUTE_MS
|
|
2688
|
+
muteClamped = true
|
|
2689
|
+
}
|
|
2690
|
+
muteTarget = wanted
|
|
2691
|
+
}
|
|
2692
|
+
const muteWasActive = isMuted(state.data)
|
|
1748
2693
|
const saved = await commit(() => {
|
|
1749
2694
|
if (patch !== null && typeof patch === 'object') {
|
|
1750
|
-
if (typeof patch.enabled === 'boolean')
|
|
2695
|
+
if (typeof patch.enabled === 'boolean') {
|
|
2696
|
+
state.data.enabled = patch.enabled
|
|
2697
|
+
// An explicit switch outranks the timer: flipping the master switch
|
|
2698
|
+
// ends any pause immediately.
|
|
2699
|
+
state.data.disabledUntil = 0
|
|
2700
|
+
}
|
|
1751
2701
|
if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
|
|
1752
2702
|
state.data.budgetChars = clampBudget(patch.budgetChars)
|
|
1753
2703
|
}
|
|
2704
|
+
if (typeof patch.backupDir === 'string') state.data.backupDir = patch.backupDir.trim()
|
|
2705
|
+
if (muteTarget !== null && typeof patch.enabled !== 'boolean') {
|
|
2706
|
+
state.data.disabledUntil = muteTarget
|
|
2707
|
+
}
|
|
1754
2708
|
}
|
|
1755
2709
|
})
|
|
1756
|
-
|
|
2710
|
+
// A changed backup location moves where history snapshots and manual
|
|
2711
|
+
// exports go, so it is reported rather than left to be discovered.
|
|
2712
|
+
const notes = []
|
|
2713
|
+
if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
|
|
2714
|
+
const wanted = patch.backupDir.trim()
|
|
2715
|
+
notes.push(wanted === ''
|
|
2716
|
+
? '已恢复默认备份位置(版本历史与导出与存储文件同目录)'
|
|
2717
|
+
: '备份位置已更新:' + wanted + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)')
|
|
2718
|
+
}
|
|
2719
|
+
if (muteTarget !== null && !(patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean')) {
|
|
2720
|
+
if (muteTarget === 0) {
|
|
2721
|
+
if (muteWasActive) notes.push('已恢复注入。')
|
|
2722
|
+
} else {
|
|
2723
|
+
notes.push('已暂停注入至 ' + new Date(muteTarget).toLocaleString()
|
|
2724
|
+
+ '(到期自动恢复;暂停期间不注入任何指令或用户形象)'
|
|
2725
|
+
+ (muteClamped ? ';暂停时长上限为 7 天,已按上限记录' : ''))
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
if (patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean' && muteWasActive) {
|
|
2729
|
+
notes.push('总开关已切换,暂停注入状态已一并清除')
|
|
2730
|
+
}
|
|
2731
|
+
return settle(notes.length > 0 ? notes.join(';') : null, saved)
|
|
1757
2732
|
})
|
|
1758
2733
|
|
|
1759
2734
|
/**
|
|
@@ -1787,7 +2762,11 @@ export function apply(ctx) {
|
|
|
1787
2762
|
}
|
|
1788
2763
|
|
|
1789
2764
|
const list = state.data.entries
|
|
1790
|
-
|
|
2765
|
+
// Scoped to the active set, exactly like the single-entry commands: the
|
|
2766
|
+
// panel's selection can only ever contain active-set ids, so an id from
|
|
2767
|
+
// another set is reported as "not found" rather than acted on.
|
|
2768
|
+
const scope = activeScope(state.data)
|
|
2769
|
+
const targets = list.filter((entry) => uniqueIds.includes(entry.id) && inScope(entry, scope))
|
|
1791
2770
|
const missing = uniqueIds.length - targets.length
|
|
1792
2771
|
const missingNote = missing > 0
|
|
1793
2772
|
? '(另有 ' + missing + ' 条未找到,可能已被其他窗口或进程修改)'
|
|
@@ -1882,6 +2861,13 @@ export function apply(ctx) {
|
|
|
1882
2861
|
const profile = sanitizeProfile(raw)
|
|
1883
2862
|
if (profile === null) return { ok: false, saved: false, message: '形象名称与内容不能同时为空', snapshot: snapshot() }
|
|
1884
2863
|
profile.updatedAt = Date.now()
|
|
2864
|
+
/** Same rule as entries: the interactive path files into the active set. */
|
|
2865
|
+
profile.setId = state.data.activeSetId
|
|
2866
|
+
// ...and an id owned by another set is re-minted rather than replaced (see
|
|
2867
|
+
// the note in saveEntry — same cross-set hazard, same guard).
|
|
2868
|
+
const foreignProfileId = state.data.profiles.some((candidate) => candidate.id === profile.id
|
|
2869
|
+
&& candidate.setId !== state.data.activeSetId)
|
|
2870
|
+
if (foreignProfileId) profile.id = makeId()
|
|
1885
2871
|
const replacesExisting = state.data.profiles.some((candidate) => candidate.id === profile.id)
|
|
1886
2872
|
if (!replacesExisting && state.data.profiles.length >= MAX_PROFILES) {
|
|
1887
2873
|
return { ok: false, saved: false, message: '最多保存 ' + MAX_PROFILES + ' 个用户形象', snapshot: snapshot() }
|
|
@@ -1902,8 +2888,10 @@ export function apply(ctx) {
|
|
|
1902
2888
|
const deleteProfile = (id, baseRev) => enqueue(async () => {
|
|
1903
2889
|
if (state.versionRejected) return versionRefusal()
|
|
1904
2890
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
1905
|
-
// Mirror deleteEntry: an absent id changes nothing and must not touch disk
|
|
1906
|
-
|
|
2891
|
+
// Mirror deleteEntry: an absent id changes nothing and must not touch disk,
|
|
2892
|
+
// and an id belonging to a different set is not this panel's to delete.
|
|
2893
|
+
const scope = activeScope(state.data)
|
|
2894
|
+
if (!state.data.profiles.some((profile) => profile.id === id && inScope(profile, scope))) {
|
|
1907
2895
|
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
1908
2896
|
}
|
|
1909
2897
|
const saved = await commit(() => {
|
|
@@ -1912,6 +2900,269 @@ export function apply(ctx) {
|
|
|
1912
2900
|
return settle(null, saved)
|
|
1913
2901
|
})
|
|
1914
2902
|
|
|
2903
|
+
/* ---------------- instruction sets (F13) ---------------- */
|
|
2904
|
+
|
|
2905
|
+
/**
|
|
2906
|
+
* Create or rename an instruction set.
|
|
2907
|
+
*
|
|
2908
|
+
* A blank `set.id` is a CREATE (the Host mints the id), the same convention
|
|
2909
|
+
* entry edits use, and a supplied id is a RENAME. Creating does NOT switch:
|
|
2910
|
+
* switching is what changes what gets injected, and it stays a separate,
|
|
2911
|
+
* explicit click (the response says so). Nothing in this file ever derives a
|
|
2912
|
+
* set from the process cwd or from an open folder — see the README note on
|
|
2913
|
+
* the incident that rule comes from.
|
|
2914
|
+
*/
|
|
2915
|
+
const saveSet = (raw, baseRev) => enqueue(async () => {
|
|
2916
|
+
if (state.versionRejected) return versionRefusal()
|
|
2917
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
2918
|
+
const wanted = sanitizeSet(raw)
|
|
2919
|
+
if (wanted === null) {
|
|
2920
|
+
return { ok: false, saved: false, message: '配置集名称不能为空(单行,最多 ' + MAX_SET_NAME + ' 字符)', snapshot: snapshot() }
|
|
2921
|
+
}
|
|
2922
|
+
const index = state.data.sets.findIndex((set) => set.id === wanted.id)
|
|
2923
|
+
if (state.data.sets.some((set, at) => at !== index && set.name === wanted.name)) {
|
|
2924
|
+
return { ok: false, saved: false, message: '已存在同名配置集「' + wanted.name + '」', snapshot: snapshot() }
|
|
2925
|
+
}
|
|
2926
|
+
if (index < 0) {
|
|
2927
|
+
if (state.data.sets.length >= MAX_SETS) {
|
|
2928
|
+
return { ok: false, saved: false, message: '最多创建 ' + MAX_SETS + ' 个配置集', snapshot: snapshot() }
|
|
2929
|
+
}
|
|
2930
|
+
const cut = truncatedSetFields(raw)
|
|
2931
|
+
const saved = await commit(() => {
|
|
2932
|
+
state.data.sets.push({ id: wanted.id, name: wanted.name })
|
|
2933
|
+
})
|
|
2934
|
+
const note = '已创建配置集「' + wanted.name + '」'
|
|
2935
|
+
+ (cut.length > 0 ? ';名称超过 ' + MAX_SET_NAME + ' 字符上限,超出部分已被截断' : '')
|
|
2936
|
+
+ ';它是空的,点击「切换」后才会注入它'
|
|
2937
|
+
return settle(note, saved)
|
|
2938
|
+
}
|
|
2939
|
+
if (state.data.sets[index].name === wanted.name) {
|
|
2940
|
+
return { ok: true, saved: false, message: '名称未变化', snapshot: snapshot() }
|
|
2941
|
+
}
|
|
2942
|
+
const cut = truncatedSetFields(raw)
|
|
2943
|
+
const saved = await commit(() => {
|
|
2944
|
+
state.data.sets[index].name = wanted.name
|
|
2945
|
+
})
|
|
2946
|
+
return settle('已重命名为「' + wanted.name + '」' + (cut.length > 0 ? '(名称已按长度上限截断)' : ''), saved)
|
|
2947
|
+
})
|
|
2948
|
+
|
|
2949
|
+
/**
|
|
2950
|
+
* Delete a set together with everything filed under it. Refused for the
|
|
2951
|
+
* ACTIVE set: deleting what is currently injected would change the prompt as
|
|
2952
|
+
* a side effect of a management action, so the user switches first and the
|
|
2953
|
+
* deletion becomes a separate, visible step.
|
|
2954
|
+
*/
|
|
2955
|
+
const deleteSet = (id, baseRev) => enqueue(async () => {
|
|
2956
|
+
if (state.versionRejected) return versionRefusal()
|
|
2957
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
2958
|
+
const index = state.data.sets.findIndex((set) => set.id === id)
|
|
2959
|
+
if (index < 0) {
|
|
2960
|
+
return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
|
|
2961
|
+
}
|
|
2962
|
+
if (state.data.sets.length <= 1) {
|
|
2963
|
+
return { ok: false, saved: false, message: '至少要保留一个配置集', snapshot: snapshot() }
|
|
2964
|
+
}
|
|
2965
|
+
if (id === state.data.activeSetId) {
|
|
2966
|
+
return { ok: false, saved: false, message: '不能删除当前激活的配置集;请先切换到其他配置集', snapshot: snapshot() }
|
|
2967
|
+
}
|
|
2968
|
+
const doomedEntries = state.data.entries.filter((entry) => entry.setId === id).length
|
|
2969
|
+
const doomedProfiles = state.data.profiles.filter((profile) => profile.setId === id).length
|
|
2970
|
+
const saved = await commit(() => {
|
|
2971
|
+
state.data.sets.splice(index, 1)
|
|
2972
|
+
state.data.entries = state.data.entries.filter((entry) => entry.setId !== id)
|
|
2973
|
+
state.data.profiles = state.data.profiles.filter((profile) => profile.setId !== id)
|
|
2974
|
+
})
|
|
2975
|
+
return settle('已删除配置集,并移除其中的 ' + doomedEntries + ' 条指令、' + doomedProfiles + ' 个用户形象', saved)
|
|
2976
|
+
})
|
|
2977
|
+
|
|
2978
|
+
/**
|
|
2979
|
+
* Make one set the active one — the single command that changes what is
|
|
2980
|
+
* injected. It writes (the choice survives a restart) and therefore bumps
|
|
2981
|
+
* `rev`, so every other open panel follows along.
|
|
2982
|
+
*/
|
|
2983
|
+
const setActiveSet = (id, baseRev) => enqueue(async () => {
|
|
2984
|
+
if (state.versionRejected) return versionRefusal()
|
|
2985
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
2986
|
+
const target = state.data.sets.find((set) => set.id === id)
|
|
2987
|
+
if (target === undefined) {
|
|
2988
|
+
return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
|
|
2989
|
+
}
|
|
2990
|
+
if (id === state.data.activeSetId) {
|
|
2991
|
+
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
2992
|
+
}
|
|
2993
|
+
const saved = await commit(() => {
|
|
2994
|
+
state.data.activeSetId = id
|
|
2995
|
+
})
|
|
2996
|
+
return settle('已切换到配置集「' + target.name + '」,注入内容已随之更新', saved)
|
|
2997
|
+
})
|
|
2998
|
+
|
|
2999
|
+
/**
|
|
3000
|
+
* Copy a set (its entries and profiles, with fresh ids) into a new one — the
|
|
3001
|
+
* "start from a template" flow for per-client isolation. The copy is NOT
|
|
3002
|
+
* activated: it is a new, untested configuration until the user switches.
|
|
3003
|
+
*/
|
|
3004
|
+
const duplicateSet = (id, name, baseRev) => enqueue(async () => {
|
|
3005
|
+
if (state.versionRejected) return versionRefusal()
|
|
3006
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3007
|
+
const source = state.data.sets.find((set) => set.id === id)
|
|
3008
|
+
if (source === undefined) {
|
|
3009
|
+
return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
|
|
3010
|
+
}
|
|
3011
|
+
if (state.data.sets.length >= MAX_SETS) {
|
|
3012
|
+
return { ok: false, saved: false, message: '最多 ' + MAX_SETS + ' 个配置集', snapshot: snapshot() }
|
|
3013
|
+
}
|
|
3014
|
+
const wanted = singleLine(typeof name === 'string' ? name : '', MAX_SET_NAME)
|
|
3015
|
+
|| singleLine(source.name + ' 副本', MAX_SET_NAME)
|
|
3016
|
+
if (state.data.sets.some((set) => set.name === wanted)) {
|
|
3017
|
+
return { ok: false, saved: false, message: '已存在同名配置集「' + wanted + '」', snapshot: snapshot() }
|
|
3018
|
+
}
|
|
3019
|
+
const sourceEntries = state.data.entries.filter((entry) => entry.setId === id)
|
|
3020
|
+
if (state.data.entries.length + sourceEntries.length > MAX_ENTRIES) {
|
|
3021
|
+
return {
|
|
3022
|
+
ok: false,
|
|
3023
|
+
saved: false,
|
|
3024
|
+
message: '复制后条目总数会超过 ' + MAX_ENTRIES + ' 条上限(当前 ' + state.data.entries.length
|
|
3025
|
+
+ ' 条,需复制 ' + sourceEntries.length + ' 条)',
|
|
3026
|
+
snapshot: snapshot(),
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
const sourceProfiles = state.data.profiles.filter((profile) => profile.setId === id)
|
|
3030
|
+
if (state.data.profiles.length + sourceProfiles.length > MAX_PROFILES) {
|
|
3031
|
+
return { ok: false, saved: false, message: '复制后用户形象总数会超过 ' + MAX_PROFILES + ' 个上限', snapshot: snapshot() }
|
|
3032
|
+
}
|
|
3033
|
+
const newId = makeSetId()
|
|
3034
|
+
const stamp = Date.now()
|
|
3035
|
+
const entries = sourceEntries.map((entry) => ({ ...entry, id: makeId(), setId: newId, updatedAt: stamp }))
|
|
3036
|
+
const profiles = sourceProfiles.map((profile) => ({ ...profile, id: makeId(), setId: newId, updatedAt: stamp }))
|
|
3037
|
+
const saved = await commit(() => {
|
|
3038
|
+
state.data.sets.push({ id: newId, name: wanted })
|
|
3039
|
+
state.data.entries.push(...entries)
|
|
3040
|
+
state.data.profiles.push(...profiles)
|
|
3041
|
+
})
|
|
3042
|
+
return settle('已复制为「' + wanted + '」(' + entries.length + ' 条指令、' + profiles.length + ' 个用户形象)'
|
|
3043
|
+
+ ';点击「切换」后才会注入它', saved)
|
|
3044
|
+
})
|
|
3045
|
+
|
|
3046
|
+
/* ---------------- version history (F14) ---------------- */
|
|
3047
|
+
|
|
3048
|
+
/**
|
|
3049
|
+
* The snapshot index, newest first. Names carry the timestamp, revision and
|
|
3050
|
+
* device, so nothing here needs to read a file to sort; counts are read from
|
|
3051
|
+
* the small ones only, so a maximal store cannot make listing expensive.
|
|
3052
|
+
*/
|
|
3053
|
+
const HISTORY_INDEX_MAX_BYTES = 1048576
|
|
3054
|
+
const historyList = () => enqueue(async () => {
|
|
3055
|
+
const dir = historyDirPath()
|
|
3056
|
+
let names = []
|
|
3057
|
+
try {
|
|
3058
|
+
names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
|
|
3059
|
+
} catch (err) {
|
|
3060
|
+
// A missing directory is not an error: nothing has been written yet.
|
|
3061
|
+
if (err === null || err === undefined || err.code !== 'ENOENT') {
|
|
3062
|
+
return { ok: false, saved: false, message: '无法读取版本历史目录:' + describeError(err), snapshot: snapshot(), history: { dir, items: [] } }
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
const items = []
|
|
3066
|
+
for (const name of names) {
|
|
3067
|
+
const full = join(dir, name)
|
|
3068
|
+
let info = null
|
|
3069
|
+
try {
|
|
3070
|
+
info = await stat(full)
|
|
3071
|
+
} catch {
|
|
3072
|
+
continue
|
|
3073
|
+
}
|
|
3074
|
+
const item = {
|
|
3075
|
+
name,
|
|
3076
|
+
epoch: historyEpochOf(name),
|
|
3077
|
+
rev: historyRevOf(name),
|
|
3078
|
+
device: historyDeviceOf(name),
|
|
3079
|
+
foreign: historyDeviceOf(name) !== DEVICE,
|
|
3080
|
+
bytes: info.size,
|
|
3081
|
+
entries: null,
|
|
3082
|
+
profiles: null,
|
|
3083
|
+
sets: null,
|
|
3084
|
+
}
|
|
3085
|
+
if (info.size <= HISTORY_INDEX_MAX_BYTES) {
|
|
3086
|
+
try {
|
|
3087
|
+
const parsed = JSON.parse(await readFile(full, 'utf8'))
|
|
3088
|
+
item.entries = Array.isArray(parsed.entries) ? parsed.entries.length : 0
|
|
3089
|
+
item.profiles = Array.isArray(parsed.profiles) ? parsed.profiles.length : 0
|
|
3090
|
+
item.sets = Array.isArray(parsed.sets) ? parsed.sets.length : 1
|
|
3091
|
+
} catch {
|
|
3092
|
+
// An unreadable snapshot still lists; restore will report the detail.
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
items.push(item)
|
|
3096
|
+
}
|
|
3097
|
+
items.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
|
|
3098
|
+
state.historyCount = items.length
|
|
3099
|
+
return { ok: true, saved: false, message: null, snapshot: snapshot(), history: { dir, items } }
|
|
3100
|
+
})
|
|
3101
|
+
|
|
3102
|
+
/**
|
|
3103
|
+
* Roll the store back to one snapshot. The snapshot becomes the new CURRENT
|
|
3104
|
+
* state and is written as a new revision (rev only ever moves forward, so a
|
|
3105
|
+
* client holding an older rev is refused rather than silently reverted) —
|
|
3106
|
+
* and, like every other write, it leaves its own snapshot behind, so the
|
|
3107
|
+
* rollback itself can be undone from the history list.
|
|
3108
|
+
*/
|
|
3109
|
+
const historyRestore = (name, baseRev) => enqueue(async () => {
|
|
3110
|
+
if (state.versionRejected) return versionRefusal()
|
|
3111
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3112
|
+
if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
|
|
3113
|
+
return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
|
|
3114
|
+
}
|
|
3115
|
+
let parsed = null
|
|
3116
|
+
try {
|
|
3117
|
+
parsed = JSON.parse(await readFile(join(historyDirPath(), name), 'utf8'))
|
|
3118
|
+
} catch (err) {
|
|
3119
|
+
return { ok: false, saved: false, message: '无法读取该历史快照:' + describeError(err), snapshot: snapshot() }
|
|
3120
|
+
}
|
|
3121
|
+
const adopted = adoptParsedStore(parsed)
|
|
3122
|
+
if (adopted.error !== undefined) {
|
|
3123
|
+
return { ok: false, saved: false, message: '该历史快照不是有效的存储文件:' + adopted.error, snapshot: snapshot() }
|
|
3124
|
+
}
|
|
3125
|
+
if (adopted.rejectedVersion !== undefined) {
|
|
3126
|
+
return {
|
|
3127
|
+
ok: false,
|
|
3128
|
+
saved: false,
|
|
3129
|
+
message: '该历史快照为格式版本 ' + adopted.rejectedVersion + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
|
|
3130
|
+
+ ';已拒绝回滚,当前内容未被修改',
|
|
3131
|
+
snapshot: snapshot(),
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
const { loaded } = adopted
|
|
3135
|
+
const notes = []
|
|
3136
|
+
const warnings = loadWarnings(loaded)
|
|
3137
|
+
if (warnings !== null) notes.push(warnings)
|
|
3138
|
+
const saved = await commit(() => {
|
|
3139
|
+
loaded.data.rev = state.data.rev || 0
|
|
3140
|
+
loaded.data.backupDir = backupDirOf()
|
|
3141
|
+
state.data = loaded.data
|
|
3142
|
+
})
|
|
3143
|
+
const when = new Date(historyEpochOf(name)).toLocaleString()
|
|
3144
|
+
const head = '已回滚到 ' + when + ' 的快照(快照 rev ' + historyRevOf(name) + ',本次写为新 rev ' + (state.data.rev || 0) + '):'
|
|
3145
|
+
+ describeStoreShape(state.data)
|
|
3146
|
+
return settle([head, ...notes].join(';'), saved)
|
|
3147
|
+
})
|
|
3148
|
+
|
|
3149
|
+
/**
|
|
3150
|
+
* Delete one snapshot. `saved` stays false: the STORE was not written, which
|
|
3151
|
+
* is exactly what the field means everywhere else.
|
|
3152
|
+
*/
|
|
3153
|
+
const historyDelete = (name) => enqueue(async () => {
|
|
3154
|
+
if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
|
|
3155
|
+
return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
|
|
3156
|
+
}
|
|
3157
|
+
try {
|
|
3158
|
+
await unlink(join(historyDirPath(), name))
|
|
3159
|
+
} catch (err) {
|
|
3160
|
+
return { ok: false, saved: false, message: '删除历史快照失败:' + describeError(err), snapshot: snapshot() }
|
|
3161
|
+
}
|
|
3162
|
+
await refreshHistoryCount()
|
|
3163
|
+
return { ok: true, saved: false, message: '已删除该历史快照', snapshot: snapshot() }
|
|
3164
|
+
})
|
|
3165
|
+
|
|
1915
3166
|
const reload = () => enqueue(async () => {
|
|
1916
3167
|
// `saved` is only true when the load itself wrote to disk (the
|
|
1917
3168
|
// backup-promotion or legacy-migration branch); a plain re-read claiming
|
|
@@ -1920,6 +3171,18 @@ export function apply(ctx) {
|
|
|
1920
3171
|
syncSection()
|
|
1921
3172
|
return { ok: state.storageError === null, saved: wrote === true, message: state.storageError, snapshot: snapshot() }
|
|
1922
3173
|
})
|
|
3174
|
+
/**
|
|
3175
|
+
* An export is a BACKUP, so it carries the whole store — every set, with
|
|
3176
|
+
* each item's `setId` — not just the active one (F13). `version` is the
|
|
3177
|
+
* envelope's own version; `data.version` is the store format inside it.
|
|
3178
|
+
*/
|
|
3179
|
+
const exportEnvelope = () => ({
|
|
3180
|
+
app: 'dsh-instruction-memory',
|
|
3181
|
+
version: FORMAT_VERSION,
|
|
3182
|
+
exportedAt: new Date().toISOString(),
|
|
3183
|
+
data: persistableData(),
|
|
3184
|
+
})
|
|
3185
|
+
|
|
1923
3186
|
const exportData = () => enqueue(async () => ({
|
|
1924
3187
|
ok: true,
|
|
1925
3188
|
// An export reads the store and never writes it; a truthful `saved:false`
|
|
@@ -1928,10 +3191,135 @@ export function apply(ctx) {
|
|
|
1928
3191
|
message: null,
|
|
1929
3192
|
// A self-describing envelope: re-importable by this plugin, and readable
|
|
1930
3193
|
// as plain JSON by anything else.
|
|
1931
|
-
export:
|
|
3194
|
+
export: exportEnvelope(),
|
|
1932
3195
|
snapshot: snapshot(),
|
|
1933
3196
|
}))
|
|
1934
3197
|
|
|
3198
|
+
/**
|
|
3199
|
+
* Write an export straight into the backup directory (F18) — the "land the
|
|
3200
|
+
* backup in my cloud-synced folder" button. The default directory is the
|
|
3201
|
+
* store directory, so this works before a custom location is configured too.
|
|
3202
|
+
*/
|
|
3203
|
+
const exportToDir = () => enqueue(async () => {
|
|
3204
|
+
const dir = backupDirOf() !== '' ? backupDirOf() : dirname(storePath)
|
|
3205
|
+
const target = join(dir, exportFileName(Date.now()))
|
|
3206
|
+
try {
|
|
3207
|
+
await mkdir(dir, { recursive: true })
|
|
3208
|
+
await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', 'utf8')
|
|
3209
|
+
} catch (err) {
|
|
3210
|
+
return { ok: false, saved: false, message: '写入备份目录失败:' + describeError(err), snapshot: snapshot() }
|
|
3211
|
+
}
|
|
3212
|
+
return { ok: true, saved: false, message: '已写入 ' + target, snapshot: snapshot() }
|
|
3213
|
+
})
|
|
3214
|
+
|
|
3215
|
+
/**
|
|
3216
|
+
* The raw store text for 源码模式 (F16): exactly the bytes a save would write.
|
|
3217
|
+
* Editing it is therefore equivalent to editing memory.json — but it goes
|
|
3218
|
+
* through the same loader, version gate, disclosures and rev check as every
|
|
3219
|
+
* other write instead of being trusted because it came from the UI.
|
|
3220
|
+
*/
|
|
3221
|
+
const readSource = () => enqueue(async () => {
|
|
3222
|
+
/**
|
|
3223
|
+
* While the file on disk declares an unsupported version, the in-memory
|
|
3224
|
+
* state is a placeholder (nothing was adopted), so handing it out as "the
|
|
3225
|
+
* source" would show an EMPTY store and invite the user to save it over
|
|
3226
|
+
* their real file. The documented way out of that state is to fix the file
|
|
3227
|
+
* (or upgrade the plugin) and hit 重新载入 — so say that instead.
|
|
3228
|
+
*/
|
|
3229
|
+
if (state.versionRejected) {
|
|
3230
|
+
return {
|
|
3231
|
+
ok: false,
|
|
3232
|
+
saved: false,
|
|
3233
|
+
message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',源码模式已暂停:请先用编辑器修改 '
|
|
3234
|
+
+ storePath + '(或升级插件)后点「重新载入」',
|
|
3235
|
+
snapshot: snapshot(),
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
// Opening source mode is a deliberate user action, so it is also allowed to
|
|
3239
|
+
// materialise a first-run store (same rule as the `state` method).
|
|
3240
|
+
const materialized = await ensureStoreMaterialized()
|
|
3241
|
+
return { ok: true, saved: materialized === true, message: null, source: serialize(), snapshot: snapshot() }
|
|
3242
|
+
})
|
|
3243
|
+
|
|
3244
|
+
/**
|
|
3245
|
+
* The shared core of 源码保存 (F16) and 整体替换恢复 (F14): adopt a whole
|
|
3246
|
+
* parsed store and commit it in one transaction, reporting every correction
|
|
3247
|
+
* the loader had to make. Callers add their own opening line.
|
|
3248
|
+
*/
|
|
3249
|
+
const commitAdoptedStore = async (parsed, head) => {
|
|
3250
|
+
const adopted = adoptParsedStore(parsed)
|
|
3251
|
+
if (adopted.error !== undefined) {
|
|
3252
|
+
return { ok: false, saved: false, message: adopted.error, snapshot: snapshot() }
|
|
3253
|
+
}
|
|
3254
|
+
if (adopted.rejectedVersion !== undefined) {
|
|
3255
|
+
return {
|
|
3256
|
+
ok: false,
|
|
3257
|
+
saved: false,
|
|
3258
|
+
message: '内容声明为格式版本 ' + adopted.rejectedVersion + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
|
|
3259
|
+
+ ';已拒绝保存,当前内容未被修改',
|
|
3260
|
+
snapshot: snapshot(),
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
const { loaded } = adopted
|
|
3264
|
+
const notes = []
|
|
3265
|
+
const warnings = loadWarnings(loaded)
|
|
3266
|
+
if (warnings !== null) notes.push(warnings)
|
|
3267
|
+
const truncation = truncationNotes(parsed)
|
|
3268
|
+
if (truncation !== null) notes.push(truncation)
|
|
3269
|
+
const saved = await commit(() => {
|
|
3270
|
+
// rev only ever moves forward: a restore is a NEW revision of the store,
|
|
3271
|
+
// so a client holding an older one is refused rather than silently
|
|
3272
|
+
// reverted (and `baseRev` conflict handling stays meaningful).
|
|
3273
|
+
loaded.data.rev = state.data.rev || 0
|
|
3274
|
+
loaded.data.backupDir = backupDirOf()
|
|
3275
|
+
state.data = loaded.data
|
|
3276
|
+
})
|
|
3277
|
+
return settle([head + describeStoreShape(state.data), ...notes].join(';'), saved)
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
const writeSource = (text, baseRev) => enqueue(async () => {
|
|
3281
|
+
if (state.versionRejected) return versionRefusal()
|
|
3282
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3283
|
+
if (typeof text !== 'string') {
|
|
3284
|
+
return { ok: false, saved: false, message: '源码必须是 JSON 文本', snapshot: snapshot() }
|
|
3285
|
+
}
|
|
3286
|
+
if (text.length > MAX_BODY_CHARS) {
|
|
3287
|
+
return {
|
|
3288
|
+
ok: false,
|
|
3289
|
+
saved: false,
|
|
3290
|
+
message: '源码过长:' + text.length + ' 字符,上限 ' + MAX_BODY_CHARS + ' 字符',
|
|
3291
|
+
snapshot: snapshot(),
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
let parsed = null
|
|
3295
|
+
try {
|
|
3296
|
+
parsed = JSON.parse(text)
|
|
3297
|
+
} catch (err) {
|
|
3298
|
+
return { ok: false, saved: false, message: '源码不是有效的 JSON:' + describeError(err), snapshot: snapshot() }
|
|
3299
|
+
}
|
|
3300
|
+
return commitAdoptedStore(parsed, '已按源码保存:')
|
|
3301
|
+
})
|
|
3302
|
+
|
|
3303
|
+
/**
|
|
3304
|
+
* Replace the whole store from a backup file — the "用备份整体替换" door
|
|
3305
|
+
* (F14). Import merges by design and can never restore an older whole-store
|
|
3306
|
+
* state; this is that missing path, and it is explicit: the panel confirms
|
|
3307
|
+
* first, and nothing is unrecoverable (the replaced content stays in
|
|
3308
|
+
* memory.json.bak and, whenever history is writable, in the version list).
|
|
3309
|
+
*/
|
|
3310
|
+
const restoreData = (payload, baseRev) => enqueue(async () => {
|
|
3311
|
+
if (state.versionRejected) return versionRefusal()
|
|
3312
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3313
|
+
if (payload === null || typeof payload !== 'object') {
|
|
3314
|
+
return { ok: false, saved: false, message: '恢复内容不是有效的 JSON 对象', snapshot: snapshot() }
|
|
3315
|
+
}
|
|
3316
|
+
// Accept our own export envelope as well as a bare store object.
|
|
3317
|
+
const inner = payload.data !== null && typeof payload.data === 'object' && !Array.isArray(payload.data)
|
|
3318
|
+
? payload.data
|
|
3319
|
+
: payload
|
|
3320
|
+
return commitAdoptedStore(inner, '已用备份整体替换当前记忆:')
|
|
3321
|
+
})
|
|
3322
|
+
|
|
1935
3323
|
const importData = (payload, baseRev) => enqueue(async () => {
|
|
1936
3324
|
if (state.versionRejected) return versionRefusal()
|
|
1937
3325
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
@@ -1945,26 +3333,56 @@ export function apply(ctx) {
|
|
|
1945
3333
|
: (Array.isArray(inner.entries) ? inner.entries : null)
|
|
1946
3334
|
const rawProfiles = Array.isArray(payload.profiles) ? payload.profiles
|
|
1947
3335
|
: (Array.isArray(inner.profiles) ? inner.profiles : null)
|
|
3336
|
+
// Our own export also carries the set list, which is what lets an import
|
|
3337
|
+
// recreate the sets its items belong to instead of dumping everything into
|
|
3338
|
+
// the active one.
|
|
3339
|
+
const rawSets = Array.isArray(payload.sets) ? payload.sets
|
|
3340
|
+
: (Array.isArray(inner.sets) ? inner.sets : null)
|
|
1948
3341
|
if ((rawEntries === null || rawEntries.length === 0)
|
|
1949
3342
|
&& (rawProfiles === null || rawProfiles.length === 0)) {
|
|
1950
3343
|
return { ok: false, saved: false, message: '导入文件中没有找到条目或用户形象', snapshot: snapshot() }
|
|
1951
3344
|
}
|
|
3345
|
+
/**
|
|
3346
|
+
* Set mapping (F13). An imported item is filed under the set it names when
|
|
3347
|
+
* that set already exists here, or when the payload DECLARES it (our own
|
|
3348
|
+
* export carries the sets array) and there is room to create it. Anything
|
|
3349
|
+
* else lands in the active set, and how many did so is disclosed — the
|
|
3350
|
+
* alternative, dropping items whose set is unknown, would be data loss.
|
|
3351
|
+
*/
|
|
3352
|
+
const localSetIds = new Set(state.data.sets.map((set) => set.id))
|
|
3353
|
+
const declaredSets = []
|
|
3354
|
+
const declaredIds = new Set()
|
|
3355
|
+
for (const candidate of (Array.isArray(rawSets) ? rawSets : [])) {
|
|
3356
|
+
const set = sanitizeSet(candidate)
|
|
3357
|
+
if (set === null || localSetIds.has(set.id) || declaredIds.has(set.id)) continue
|
|
3358
|
+
declaredIds.add(set.id)
|
|
3359
|
+
declaredSets.push(set)
|
|
3360
|
+
}
|
|
3361
|
+
const activeId = state.data.activeSetId
|
|
1952
3362
|
// 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
|
|
3363
|
+
// Duplicates (same id within the same set, or same title+content within the
|
|
3364
|
+
// same set) and invalid items are skipped and counted, not silently
|
|
3365
|
+
// dropped. De-duplicating per set is what makes re-importing idempotent
|
|
3366
|
+
// while still allowing the same instruction to exist in two sets.
|
|
1955
3367
|
//
|
|
1956
3368
|
// The whole plan is computed BEFORE the store is touched, so the merge and
|
|
1957
3369
|
// its counters cannot disagree with what actually gets appended.
|
|
1958
3370
|
const existing = state.data.entries
|
|
1959
|
-
const
|
|
1960
|
-
const
|
|
3371
|
+
const bodyKey = (setId, title, content) => setId + '\u0000' + title + '\u0000' + content
|
|
3372
|
+
const seenIds = new Set(existing.map((entry) => entry.setId + '\u0000' + entry.id))
|
|
3373
|
+
const seenBodies = new Set(existing.map((entry) => bodyKey(entry.setId, entry.title, entry.content)))
|
|
1961
3374
|
const accepted = []
|
|
1962
3375
|
const cutFields = new Set()
|
|
1963
3376
|
let truncatedCount = 0
|
|
1964
3377
|
let skipped = 0
|
|
1965
3378
|
for (const candidate of (rawEntries || [])) {
|
|
1966
3379
|
const entry = sanitizeEntry(candidate)
|
|
1967
|
-
if (entry === null
|
|
3380
|
+
if (entry === null) {
|
|
3381
|
+
skipped += 1
|
|
3382
|
+
continue
|
|
3383
|
+
}
|
|
3384
|
+
const wanted = entry.setId !== '' ? entry.setId : activeId
|
|
3385
|
+
if (seenIds.has(wanted + '\u0000' + entry.id) || seenBodies.has(bodyKey(wanted, entry.title, entry.content))) {
|
|
1968
3386
|
skipped += 1
|
|
1969
3387
|
continue
|
|
1970
3388
|
}
|
|
@@ -1982,25 +3400,29 @@ export function apply(ctx) {
|
|
|
1982
3400
|
for (const field of cut) cutFields.add(field)
|
|
1983
3401
|
}
|
|
1984
3402
|
entry.updatedAt = Date.now()
|
|
1985
|
-
accepted.push(entry)
|
|
1986
|
-
seenIds.add(entry.id)
|
|
1987
|
-
seenBodies.add(entry.title
|
|
3403
|
+
accepted.push({ entry, wanted })
|
|
3404
|
+
seenIds.add(wanted + '\u0000' + entry.id)
|
|
3405
|
+
seenBodies.add(bodyKey(wanted, entry.title, entry.content))
|
|
1988
3406
|
}
|
|
1989
3407
|
// Persona profiles merge under the same rules (same id, or same
|
|
1990
3408
|
// name+content) in the SAME transaction, so a mixed import never lands
|
|
1991
3409
|
// half-way.
|
|
1992
3410
|
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))
|
|
3411
|
+
const seenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
|
|
3412
|
+
const seenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
|
|
1995
3413
|
const acceptedProfiles = []
|
|
1996
3414
|
const cutProfileFields = new Set()
|
|
1997
3415
|
let truncatedProfileCount = 0
|
|
1998
3416
|
let skippedProfiles = 0
|
|
1999
3417
|
for (const candidate of (rawProfiles || [])) {
|
|
2000
3418
|
const profile = sanitizeProfile(candidate)
|
|
2001
|
-
if (profile === null
|
|
2002
|
-
|
|
2003
|
-
|
|
3419
|
+
if (profile === null) {
|
|
3420
|
+
skippedProfiles += 1
|
|
3421
|
+
continue
|
|
3422
|
+
}
|
|
3423
|
+
const wanted = profile.setId !== '' ? profile.setId : activeId
|
|
3424
|
+
if (seenProfileIds.has(wanted + '\u0000' + profile.id)
|
|
3425
|
+
|| seenProfileBodies.has(wanted + '\u0000' + profile.name + '\u0000' + profile.content)) {
|
|
2004
3426
|
skippedProfiles += 1
|
|
2005
3427
|
continue
|
|
2006
3428
|
}
|
|
@@ -2014,25 +3436,121 @@ export function apply(ctx) {
|
|
|
2014
3436
|
for (const field of cut) cutProfileFields.add(field)
|
|
2015
3437
|
}
|
|
2016
3438
|
profile.updatedAt = Date.now()
|
|
2017
|
-
acceptedProfiles.push(profile)
|
|
2018
|
-
seenProfileIds.add(profile.id)
|
|
2019
|
-
seenProfileBodies.add(profile.name + '\u0000' + profile.content)
|
|
3439
|
+
acceptedProfiles.push({ profile, wanted })
|
|
3440
|
+
seenProfileIds.add(wanted + '\u0000' + profile.id)
|
|
3441
|
+
seenProfileBodies.add(wanted + '\u0000' + profile.name + '\u0000' + profile.content)
|
|
3442
|
+
}
|
|
3443
|
+
// Create only the declared sets that the accepted items actually reference,
|
|
3444
|
+
// in declaration order, while there is room.
|
|
3445
|
+
const wantedIds = []
|
|
3446
|
+
for (const item of [...accepted, ...acceptedProfiles]) {
|
|
3447
|
+
if (!localSetIds.has(item.wanted) && !wantedIds.includes(item.wanted)) wantedIds.push(item.wanted)
|
|
3448
|
+
}
|
|
3449
|
+
const createdSets = []
|
|
3450
|
+
const createdIds = new Set()
|
|
3451
|
+
for (const id of wantedIds) {
|
|
3452
|
+
if (state.data.sets.length + createdSets.length >= MAX_SETS) break
|
|
3453
|
+
const declared = declaredSets.find((set) => set.id === id)
|
|
3454
|
+
if (declared === undefined) continue
|
|
3455
|
+
createdSets.push({ id: declared.id, name: declared.name })
|
|
3456
|
+
createdIds.add(declared.id)
|
|
2020
3457
|
}
|
|
2021
|
-
|
|
3458
|
+
let fellBack = 0
|
|
3459
|
+
const finalIdOf = (wanted) => (localSetIds.has(wanted) || createdIds.has(wanted) ? wanted : activeId)
|
|
3460
|
+
/**
|
|
3461
|
+
* Fallback de-duplication (review fix). The plan phase de-duplicated
|
|
3462
|
+
* against the set each item DECLARED, but finalIdOf() may retarget an item
|
|
3463
|
+
* into the active set (its set is unknown here, or the MAX_SETS cap blocked
|
|
3464
|
+
* creating it). Every retargeted item is therefore re-checked against the
|
|
3465
|
+
* set it REALLY lands in — pre-existing content, same-batch items already
|
|
3466
|
+
* staged there, and same-batch items that reached it through the normal
|
|
3467
|
+
* path — before anything is appended. The normal path (set exists or was
|
|
3468
|
+
* created) keeps the plan-phase decision untouched, which is what keeps
|
|
3469
|
+
* re-importing the same export file a no-op.
|
|
3470
|
+
*/
|
|
3471
|
+
const stagedEntries = []
|
|
3472
|
+
const stagedProfiles = []
|
|
3473
|
+
let dedupedOnFallback = 0
|
|
3474
|
+
{
|
|
3475
|
+
const finalSeenIds = new Set(existing.map((entry) => entry.setId + '\u0000' + entry.id))
|
|
3476
|
+
const finalSeenBodies = new Set(existing.map((entry) => bodyKey(entry.setId, entry.title, entry.content)))
|
|
3477
|
+
// Items the plan phase accepted under their declared set occupy that
|
|
3478
|
+
// set's id/body space even when the item itself later falls back (a
|
|
3479
|
+
// fallback item's declared set is neither local nor created, so the keys
|
|
3480
|
+
// never collide with these — they only guard the normal path).
|
|
3481
|
+
for (const item of accepted) {
|
|
3482
|
+
finalSeenIds.add(item.wanted + '\u0000' + item.entry.id)
|
|
3483
|
+
finalSeenBodies.add(bodyKey(item.wanted, item.entry.title, item.entry.content))
|
|
3484
|
+
}
|
|
3485
|
+
for (const item of accepted) {
|
|
3486
|
+
const finalId = finalIdOf(item.wanted)
|
|
3487
|
+
if (finalId !== item.wanted) {
|
|
3488
|
+
if (finalSeenBodies.has(bodyKey(finalId, item.entry.title, item.entry.content))) {
|
|
3489
|
+
dedupedOnFallback += 1
|
|
3490
|
+
continue
|
|
3491
|
+
}
|
|
3492
|
+
if (finalSeenIds.has(finalId + '\u0000' + item.entry.id)) {
|
|
3493
|
+
// The body is new to the fallback target but the id is taken
|
|
3494
|
+
// there: re-mint instead of writing a duplicate id into one set.
|
|
3495
|
+
item.entry.id = makeId()
|
|
3496
|
+
}
|
|
3497
|
+
fellBack += 1
|
|
3498
|
+
finalSeenIds.add(finalId + '\u0000' + item.entry.id)
|
|
3499
|
+
finalSeenBodies.add(bodyKey(finalId, item.entry.title, item.entry.content))
|
|
3500
|
+
}
|
|
3501
|
+
item.entry.setId = finalId
|
|
3502
|
+
stagedEntries.push(item.entry)
|
|
3503
|
+
}
|
|
3504
|
+
const finalSeenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
|
|
3505
|
+
const finalSeenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
|
|
3506
|
+
for (const item of acceptedProfiles) {
|
|
3507
|
+
finalSeenProfileIds.add(item.wanted + '\u0000' + item.profile.id)
|
|
3508
|
+
finalSeenProfileBodies.add(item.wanted + '\u0000' + item.profile.name + '\u0000' + item.profile.content)
|
|
3509
|
+
}
|
|
3510
|
+
for (const item of acceptedProfiles) {
|
|
3511
|
+
const finalId = finalIdOf(item.wanted)
|
|
3512
|
+
if (finalId !== item.wanted) {
|
|
3513
|
+
if (finalSeenProfileBodies.has(finalId + '\u0000' + item.profile.name + '\u0000' + item.profile.content)) {
|
|
3514
|
+
dedupedOnFallback += 1
|
|
3515
|
+
continue
|
|
3516
|
+
}
|
|
3517
|
+
if (finalSeenProfileIds.has(finalId + '\u0000' + item.profile.id)) {
|
|
3518
|
+
item.profile.id = makeId()
|
|
3519
|
+
}
|
|
3520
|
+
fellBack += 1
|
|
3521
|
+
finalSeenProfileIds.add(finalId + '\u0000' + item.profile.id)
|
|
3522
|
+
finalSeenProfileBodies.add(finalId + '\u0000' + item.profile.name + '\u0000' + item.profile.content)
|
|
3523
|
+
}
|
|
3524
|
+
item.profile.setId = finalId
|
|
3525
|
+
stagedProfiles.push(item.profile)
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
if (stagedEntries.length === 0 && stagedProfiles.length === 0) {
|
|
2022
3529
|
// Nothing was written to disk, so claiming saved:true would be a lie the
|
|
2023
3530
|
// panel cannot distinguish from a real save.
|
|
2024
3531
|
return {
|
|
2025
3532
|
ok: true,
|
|
2026
3533
|
saved: false,
|
|
2027
|
-
message: '没有新增内容:' + skipped + ' 条条目、' + skippedProfiles + ' 个用户形象已存在或无效'
|
|
3534
|
+
message: '没有新增内容:' + skipped + ' 条条目、' + skippedProfiles + ' 个用户形象已存在或无效'
|
|
3535
|
+
+ (dedupedOnFallback > 0 ? ';另有 ' + dedupedOnFallback + ' 项因回退到当前配置集后与已有内容重复被跳过' : ''),
|
|
2028
3536
|
snapshot: snapshot(),
|
|
2029
3537
|
}
|
|
2030
3538
|
}
|
|
2031
3539
|
const saved = await commit(() => {
|
|
2032
|
-
state.data.
|
|
2033
|
-
state.data.
|
|
3540
|
+
state.data.sets.push(...createdSets)
|
|
3541
|
+
for (const entry of stagedEntries) state.data.entries.push(entry)
|
|
3542
|
+
for (const profile of stagedProfiles) state.data.profiles.push(profile)
|
|
2034
3543
|
})
|
|
2035
3544
|
const parts = []
|
|
3545
|
+
if (createdSets.length > 0) {
|
|
3546
|
+
parts.push('新建配置集 ' + createdSets.length + ' 个(' + createdSets.map((set) => '「' + set.name + '」').join('、') + ')')
|
|
3547
|
+
}
|
|
3548
|
+
if (fellBack > 0) {
|
|
3549
|
+
parts.push('有 ' + fellBack + ' 项因原配置集不存在或已达 ' + MAX_SETS + ' 个上限,已归入当前配置集')
|
|
3550
|
+
}
|
|
3551
|
+
if (dedupedOnFallback > 0) {
|
|
3552
|
+
parts.push('另有 ' + dedupedOnFallback + ' 项因回退到当前配置集后与已有内容重复被跳过')
|
|
3553
|
+
}
|
|
2036
3554
|
if (accepted.length > 0) {
|
|
2037
3555
|
const truncated = truncatedCount > 0
|
|
2038
3556
|
? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
|
|
@@ -2078,6 +3596,43 @@ export function apply(ctx) {
|
|
|
2078
3596
|
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
2079
3597
|
case 'set-options':
|
|
2080
3598
|
return setOptions(args, args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3599
|
+
case 'save-set':
|
|
3600
|
+
return saveSet(
|
|
3601
|
+
args !== null && typeof args === 'object' ? args.set : null,
|
|
3602
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3603
|
+
case 'delete-set':
|
|
3604
|
+
return deleteSet(
|
|
3605
|
+
args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
|
|
3606
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3607
|
+
case 'set-active-set':
|
|
3608
|
+
return setActiveSet(
|
|
3609
|
+
args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
|
|
3610
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3611
|
+
case 'duplicate-set':
|
|
3612
|
+
return duplicateSet(
|
|
3613
|
+
args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
|
|
3614
|
+
args !== null && typeof args === 'object' ? args.name : undefined,
|
|
3615
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3616
|
+
case 'history-list':
|
|
3617
|
+
return historyList()
|
|
3618
|
+
case 'history-restore':
|
|
3619
|
+
return historyRestore(
|
|
3620
|
+
args !== null && typeof args === 'object' && typeof args.name === 'string' ? args.name : '',
|
|
3621
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3622
|
+
case 'history-delete':
|
|
3623
|
+
return historyDelete(args !== null && typeof args === 'object' && typeof args.name === 'string' ? args.name : '')
|
|
3624
|
+
case 'read-source':
|
|
3625
|
+
return readSource()
|
|
3626
|
+
case 'write-source':
|
|
3627
|
+
return writeSource(
|
|
3628
|
+
args !== null && typeof args === 'object' ? args.text : null,
|
|
3629
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3630
|
+
case 'restore-data':
|
|
3631
|
+
return restoreData(
|
|
3632
|
+
args !== null && typeof args === 'object' ? args.payload : null,
|
|
3633
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
3634
|
+
case 'export-to-dir':
|
|
3635
|
+
return exportToDir()
|
|
2081
3636
|
case 'reload':
|
|
2082
3637
|
return reload()
|
|
2083
3638
|
case 'export-data':
|
|
@@ -2107,11 +3662,8 @@ export function apply(ctx) {
|
|
|
2107
3662
|
|
|
2108
3663
|
/* ---------------- same-origin JSON route for the Client half ---------------- */
|
|
2109
3664
|
|
|
2110
|
-
//
|
|
2111
|
-
//
|
|
2112
|
-
// requestOriginVerdict). Until then the route does not exist at all, so the
|
|
2113
|
-
// default only matters if something registers it out of band.
|
|
2114
|
-
let guardHost = true
|
|
3665
|
+
// `guardHost` is declared next to `state` (F12 publishes it as
|
|
3666
|
+
// `security.hostGuarded`); only `routeDispose` lives here.
|
|
2115
3667
|
|
|
2116
3668
|
let routeDispose = null
|
|
2117
3669
|
|
|
@@ -2286,6 +3838,9 @@ export function apply(ctx) {
|
|
|
2286
3838
|
// the README.
|
|
2287
3839
|
const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
|
|
2288
3840
|
guardHost = boundHost === '' || isLoopbackName(boundHost)
|
|
3841
|
+
// F12: the settings page can only warn about a non-loopback bind if the
|
|
3842
|
+
// snapshot carries what the route actually saw at registration time.
|
|
3843
|
+
state.boundHost = boundHost
|
|
2289
3844
|
try {
|
|
2290
3845
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
2291
3846
|
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|