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.
- package/CHANGELOG.md +189 -0
- package/LICENSE +21 -0
- package/README.md +776 -2
- package/cordis.patch.yml +14 -0
- package/icon.svg +13 -0
- package/lib/ROADMAP-lexicon.md +52 -0
- package/lib/client.js +13354 -0
- package/lib/core/api.js +278 -0
- package/lib/core/bus.js +98 -0
- package/lib/core/copy.js +614 -0
- package/lib/core/core.js +309 -0
- package/lib/core/dictionary.js +1187 -0
- package/lib/core/entries.js +454 -0
- package/lib/core/highlight.js +282 -0
- package/lib/core/hover.js +470 -0
- package/lib/core/hovercard.js +173 -0
- package/lib/core/interact.js +1802 -0
- package/lib/core/lexicon.en.js +872 -0
- package/lib/core/lexicon.zh.js +249 -0
- package/lib/core/overlay.js +239 -0
- package/lib/core/pack.js +372 -0
- package/lib/core/package.json +4 -0
- package/lib/core/selection.js +83 -0
- package/lib/core/settings.js +366 -0
- package/lib/core/shell.js +1003 -0
- package/lib/core/stopwords.js +147 -0
- package/lib/core/store.js +397 -0
- package/lib/core/styles.js +574 -0
- package/lib/core/terms.js +398 -0
- package/lib/core/transfer.js +382 -0
- package/lib/core/views.js +2428 -0
- package/lib/index.js +1110 -0
- package/lib/pack-code.js +84 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -3
|
@@ -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
|
+
};
|