dsh-plugin-term-dictionary 0.0.0-stage → 1.0.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,419 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The dictionary entry: its shape, its construction, and the rules for merging
5
+ * two versions of it.
6
+ *
7
+ * One entry per normalized term. The entry is the durable record a user curates;
8
+ * everything the detector knows about a word is derived from it or from the
9
+ * built-in glossary, never stored twice.
10
+ */
11
+
12
+ const core = require("./core.js");
13
+
14
+ /** Where an entry's definition came from, weakest first. */
15
+ const SOURCES = ["auto", "heuristic", "llm", "user", "import"];
16
+
17
+ /** Human-readable source labels, localized at render time. */
18
+ const SOURCE_RANK = {
19
+ auto: 0,
20
+ heuristic: 1,
21
+ llm: 2,
22
+ user: 3,
23
+ import: 1
24
+ };
25
+
26
+ /** Upper bound on one stored definition, so a runaway model cannot bloat the file. */
27
+ const MAX_DEFINITION_CHARS = 600;
28
+
29
+ /** Upper bound on stored context, which is evidence rather than content. */
30
+ const MAX_CONTEXT_CHARS = 400;
31
+
32
+ /** Upper bound on a feedback note, which is a remark rather than an explanation. */
33
+ const MAX_NOTE_CHARS = 200;
34
+
35
+ /**
36
+ * What a user can say is wrong with an entry.
37
+ *
38
+ * A closed set, because the note travels: the feedback report and (later) a shared pack carry these
39
+ * ids, and free-text categories would make them unreadable to whoever receives them. `other` is the
40
+ * escape hatch, and a note is always allowed on top.
41
+ */
42
+ const FEEDBACK_KINDS = ["wrong-gloss", "wrong-term", "should-not-collect", "other"];
43
+
44
+ /**
45
+ * Read one feedback value into the stored shape.
46
+ *
47
+ * An unknown kind becomes `other` rather than refusing the record: a remark whose category this
48
+ * version does not know is still a remark the user made, and dropping it would lose their words to a
49
+ * vocabulary change.
50
+ *
51
+ * @param value - `{ kind, note }`, or null to mean "nothing to say".
52
+ * @returns `{ kind, note }`, or null.
53
+ */
54
+ function normalizeFeedback(value) {
55
+ if (value === null || typeof value !== "object") return null;
56
+ return {
57
+ kind: FEEDBACK_KINDS.includes(value.kind) ? value.kind : "other",
58
+ note: clampText(value.note, MAX_NOTE_CHARS)
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Whether the user has said anything about this entry.
64
+ *
65
+ * Two independent facts count: a remark (`feedback`) and a verdict on the explanation
66
+ * (`untrusted`). They are deliberately separate — "here is what is wrong" and "stop treating this as
67
+ * authoritative" are different claims, and the second is what the automatic explainer obeys.
68
+ *
69
+ * @param entry - a normalized entry.
70
+ * @returns true when either is set.
71
+ */
72
+ function isFlagged(entry) {
73
+ return entry?.feedback !== null && entry?.feedback !== undefined ? true : entry?.untrusted === true;
74
+ }
75
+
76
+ /**
77
+ * Clamp a string field to a maximum length without cutting mid-surrogate.
78
+ * @param value - the raw value.
79
+ * @param limit - maximum characters to keep.
80
+ * @returns the trimmed, clamped string (empty for non-strings).
81
+ */
82
+ function clampText(value, limit) {
83
+ if (typeof value !== "string") return "";
84
+ const trimmed = value.trim();
85
+ if (trimmed.length <= limit) return trimmed;
86
+ return `${trimmed.slice(0, limit - 1).trimEnd()}…`;
87
+ }
88
+
89
+ /**
90
+ * Normalize an untrusted value into a dictionary entry.
91
+ *
92
+ * Every read path (host file, HTTP body, local storage) goes through this, so a
93
+ * corrupt or older record can never introduce a malformed entry into the store.
94
+ *
95
+ * @param value - a candidate entry as plain data.
96
+ * @param options - `now` overrides the timestamp used for missing fields.
97
+ * @returns a complete entry, or null when the record has no usable term.
98
+ */
99
+ function normalizeEntry(value, options) {
100
+ if (value === null || typeof value !== "object") return null;
101
+ const term = typeof value.term === "string" ? value.term.replace(/\s+/g, " ").trim() : "";
102
+ if (term === "") return null;
103
+ const key = core.normalizeTerm(term);
104
+ if (key === "") return null;
105
+ const now = typeof options?.now === "number" ? options.now : Date.now();
106
+ const definition = value.definition !== null && typeof value.definition === "object" ? value.definition : {};
107
+ const seen = typeof value.seen === "number" && Number.isFinite(value.seen) && value.seen > 0 ? Math.floor(value.seen) : 1;
108
+ const aliases = Array.isArray(value.aliases)
109
+ ? [...new Set(value.aliases.filter((alias) => typeof alias === "string" && alias.trim() !== "").map((alias) => alias.trim()))].slice(0, 8)
110
+ : [];
111
+ return {
112
+ version: 1,
113
+ id: typeof value.id === "string" && value.id !== "" ? value.id : core.termId(key),
114
+ term,
115
+ key,
116
+ aliases,
117
+ definition: {
118
+ zh: clampText(definition.zh, 60),
119
+ gloss: clampText(definition.gloss, MAX_DEFINITION_CHARS),
120
+ usage: clampText(definition.usage, MAX_DEFINITION_CHARS),
121
+ notes: clampText(definition.notes, MAX_DEFINITION_CHARS)
122
+ },
123
+ domain: clampText(value.domain, 24),
124
+ source: SOURCES.includes(value.source) ? value.source : "auto",
125
+ confidence: typeof value.confidence === "number" && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : 0.5,
126
+ createdAt: typeof value.createdAt === "number" && value.createdAt > 0 ? value.createdAt : now,
127
+ updatedAt: typeof value.updatedAt === "number" && value.updatedAt > 0 ? value.updatedAt : now,
128
+ seen,
129
+ lastSeenAt: typeof value.lastSeenAt === "number" && value.lastSeenAt > 0 ? value.lastSeenAt : now,
130
+ context: clampText(value.context, MAX_CONTEXT_CHARS),
131
+ sessionId: typeof value.sessionId === "string" ? clampText(value.sessionId, 80) : "",
132
+ pinned: value.pinned === true,
133
+ // When the pin was last decided, epoch milliseconds, 0 for "never pinned".
134
+ //
135
+ // The boolean needs its OWN clock because `updatedAt` belongs to the definition:
136
+ // the merge advances `updatedAt` only when the arriving content wins, so a
137
+ // pin-only edit carries no timestamp at all — `upsertEntry` merges, sees no
138
+ // definition change, and keeps the old value. Without `pinnedAt` there is nothing
139
+ // for two sides to compare, and the merge can only guess (it used to OR, which
140
+ // made unpinning impossible).
141
+ pinnedAt: typeof value.pinnedAt === "number" && Number.isFinite(value.pinnedAt) && value.pinnedAt > 0 ? Math.floor(value.pinnedAt) : 0,
142
+ // A tombstone: epoch milliseconds of the deletion, or 0 for a live entry.
143
+ // Without it a union merge can only ever add, so a deletion made in the page
144
+ // would be undone by the next round trip. See `mergeRecords`.
145
+ deletedAt:
146
+ typeof value.deletedAt === "number" && Number.isFinite(value.deletedAt) && value.deletedAt > 0 ? Math.floor(value.deletedAt) : 0,
147
+ // What the user said about this entry, and when they last said it.
148
+ //
149
+ // Same reason as `pinnedAt`: the remark is a DECISION the user can retract, so the decision
150
+ // needs a clock of its own. `updatedAt` follows the definition, and a remark changes no
151
+ // definition — without `feedbackAt` a retraction could not be told from silence, and the copy
152
+ // that still carries the remark would win every merge.
153
+ feedback: normalizeFeedback(value.feedback),
154
+ feedbackAt: typeof value.feedbackAt === "number" && Number.isFinite(value.feedbackAt) && value.feedbackAt > 0 ? Math.floor(value.feedbackAt) : 0,
155
+ // "Do not treat this explanation as authoritative." The automatic explainer obeys it; see
156
+ // `requestExplanation` in the shell. Stamped for the same reason as `feedbackAt`, since it is
157
+ // a verdict the user can take back.
158
+ untrusted: value.untrusted === true,
159
+ untrustedAt: typeof value.untrustedAt === "number" && Number.isFinite(value.untrustedAt) && value.untrustedAt > 0 ? Math.floor(value.untrustedAt) : 0,
160
+ // Transient merge directives, consumed by the merge and never stored.
161
+ //
162
+ // `observed` marks the record a sighting produced, so a sighting counts once
163
+ // and a document load counts zero. `edited` marks the record an explicit edit
164
+ // produced, which is the only thing allowed to advance `updatedAt`: without
165
+ // that distinction a merge would stamp the current time onto content it merely
166
+ // received, and the deletion rule would then compare a deletion against a
167
+ // timestamp that never reflected an edit.
168
+ ...(value.observed === true ? { observed: true } : {}),
169
+ ...(value.edited === true ? { edited: true } : {})
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Whether an entry is a tombstone rather than a live dictionary entry.
175
+ * @param entry - a normalized entry.
176
+ * @returns true when the entry records a deletion.
177
+ */
178
+ function isTombstone(entry) {
179
+ return (entry?.deletedAt ?? 0) > 0;
180
+ }
181
+
182
+ /**
183
+ * Build a new entry for a term the dictionary does not have yet.
184
+ *
185
+ * The definition is filled from the best source available at call time: an
186
+ * explicit user definition, then the built-in glossary, then nothing (the UI
187
+ * offers to generate one). `source` records which of those it was, so the UI can
188
+ * show an auto-detected entry as unverified instead of pretending it is curated.
189
+ *
190
+ * @param input - term plus whatever the caller already knows about it.
191
+ * @param glossary - the glossary record for the term, if any.
192
+ * @param options - `now` overrides the creation timestamp.
193
+ * @returns a complete entry.
194
+ * @throws {TypeError} when `input.term` is missing or blank.
195
+ */
196
+ function createEntry(input, glossary, options) {
197
+ const term = typeof input?.term === "string" ? input.term.replace(/\s+/g, " ").trim() : "";
198
+ if (term === "") throw new TypeError("createEntry requires a non-empty term");
199
+ const now = typeof options?.now === "number" ? options.now : Date.now();
200
+ const provided = input.definition ?? {};
201
+ const hasUserDefinition = typeof provided.gloss === "string" && provided.gloss.trim() !== "";
202
+ const fromGlossary = !hasUserDefinition && glossary !== undefined && typeof glossary.gloss === "string" && glossary.gloss.trim() !== "";
203
+ const definition = hasUserDefinition
204
+ ? {
205
+ zh: clampText(provided.zh ?? "", 60),
206
+ gloss: clampText(provided.gloss, MAX_DEFINITION_CHARS),
207
+ usage: clampText(provided.usage ?? "", MAX_DEFINITION_CHARS),
208
+ notes: clampText(provided.notes ?? "", MAX_DEFINITION_CHARS)
209
+ }
210
+ : fromGlossary
211
+ ? {
212
+ zh: clampText(provided.zh ?? glossary.zh ?? "", 60),
213
+ gloss: clampText(glossary.gloss, MAX_DEFINITION_CHARS),
214
+ usage: clampText(provided.usage ?? "", MAX_DEFINITION_CHARS),
215
+ notes: clampText(provided.notes ?? "", MAX_DEFINITION_CHARS)
216
+ }
217
+ : { zh: clampText(provided.zh ?? "", 60), gloss: clampText(provided.gloss ?? "", MAX_DEFINITION_CHARS), usage: "", notes: "" };
218
+ const source = input.source !== undefined && SOURCES.includes(input.source)
219
+ ? input.source
220
+ : hasUserDefinition
221
+ ? "user"
222
+ : fromGlossary
223
+ ? "heuristic"
224
+ : "auto";
225
+ return normalizeEntry({
226
+ term,
227
+ // A brand-new record is not an edit of anything, so it carries no transient
228
+ // directive. Without this, spreading a record that had one would inherit it.
229
+ observed: input.observed === true,
230
+ edited: false,
231
+ aliases: input.aliases ?? [],
232
+ definition,
233
+ domain: input.domain ?? (glossary !== undefined ? glossary.domain : ""),
234
+ source,
235
+ confidence: input.confidence ?? (fromGlossary ? 0.8 : source === "user" ? 1 : 0.45),
236
+ createdAt: now,
237
+ updatedAt: now,
238
+ seen: 1,
239
+ lastSeenAt: now,
240
+ context: input.context ?? "",
241
+ sessionId: input.sessionId ?? "",
242
+ pinned: input.pinned === true,
243
+ pinnedAt: input.pinned === true ? now : 0
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Whether a patch actually changes the stored definition, so a repeated
249
+ * auto-detection does not rewrite the file on every message.
250
+ * @param entry - the stored entry.
251
+ * @param patch - the candidate replacement fields.
252
+ * @returns true when at least one definition field or the domain differs.
253
+ */
254
+ function definitionChanged(entry, patch) {
255
+ const next = patch.definition ?? {};
256
+ if ((patch.domain ?? entry.domain) !== entry.domain) return true;
257
+ return ["zh", "gloss", "usage", "notes"].some((field) => {
258
+ const value = next[field];
259
+ if (value === undefined) return false;
260
+ return clampText(value, MAX_DEFINITION_CHARS) !== entry.definition[field];
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Merge one incoming entry's CONTENT into an existing one.
266
+ *
267
+ * This function deliberately does not decide deletion. It keeps whatever deletion
268
+ * state the current record already had, and a missing current record means there is
269
+ * nothing to argue with. Deletion is decided in one place — `mergeRecords` in
270
+ * `dictionary.js` — because two independent decisions is exactly how a merge became
271
+ * order-dependent: a tombstone arriving as `incoming` would resurrect a record, and
272
+ * the same pair merged the other way would not.
273
+ *
274
+ * Later, stronger evidence wins for the content: a user edit replaces an automatic
275
+ * definition, while a later automatic sighting of an entry the user already curated
276
+ * only bumps the sighting counters and never touches the definition.
277
+ *
278
+ * @param current - the stored record.
279
+ * @param incoming - the candidate record, already normalized.
280
+ * @param options - `now` overrides the update timestamp.
281
+ * @returns the merged record.
282
+ */
283
+ function mergeEntry(current, incoming, options) {
284
+ if (current === undefined || current === null) return incoming;
285
+ const incomingRank = SOURCE_RANK[incoming.source] ?? 0;
286
+ const currentRank = SOURCE_RANK[current.source] ?? 0;
287
+ const takeDefinition =
288
+ incoming.definition.gloss !== "" &&
289
+ (current.definition.gloss === "" || (incomingRank >= currentRank && definitionChanged(current, incoming)));
290
+ // `updatedAt` is the time the kept content was authored, so it follows the content
291
+ // rather than the merge. Re-stamping it with the current time was a real defect:
292
+ // the deletion rule compares a deletion against this value, and a fabricated
293
+ // "now" made content that merely arrived look like an edit made after the
294
+ // deletion. Only `editEntry` advances it, and it does so before the merge.
295
+ const merged = {
296
+ ...current,
297
+ // `deletedAt` is NOT inherited from `current`: a content merge must not carry a
298
+ // deletion forward, or the decision that consumes this result cannot tell a
299
+ // live arrival from a deleted one. Whoever merges is responsible for passing
300
+ // the deletion evidence separately.
301
+ deletedAt: 0,
302
+ aliases: [...new Set([...current.aliases, ...incoming.aliases])].slice(0, 8),
303
+ definition: takeDefinition ? incoming.definition : current.definition,
304
+ domain: takeDefinition && incoming.domain !== "" ? incoming.domain : current.domain,
305
+ // The STRONGEST provenance survives, not the arriving one. Taking the arriving
306
+ // source let a later automatic copy of an already-curated term downgrade it to
307
+ // `auto`, which then denied that term its own revival — and let its source be
308
+ // laundered. Provenance describes the record, and the record's best evidence of
309
+ // who wrote it does not expire.
310
+ source: incomingRank > currentRank ? incoming.source : current.source,
311
+ confidence: Math.max(current.confidence, incoming.confidence),
312
+ // The record's time belongs to the content it kept, never to content it discarded.
313
+ // An automatic copy that arrives newer but LOSES the content contest must not hand
314
+ // its timestamp to the user's text: the merge would then read the record as "the
315
+ // user asked for this after the deletion", and a passing sighting would undo a
316
+ // deletion. So the kept content's own time wins, and the later of the two is used
317
+ // only when the arriving content is what was kept.
318
+ updatedAt: takeDefinition ? Math.max(current.updatedAt ?? 0, incoming.updatedAt ?? 0) : current.updatedAt ?? 0,
319
+ seen: current.seen + (incoming.observed === true ? 1 : 0),
320
+ lastSeenAt: Math.max(current.lastSeenAt ?? 0, incoming.lastSeenAt ?? 0),
321
+ context: current.context !== "" ? current.context : incoming.context,
322
+ // `pinned` follows the most recent DECISION, not a logical OR.
323
+ //
324
+ // It was `current.pinned || incoming.pinned`, which made unpinning impossible in
325
+ // the running plugin: the user's unpin wrote false locally, the host's copy still
326
+ // said true, the merge OR-ed them back to true, and the page adopts the merged
327
+ // document — so the pin snapped back on every click ("钉选功能无法取消"). An OR can
328
+ // only ever add a pin, and a flag the user toggles needs the later decision to win.
329
+ //
330
+ // The decision is compared on `pinnedAt`, not `updatedAt`: a pin-only edit changes
331
+ // no definition, and `updatedAt` deliberately follows the content, so it cannot
332
+ // carry a pin change. `Math.max` on the timestamp keeps the result independent of
333
+ // merge order, which matters because both sides run this function and must
334
+ // converge on the same document.
335
+ pinned: (incoming.pinnedAt ?? 0) > (current.pinnedAt ?? 0) ? incoming.pinned === true : current.pinned === true,
336
+ pinnedAt: Math.max(incoming.pinnedAt ?? 0, current.pinnedAt ?? 0),
337
+ // The same rule for the two things the user can say about an entry: the later DECISION wins,
338
+ // including a decision to say nothing. An OR here would make retracting a remark impossible
339
+ // in exactly the way it made unpinning impossible.
340
+ feedback: (incoming.feedbackAt ?? 0) > (current.feedbackAt ?? 0) ? incoming.feedback : current.feedback,
341
+ feedbackAt: Math.max(incoming.feedbackAt ?? 0, current.feedbackAt ?? 0),
342
+ untrusted: (incoming.untrustedAt ?? 0) > (current.untrustedAt ?? 0) ? incoming.untrusted === true : current.untrusted === true,
343
+ untrustedAt: Math.max(incoming.untrustedAt ?? 0, current.untrustedAt ?? 0)
344
+ };
345
+ return merged;
346
+ }
347
+
348
+ /**
349
+ * Whether an entry still carries an unexplained term: it exists for tracking but
350
+ * the user has no explanation yet, which is what the panel offers to fix. A
351
+ * tombstone counts as explained so it is never offered for editing.
352
+ * @param entry - a normalized entry.
353
+ * @returns true when the entry has no definition text.
354
+ */
355
+ function isUnexplained(entry) {
356
+ if ((entry.deletedAt ?? 0) > 0) return false;
357
+ return entry.definition.gloss === "" && entry.definition.zh === "";
358
+ }
359
+
360
+ /**
361
+ * One entry as the text a person would paste somewhere.
362
+ *
363
+ * Why this exists as a function rather than as a selection: selecting the text in the panel is a
364
+ * moving target. The list re-sorts itself as sightings arrive, the row under the pointer re-renders,
365
+ * and — the defect that prompted this — the click that ENDS a selection used to open the editor on top
366
+ * of it, so the selection was gone before it could be copied. A copy action needs no selection at all,
367
+ * which is what makes it immune to all of that.
368
+ *
369
+ * The fields are chosen for pasting into a conversation or a note: the term, its Chinese name, the
370
+ * explanation and an example. Aliases and the domain are lookup metadata — a reader searching the
371
+ * panel uses them, a person pasting an entry does not want them in the middle of the sentence.
372
+ *
373
+ * @param entry - a normalized entry.
374
+ * @returns the text, or "" when there is no term to copy.
375
+ */
376
+ function entryAsText(entry) {
377
+ if (entry === null || entry === undefined || typeof entry.term !== "string" || entry.term === "") return "";
378
+ const definition = entry.definition ?? {};
379
+ const lines = [definition.zh === undefined || definition.zh === "" ? entry.term : `${entry.term}(${definition.zh})`];
380
+ if (definition.gloss !== undefined && definition.gloss !== "") lines.push(definition.gloss);
381
+ if (definition.usage !== undefined && definition.usage !== "") lines.push(`例:${definition.usage}`);
382
+ return lines.join("\n");
383
+ }
384
+
385
+ /**
386
+ * Case-insensitive free-text match over a term, its translation, its definition
387
+ * and its aliases, used by the panel's search box.
388
+ * @param entry - a normalized entry.
389
+ * @param query - raw search text.
390
+ * @returns true when the entry matches.
391
+ */
392
+ function matchesQuery(entry, query) {
393
+ const needle = typeof query === "string" ? query.trim().toLowerCase() : "";
394
+ if (needle === "") return true;
395
+ const haystack = [entry.term, entry.definition.zh, entry.definition.gloss, entry.domain, ...entry.aliases]
396
+ .join("\n")
397
+ .toLowerCase();
398
+ return haystack.includes(needle);
399
+ }
400
+
401
+ module.exports = {
402
+ SOURCES,
403
+ FEEDBACK_KINDS,
404
+ SOURCE_RANK,
405
+ MAX_DEFINITION_CHARS,
406
+ MAX_CONTEXT_CHARS,
407
+ MAX_NOTE_CHARS,
408
+ normalizeEntry,
409
+ normalizeFeedback,
410
+ entryAsText,
411
+ isFlagged,
412
+ isTombstone,
413
+ createEntry,
414
+ mergeEntry,
415
+ definitionChanged,
416
+ isUnexplained,
417
+ matchesQuery,
418
+ clampText
419
+ };