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,312 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The plugin's switches, persisted in the page.
5
+ *
6
+ * Why these live beside the dictionary instead of inside it
7
+ * ---------------------------------------------------------
8
+ * The dictionary document is MERGED: it travels to the host, comes back, and every
9
+ * reader has to agree on it. A preference is not like that. There is one user and one
10
+ * page, nothing to reconcile, and putting a switch in the merged document would drag it
11
+ * through the whole tombstone/revival machinery — and would make two windows fight over
12
+ * a setting neither of them shares a meaning for. So the switches get their own
13
+ * `localStorage` key and the document keeps only entries.
14
+ *
15
+ * Failure is not fatal: an unreadable or unavailable store yields the defaults, because
16
+ * a plugin that cannot remember "auto-collect is off" must still collect nothing rather
17
+ * than refuse to start.
18
+ */
19
+
20
+ /** The storage key. Versioned so a future shape change can migrate rather than guess. */
21
+ const SETTINGS_KEY = "dsh-plugin-term-dictionary:settings:v1";
22
+
23
+ /** The most sources the list will hold. A UI bound, not a security one. */
24
+ const MAX_SOURCES = 12;
25
+
26
+ /**
27
+ * The URL rule, imported rather than restated: the settings store, the page and the host's transport
28
+ * must agree on what a usable source is, and two copies of that rule is how one of them drifts.
29
+ */
30
+ const { refuseUrl } = require("./pack.js");
31
+
32
+ /**
33
+ * The three switches and their defaults.
34
+ *
35
+ * `autoExplain` defaults ON because the complaint that produced it was the opposite:
36
+ * collected terms arrived with no explanation and stayed "待补充" forever, which made the
37
+ * dictionary a list of words rather than a dictionary.
38
+ */
39
+ const DEFAULT_SETTINGS = {
40
+ /** Collect rare terminology from agent replies automatically. */
41
+ autoCollect: true,
42
+ /** Mark collected terms in the transcript (the "词条块"). */
43
+ autoAnnotate: true,
44
+ /** Ask the model for an explanation as soon as a term is collected. */
45
+ autoExplain: true,
46
+ /** Which language an explanation is written in: the UI's, or one forced. */
47
+ explainLang: "auto",
48
+ /** How much an explanation says. */
49
+ explainDepth: "normal",
50
+ /** Whether the model also gets the paragraph the term appeared in, not just its sentence. */
51
+ explainParagraph: true,
52
+ /**
53
+ * The shortest term automatic collection will record.
54
+ *
55
+ * Four by default: below that a "term" is a fragment (`12-0D`, `id`), and the dictionary is
56
+ * worse for holding it.
57
+ */
58
+ collectMinLength: 4,
59
+ /**
60
+ * Whether code-shaped names are collected (`data-conversation-region`, `Set-Content`).
61
+ *
62
+ * OFF by default. When the dictionary was cleaned out, every junk entry was code-shaped and
63
+ * none of the real ones were — so this is the switch that would have prevented it.
64
+ */
65
+ collectIdentifiers: false,
66
+ /** Whether Chinese terms are collected. */
67
+ collectCjk: true,
68
+ /**
69
+ * How long the pointer must rest on a term before the hover card appears, in milliseconds.
70
+ *
71
+ * The hard-coded 110ms, exposed: a slower reader wants a steadier card, a faster one is
72
+ * annoyed by it.
73
+ */
74
+ hoverInMs: 110,
75
+ /**
76
+ * How long the card lingers after the pointer leaves the term, in milliseconds.
77
+ *
78
+ * ZERO by default, and that default is a decision rather than an omission: a delay on the EXIT
79
+ * was removed once already because it read as lag — the card sat there for the whole delay after
80
+ * the pointer had gone. Anyone who wants a grace period may have one; nobody gets it by accident.
81
+ */
82
+ hoverOutMs: 0,
83
+ /**
84
+ * The term-pack sources the user added: https URLs of static `index.json` files.
85
+ *
86
+ * A LIST, which is a fourth value shape next to the switches, the option bars and the numbers —
87
+ * and configuration rather than page state, because a source that disappears when the panel closes
88
+ * is not configuration. Validated by the same rule the host applies before it fetches anything
89
+ * ({@link module:core/pack.refuseUrl}), so the page and the transport cannot disagree about what a
90
+ * usable source is.
91
+ */
92
+ packSources: []
93
+ };
94
+
95
+ /** The accepted minimum term lengths, for the panel's cycling control. */
96
+ const COLLECT_LENGTHS = [2, 3, 4, 6, 8];
97
+
98
+ /** The accepted hover delays, in milliseconds: the bounds for both the slider and manual entry. */
99
+ const HOVER_DELAY_MIN = 0;
100
+ const HOVER_DELAY_MAX = 200;
101
+
102
+ /** The step the hover slider moves in. */
103
+ const HOVER_DELAY_STEP = 10;
104
+
105
+ /** The accepted explanation languages. `auto` follows the active UI language. */
106
+ const EXPLAIN_LANGS = ["auto", "zh", "en"];
107
+
108
+ /** The accepted explanation depths. */
109
+ const EXPLAIN_DEPTHS = ["brief", "normal", "detailed"];
110
+
111
+ /**
112
+ * Read one switch's value from an unknown record.
113
+ * @param value - the raw stored value.
114
+ * @param fallback - the default.
115
+ * @returns a boolean.
116
+ */
117
+ function readFlag(value, fallback) {
118
+ return typeof value === "boolean" ? value : fallback;
119
+ }
120
+
121
+ /**
122
+ * Read one choice from an unknown record.
123
+ *
124
+ * An unrecognized value falls back rather than being stored: a hand-edited or older record
125
+ * must not be able to put a value in the prompt builder that it has no branch for.
126
+ *
127
+ * @param value - the raw stored value.
128
+ * @param allowed - the accepted values.
129
+ * @param fallback - the default.
130
+ * @returns one of `allowed`.
131
+ */
132
+ function readChoice(value, allowed, fallback) {
133
+ // `includes` is a strict comparison, so this admits a NUMBER list (the minimum term length)
134
+ // as well as a string one, and an object or array can still never match a member.
135
+ return allowed.includes(value) ? value : fallback;
136
+ }
137
+
138
+ /**
139
+ * Read the source list from an unknown record.
140
+ *
141
+ * Every entry is put through the host's own URL rule, so a stored list can never contain something the
142
+ * fetch would later refuse — a source that cannot work is not kept. Duplicates are collapsed and the
143
+ * list is capped: the cap is a UI bound (a list of twenty sources is a list nobody reads), not a
144
+ * security one.
145
+ *
146
+ * @param value - the raw stored value.
147
+ * @returns a fresh array of https URLs.
148
+ */
149
+ function readSources(value) {
150
+ if (!Array.isArray(value)) return [];
151
+ const sources = [];
152
+ for (const entry of value) {
153
+ if (typeof entry !== "string") continue;
154
+ const url = entry.trim();
155
+ if (refuseUrl(url) !== null) continue;
156
+ if (sources.includes(url)) continue;
157
+ sources.push(url);
158
+ if (sources.length >= MAX_SOURCES) break;
159
+ }
160
+ return sources;
161
+ }
162
+
163
+ /**
164
+ * Read one delay from an unknown record, as a whole number of milliseconds inside the range. *
165
+ * Clamped rather than rejected, and rounded rather than trusted: a slider and a text field feed the
166
+ * same setter, so `"120"`, `120.4` and `5000` all arrive here, and a delay outside the range is a
167
+ * worse outcome than the nearest valid one.
168
+ *
169
+ * @param value - the raw stored value.
170
+ * @param fallback - the default.
171
+ * @returns an integer between {@link HOVER_DELAY_MIN} and {@link HOVER_DELAY_MAX}.
172
+ */
173
+ function readDelay(value, fallback) {
174
+ const number = typeof value === "number" ? value : Number.parseFloat(value);
175
+ if (!Number.isFinite(number)) return fallback;
176
+ return Math.min(HOVER_DELAY_MAX, Math.max(HOVER_DELAY_MIN, Math.round(number)));
177
+ }
178
+
179
+ /**
180
+ * Normalize a stored settings record.
181
+ * @param raw - the parsed record, or anything.
182
+ * @returns a complete settings object.
183
+ */
184
+ function normalizeSettings(raw) {
185
+ const value = raw !== null && typeof raw === "object" ? raw : {};
186
+ return {
187
+ autoCollect: readFlag(value.autoCollect, DEFAULT_SETTINGS.autoCollect),
188
+ autoAnnotate: readFlag(value.autoAnnotate, DEFAULT_SETTINGS.autoAnnotate),
189
+ autoExplain: readFlag(value.autoExplain, DEFAULT_SETTINGS.autoExplain),
190
+ explainLang: readChoice(value.explainLang, EXPLAIN_LANGS, DEFAULT_SETTINGS.explainLang),
191
+ explainDepth: readChoice(value.explainDepth, EXPLAIN_DEPTHS, DEFAULT_SETTINGS.explainDepth),
192
+ explainParagraph: readFlag(value.explainParagraph, DEFAULT_SETTINGS.explainParagraph),
193
+ collectMinLength: readChoice(value.collectMinLength, COLLECT_LENGTHS, DEFAULT_SETTINGS.collectMinLength),
194
+ collectIdentifiers: readFlag(value.collectIdentifiers, DEFAULT_SETTINGS.collectIdentifiers),
195
+ collectCjk: readFlag(value.collectCjk, DEFAULT_SETTINGS.collectCjk),
196
+ hoverInMs: readDelay(value.hoverInMs, DEFAULT_SETTINGS.hoverInMs),
197
+ hoverOutMs: readDelay(value.hoverOutMs, DEFAULT_SETTINGS.hoverOutMs),
198
+ packSources: readSources(value.packSources)
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Build the page's settings store.
204
+ *
205
+ * @param options - `storage` (a `Storage`-alike) and an optional `logger`.
206
+ * @returns `{ getSnapshot, subscribe, set, reset, dispose }`.
207
+ */
208
+ function createSettingsStore(options) {
209
+ const storage = options?.storage ?? null;
210
+ const logger = options?.logger ?? null;
211
+ let current = DEFAULT_SETTINGS;
212
+
213
+ /** Read what was persisted. Anything unusable leaves the defaults in place. */
214
+ function load() {
215
+ if (storage === null || typeof storage.getItem !== "function") return;
216
+ try {
217
+ const text = storage.getItem(SETTINGS_KEY);
218
+ if (typeof text !== "string" || text === "") return;
219
+ current = normalizeSettings(JSON.parse(text));
220
+ } catch (error) {
221
+ logger?.warn?.(`term-dictionary: reading the settings failed: ${error instanceof Error ? error.message : String(error)}`);
222
+ }
223
+ }
224
+
225
+ const listeners = new Set();
226
+
227
+ /** Tell every subscriber the settings changed. */
228
+ function emit() {
229
+ for (const listener of [...listeners]) {
230
+ try {
231
+ listener();
232
+ } catch (error) {
233
+ logger?.warn?.(`term-dictionary: a settings listener failed: ${error instanceof Error ? error.message : String(error)}`);
234
+ }
235
+ }
236
+ }
237
+
238
+ /** Write the current settings, tolerating a storage that refuses. */
239
+ function persist() {
240
+ if (storage === null || typeof storage.setItem !== "function") return;
241
+ try {
242
+ storage.setItem(SETTINGS_KEY, JSON.stringify(current));
243
+ } catch (error) {
244
+ logger?.warn?.(`term-dictionary: saving the settings failed: ${error instanceof Error ? error.message : String(error)}`);
245
+ }
246
+ }
247
+
248
+ load();
249
+
250
+ return {
251
+ /**
252
+ * The current settings.
253
+ *
254
+ * Returns the SAME object until something changes, because
255
+ * `useSyncExternalStore` compares snapshots by identity and a fresh object each
256
+ * call would re-render forever.
257
+ * @returns the settings.
258
+ */
259
+ getSnapshot() {
260
+ return current;
261
+ },
262
+ /**
263
+ * Observe changes.
264
+ * @param listener - the callback.
265
+ * @returns the unsubscribe function.
266
+ */
267
+ subscribe(listener) {
268
+ listeners.add(listener);
269
+ return () => listeners.delete(listener);
270
+ },
271
+ /**
272
+ * Set one switch.
273
+ * @param key - the setting's name.
274
+ * @param value - the new value.
275
+ * @returns the new settings.
276
+ */
277
+ set(key, value) {
278
+ if (!Object.prototype.hasOwnProperty.call(DEFAULT_SETTINGS, key)) return current;
279
+ const next = normalizeSettings({ ...current, [key]: value });
280
+ if (next[key] === current[key]) return current;
281
+ current = next;
282
+ persist();
283
+ emit();
284
+ return current;
285
+ },
286
+ /** Put every switch back to its default. */
287
+ reset() {
288
+ current = DEFAULT_SETTINGS;
289
+ persist();
290
+ emit();
291
+ return current;
292
+ },
293
+ /** Drop every listener. */
294
+ dispose() {
295
+ listeners.clear();
296
+ }
297
+ };
298
+ }
299
+
300
+ module.exports = {
301
+ createSettingsStore,
302
+ SETTINGS_KEY,
303
+ DEFAULT_SETTINGS,
304
+ normalizeSettings,
305
+ MAX_SOURCES,
306
+ EXPLAIN_LANGS,
307
+ EXPLAIN_DEPTHS,
308
+ COLLECT_LENGTHS,
309
+ HOVER_DELAY_MIN,
310
+ HOVER_DELAY_MAX,
311
+ HOVER_DELAY_STEP
312
+ };