dsh-plugin-term-dictionary 0.0.0-stage → 1.1.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.
@@ -0,0 +1,1187 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The dictionary document and the operations over it.
5
+ *
6
+ * This file is deliberately free of `node:` and DOM imports: the host half hands
7
+ * it a file system through {@link createFileStore}, and the browser half hands it
8
+ * `localStorage` through {@link createBrowserStore}. The host and the page then
9
+ * apply exactly the same merge rules, which is what keeps an edit made offline in
10
+ * the page from being mangled when it reaches the file.
11
+ */
12
+
13
+ const entries = require("./entries.js");
14
+
15
+ const { normalizeEntry, mergeEntry, isUnexplained, isFlagged, normalizeFeedback, matchesQuery } = entries;
16
+
17
+ /** Current document schema version. */
18
+ const SCHEMA_VERSION = 1;
19
+
20
+ /** Upper bound on stored entries, so an accidental detector loop cannot grow without limit. */
21
+ const MAX_ENTRIES = 5000;
22
+
23
+ /**
24
+ * Freeze a document and keep its tombstones out of `entries`.
25
+ *
26
+ * The invariant this enforces, and the reason it is one function rather than a
27
+ * rule every caller has to remember:
28
+ *
29
+ * - `entries` holds live entries only. Every reader — the panel, the popup, the
30
+ * detector, the JSON export, the HTTP response — reads `entries` and therefore
31
+ * cannot see a deleted term.
32
+ * - `deletedKeys` names the deleted terms, in one flat array, so the two
33
+ * questions stay separable: "may I show this?" is answered by `entries`, and
34
+ * "is this key deleted?" is answered by `deletedKeys`. Merging a deleted term
35
+ * back into `entries` would answer the second question wrongly.
36
+ * - `backing` are the tombstones themselves, needed only when a document is
37
+ * merged with another. Callers never read it; they pass the whole document to
38
+ * {@link mergeDocuments}, which uses it.
39
+ *
40
+ * @param state - the document to seal, or a bare record list; tombstones or not.
41
+ * @param options - `now` seeds the timestamp when the document has none.
42
+ * @returns the frozen, reader-safe document.
43
+ */
44
+ function seal(state, options) {
45
+ // An array is accepted for convenience and means "these are the records"; a
46
+ // document is read through `recordsOf`, which knows where its tombstones live.
47
+ const all = Array.isArray(state) ? state : recordsOf(state);
48
+ // `=== 0` rather than falsy: a record that simply lacks the field is live, matching
49
+ // `normalizeEntry`. Testing `deletedAt === 0` would make such a record neither
50
+ // live nor deleted and drop it silently.
51
+ const live = all.filter((entry) => (entry.deletedAt ?? 0) === 0);
52
+ const dead = all.filter((entry) => (entry.deletedAt ?? 0) > 0);
53
+ const freeze = (entry) => Object.freeze({ ...entry, definition: Object.freeze({ ...entry.definition }) });
54
+ return Object.freeze({
55
+ schemaVersion: SCHEMA_VERSION,
56
+ updatedAt: typeof state?.updatedAt === "number" ? state.updatedAt : options?.now ?? 0,
57
+ entries: Object.freeze(live.map(freeze)),
58
+ deletedKeys: Object.freeze([...new Set(dead.map((entry) => entry.key))]),
59
+ backing: Object.freeze(dead.map(freeze))
60
+ });
61
+ }
62
+
63
+ /**
64
+ * The tombstones a document carries.
65
+ *
66
+ * `backing` is authoritative when it is present: a document that has been sealed
67
+ * once keeps its tombstones there, and `entries` is empty of them. Reading both
68
+ * would count a tombstone twice and duplicate it on every re-seal.
69
+ *
70
+ * @param state - a document, sealed or raw.
71
+ * @returns the deleted markers.
72
+ */
73
+ function tombstonesOf(state) {
74
+ if (Array.isArray(state?.backing)) return state.backing;
75
+ return (state?.entries ?? []).filter((entry) => entry.deletedAt > 0);
76
+ }
77
+
78
+ /**
79
+ * Every record a document holds: live entries plus tombstones.
80
+ *
81
+ * Deduplicated by id, because a raw (unsealed) document may hold the same record in
82
+ * both places — that happens when a tombstone is produced and the caller keeps the
83
+ * pre-split list. Without the dedupe a tombstone would be copied twice on the next
84
+ * seal.
85
+ *
86
+ * @param state - a document.
87
+ * @returns the records to persist or merge.
88
+ */
89
+ function recordsOf(state) {
90
+ const byId = new Map();
91
+ for (const entry of [...(state?.entries ?? []), ...tombstonesOf(state)]) {
92
+ if (byId.has(entry.id)) continue;
93
+ byId.set(entry.id, entry);
94
+ }
95
+ return [...byId.values()];
96
+ }
97
+
98
+ /**
99
+ * Build a frozen document from a raw record list.
100
+ *
101
+ * The split is done here and handed to {@link seal} as an already-separated
102
+ * document: passing the raw list straight through would make `seal` treat the
103
+ * tombstones as both live records and backing at once, duplicating them.
104
+ *
105
+ * @param records - live entries plus tombstones.
106
+ * @param options - `now` seeds the timestamp.
107
+ * @returns the frozen document.
108
+ */
109
+ function sealRecords(records, options) {
110
+ const list = Array.isArray(records) ? records : [];
111
+ // `?? 0` matches `normalizeEntry`: a record that simply lacks `deletedAt` is live.
112
+ // Filtering on `=== 0` dropped such a record from both lists, which silently lost
113
+ // terms whose producer had not set the field.
114
+ return seal(
115
+ {
116
+ updatedAt: options?.now ?? 0,
117
+ entries: list.filter((entry) => (entry.deletedAt ?? 0) === 0),
118
+ backing: list.filter((entry) => (entry.deletedAt ?? 0) > 0)
119
+ },
120
+ options
121
+ );
122
+ }
123
+
124
+ /**
125
+ * An empty document.
126
+ * @param options - `now` seeds the timestamp.
127
+ * @returns a sealed empty state.
128
+ */
129
+ function emptyState(options) {
130
+ return seal([], options);
131
+ }
132
+
133
+ /**
134
+ * Normalize an untrusted document, dropping every record that cannot be
135
+ * repaired. Accepts a bare array too, because an early version of the file and a
136
+ * hand-edited file are both plausible.
137
+ *
138
+ * `deletedKeys` is accepted as an input as well as produced: a client that never
139
+ * held a tombstone — a second window, a cleared storage — learns which terms were
140
+ * deleted from the host's answer and can build the markers itself.
141
+ *
142
+ * @param value - parsed JSON from the file, the network or local storage.
143
+ * @param options - `now` seeds timestamps for records missing them.
144
+ * @returns a document carrying its tombstones in `backing`, invisible to readers.
145
+ */
146
+ function normalizeState(value, options) {
147
+ const source = Array.isArray(value) ? { entries: value } : value;
148
+ if (source === null || typeof source !== "object") return emptyState(options);
149
+ const now = options?.now ?? Date.now();
150
+ const updatedAt = typeof source.updatedAt === "number" && source.updatedAt > 0 ? source.updatedAt : now;
151
+ // Each record arrives in one of two channels whose names state which they are:
152
+ // `entries` are live and `backing` are tombstones. A record whose own `deletedAt`
153
+ // contradicts the channel it arrived in is malformed, and it is resolved toward the
154
+ // channel rather than toward the record: `backing` is the deletion channel, and
155
+ // honouring a "live" record found there would let a payload smuggle live content
156
+ // past the deletion rules by putting it in the tombstone list.
157
+ const raw = [
158
+ ...(Array.isArray(source.entries) ? source.entries : []).map((candidate) => ({ candidate, tombstone: false })),
159
+ ...(Array.isArray(source.backing) ? source.backing : []).map((candidate) => ({ candidate, tombstone: true }))
160
+ ];
161
+ // A bare key list carries no entry to merge, so a marker is synthesized for it.
162
+ const explicitDeletions = Array.isArray(source.deletedKeys) ? source.deletedKeys.filter((key) => typeof key === "string" && key.trim() !== "") : [];
163
+ const byId = new Map();
164
+ for (const { candidate, tombstone } of raw) {
165
+ const normalized = normalizeEntry(candidate, options);
166
+ if (normalized === null) continue;
167
+ const entry = tombstone && normalized.deletedAt === 0
168
+ ? { ...normalized, deletedAt: Math.max(updatedAt, (normalized.updatedAt ?? 0) + 1) }
169
+ : !tombstone && normalized.deletedAt > 0
170
+ ? { ...normalized, deletedAt: 0 }
171
+ : normalized;
172
+ const existing = byId.get(entry.id);
173
+ byId.set(entry.id, existing === undefined ? entry : mergeContent(existing, entry, options));
174
+ }
175
+ // A bare key is a deletion whose time is unknown, and it stays that way. Turning
176
+ // it into a tombstone stamped with the current time would invent an edit-ordering
177
+ // fact that nobody supplied, and the merge would then use it to overrule a real
178
+ // edit. The key is carried forward as a key; only a merge turns it into a
179
+ // deletion, and only for a record nobody has curated.
180
+ const records = [...byId.values()].sort((left, right) => right.lastSeenAt - left.lastSeenAt || left.term.localeCompare(right.term));
181
+ const sealed = sealRecords(records.slice(0, MAX_ENTRIES), { now: updatedAt });
182
+ return explicitDeletions.length === 0
183
+ ? sealed
184
+ : Object.freeze({ ...sealed, deletedKeys: Object.freeze([...new Set([...sealed.deletedKeys, ...explicitDeletions])]) });
185
+ }
186
+
187
+ /**
188
+ * Merge two documents into one, honouring tombstones.
189
+ *
190
+ * This is the only place a deletion is decided. A tombstone from either side wins
191
+ * unless the other side carries a strictly newer edit, which is what makes a
192
+ * deletion survive a round trip through a union merge.
193
+ *
194
+ * @param base - the local document.
195
+ * @param incoming - the document received from the other side.
196
+ * @param options - `now` seeds timestamps, and `deletedKeys` carries the deletion
197
+ * notices the incoming document announced. They are passed in rather than read
198
+ * off `incoming` because normalization already resolved a notice against the
199
+ * concrete record beside it, which is right for a single document but would lose
200
+ * the notice here, where both signals have to be weighed.
201
+ * @returns the merged document, its tombstones in `backing`.
202
+ */
203
+ function mergeDocuments(base, incoming, options) {
204
+ const records = new Map();
205
+ for (const entry of recordsOf(base)) records.set(entry.id, entry);
206
+ // Notices are collected first, then the union is computed, and only then is the
207
+ // deletion decided — once per term. Applying a notice before the merge (and
208
+ // overwriting whatever was there) is what made the result order-dependent: a
209
+ // notice arriving with an older stamp than a live record would destroy that
210
+ // record instead of losing to it.
211
+ const notices = collectNotices(base, incoming, options);
212
+ // `incomingRecords` carries the `observed` marker, which normalization strips
213
+ // because it describes how an arrival is used rather than what is stored; the
214
+ // notices still come from the normalized document.
215
+ const arriving = Array.isArray(options?.incomingRecords) ? options.incomingRecords : recordsOf(incoming);
216
+ // The newest time the USER asked for each term, gathered from the two records each
217
+ // merge actually compared. It cannot be read off the merged record: the record's
218
+ // `updatedAt` follows the content that won, so a user's prompt for an explanation —
219
+ // which is a genuine request to revive — would be discarded along with the
220
+ // explanation when the existing definition outranked it.
221
+ const craftedAt = new Map();
222
+ const remember = (record) => {
223
+ if (record === undefined || record === null) return;
224
+ const newest = craftedTimeOf([record]);
225
+ if (newest > 0) craftedAt.set(record.id, Math.max(craftedAt.get(record.id) ?? 0, newest));
226
+ };
227
+ for (const entry of arriving) {
228
+ const existing = records.get(entry.id);
229
+ if (existing === undefined) {
230
+ records.set(entry.id, entry);
231
+ remember(entry);
232
+ continue;
233
+ }
234
+ // The document-level notice is passed through: the two records alone cannot
235
+ // express a deletion that lives in `backing`/`deletedKeys` but not on either of
236
+ // them, and deciding without it left the term live again.
237
+ const combined = mergeContent(existing, entry, { ...options, notice: notices.get(entry.id) });
238
+ records.set(entry.id, combined);
239
+ remember(existing);
240
+ remember(entry);
241
+ }
242
+ for (const entry of records.values()) remember(entry);
243
+ // The union is complete, so the one deletion decision runs once per term.
244
+ const decided = [...records.values()].map((entry) =>
245
+ mergeRecords(entry, notices.get(entry.id), { ...options, craftedAt: craftedAt.get(entry.id) ?? 0 })
246
+ );
247
+ const updatedAt = Math.max(base?.updatedAt ?? 0, incoming?.updatedAt ?? 0, options?.now ?? 0);
248
+ return sealRecords(decided, { now: updatedAt });
249
+ }
250
+
251
+ /**
252
+ * Apply the one deletion rule to one record.
253
+ *
254
+ * This is the only place a term is deleted or REVIVED, and it takes three inputs:
255
+ * the merged record, the strongest deletion notice for it, and whether an explicit
256
+ * edit arrived for it. Keeping all three in one decision is what makes the merge
257
+ * order-independent — when two code paths could each decide, one of them decides
258
+ * wrongly, and both times this plugin got it wrong it was exactly that shape.
259
+ *
260
+ * @param record - the merged record, or undefined when the term is unknown.
261
+ * @param notice - `{ deletedAt }` for the deletion, or undefined when there is none.
262
+ * @param options - `now` stamps a deletion whose time is not known.
263
+ * @returns the record with its deletion state settled.
264
+ */
265
+ function mergeRecords(record, notice, options) {
266
+ if (record === undefined) return record;
267
+ const now = options?.now ?? Date.now();
268
+ // No notice means nothing in this merge wants the record deleted, so it stays
269
+ // whatever it already was. Returning it untouched is what keeps the two sides
270
+ // order-independent for records nobody is deleting.
271
+ if (notice === undefined) return record;
272
+ // Every branch returns a copy. Mutating the record in place would edit a document
273
+ // the caller still holds — and a merge must never change its own inputs.
274
+ if (notice.deletedAt > 0) {
275
+ // A dated deletion wins unless a CRAFTED record asked for the term strictly after
276
+ // it. Two things are deliberately not the test here:
277
+ //
278
+ // - `record.deletedAt`, because a record that has just been merged may already
279
+ // carry the deletion it is supposed to be weighed against. Reading it made the
280
+ // comparison dead code in exactly the case it exists for.
281
+ // - the merged record's own `updatedAt`, because content that lost the content
282
+ // contest does not speak for the term. An automatic copy that arrives later and
283
+ // is discarded must not hand its timestamp to the text that was kept — that is
284
+ // how a deletion of the user's own older text got undone by a passing sighting.
285
+ //
286
+ // So the comparison uses the newest time a record the USER asked for carries, and
287
+ // being strictly newer is what revives. Time alone is not intent; provenance is.
288
+ // A NAMED restoration first: `restoreEntry` records that the user asked for this term back, which
289
+ // is not an inference from a clock. The comparison below stays for the paths that revive by editing.
290
+ if ((record.restoredAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
291
+ if ((options?.craftedAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
292
+ const alreadyDeleted = record.deletedAt ?? 0;
293
+ return { ...record, deletedAt: Math.max(alreadyDeleted, notice.deletedAt) };
294
+ }
295
+ // An undated notice carries no evidence of *when* the deletion happened, so it
296
+ // cannot be weighed against a time. What it can still do is delete a term nobody
297
+ // has curated. Curation means the user wrote something for it; a bare sighting —
298
+ // which is all an automatic entry ever is — does not count, however recent its
299
+ // timestamps are.
300
+ const curated = typeof record.definition?.gloss === "string" && record.definition.gloss !== "";
301
+ return curated ? { ...record } : { ...record, deletedAt: record.deletedAt > 0 ? record.deletedAt : now };
302
+ }
303
+
304
+ /**
305
+ * Combine two records' content and their deletion evidence, leaving the deletion
306
+ * DECISION to {@link mergeRecords}.
307
+ *
308
+ * Content merge and deletion decision are deliberately separate: `mergeDocuments`
309
+ * merges every arriving record through here and then decides all of them once. If
310
+ * this function also decided — as it did until this shape — a revival would be
311
+ * honoured for the single-record write paths and silently overruled for the bulk
312
+ * one, so a saved edit was re-deleted by the next sync.
313
+ *
314
+ * Deletion evidence is carried forward rather than acted on: whichever side holds a
315
+ * tombstone keeps it on the combined record, and the notice passed to
316
+ * {@link mergeRecords} names the later of the two deletion times.
317
+ *
318
+ * @param current - the stored record, or undefined.
319
+ * @param incoming - the candidate record, or undefined.
320
+ * @param options - `now` seeds timestamps.
321
+ * @returns the combined record, or undefined when neither side has one.
322
+ */
323
+ function mergeContent(current, incoming, options) {
324
+ if (current === undefined) return incoming;
325
+ if (incoming === undefined) return current;
326
+ // A record that arrives without a provenance of its own cannot be distinguished from
327
+ // an explicit `auto` one by the time it reaches this function — `normalizeEntry` has
328
+ // already defaulted the missing field — so no inheritance happens here. The safe
329
+ // reading is the one that cannot undo a deletion, and the plugin's own clients always
330
+ // send a `source`, so the ambiguity is confined to a hand-written payload.
331
+ const merged = entries.mergeEntry(current, incoming, options);
332
+ // The deletion evidence is the stronger of what the two records carry and the
333
+ // notice the enclosing merge collected. The notice is the one that knows about a
334
+ // deletion recorded in `backing`/`deletedKeys` rather than on a record.
335
+ const notice = options?.notice ?? collectDeletion(current, incoming);
336
+ // The record is presented LIVE (`deletedAt: 0`), so the decision below weighs the
337
+ // deletion instead of short-circuiting on a record that already looks deleted.
338
+ //
339
+ // `updatedAt` is deliberately NOT re-stamped here. Stamping it with the newest time
340
+ // either side supplied let an automatic copy that LOST the content contest hand its
341
+ // timestamp to the user's old text — so the stored content claimed to be newer than a
342
+ // deletion it predated, and the term came back. The time follows the content that won,
343
+ // exactly as `mergeEntry` sets it, and the revival is weighed separately against the
344
+ // newest time a record the user actually asked for carries.
345
+ return mergeRecords({ ...merged, deletedAt: 0 }, notice, { ...options, craftedAt: craftedTimeOf([current, incoming]) });
346
+ }
347
+
348
+ /**
349
+ * The deletion evidence one pair of records carries.
350
+ * @param current - the stored record.
351
+ * @param incoming - the candidate record.
352
+ * @returns `{ deletedAt }` for the later deletion, or undefined when neither is deleted.
353
+ */
354
+ function collectDeletion(current, incoming) {
355
+ const currentDeletedAt = current?.deletedAt ?? 0;
356
+ const incomingDeletedAt = incoming?.deletedAt ?? 0;
357
+ const deletedAt = Math.max(currentDeletedAt, incomingDeletedAt);
358
+ return deletedAt > 0 ? { deletedAt } : undefined;
359
+ }
360
+
361
+ /**
362
+ * The newest time a record the user asked for carries, or 0.
363
+ *
364
+ * This is the timestamp a revival is weighed against, and it exists because the merged
365
+ * record's own `updatedAt` describes whichever content won rather than what the user
366
+ * did. Only `user` (typed in the editor) and `llm` (an explanation the user requested)
367
+ * count: `glossary`, `heuristic` and `auto` are the plugin noticing a term by itself,
368
+ * and a detection is never a reason to reverse a deletion.
369
+ *
370
+ * @param records - the records being merged.
371
+ * @returns the newest crafted time, or 0 when none of them is crafted.
372
+ */
373
+ function craftedTimeOf(records) {
374
+ let newest = 0;
375
+ for (const record of records) {
376
+ if (record === undefined || record === null) continue;
377
+ if (record.source !== "user" && record.source !== "llm") continue;
378
+ // A TOMBSTONE contributes nothing, whatever it claims about who wrote it. A
379
+ // tombstone's own `updatedAt` is its deletion time in every document this plugin
380
+ // writes, so counting it would let a deletion provide the authority to undo
381
+ // itself — and a hand-written payload with `updatedAt` past `deletedAt` would be a
382
+ // revival request dressed as a deletion.
383
+ if ((record.deletedAt ?? 0) > 0) continue;
384
+ newest = Math.max(newest, record.updatedAt ?? 0);
385
+ }
386
+ return newest;
387
+ }
388
+
389
+ /**
390
+ * Every deletion notice the two sides carry, keyed by the record id it targets.
391
+ *
392
+ * Both a real tombstone (`backing`) and a bare key (`deletedKeys`) are notices. A
393
+ * tombstone knows when the deletion happened; a bare key does not, and that
394
+ * difference is preserved rather than papered over with the merge clock — an
395
+ * undated notice must not look newer than a real edit.
396
+ *
397
+ * @param base - the local document.
398
+ * @param incoming - the document received from the other side.
399
+ * @param options - `deletedKeys` overrides the keys announced by `incoming`.
400
+ * @returns a map of id to `{ deletedAt }`.
401
+ */
402
+ function collectNotices(base, incoming, options) {
403
+ const announced = Array.isArray(options?.deletedKeys) ? options.deletedKeys : incoming?.deletedKeys ?? [];
404
+ const notices = new Map();
405
+ /** Keep the later of two deletion times for one id; 0 means "no time known". */
406
+ const remember = (id, deletedAt) => {
407
+ const existing = notices.get(id);
408
+ if (existing === undefined) {
409
+ notices.set(id, { deletedAt });
410
+ return;
411
+ }
412
+ // A dated notice outranks an undated one; between two undated ones nothing
413
+ // changes.
414
+ notices.set(id, { deletedAt: Math.max(existing.deletedAt, deletedAt) });
415
+ };
416
+ for (const entry of tombstonesOf(base)) remember(entry.id, entry.deletedAt);
417
+ for (const entry of tombstonesOf(incoming)) remember(entry.id, entry.deletedAt);
418
+ for (const key of announced) {
419
+ if (typeof key !== "string" || key.trim() === "") continue;
420
+ const normalized = normalizeEntry({ term: key }, options);
421
+ if (normalized === null || notices.has(normalized.id)) continue;
422
+ // A bare key has no time of its own; it borrows the local tombstone's when
423
+ // this side has one, and otherwise stays undated.
424
+ const local = findEntryIncludingDeleted(base, key);
425
+ remember(normalized.id, local !== undefined && local.deletedAt > 0 ? local.deletedAt : 0);
426
+ }
427
+ return notices;
428
+ }
429
+
430
+ /**
431
+ * Serialize a document for storage.
432
+ *
433
+ * Live entries go in `entries`; tombstones go in `backing`, and their keys are
434
+ * also listed in `deletedKeys` so a reader that has no tombstone of its own —
435
+ * another window, a cleared storage — can still learn that a term was deleted.
436
+ *
437
+ * Callers pass a document whose `backing` still holds the tombstones (a store's
438
+ * in-memory document) or a sealed one (the HTTP responses). A sealed document has
439
+ * already moved them to `backing`, so both shapes serialize identically.
440
+ *
441
+ * @param state - a document.
442
+ * @returns a plain object safe to write as JSON.
443
+ */
444
+ function serializeState(state) {
445
+ const backing = tombstonesOf(state);
446
+ const deletedKeys = Array.isArray(state.deletedKeys) && state.deletedKeys.length > 0
447
+ ? state.deletedKeys
448
+ : [...new Set(backing.map((entry) => entry.key))];
449
+ // A document can carry a tombstone record and a bare-key marker for the same
450
+ // term; the record is the richer one and the marker only exists to fill a gap,
451
+ // so both are published but a term is named once.
452
+ const records = new Map();
453
+ for (const entry of backing) records.set(entry.id, stripTransient(entry));
454
+ return {
455
+ schemaVersion: SCHEMA_VERSION,
456
+ updatedAt: state.updatedAt,
457
+ entries: state.entries.map(stripTransient),
458
+ ...(deletedKeys.length === 0 ? {} : { deletedKeys: [...deletedKeys] }),
459
+ ...(records.size === 0 ? {} : { backing: [...records.values()] })
460
+ };
461
+ }
462
+
463
+ /**
464
+ * Drop the transient `observed` marker from a record on its way out.
465
+ *
466
+ * `observed` means "this arrival is a sighting, count it once", which describes how
467
+ * a record is being used rather than what is stored: a document that kept it would
468
+ * count one sighting again on every reload.
469
+ *
470
+ * @param entry - the record to publish.
471
+ * @returns the record without its transient field.
472
+ */
473
+ function stripTransient(entry) {
474
+ if (entry === null || typeof entry !== "object") return entry;
475
+ if (entry.observed !== true) return entry;
476
+ const { observed, ...rest } = entry;
477
+ void observed;
478
+ return rest;
479
+ }
480
+
481
+ /**
482
+ * Insert or merge one entry.
483
+ *
484
+ * Merging is by normalized term, not by the caller's id, so two spellings of the
485
+ * same term can never become two entries even if a caller invents its own id.
486
+ *
487
+ * @param state - the current document.
488
+ * @param candidate - the entry to store; normalized here.
489
+ * @param options - `now` overrides the update timestamp.
490
+ * @returns the next document plus the stored entry.
491
+ */
492
+ function upsertEntry(state, candidate, options) {
493
+ const entry = normalizeEntry(candidate, options);
494
+ if (entry === null) return { state, entry: null, changed: false };
495
+ // Looked up over the document's records, not its live entries: a term whose only
496
+ // record is a tombstone must merge with that tombstone rather than be inserted
497
+ // beside it as a second, live entry.
498
+ const records = recordsOf(state);
499
+ const index = records.findIndex((existing) => existing.id === entry.id || existing.key === entry.key);
500
+ if (index < 0) {
501
+ // A fresh entry is stored as constructed: merging it with itself would
502
+ // double-count the sighting it already records.
503
+ const next = sealRecords([entry, ...records].slice(0, MAX_ENTRIES), { now: options?.now ?? Date.now() });
504
+ return { state: next, entry, changed: true };
505
+ }
506
+ const merged = mergeContent(records[index], entry, options);
507
+ const changed = JSON.stringify(merged) !== JSON.stringify(records[index]);
508
+ const list = [...records];
509
+ list[index] = merged;
510
+ const next = sealRecords(list, { now: options?.now ?? Date.now() });
511
+ return { state: next, entry: merged, changed };
512
+ }
513
+
514
+ /**
515
+ * Apply an explicit user edit to an entry, creating it when absent.
516
+ *
517
+ * The edit is marked as such, which is what authorises it to re-stamp the record's
518
+ * `updatedAt` and therefore to revive a term the user had deleted. A sighting, by
519
+ * contrast, never does either.
520
+ *
521
+ * The patch is `{ definition, domain, aliases, pinned, feedback, untrusted }`. `feedback: null` is how
522
+ * a remark is retracted; a patch that does not mention `feedback`/`untrusted` must leave both their
523
+ * values AND their stamps alone, or an unrelated save would re-decide who said what last.
524
+ *
525
+ * @param state - the current document.
526
+ * @param term - the term being edited.
527
+ * @param patch - the fields to apply.
528
+ * @param options - `now` overrides the update timestamp.
529
+ * @returns the next document plus the stored entry.
530
+ */
531
+ function editEntry(state, term, patch, options) {
532
+ const now = options?.now ?? Date.now();
533
+ // The lookup includes tombstones: the user is looking at this term and typing an
534
+ // explanation, so the entry is being edited whether or not it is currently
535
+ // deleted, and the edit is what revives it.
536
+ const existing = findEntryIncludingDeleted(state, term);
537
+ const base = existing ?? {
538
+ term: typeof term === "string" ? term.trim() : "",
539
+ definition: { zh: "", gloss: "", usage: "", notes: "" },
540
+ aliases: [],
541
+ domain: "",
542
+ source: "user",
543
+ confidence: 1,
544
+ createdAt: now,
545
+ seen: 1
546
+ };
547
+ // Reviving a deleted term means the record must read as changed strictly AFTER the
548
+ // deletion, because that is the comparison the merge makes. When the deletion was
549
+ // stamped with a clock running ahead of this machine's, `now` alone is older than
550
+ // the tombstone and the user's text would be thrown away on the next sync — an
551
+ // editor that accepts text and then deletes it. The revival is therefore stamped one
552
+ // millisecond past the deletion it supersedes: the one fact that is certainly true
553
+ // is that this edit happened after the user saw that deletion.
554
+ const tombstoneAt = existing !== undefined && existing.deletedAt > 0 ? existing.deletedAt : 0;
555
+ const stamp = tombstoneAt > 0 ? Math.max(now, tombstoneAt + 1) : now;
556
+ // A pin decision gets its own stamp, and ONLY when the caller states one. `updatedAt`
557
+ // cannot carry it (a pin-only edit changes no definition, and the merge keeps
558
+ // `updatedAt` with the content), so `pinnedAt` is what lets the merge tell a later
559
+ // unpin from an earlier pin. A patch that does not mention `pinned` — a sighting, or
560
+ // an editor save that never touched the toggle — must not move it.
561
+ const pinnedPatch = typeof patch?.pinned === "boolean" ? patch.pinned : null;
562
+ // A remark and a verdict on the explanation are decisions exactly like a pin, and each carries its
563
+ // own stamp for the same reason `pinnedAt` exists: neither changes a definition, so `updatedAt`
564
+ // cannot carry them, and a retraction has to be tellable from silence across a merge.
565
+ //
566
+ // `hasOwnProperty` rather than `!== undefined` for the remark, because `feedback: null` IS the
567
+ // retraction — the two cases a plain undefined check would fuse together are "the user took their
568
+ // remark back" and "this patch says nothing about remarks", and only the first may move the stamp.
569
+ const mentionsFeedback = patch !== null && patch !== undefined && Object.prototype.hasOwnProperty.call(patch, "feedback");
570
+ const feedbackStamp = mentionsFeedback ? Math.max(stamp, (base.feedbackAt ?? 0) + 1) : base.feedbackAt ?? 0;
571
+ const untrustedPatch = typeof patch?.untrusted === "boolean" ? patch.untrusted : null;
572
+ const untrustedStamp = untrustedPatch === null ? base.untrustedAt ?? 0 : Math.max(stamp, (base.untrustedAt ?? 0) + 1);
573
+ // A move is a decision like a pin or a remark, and it is stamped strictly past the last one so that two
574
+ // moves inside the same millisecond still have an order.
575
+ const mentionsGroup = patch !== null && patch !== undefined && Object.prototype.hasOwnProperty.call(patch, "group");
576
+ const groupStamp = mentionsGroup ? Math.max(stamp, (base.groupAt ?? 0) + 1) : base.groupAt ?? 0;
577
+ const candidate = normalizeEntry(
578
+ {
579
+ ...base,
580
+ // An explicit edit clears the tombstone: the record is live from here, and the
581
+ // `stamp` above is what keeps it live against the tombstone still on the wire.
582
+ deletedAt: 0,
583
+ aliases: patch?.aliases ?? base.aliases,
584
+ domain: patch?.domain ?? base.domain,
585
+ // `patch.group` may legitimately be `""` — that is how an entry is moved back to the top level —
586
+ // so this reads with `hasOwnProperty` rather than with `??`: a patch that says nothing about the
587
+ // group must leave it, and its stamp, exactly where they were.
588
+ group: mentionsGroup ? patch.group : base.group,
589
+ groupAt: groupStamp,
590
+ definition: { ...base.definition, ...(patch?.definition ?? {}) },
591
+ source: "user",
592
+ confidence: 1,
593
+ pinned: pinnedPatch ?? base.pinned,
594
+ pinnedAt: pinnedPatch === null ? (base.pinnedAt ?? 0) : stamp,
595
+ feedback: mentionsFeedback ? normalizeFeedback(patch.feedback) : base.feedback,
596
+ feedbackAt: feedbackStamp,
597
+ untrusted: untrustedPatch ?? base.untrusted,
598
+ untrustedAt: untrustedStamp,
599
+ updatedAt: stamp,
600
+ lastSeenAt: stamp
601
+ },
602
+ { now: stamp }
603
+ );
604
+ return upsertEntry(state, { ...candidate, source: "user", deletedAt: 0 }, { now: stamp });
605
+ }
606
+
607
+ /**
608
+ * Bring a deleted term back, as an explicit act rather than as a side effect of an edit.
609
+ *
610
+ * This exists because "restore this deleted term" is a decision, and until now it was expressed as "write
611
+ * an edit and hope the merge weighs it right": `editEntry` produced a crafted timestamp, and a merge
612
+ * revived the record when that timestamp beat the tombstone's. That works — there is a check for it — but
613
+ * it means the import path, the editor, a sighting and a second window all revive through one inferred
614
+ * comparison, and a term the user had collected and deleted came back as "nothing happened".
615
+ *
616
+ * So a restoration is its own thing:
617
+ *
618
+ * - `restoredAt` is the evidence, weighed against `notice.deletedAt` like `pinnedAt` is weighed against
619
+ * silence. It is what the merge reads, and it exists even when the tombstone's clock ran ahead;
620
+ * - the tombstone is WITHDRAWN from the document this returns — the key leaves `deletedKeys` and the
621
+ * record leaves `backing` — so a reader of this document is not told the term is deleted at all. Being
622
+ * merely outvoted is what let a later merge put the deletion back;
623
+ * - the text is kept: restoring is not the same as rewriting, so the patch is applied on top of whatever
624
+ * the tombstone still remembers.
625
+ *
626
+ * @param state - the current document.
627
+ * @param idOrTerm - the entry id, term or alias.
628
+ * @param patch - `definition`, `domain`, `group`, `aliases`, `pinned`.
629
+ * @param options - `now` overrides the restoration time.
630
+ * @returns `{ state, entry, restored }`; `restored` is false when the term was not deleted.
631
+ */
632
+ function restoreEntry(state, idOrTerm, patch, options) {
633
+ const found = findEntryIncludingDeleted(state, idOrTerm);
634
+ if (found === undefined) {
635
+ // Nothing to restore: the caller wanted a new entry, and `editEntry` is the path for that.
636
+ const created = editEntry(state, typeof idOrTerm === "string" ? idOrTerm : "", patch, options);
637
+ return { ...created, restored: false };
638
+ }
639
+ if ((found.deletedAt ?? 0) === 0) {
640
+ // Already live: a restoration must not disturb it, and saying "restored" would be a lie the caller
641
+ // might act on (the import reports it).
642
+ return { state, entry: found, restored: false };
643
+ }
644
+ // The stamp is strictly past the deletion for the same reason `editEntry`'s is: a clock that ran ahead
645
+ // must not let the tombstone win a comparison it lost the argument for.
646
+ const now = options?.now ?? Date.now();
647
+ const stamp = Math.max(now, (found.deletedAt ?? 0) + 1);
648
+ const edited = editEntry(state, found.term, { ...patch, definition: { ...found.definition, ...(patch?.definition ?? {}) } }, { now: stamp });
649
+ const key = found.key;
650
+ // The withdrawal, in the document itself: the record is rewritten live and the whole thing is re-sealed,
651
+ // so `deletedKeys` and `backing` are derived fresh and no longer announce the deletion. (An earlier
652
+ // version also filtered them by hand first, which the re-seal made redundant — a mutation proved it by
653
+ // not biting, so the dead work is gone.)
654
+ const records = recordsOf(edited.state).map((entry) =>
655
+ entry.id === found.id || entry.key === key ? { ...entry, deletedAt: 0, restoredAt: stamp, source: "user", updatedAt: stamp, lastSeenAt: stamp } : entry
656
+ );
657
+ const next = sealRecords(records, { now: stamp });
658
+ return { state: next, entry: next.entries.find((entry) => entry.key === key) ?? edited.entry, restored: true };
659
+ }
660
+
661
+ /**
662
+ * Record one sighting of a term without replacing its meaning: the counters move
663
+ * and a missing definition is filled from the glossary, but a curated definition
664
+ * survives untouched.
665
+ * @param state - the current document.
666
+ * @param sighting - `term`, optional `glossary`, `context`, `sessionId`.
667
+ * @param options - `now` overrides the timestamp.
668
+ * @returns the next document plus what happened.
669
+ */
670
+ function recordSighting(state, sighting, options) {
671
+ const now = options?.now ?? Date.now();
672
+ const term = typeof sighting?.term === "string" ? sighting.term.trim() : "";
673
+ if (term === "") return { state, entry: null, changed: false, created: false };
674
+ // An explicit definition from this sighting — a model's answer, for instance —
675
+ // outranks a glossary gloss, which is only a fallback.
676
+ const supplied = sighting.definition !== null && typeof sighting.definition === "object" && typeof sighting.definition.gloss === "string" && sighting.definition.gloss.trim() !== ""
677
+ ? sighting.definition
678
+ : null;
679
+ const fallback = supplied ?? sighting.glossary ?? null;
680
+ // Deliberately the tombstone-aware lookup. This is what makes a re-sighting of a
681
+ // deleted term a no-op instead of a resurrection, and it has to live here rather
682
+ // than in each caller: every collection path — the page's, a second window's, the
683
+ // host's own `record` action — funnels through this function.
684
+ const existing = findEntryIncludingDeleted(state, term);
685
+ // A sighting of a term the user deleted is refused outright and writes nothing. The
686
+ // tombstone is the guard, not a counter, so bumping the counters on a record nobody
687
+ // can see — and re-persisting and re-syncing the document for it on every page load
688
+ // — buys nothing. Returning the state untouched is what makes the refusal honest.
689
+ if (existing !== undefined && existing.deletedAt > 0) {
690
+ return { state, entry: existing, changed: false, created: false, deleted: true };
691
+ }
692
+ if (existing === undefined) {
693
+ const created = entries.createEntry(
694
+ {
695
+ term,
696
+ definition: { gloss: fallback?.gloss ?? "", zh: fallback?.zh ?? "" },
697
+ domain: fallback?.domain ?? "",
698
+ source: supplied !== null ? (sighting.source ?? "llm") : sighting.glossary ? "heuristic" : "auto",
699
+ confidence: sighting.confidence ?? (fallback ? 0.8 : 0.45),
700
+ context: sighting.context ?? "",
701
+ sessionId: sighting.sessionId ?? ""
702
+ },
703
+ fallback ?? undefined,
704
+ { now }
705
+ );
706
+ const result = upsertEntry(state, created, { now });
707
+ return { ...result, created: true };
708
+ }
709
+ // A sighting is an observation, not an edit. It advances the sighting counters
710
+ // and an empty definition may be filled from the glossary, but the entry's own
711
+ // `updatedAt` is preserved: that is what keeps the revival rule honest, because
712
+ // otherwise the transcript re-observing a term would be "an edit newer than the
713
+ // deletion" on every page load and the user's deletion could never hold.
714
+ //
715
+ // `observed` is what tells the merge to count this once. The stored `seen` is
716
+ // carried through unchanged, so the count is a count of sightings rather than a
717
+ // number that grows every time a document is loaded or merged.
718
+ const bumped = normalizeEntry(
719
+ { ...existing, observed: true, lastSeenAt: now, updatedAt: existing.updatedAt },
720
+ { now: Math.max(existing.updatedAt ?? 0, now) }
721
+ );
722
+ const canFill = existing.definition.gloss === "" && fallback !== null && typeof fallback.gloss === "string" && fallback.gloss !== "";
723
+ // An entry the user called untrustworthy is not filled in by a SIGHTING.
724
+ //
725
+ // This is the authoritative half of that rule, and it lives here rather than in the caller because
726
+ // every automatic writer — the page's explain queue, a background refresh, the host's own `record`
727
+ // action — funnels through this function. `overridesUntrusted` is the explicit escape hatch: a
728
+ // generation the user pressed the button for is not the machine deciding on its own.
729
+ const refused = existing.untrusted === true && sighting.overridesUntrusted !== true;
730
+ const filled = canFill && !refused
731
+ ? normalizeEntry(
732
+ {
733
+ ...bumped,
734
+ definition: { zh: fallback.zh ?? "", gloss: fallback.gloss, usage: fallback.usage ?? "", notes: fallback.notes ?? "" },
735
+ domain: fallback.domain ?? existing.domain,
736
+ source: supplied !== null ? (sighting.source ?? "llm") : "heuristic",
737
+ confidence: Math.max(existing.confidence, supplied !== null ? 0.9 : 0.8)
738
+ },
739
+ { now: Math.max(existing.updatedAt ?? 0, now) }
740
+ )
741
+ : bumped;
742
+ const result = upsertEntry(state, filled, { now: Math.max(existing.updatedAt ?? 0, now) });
743
+ // A record that came back as a tombstone was a deletion this sighting cannot
744
+ // undo: the caller should treat the term as still deleted rather than as newly
745
+ // created, so the UI does not report having collected it.
746
+ return { ...result, created: false, deleted: (result.entry?.deletedAt ?? 0) > 0 };
747
+ }
748
+
749
+ /**
750
+ * Remove one entry by id or term.
751
+ *
752
+ * The entry is not erased from the document: it becomes a tombstone carrying the
753
+ * deletion time. A union merge can only add, so a deletion that left no trace
754
+ * would be undone the moment the other side's copy was merged back in. The
755
+ * tombstone is filtered out of every projection the reader sees, and the caller
756
+ * receives the same document it would have received from an erase.
757
+ *
758
+ * The deletion time is `max(now, record.updatedAt + 1)`. A deletion must be strictly
759
+ * newer than the content it deletes or the merge will judge that content to have
760
+ * superseded the deletion — and `editEntry` stamps a revival one millisecond past the
761
+ * tombstone it supersedes, so on a client whose clock is behind, a plain `now` would
762
+ * be older than the entry it is trying to delete and the delete button would do
763
+ * nothing.
764
+ *
765
+ * @param state - the current document.
766
+ * @param idOrTerm - the entry id, or its term text.
767
+ * @param options - `now` overrides the deletion timestamp.
768
+ * @returns the next document plus whether anything was removed.
769
+ */
770
+ function removeEntry(state, idOrTerm, options) {
771
+ // The by-id lookup here must see tombstones too: deleting an entry that is
772
+ // already deleted is a no-op, not a new deletion notice.
773
+ const target = findEntryIncludingDeleted(state, idOrTerm);
774
+ if (target === undefined) return { state, removed: false, tombstone: null };
775
+ const now = options?.now ?? Date.now();
776
+ // A deletion must be strictly newer than the content it deletes, so the stamp never
777
+ // goes backwards. See this function's note above.
778
+ const deletedAt = Math.max(now, (target.updatedAt ?? 0) + 1);
779
+ // `updatedAt` is set to the deletion time, not to the later of the two: the
780
+ // tombstone must not claim to be newer than it is, or a copy that is merely
781
+ // newer than the deletion would be judged "newer than the tombstone" and revive
782
+ // the entry. An edit and a deletion at the same instant is a deletion.
783
+ const dead = normalizeEntry({ ...target, deletedAt, updatedAt: deletedAt }, { now: deletedAt });
784
+ // Mapped over the document's *records*, not its live entries: the target may
785
+ // itself already be a tombstone, and a fresh list is built so a tombstone is
786
+ // never appended twice.
787
+ const list = recordsOf(state).map((entry) => (entry.id === target.id ? dead : entry));
788
+ // The tombstone rides in `backing`, invisible to readers but available to the
789
+ // next merge — that is what lets the deletion travel to the host and back.
790
+ return { state: sealRecords(list, { now }), tombstone: dead, removed: true };
791
+ }
792
+
793
+ /**
794
+ * Bring a deleted entry back, as the user's own request.
795
+ *
796
+ * Two things make a revival actually stick, and both are rules the merge already had:
797
+ *
798
+ * - the record must be CRAFTED — `source` of `user` or `llm` — because that is the time
799
+ * {@link mergeRecords} weighs against a deletion. A term the plugin collected by itself is
800
+ * collected again by itself, so inheriting `auto` here would produce a revival that vanishes on
801
+ * the next sync and looks like the button did nothing;
802
+ * - its time must be **strictly** newer than the deletion it undoes. Everything in this file that
803
+ * reverses a deletion turns on that comparison, and `+1` rather than `now` alone is what keeps it
804
+ * true when a tombstone is stamped ahead of the wall clock.
805
+ *
806
+ * The tombstone itself leaves no trace: it is dropped from `backing`, so a later merge cannot weigh
807
+ * it a second time — which is what "retract from the blacklist" has to mean.
808
+ *
809
+ * @param state - the current document.
810
+ * @param idOrTerm - the entry id, its term, or an alias.
811
+ * @param options - `now` overrides the revival timestamp.
812
+ * @returns the next document, whether anything was revived, and the entry.
813
+ */
814
+ function reviveEntry(state, idOrTerm, options) {
815
+ const target = findEntryIncludingDeleted(state, idOrTerm);
816
+ if (target === undefined) return { state, revived: false, entry: null };
817
+ if ((target.deletedAt ?? 0) <= 0) return { state, revived: false, entry: target };
818
+ const now = options?.now ?? Date.now();
819
+ const updatedAt = Math.max(now, (target.deletedAt ?? 0) + 1);
820
+ const revived = normalizeEntry({ ...target, deletedAt: 0, updatedAt, source: "user" }, { now: updatedAt });
821
+ // Mapped over the records, like the deletion: the target IS a tombstone here, and a fresh list is
822
+ // what keeps it from being carried into `backing` as well as into `entries`.
823
+ const list = recordsOf(state).map((entry) => (entry.id === target.id ? revived : entry));
824
+ return { state: sealRecords(list, { now: updatedAt }), revived: true, entry: revived };
825
+ }
826
+
827
+ /**
828
+ * Delete every live entry at once, EXCEPT the pinned ones.
829
+ *
830
+ * The bulk path needs the same treatment as the single one: without a tombstone
831
+ * per entry, a union merge restores everything from the other side — and because
832
+ * the page adopts the host's answer, that restoration would be immediate rather
833
+ * than merely on the next reload.
834
+ *
835
+ * Pinning is how a reader says "this one, never mind the noise", so a bulk clear
836
+ * that swept the pinned entries up would delete exactly the ones that had been
837
+ * singled out — the opposite of what a pin is for. They survive, and the count
838
+ * returned is the number actually tombstoned rather than the number of entries
839
+ * that existed, because those are the two numbers a caller reports and only one
840
+ * of them is true.
841
+ *
842
+ * @param state - the current document.
843
+ * @param options - `now` overrides the deletion timestamp.
844
+ * @returns the next document plus how many entries were deleted.
845
+ */
846
+ function clearAll(state, options) {
847
+ const now = options?.now ?? Date.now();
848
+ let removed = 0;
849
+ const records = recordsOf(state).map((entry) => {
850
+ if (entry.deletedAt > 0) return entry;
851
+ if (entry.pinned === true) return entry;
852
+ removed++;
853
+ // Same rule as the single deletion: a deletion is strictly newer than what it
854
+ // deletes, so a clock behind the entry's own stamp cannot make it a no-op.
855
+ const deletedAt = Math.max(now, (entry.updatedAt ?? 0) + 1);
856
+ return normalizeEntry({ ...entry, deletedAt, updatedAt: deletedAt }, { now: deletedAt });
857
+ });
858
+ return { state: sealRecords(records, { now }), removed };
859
+ }
860
+
861
+ /**
862
+ * Find one live entry by id, term, or alias.
863
+ *
864
+ * Tombstones are invisible to callers: everything a user can reach — the panel,
865
+ * the popup, the editor, `editEntry`, `removeEntry` — must behave as if a deleted
866
+ * term is simply not there. {@link findEntryIncludingDeleted} is the one lookup
867
+ * that still sees them, for the merge path.
868
+ *
869
+ * @param state - the document.
870
+ * @param idOrTerm - the entry id, or its term text.
871
+ * @returns the entry, or undefined when it is absent or deleted.
872
+ */
873
+ function findEntry(state, idOrTerm) {
874
+ const found = findEntryIncludingDeleted(state, idOrTerm);
875
+ return found !== undefined && found.deletedAt === 0 ? found : undefined;
876
+ }
877
+
878
+ /**
879
+ * Find one entry by id, term, or alias, tombstones included.
880
+ * @param state - the document.
881
+ * @param idOrTerm - the entry id, or its term text.
882
+ * @returns the entry, or undefined.
883
+ */
884
+ function findEntryIncludingDeleted(state, idOrTerm) {
885
+ if (typeof idOrTerm !== "string" || idOrTerm.trim() === "") return undefined;
886
+ const needle = idOrTerm.trim();
887
+ // Searched over the document's *records*: a sealed document keeps its tombstones
888
+ // in `backing`, so reading `entries` alone would make a deleted term look absent
889
+ // and every merge would treat it as brand new.
890
+ const records = recordsOf(state);
891
+ const byId = records.find((entry) => entry.id === needle);
892
+ if (byId !== undefined) return byId;
893
+ const key = needle.toLowerCase().replace(/\s+/g, " ");
894
+ return records.find(
895
+ (entry) => entry.key === key || entry.aliases.some((alias) => alias.toLowerCase() === key)
896
+ );
897
+ }
898
+
899
+ /**
900
+ * Merge a whole remote document into a local one without losing local edits.
901
+ *
902
+ * Delegates to {@link mergeDocuments}, so tombstones from either side are
903
+ * honoured and the merged document keeps them until it is sealed.
904
+ *
905
+ * @param base - the local document.
906
+ * @param incoming - the document received from the other side.
907
+ * @param options - `now` seeds timestamps.
908
+ * @returns the merged document.
909
+ */
910
+ function mergeState(base, incoming, options) {
911
+ // The raw announcements are read before normalization, because normalization
912
+ // resolves a notice against the concrete record beside it — correct for a single
913
+ // document, but it would lose the notice at the one moment the merge needs it.
914
+ const announced = Array.isArray(incoming) ? [] : incoming?.deletedKeys;
915
+ const normalized = normalizeState(incoming, options);
916
+ return mergeDocuments(base, normalized, {
917
+ ...options,
918
+ deletedKeys: Array.isArray(announced) ? announced : normalized.deletedKeys,
919
+ // Carries the `observed` marker, which is what counts an arriving sighting once.
920
+ incomingRecords: rawRecordsOf(incoming, options)
921
+ });
922
+ }
923
+
924
+ /**
925
+ * The records an untrusted document carries, normalized but with their transient
926
+ * `observed` marker intact.
927
+ *
928
+ * {@link normalizeState} deliberately drops `observed`, because it describes how a
929
+ * record is being used rather than what is stored. A merge is the one place it
930
+ * matters — it is how a sighting is counted exactly once — so it is restored here.
931
+ *
932
+ * @param value - the document as received.
933
+ * @param options - `now` seeds timestamps.
934
+ * @returns the records to merge.
935
+ */
936
+ function rawRecordsOf(value, options) {
937
+ const source = Array.isArray(value) ? { entries: value } : value;
938
+ if (source === null || typeof source !== "object") return [];
939
+ return [
940
+ ...(Array.isArray(source.entries) ? source.entries : []),
941
+ ...(Array.isArray(source.backing) ? source.backing : [])
942
+ ]
943
+ .map((candidate) => {
944
+ const entry = normalizeEntry(candidate, options);
945
+ if (entry === null) return null;
946
+ return candidate?.observed === true ? { ...entry, observed: true } : entry;
947
+ })
948
+ .filter((entry) => entry !== null);
949
+ }
950
+
951
+ /**
952
+ * Counts the panel header shows.
953
+ * @param state - the document.
954
+ * @returns totals by verification state.
955
+ */
956
+ function summarize(state) {
957
+ // Filtered defensively: a sealed document has no tombstones, but a document
958
+ // straight out of a merge does, and a deleted term must never be counted.
959
+ const live = state.entries.filter((entry) => entry.deletedAt === 0);
960
+ let explained = 0;
961
+ let pinned = 0;
962
+ for (const entry of live) {
963
+ if (!isUnexplained(entry)) explained++;
964
+ if (entry.pinned) pinned++;
965
+ }
966
+ return { total: live.length, explained, unexplained: live.length - explained, pinned };
967
+ }
968
+
969
+ /**
970
+ * The groups immediately inside one path, with how many entries each holds.
971
+ *
972
+ * Only the immediate children: the list shows one level at a time, and a count covers everything under
973
+ * that child, however deep. A group exists as long as something is in it — there is no separate list of
974
+ * empty groups to keep in step with the entries, which is the trade this makes deliberately.
975
+ *
976
+ * @param entries - every live entry.
977
+ * @param prefix - the path to look inside, `""` for the top level.
978
+ * @returns `[{ group, name, count }]`, alphabetical.
979
+ */
980
+ function groupsIn(entries, prefix) {
981
+ const base = prefix === undefined || prefix === null ? "" : String(prefix);
982
+ const counts = new Map();
983
+ for (const entry of Array.isArray(entries) ? entries : []) {
984
+ if ((entry?.deletedAt ?? 0) !== 0) continue;
985
+ const group = typeof entry?.group === "string" ? entry.group : "";
986
+ if (group === "" || group === base) continue;
987
+ let inside = null;
988
+ if (base === "") inside = group;
989
+ else if (group.startsWith(`${base}/`)) inside = group.slice(base.length + 1);
990
+ if (inside === null || inside === "") continue;
991
+ const name = inside.split("/")[0];
992
+ counts.set(name, (counts.get(name) ?? 0) + 1);
993
+ }
994
+ return [...counts.entries()]
995
+ .map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count }))
996
+ .sort((left, right) => left.name.localeCompare(right.name));
997
+ }
998
+
999
+ /**
1000
+ * The panel's visible list: one level of the group tree, filtered by query, then ordered by when each
1001
+ * term was last seen.
1002
+ * @param state - the document.
1003
+ * @param query - search text.
1004
+ * @param filter - `all`, `unexplained`, `pinned` or `flagged`.
1005
+ * @param group - the group path to look inside, `""` for the top level.
1006
+ * @returns the ordered entries.
1007
+ */
1008
+ function listEntries(state, query, filter, group) {
1009
+ const base = group === undefined || group === null ? "" : String(group);
1010
+ const matched = state.entries.filter((entry) => {
1011
+ if (entry.deletedAt !== 0) return false;
1012
+ // The list shows the level you are standing in, not everything below it: a group is a directory,
1013
+ // and what is inside it is what you see after entering it.
1014
+ if ((typeof entry.group === "string" ? entry.group : "") !== base) return false;
1015
+ if (!matchesQuery(entry, query)) return false;
1016
+ if (filter === "unexplained") return isUnexplained(entry);
1017
+ if (filter === "pinned") return entry.pinned;
1018
+ // "Flagged" gathers both things a user can say about an entry — a remark and a verdict on the
1019
+ // explanation — because the panel's one job for them is the same: show what was said, and let
1020
+ // it be taken back.
1021
+ if (filter === "flagged") return isFlagged(entry);
1022
+ return true;
1023
+ });
1024
+ // Newest term first, and NOTHING ELSE.
1025
+ //
1026
+ // It used to sort pinned first and unexplained first, and it then sorted by when each term was last
1027
+ // SEEN — every one of which moves a row under the reader's hand: pin a term and it jumped to the top,
1028
+ // answer a prompt and it jumped, and the transcript merely mentioning a term again shuffled the list.
1029
+ // Creation time is the one thing no action changes, so the list is still while the reader reads it.
1030
+ // The pin is shown on the row itself and 「已钉选」 gathers the pinned ones, so ordering has no job left.
1031
+ return matched.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
1032
+ }
1033
+
1034
+ /**
1035
+ * The panel's list of what the user deleted: the tombstones, newest deletion first.
1036
+ *
1037
+ * The dictionary is a blacklist as well as a glossary — a term the user struck out is refused by
1038
+ * collection and by import — and a blacklist nobody can read is one nobody can correct. This is the
1039
+ * read half of that; {@link reviveEntry} is the write half.
1040
+ *
1041
+ * Sorted by the DELETION time rather than by `lastSeenAt`: the row's own reason for being on this
1042
+ * list is when it was struck out, and the newest mistake is the one most likely to be retracted.
1043
+ *
1044
+ * @param state - the document.
1045
+ * @param query - search text.
1046
+ * @returns the tombstone records.
1047
+ */
1048
+ function listDeleted(state, query) {
1049
+ return tombstonesOf(state)
1050
+ .filter((entry) => matchesQuery(entry, query))
1051
+ .sort((left, right) => (right.deletedAt ?? 0) - (left.deletedAt ?? 0) || left.term.localeCompare(right.term));
1052
+ }
1053
+
1054
+ //#region storage backends
1055
+
1056
+ /**
1057
+ * A store backed by one JSON file.
1058
+ *
1059
+ * Writes are serialized through a promise chain and atomic (write a sibling
1060
+ * temporary file, then rename), so a crash mid-write cannot truncate the
1061
+ * dictionary and two concurrent edits cannot interleave.
1062
+ *
1063
+ * @param fileSystem - `{ readFileSync, writeFileSync, renameSync, mkdirSync, existsSync }`.
1064
+ * @param filePath - absolute path of the dictionary file.
1065
+ * @param logger - optional `{ warn }` sink for read/write failures.
1066
+ * @returns a store with `load` and `save`.
1067
+ */
1068
+ function createFileStore(fileSystem, filePath, logger) {
1069
+ let queue = Promise.resolve();
1070
+ return {
1071
+ path: filePath,
1072
+ /**
1073
+ * Read the document, returning an empty one when the file is absent or
1074
+ * unreadable. A corrupt file is reported but never fatal: the plugin keeps
1075
+ * working and the next save repairs it.
1076
+ * @returns the loaded document.
1077
+ */
1078
+ load() {
1079
+ try {
1080
+ if (!fileSystem.existsSync(filePath)) return emptyState();
1081
+ const text = fileSystem.readFileSync(filePath, "utf8");
1082
+ if (typeof text !== "string" || text.trim() === "") return emptyState();
1083
+ return normalizeState(JSON.parse(text));
1084
+ } catch (error) {
1085
+ logger?.warn?.(`term-dictionary: reading ${filePath} failed: ${error instanceof Error ? error.message : String(error)}`);
1086
+ return emptyState();
1087
+ }
1088
+ },
1089
+ /**
1090
+ * Persist the document.
1091
+ * @param state - the document to write.
1092
+ * @returns a promise settling when the write lands.
1093
+ */
1094
+ save(state) {
1095
+ const payload = `${JSON.stringify(serializeState(state), null, 2)}\n`;
1096
+ queue = queue.then(() => {
1097
+ const temporary = `${filePath}.tmp`;
1098
+ fileSystem.mkdirSync(fileSystem.dirname?.(filePath) ?? filePath.replace(/[\\/][^\\/]+$/, ""), { recursive: true });
1099
+ fileSystem.writeFileSync(temporary, payload, "utf8");
1100
+ fileSystem.renameSync(temporary, filePath);
1101
+ });
1102
+ return queue.catch((error) => {
1103
+ logger?.warn?.(`term-dictionary: writing ${filePath} failed: ${error instanceof Error ? error.message : String(error)}`);
1104
+ });
1105
+ }
1106
+ };
1107
+ }
1108
+
1109
+ /** Local-storage key holding the page's copy of the dictionary. */
1110
+ const STORAGE_KEY = "dsh-plugin-term-dictionary:v1";
1111
+
1112
+ /**
1113
+ * A store backed by `localStorage`, so the plugin keeps working with no host
1114
+ * half, no Web carrier and no network.
1115
+ * @param storage - a `Storage`-shaped object; a missing one degrades to memory.
1116
+ * @param logger - optional `{ warn }` sink.
1117
+ * @returns a store with `load` and `save`.
1118
+ */
1119
+ function createBrowserStore(storage, logger) {
1120
+ let memory = emptyState();
1121
+ return {
1122
+ /**
1123
+ * Read the page's copy.
1124
+ * @returns the loaded document.
1125
+ */
1126
+ load() {
1127
+ try {
1128
+ if (storage === undefined || storage === null) return memory;
1129
+ const text = storage.getItem(STORAGE_KEY);
1130
+ if (typeof text !== "string" || text.trim() === "") return emptyState();
1131
+ return normalizeState(JSON.parse(text));
1132
+ } catch (error) {
1133
+ logger?.warn?.(`term-dictionary: reading local storage failed: ${error instanceof Error ? error.message : String(error)}`);
1134
+ return emptyState();
1135
+ }
1136
+ },
1137
+ /**
1138
+ * Persist the page's copy.
1139
+ * @param state - the document to write.
1140
+ * @returns a promise settling when the write lands.
1141
+ */
1142
+ save(state) {
1143
+ try {
1144
+ if (storage === undefined || storage === null) {
1145
+ memory = state;
1146
+ return Promise.resolve();
1147
+ }
1148
+ storage.setItem(STORAGE_KEY, JSON.stringify(serializeState(state)));
1149
+ } catch (error) {
1150
+ logger?.warn?.(`term-dictionary: writing local storage failed: ${error instanceof Error ? error.message : String(error)}`);
1151
+ }
1152
+ return Promise.resolve();
1153
+ }
1154
+ };
1155
+ }
1156
+
1157
+ //#endregion
1158
+
1159
+ module.exports = {
1160
+ SCHEMA_VERSION,
1161
+ MAX_ENTRIES,
1162
+ STORAGE_KEY,
1163
+ emptyState,
1164
+ normalizeState,
1165
+ serializeState,
1166
+ seal,
1167
+ sealRecords,
1168
+ recordsOf,
1169
+ mergeDocuments,
1170
+ tombstonesOf,
1171
+ upsertEntry,
1172
+ editEntry,
1173
+ restoreEntry,
1174
+ recordSighting,
1175
+ removeEntry,
1176
+ reviveEntry,
1177
+ clearAll,
1178
+ findEntry,
1179
+ findEntryIncludingDeleted,
1180
+ mergeState,
1181
+ summarize,
1182
+ listEntries,
1183
+ groupsIn,
1184
+ listDeleted,
1185
+ createFileStore,
1186
+ createBrowserStore
1187
+ };