dsh-plugin-term-dictionary 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,366 @@
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 source a fresh install starts with: this plugin's own repository, as a static file.
28
+ *
29
+ * Why ship one at all: without it the packs page opens on an empty list, and a first-time reader has
30
+ * to be TOLD a URL before they can see what a term pack even is. The alternative — fetching from a
31
+ * server we run — is the thing this design refuses to have, so the default is an ordinary source like
32
+ * any other: visible in the list, removable in one press, and fetched only when the reader asks (the
33
+ * page refreshes it on first open, which is a request to the source THEY have configured).
34
+ *
35
+ * `@main` rather than a tag because a default should follow the packs the repository actually has:
36
+ * pinned to a tag, a new pack would need a plugin release before anyone could see it.
37
+ */
38
+ const DEFAULT_PACK_SOURCE = "https://cdn.jsdelivr.net/gh/lakerian/dsh-plugin-term-dictionary@main/packs/index.json";
39
+
40
+ /**
41
+ * The URL rule, imported rather than restated: the settings store, the page and the host's transport
42
+ * must agree on what a usable source is, and two copies of that rule is how one of them drifts.
43
+ */
44
+ const { refuseUrl } = require("./pack.js");
45
+
46
+ const ENTRY_COLUMNS = ["auto", "1", "2", "3"];
47
+
48
+ /** The two row-height readings: equal cards, or each card as tall as its own text. */
49
+ const ENTRY_ROWS = ["uniform", "compact"];
50
+
51
+ /** The narrowest a column may be when the layout is choosing. Below this a gloss wraps every other word. */
52
+ const MIN_COLUMN_PX = 260;
53
+
54
+ /**
55
+ * The three switches and their defaults.
56
+ *
57
+ * `autoExplain` defaults ON because the complaint that produced it was the opposite:
58
+ * collected terms arrived with no explanation and stayed "待补充" forever, which made the
59
+ * dictionary a list of words rather than a dictionary.
60
+ */
61
+ const DEFAULT_SETTINGS = {
62
+ /** Collect rare terminology from agent replies automatically. */
63
+ autoCollect: true,
64
+ /** Mark collected terms in the transcript (the "词条块"). */
65
+ autoAnnotate: true,
66
+ /** Ask the model for an explanation as soon as a term is collected. */
67
+ autoExplain: true,
68
+ /** Which language an explanation is written in: the UI's, or one forced. */
69
+ explainLang: "auto",
70
+ /** How much an explanation says. */
71
+ explainDepth: "normal",
72
+ /** Whether the model also gets the paragraph the term appeared in, not just its sentence. */
73
+ explainParagraph: true,
74
+ /**
75
+ * The shortest term automatic collection will record.
76
+ *
77
+ * Four by default: below that a "term" is a fragment (`12-0D`, `id`), and the dictionary is
78
+ * worse for holding it.
79
+ */
80
+ collectMinLength: 4,
81
+ /**
82
+ * Whether code-shaped names are collected (`data-conversation-region`, `Set-Content`).
83
+ *
84
+ * OFF by default. When the dictionary was cleaned out, every junk entry was code-shaped and
85
+ * none of the real ones were — so this is the switch that would have prevented it.
86
+ */
87
+ collectIdentifiers: false,
88
+ /** Whether Chinese terms are collected. */
89
+ collectCjk: true,
90
+ /**
91
+ * How long the pointer must rest on a term before the hover card appears, in milliseconds.
92
+ *
93
+ * The hard-coded 110ms, exposed: a slower reader wants a steadier card, a faster one is
94
+ * annoyed by it.
95
+ */
96
+ hoverInMs: 110,
97
+ /**
98
+ * How long the card lingers after the pointer leaves the term, in milliseconds.
99
+ *
100
+ * ZERO by default, and that default is a decision rather than an omission: a delay on the EXIT
101
+ * was removed once already because it read as lag — the card sat there for the whole delay after
102
+ * the pointer had gone. Anyone who wants a grace period may have one; nobody gets it by accident.
103
+ */
104
+ hoverOutMs: 0,
105
+ /**
106
+ * How many columns the entry list is laid out in.
107
+ *
108
+ * `auto` fits as many as the panel is wide enough for, and is the default: a dictionary row is
109
+ * short, a single column of them is a very long strip to read, and a narrow panel simply gets one
110
+ * column back. The fixed values exist for a reader who wants the layout to stop moving under them.
111
+ */
112
+ entryColumns: "auto",
113
+ /**
114
+ * How tall a row of the entry list is.
115
+ *
116
+ * `uniform` gives every card in a grid row the height of the tallest, so the cards line up and no
117
+ * empty strip is left under the short ones. `compact` lets each keep its own height, which reads
118
+ * tighter in a single column and leaves exactly those strips in a grid. Both are defensible, which
119
+ * is why it is a preference rather than a constant.
120
+ */
121
+ entryRows: "uniform",
122
+ /**
123
+ * The term-pack sources the user added: https URLs of static `index.json` files.
124
+ *
125
+ * A LIST, which is a fourth value shape next to the switches, the option bars and the numbers —
126
+ * and configuration rather than page state, because a source that disappears when the panel closes
127
+ * is not configuration. Validated by the same rule the host applies before it fetches anything
128
+ * ({@link module:core/pack.refuseUrl}), so the page and the transport cannot disagree about what a
129
+ * usable source is.
130
+ *
131
+ * The default is not empty: see {@link DEFAULT_PACK_SOURCE}. An ABSENT key means "never touched, use
132
+ * the default"; an empty array means "the reader removed them all", which is honoured (§
133
+ * `normalizeSettings`) — the two are different statements and a store that conflated them would
134
+ * resurrect the default every time somebody cleared the list.
135
+ */
136
+ packSources: [DEFAULT_PACK_SOURCE]
137
+ };
138
+
139
+ /** The accepted minimum term lengths, for the panel's cycling control. */
140
+ const COLLECT_LENGTHS = [2, 3, 4, 6, 8];
141
+
142
+ /** The accepted hover delays, in milliseconds: the bounds for both the slider and manual entry. */
143
+ const HOVER_DELAY_MIN = 0;
144
+ const HOVER_DELAY_MAX = 200;
145
+
146
+ /** The step the hover slider moves in. */
147
+ const HOVER_DELAY_STEP = 10;
148
+
149
+ /** The accepted explanation languages. `auto` follows the active UI language. */
150
+ const EXPLAIN_LANGS = ["auto", "zh", "en"];
151
+
152
+ /** The accepted explanation depths. */
153
+ const EXPLAIN_DEPTHS = ["brief", "normal", "detailed"];
154
+
155
+ /**
156
+ * Read one switch's value from an unknown record.
157
+ * @param value - the raw stored value.
158
+ * @param fallback - the default.
159
+ * @returns a boolean.
160
+ */
161
+ function readFlag(value, fallback) {
162
+ return typeof value === "boolean" ? value : fallback;
163
+ }
164
+
165
+ /**
166
+ * Read one choice from an unknown record.
167
+ *
168
+ * An unrecognized value falls back rather than being stored: a hand-edited or older record
169
+ * must not be able to put a value in the prompt builder that it has no branch for.
170
+ *
171
+ * @param value - the raw stored value.
172
+ * @param allowed - the accepted values.
173
+ * @param fallback - the default.
174
+ * @returns one of `allowed`.
175
+ */
176
+ function readChoice(value, allowed, fallback) {
177
+ // `includes` is a strict comparison, so this admits a NUMBER list (the minimum term length)
178
+ // as well as a string one, and an object or array can still never match a member.
179
+ return allowed.includes(value) ? value : fallback;
180
+ }
181
+
182
+ /**
183
+ * Read the source list from an unknown record.
184
+ *
185
+ * Every entry is put through the host's own URL rule, so a stored list can never contain something the
186
+ * fetch would later refuse — a source that cannot work is not kept. Duplicates are collapsed and the
187
+ * list is capped: the cap is a UI bound (a list of twenty sources is a list nobody reads), not a
188
+ * security one.
189
+ *
190
+ * @param value - the raw stored value.
191
+ * @returns a fresh array of https URLs.
192
+ */
193
+ function readSources(value) {
194
+ if (!Array.isArray(value)) return [];
195
+ const sources = [];
196
+ for (const entry of value) {
197
+ if (typeof entry !== "string") continue;
198
+ const url = entry.trim();
199
+ if (refuseUrl(url) !== null) continue;
200
+ if (sources.includes(url)) continue;
201
+ sources.push(url);
202
+ if (sources.length >= MAX_SOURCES) break;
203
+ }
204
+ return sources;
205
+ }
206
+
207
+ /**
208
+ * Read one delay from an unknown record, as a whole number of milliseconds inside the range. *
209
+ * Clamped rather than rejected, and rounded rather than trusted: a slider and a text field feed the
210
+ * same setter, so `"120"`, `120.4` and `5000` all arrive here, and a delay outside the range is a
211
+ * worse outcome than the nearest valid one.
212
+ *
213
+ * @param value - the raw stored value.
214
+ * @param fallback - the default.
215
+ * @returns an integer between {@link HOVER_DELAY_MIN} and {@link HOVER_DELAY_MAX}.
216
+ */
217
+ function readDelay(value, fallback) {
218
+ const number = typeof value === "number" ? value : Number.parseFloat(value);
219
+ if (!Number.isFinite(number)) return fallback;
220
+ return Math.min(HOVER_DELAY_MAX, Math.max(HOVER_DELAY_MIN, Math.round(number)));
221
+ }
222
+
223
+ /**
224
+ * Normalize a stored settings record.
225
+ * @param raw - the parsed record, or anything.
226
+ * @returns a complete settings object.
227
+ */
228
+ function normalizeSettings(raw) {
229
+ const value = raw !== null && typeof raw === "object" ? raw : {};
230
+ return {
231
+ autoCollect: readFlag(value.autoCollect, DEFAULT_SETTINGS.autoCollect),
232
+ autoAnnotate: readFlag(value.autoAnnotate, DEFAULT_SETTINGS.autoAnnotate),
233
+ autoExplain: readFlag(value.autoExplain, DEFAULT_SETTINGS.autoExplain),
234
+ explainLang: readChoice(value.explainLang, EXPLAIN_LANGS, DEFAULT_SETTINGS.explainLang),
235
+ explainDepth: readChoice(value.explainDepth, EXPLAIN_DEPTHS, DEFAULT_SETTINGS.explainDepth),
236
+ explainParagraph: readFlag(value.explainParagraph, DEFAULT_SETTINGS.explainParagraph),
237
+ collectMinLength: readChoice(value.collectMinLength, COLLECT_LENGTHS, DEFAULT_SETTINGS.collectMinLength),
238
+ collectIdentifiers: readFlag(value.collectIdentifiers, DEFAULT_SETTINGS.collectIdentifiers),
239
+ collectCjk: readFlag(value.collectCjk, DEFAULT_SETTINGS.collectCjk),
240
+ hoverInMs: readDelay(value.hoverInMs, DEFAULT_SETTINGS.hoverInMs),
241
+ hoverOutMs: readDelay(value.hoverOutMs, DEFAULT_SETTINGS.hoverOutMs),
242
+ entryColumns: readChoice(value.entryColumns, ENTRY_COLUMNS, DEFAULT_SETTINGS.entryColumns),
243
+ entryRows: readChoice(value.entryRows, ENTRY_ROWS, DEFAULT_SETTINGS.entryRows),
244
+ // Absent means "never touched" and gets the shipped default; an empty array means the reader
245
+ // removed every source, and is kept as it is. A fresh array either way: a snapshot is compared by
246
+ // identity, so handing back the shared default array would make one page's edit appear in
247
+ // another page's defaults.
248
+ packSources: value.packSources === undefined ? [...DEFAULT_SETTINGS.packSources] : readSources(value.packSources)
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Build the page's settings store.
254
+ *
255
+ * @param options - `storage` (a `Storage`-alike) and an optional `logger`.
256
+ * @returns `{ getSnapshot, subscribe, set, reset, dispose }`.
257
+ */
258
+ function createSettingsStore(options) {
259
+ const storage = options?.storage ?? null;
260
+ const logger = options?.logger ?? null;
261
+ let current = DEFAULT_SETTINGS;
262
+
263
+ /** Read what was persisted. Anything unusable leaves the defaults in place. */
264
+ function load() {
265
+ if (storage === null || typeof storage.getItem !== "function") return;
266
+ try {
267
+ const text = storage.getItem(SETTINGS_KEY);
268
+ if (typeof text !== "string" || text === "") return;
269
+ current = normalizeSettings(JSON.parse(text));
270
+ } catch (error) {
271
+ logger?.warn?.(`term-dictionary: reading the settings failed: ${error instanceof Error ? error.message : String(error)}`);
272
+ }
273
+ }
274
+
275
+ const listeners = new Set();
276
+
277
+ /** Tell every subscriber the settings changed. */
278
+ function emit() {
279
+ for (const listener of [...listeners]) {
280
+ try {
281
+ listener();
282
+ } catch (error) {
283
+ logger?.warn?.(`term-dictionary: a settings listener failed: ${error instanceof Error ? error.message : String(error)}`);
284
+ }
285
+ }
286
+ }
287
+
288
+ /** Write the current settings, tolerating a storage that refuses. */
289
+ function persist() {
290
+ if (storage === null || typeof storage.setItem !== "function") return;
291
+ try {
292
+ storage.setItem(SETTINGS_KEY, JSON.stringify(current));
293
+ } catch (error) {
294
+ logger?.warn?.(`term-dictionary: saving the settings failed: ${error instanceof Error ? error.message : String(error)}`);
295
+ }
296
+ }
297
+
298
+ load();
299
+
300
+ return {
301
+ /**
302
+ * The current settings.
303
+ *
304
+ * Returns the SAME object until something changes, because
305
+ * `useSyncExternalStore` compares snapshots by identity and a fresh object each
306
+ * call would re-render forever.
307
+ * @returns the settings.
308
+ */
309
+ getSnapshot() {
310
+ return current;
311
+ },
312
+ /**
313
+ * Observe changes.
314
+ * @param listener - the callback.
315
+ * @returns the unsubscribe function.
316
+ */
317
+ subscribe(listener) {
318
+ listeners.add(listener);
319
+ return () => listeners.delete(listener);
320
+ },
321
+ /**
322
+ * Set one switch.
323
+ * @param key - the setting's name.
324
+ * @param value - the new value.
325
+ * @returns the new settings.
326
+ */
327
+ set(key, value) {
328
+ if (!Object.prototype.hasOwnProperty.call(DEFAULT_SETTINGS, key)) return current;
329
+ const next = normalizeSettings({ ...current, [key]: value });
330
+ if (next[key] === current[key]) return current;
331
+ current = next;
332
+ persist();
333
+ emit();
334
+ return current;
335
+ },
336
+ /** Put every switch back to its default. */
337
+ reset() {
338
+ current = DEFAULT_SETTINGS;
339
+ persist();
340
+ emit();
341
+ return current;
342
+ },
343
+ /** Drop every listener. */
344
+ dispose() {
345
+ listeners.clear();
346
+ }
347
+ };
348
+ }
349
+
350
+ module.exports = {
351
+ createSettingsStore,
352
+ SETTINGS_KEY,
353
+ DEFAULT_SETTINGS,
354
+ normalizeSettings,
355
+ MAX_SOURCES,
356
+ DEFAULT_PACK_SOURCE,
357
+ ENTRY_COLUMNS,
358
+ ENTRY_ROWS,
359
+ MIN_COLUMN_PX,
360
+ EXPLAIN_LANGS,
361
+ EXPLAIN_DEPTHS,
362
+ COLLECT_LENGTHS,
363
+ HOVER_DELAY_MIN,
364
+ HOVER_DELAY_MAX,
365
+ HOVER_DELAY_STEP
366
+ };