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,2056 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The dictionary panel's React views.
5
+ *
6
+ * Layout follows the host's management pages: a titled header with a search
7
+ * field and filter tabs, then a scrolling list of rows, then a detail editor
8
+ * that replaces the list rather than opening a modal over it. Every color,
9
+ * radius and border comes from a `--dsw-alias-*` / `--dsw-radius-*` theme token,
10
+ * so light and dark themes follow without a second code path.
11
+ *
12
+ * The panel reaches the dictionary only through the injected `store`, which is
13
+ * what keeps this file free of the detector and the host API.
14
+ */
15
+
16
+ const React = require("react");
17
+ const { SEARCH_INPUT_STYLE, applyStyles } = require("./styles.js");
18
+ const { formatStamp } = require("./core.js");
19
+
20
+ const h = React.createElement;
21
+
22
+ /** How long a revealed entry stays marked after a transcript click opens it. */
23
+ const REVEAL_FLASH_MS = 2600;
24
+
25
+ /** The compact glyph the sidebar's panel list renders (and its collapsed rail). */
26
+ function DictionaryGlyph(props) {
27
+ const size = typeof props?.size === "number" ? props.size : 16;
28
+ const active = props?.active === true;
29
+ return h(
30
+ "svg",
31
+ {
32
+ width: size,
33
+ height: size,
34
+ viewBox: "0 0 20 20",
35
+ fill: "none",
36
+ "aria-hidden": "true",
37
+ style: { display: "block", color: active ? "var(--dsw-alias-label-primary)" : "inherit" }
38
+ },
39
+ h("path", {
40
+ d: "M10 5.1c-1.05-.85-2.4-1.25-4.1-1.25-.42 0-.68.26-.68.68v7.05c0 .42.26.68.68.68 1.7 0 3.05.4 4.1 1.25",
41
+ stroke: "currentColor",
42
+ strokeWidth: 1.5,
43
+ strokeLinecap: "round",
44
+ strokeLinejoin: "round"
45
+ }),
46
+ h("path", {
47
+ d: "M10 5.1c1.05-.85 2.4-1.25 4.1-1.25.42 0 .68.26.68.68v7.05c0 .42-.26.68-.68.68-1.7 0-3.05.4-4.1 1.25",
48
+ stroke: "currentColor",
49
+ strokeWidth: 1.5,
50
+ strokeLinecap: "round",
51
+ strokeLinejoin: "round"
52
+ }),
53
+ h("path", { d: "M10 5.1v8.41", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round" })
54
+ );
55
+ }
56
+
57
+ /** A small inline icon set, drawn to match the host's outline controls. */
58
+ const Icons = {
59
+ search: (size) =>
60
+ h(
61
+ "svg",
62
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
63
+ h("circle", { cx: 7, cy: 7, r: 4.4, stroke: "currentColor", strokeWidth: 1.4 }),
64
+ h("path", { d: "M10.4 10.4 14 14", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round" })
65
+ ),
66
+ close: (size) =>
67
+ h(
68
+ "svg",
69
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
70
+ h("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round" })
71
+ ),
72
+ pin: (size) =>
73
+ h(
74
+ "svg",
75
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
76
+ h("path", {
77
+ d: "M6 2h4l-.6 3.2 2.1 2.1H4.5l2.1-2.1L6 2Z",
78
+ stroke: "currentColor",
79
+ strokeWidth: 1.3,
80
+ strokeLinejoin: "round"
81
+ }),
82
+ h("path", { d: "M8 7.3V14", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round" })
83
+ ),
84
+ edit: (size) =>
85
+ h(
86
+ "svg",
87
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
88
+ h("path", {
89
+ d: "M10.6 2.9l2.5 2.5-7 7-3 .5.5-3 7-7Z",
90
+ stroke: "currentColor",
91
+ strokeWidth: 1.3,
92
+ strokeLinejoin: "round"
93
+ })
94
+ ),
95
+ trash: (size) =>
96
+ h(
97
+ "svg",
98
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
99
+ h("path", { d: "M3 4.5h10M6.2 4.5V3h3.6v1.5M4.4 4.5l.6 8h6l.6-8", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round", strokeLinejoin: "round" })
100
+ ),
101
+ /** A flag: "there is something to say about this entry". */
102
+ flag: (size) =>
103
+ h(
104
+ "svg",
105
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
106
+ h("path", {
107
+ d: "M4 2.6v10.8M4 3.4h7.4l-1.3 2.5 1.3 2.5H4",
108
+ stroke: "currentColor",
109
+ strokeWidth: 1.3,
110
+ strokeLinecap: "round",
111
+ strokeLinejoin: "round"
112
+ })
113
+ ),
114
+ /** Two sheets: copy this entry's text. */
115
+ copy: (size) =>
116
+ h(
117
+ "svg",
118
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
119
+ h("rect", { x: 5.4, y: 5.4, width: 7.2, height: 7.6, rx: 1.4, stroke: "currentColor", strokeWidth: 1.3 }),
120
+ h("path", { d: "M10.6 4.2V3.6A1.4 1.4 0 0 0 9.2 2.2H4.6A1.4 1.4 0 0 0 3.2 3.6v6.2a1.4 1.4 0 0 0 1.4 1.4h.8", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round" })
121
+ ),
122
+ sparkle: (size) =>
123
+ h(
124
+ "svg",
125
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
126
+ h("path", { d: "M8 2.2l1.3 3.5L12.8 7l-3.5 1.3L8 11.8 6.7 8.3 3.2 7l3.5-1.3L8 2.2Z", stroke: "currentColor", strokeWidth: 1.2, strokeLinejoin: "round" })
127
+ ),
128
+ plus: (size) =>
129
+ h(
130
+ "svg",
131
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
132
+ h("path", { d: "M8 3.2v9.6M3.2 8h9.6", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round" })
133
+ ),
134
+ /** A checked box, used both for "pick rows" and for a picked row. */
135
+ select: (size) =>
136
+ h(
137
+ "svg",
138
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
139
+ h("rect", { x: 2.6, y: 2.6, width: 10.8, height: 10.8, rx: 2.4, stroke: "currentColor", strokeWidth: 1.3 }),
140
+ h("path", { d: "M5.4 8.2 7.3 10.1l3.5-3.9", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round" })
141
+ )
142
+ };
143
+
144
+ /** Settings used when the panel is rendered without a settings store (tests, odd hosts). */
145
+ const FALLBACK_SETTINGS = { autoCollect: true, autoAnnotate: true, autoExplain: true, explainLang: "auto", explainDepth: "normal", explainParagraph: true, collectMinLength: 4, collectIdentifiers: false, collectCjk: true };
146
+
147
+ /** The accepted preference values, so the page offers exactly what the store validates. */
148
+ const { EXPLAIN_LANGS, EXPLAIN_DEPTHS, COLLECT_LENGTHS, HOVER_DELAY_MIN, HOVER_DELAY_MAX, HOVER_DELAY_STEP } = require("./settings.js");
149
+ const { buildExport, buildFeedbackReport, parseImport, planImport, domainsIn } = require("./transfer.js");
150
+ const { FEEDBACK_KINDS, isFlagged, entryAsText } = require("./entries.js");
151
+ const { refuseUrl } = require("./pack.js");
152
+ const { selectionInside } = require("./selection.js");
153
+
154
+ /** A subscribe that never fires, so the hook order stays the same without a store. */
155
+ const NO_SUBSCRIPTION = () => () => {};
156
+
157
+ /**
158
+ * Save a text as a file, and say so.
159
+ *
160
+ * Module level because two pages download something now, and the second copy of an anchor click is
161
+ * exactly where a browser difference gets fixed in one place and not the other.
162
+ *
163
+ * @param text - the contents.
164
+ * @param name - the file name.
165
+ * @param setToast - the panel's notice channel.
166
+ * @param t - the translate function.
167
+ */
168
+ function downloadJson(text, name, setToast, t) {
169
+ try {
170
+ const blob = new Blob([text], { type: "application/json" });
171
+ const url = URL.createObjectURL(blob);
172
+ const link = globalThis.document.createElement("a");
173
+ link.href = url;
174
+ link.download = name;
175
+ link.click();
176
+ URL.revokeObjectURL(url);
177
+ setToast?.({ kind: "auto", text: t("transferExported"), undoable: false });
178
+ } catch (error) {
179
+ setToast?.({ kind: "auto", text: t("transferFailed", { message: String(error?.message ?? error) }), undoable: false });
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Put a text on the clipboard, or say that this machine will not.
185
+ * @param text - the text to copy.
186
+ * @param setToast - the panel's notice channel.
187
+ * @param t - the translate function.
188
+ * @returns a promise settling when the copy was attempted.
189
+ */
190
+ async function writeClipboard(text, setToast, t) {
191
+ try {
192
+ if (typeof globalThis.navigator?.clipboard?.writeText !== "function") {
193
+ setToast?.({ kind: "auto", text: t("copyUnavailable"), undoable: false });
194
+ return;
195
+ }
196
+ await globalThis.navigator.clipboard.writeText(text);
197
+ setToast?.({ kind: "auto", text: t("packCodeCopied"), undoable: false });
198
+ } catch (error) {
199
+ void error;
200
+ setToast?.({ kind: "auto", text: t("copyUnavailable"), undoable: false });
201
+ }
202
+ }
203
+
204
+ /**
205
+ * A two-state preference, as a switch.
206
+ *
207
+ * `role="switch"` with `aria-checked`, so assistive technology reports a state rather than an
208
+ * anonymous button — the state is carried by the control, not by its text.
209
+ *
210
+ * @param props - `checked`, `title`, `onToggle`.
211
+ * @returns the switch element.
212
+ */
213
+ function ToggleSwitch(props) {
214
+ return h(
215
+ "button",
216
+ {
217
+ type: "button",
218
+ role: "switch",
219
+ "aria-checked": props.checked === true,
220
+ "aria-label": props.title,
221
+ title: props.title,
222
+ onClick: props.onToggle,
223
+ style: { ...applyStyles.toggleTrack, ...(props.checked === true ? applyStyles.toggleTrackOn : null) }
224
+ },
225
+ h("span", { style: { ...applyStyles.toggleKnob, ...(props.checked === true ? applyStyles.toggleKnobOn : null) } })
226
+ );
227
+ }
228
+
229
+ /**
230
+ * A preference with more than two values, as an option bar.
231
+ *
232
+ * A radiogroup rather than a set of independent buttons: exactly one value is in effect, and that
233
+ * is what the roles say.
234
+ *
235
+ * @param props - `label`, `options` (`[{ value, label }]`), `value`, `onSelect`.
236
+ * @returns the option bar element.
237
+ */
238
+ function OptionBar(props) {
239
+ const { label, options, value, onSelect } = props;
240
+ return h(
241
+ "div",
242
+ { style: applyStyles.optionBar, role: "radiogroup", "aria-label": label },
243
+ options.map((option) =>
244
+ h(
245
+ "button",
246
+ {
247
+ key: String(option.value),
248
+ type: "button",
249
+ role: "radio",
250
+ "aria-checked": option.value === value,
251
+ "data-value": String(option.value),
252
+ title: option.label,
253
+ onClick: () => onSelect(option.value),
254
+ style: { ...applyStyles.optionItem, ...(option.value === value ? applyStyles.optionItemActive : null) }
255
+ },
256
+ option.label
257
+ )
258
+ )
259
+ );
260
+ }
261
+
262
+ /**
263
+ * A delay setting: a slider, and a box to type the number.
264
+ *
265
+ * The third control shape, and it exists because the value is a NUMBER IN A RANGE rather than one of
266
+ * a few named choices: a switch cannot express 110ms, and an option bar over 0–200 in steps of ten
267
+ * would be twenty-one buttons. The two inputs are ONE control — the slider is for finding a feel,
268
+ * the box for entering a number you already know — so both write the same value, and the box is
269
+ * clamped by the store rather than by a second set of rules living here.
270
+ *
271
+ * @param props - `label`, `value`, `min`, `max`, `step`, `unit`, `onChange`.
272
+ * @returns the row's control.
273
+ */
274
+ function DelayField(props) {
275
+ const { label, value, min, max, step, unit, onChange } = props;
276
+ const number = typeof value === "number" && Number.isFinite(value) ? value : min;
277
+ return h(
278
+ "div",
279
+ { style: applyStyles.delayField },
280
+ h("input", {
281
+ type: "range",
282
+ "aria-label": label,
283
+ title: label,
284
+ min,
285
+ max,
286
+ step,
287
+ value: number,
288
+ onChange: (event) => onChange(Number.parseInt(event.target.value, 10)),
289
+ style: applyStyles.delaySlider
290
+ }),
291
+ h("input", {
292
+ type: "number",
293
+ "aria-label": label,
294
+ title: label,
295
+ min,
296
+ max,
297
+ step: 1,
298
+ value: number,
299
+ onChange: (event) => onChange(Number.parseInt(event.target.value, 10)),
300
+ style: applyStyles.delayBox
301
+ }),
302
+ h("span", { style: applyStyles.delayUnit }, unit)
303
+ );
304
+ }
305
+
306
+ /**
307
+ * Write a set of incoming records into the store, and report what became of them.
308
+ *
309
+ * Shared by the file import and the pack import, and that sharing is the point: a pack must inherit
310
+ * every property the file path has — the duplicate report, the deleted-term opt-in, and above all the
311
+ * CRAFTED REVIVAL, which is what makes a term the user had deleted stay revived through the next
312
+ * merge. A second import path written next to the first is how one of them ends up missing it.
313
+ *
314
+ * @param store - the dictionary store.
315
+ * @param incoming - records in the transfer shape (a pack's entries are exactly that).
316
+ * @param options - `includeDeleted` (the user's opt-in), `skipped` (records the parser could not use).
317
+ * @returns `{ imported, existing, deleted, duplicates }`.
318
+ */
319
+ async function importEntries(store, incoming, options) {
320
+ const plan = planImport(store.getState().entries ?? [], incoming, store.getState().deletedKeys ?? new Set(), {
321
+ includeDeleted: options?.includeDeleted === true
322
+ });
323
+ for (const entry of plan.accepted) {
324
+ await store.saveEntry(entry.term, {
325
+ definition: entry.definition,
326
+ domain: entry.domain,
327
+ aliases: entry.aliases,
328
+ pinned: entry.pinned
329
+ });
330
+ }
331
+ return {
332
+ imported: plan.accepted.length,
333
+ existing: plan.existing.length,
334
+ deleted: plan.deleted.length,
335
+ duplicates: plan.duplicates + (options?.skipped ?? 0)
336
+ };
337
+ }
338
+
339
+ /**
340
+ * The report line every import ends with, so the two pages that import cannot describe it differently.
341
+ *
342
+ * "12 imported" and "12 imported, 3 already here, 2 you deleted" are different facts, and only the
343
+ * second one is the truth.
344
+ *
345
+ * @param report - what {@link importEntries} returned, or `{ failed }`.
346
+ * @param t - the translate function.
347
+ * @returns the sentence.
348
+ */
349
+ function importReportText(report, t) {
350
+ if (report === null || report === undefined) return "";
351
+ if (typeof report.failed === "string") return t("transferFailed", { message: report.failed });
352
+ const parts = [t("transferImported", { count: report.imported })];
353
+ if (report.existing > 0) parts.push(t("transferExisting", { count: report.existing }));
354
+ if (report.deleted > 0) parts.push(t("transferSkippedDeleted", { count: report.deleted }));
355
+ if (report.duplicates > 0) parts.push(t("transferDuplicates", { count: report.duplicates }));
356
+ return parts.join(" · ");
357
+ }
358
+
359
+ /**
360
+ * The category checklist: one tick per category the dictionary actually has.
361
+ *
362
+ * Shared by the export half and the term-pack page, and that sharing is not tidiness: two copies of a
363
+ * tick box is two places for "what is selected" to be computed differently, and the mutation harness
364
+ * noticed the second copy before a user could — an anchor that identified this row stopped being
365
+ * unique the moment the pack page grew its own.
366
+ *
367
+ * @param props - `domains` (`[{ domain, count }]`), `chosen` (the ticked names), `onToggle`, `t`,
368
+ * `emptyKey` (the sentence to show when there is nothing to tick).
369
+ * @returns the checklist.
370
+ */
371
+ function CategoryChecklist(props) {
372
+ const { domains, chosen, onToggle, t, emptyKey } = props;
373
+ if (domains.length === 0) return h("p", { style: applyStyles.settingHint }, t(emptyKey ?? "transferNoDomains"));
374
+ return h(
375
+ "div",
376
+ { style: applyStyles.transferList },
377
+ domains.map((domain) =>
378
+ h(
379
+ "label",
380
+ // The marker names the category, so a check can read the checklist the page built instead
381
+ // of inferring it from the export it produced.
382
+ { key: domain.domain, style: applyStyles.transferItem, "data-term-domain": domain.domain },
383
+ h("input", {
384
+ type: "checkbox",
385
+ style: applyStyles.checkbox,
386
+ checked: chosen.includes(domain.domain),
387
+ onChange: () => onToggle(domain.domain)
388
+ }),
389
+ h("span", { style: applyStyles.transferName }, domain.domain),
390
+ h("span", { style: applyStyles.transferCount }, t("seenTimes", { count: domain.count }))
391
+ )
392
+ )
393
+ );
394
+ }
395
+
396
+ /**
397
+ * Import and export, as a page of the panel.
398
+ *
399
+ * The decisions this page only carries out live in `transfer.js`: what a file holds, what a scope
400
+ * selects, and what happens to an entry that cannot be taken. What is here is the wiring — a file
401
+ * input, a download, and a report the user can read.
402
+ *
403
+ * Two things are deliberate:
404
+ *
405
+ * - the export offers **all** or **by category**, and by category starts from the categories the
406
+ * dictionary actually has (`domainsIn`). A checkbox list of invented categories would be a menu
407
+ * of things nobody wrote;
408
+ * - the import REPORTS what it passed over. "12 imported" and "12 imported, 3 already here, 2 you
409
+ * deleted" are different facts, and only the second one is the truth.
410
+ *
411
+ * @param props - `store`, `t`.
412
+ * @returns the transfer page.
413
+ */
414
+ function TransferPage(props) {
415
+ const { store, t } = props;
416
+ const [scope, setScope] = React.useState("all");
417
+ const [chosen, setChosen] = React.useState([]);
418
+ const [report, setReport] = React.useState(null);
419
+ const [busy, setBusy] = React.useState(false);
420
+ /**
421
+ * Whether the import also takes the entries the user deleted.
422
+ *
423
+ * Off by default and asked for explicitly, because the tombstone list IS the blacklist: a file
424
+ * containing those terms is not a reason to empty it. Tick it and they come back with the file's
425
+ * text; leave it and they stay struck out, and the report says how many that was.
426
+ */
427
+ const [includeDeleted, setIncludeDeleted] = React.useState(false);
428
+ /**
429
+ * Whether the feedback report carries the explanation each remark is about.
430
+ *
431
+ * Off by default. A remark without its text is harder to act on, and the text is the user's own
432
+ * dictionary — but it is still text they did not write in the report, so attaching it is a choice
433
+ * rather than a default. The conversation context is never in the report at all; that is not a
434
+ * choice this page offers.
435
+ */
436
+ const [includeGloss, setIncludeGloss] = React.useState(false);
437
+ /**
438
+ * What came back from the host's own feedback channel, or null when nothing was sent yet.
439
+ *
440
+ * Six outcomes, and the page says all six differently: sending, recorded, this build has no channel,
441
+ * the session is gone, no conversation has been seen yet, and anything else. Collapsing them into
442
+ * "failed" is how a user ends up retrying something that cannot work.
443
+ */
444
+ const [feedbackState, setFeedbackState] = React.useState(null);
445
+ const entries = store.getState().entries ?? [];
446
+ const domains = domainsIn(entries);
447
+ const flagged = entries.filter((entry) => isFlagged(entry));
448
+
449
+ /** The text the export would write, given the current choices. */
450
+ const textOf = () => JSON.stringify(buildExport(entries, { scope, domains: chosen }, Date.now()), null, 2);
451
+
452
+ /** The feedback report the page would write, given the current choices. */
453
+ const feedbackTextOf = () => JSON.stringify(buildFeedbackReport(entries, { now: Date.now(), includeGloss }), null, 2);
454
+
455
+ /**
456
+ * The one line the host's feedback channel would record.
457
+ *
458
+ * A summary rather than the report: the host's log is a session-level remark, and pasting a whole
459
+ * JSON document into it would bury the remark. The term, the category and the user's own note are
460
+ * what the plugin can offer to say — and the note is text THEY wrote about the dictionary, not a
461
+ * piece of any conversation.
462
+ *
463
+ * @returns the line, capped by the host at 500 characters.
464
+ */
465
+ const feedbackLine = () => {
466
+ const parts = flagged.map((entry) => {
467
+ const kind = t(`feedbackKind_${entry.feedback?.kind ?? "other"}`);
468
+ return entry.untrusted === true ? `${entry.term}(${kind}+${t("flaggedUntrusted")})` : `${entry.term}(${kind})`;
469
+ });
470
+ const notes = flagged.map((entry) => entry.feedback?.note ?? "").filter((note) => note !== "");
471
+ return [
472
+ `${t("nav")}: ${flagged.length}`,
473
+ parts.join(" / "),
474
+ notes.length === 0 ? "" : `${t("feedbackNoteLabel")}: ${notes.join(" | ")}`
475
+ ]
476
+ .filter((part) => part !== "")
477
+ .join(" — ");
478
+ };
479
+
480
+ /** Record the summary into the host's own feedback log, and report what came of it. */
481
+ const sendFeedback = async () => {
482
+ if (feedbackState === "sending") return;
483
+ const sessionId = typeof props.sessionId === "function" ? props.sessionId() : "";
484
+ // Checked here rather than sent and refused: an empty id would come back as
485
+ // `session-not-found`, which blames the session for what is really "no transcript seen yet".
486
+ if (sessionId === "") {
487
+ setFeedbackState("no-session");
488
+ return;
489
+ }
490
+ if (typeof props.recordFeedback !== "function") {
491
+ setFeedbackState("unavailable");
492
+ return;
493
+ }
494
+ setFeedbackState("sending");
495
+ const answer = await props.recordFeedback({ sessionId, text: feedbackLine(), category: "product-interaction" });
496
+ if (answer?.ok === true) return setFeedbackState("ok");
497
+ const error = answer?.error;
498
+ if (error === "unavailable" || error === "session-not-found") return setFeedbackState(error);
499
+ setFeedbackState("failed");
500
+ };
501
+
502
+ /** Save one text as a file, and report it. */
503
+ const downloadText = (text, name) => {
504
+ try {
505
+ const blob = new Blob([text], { type: "application/json" });
506
+ const url = URL.createObjectURL(blob);
507
+ const link = globalThis.document.createElement("a");
508
+ link.href = url;
509
+ link.download = name;
510
+ link.click();
511
+ URL.revokeObjectURL(url);
512
+ props.setToast?.({ kind: "auto", text: t("transferExported"), undoable: false });
513
+ } catch (error) {
514
+ props.setToast?.({ kind: "auto", text: t("transferFailed", { message: String(error?.message ?? error) }), undoable: false });
515
+ }
516
+ };
517
+
518
+ /** Save the export as a file. */
519
+ const download = () => downloadText(textOf(), `term-dictionary-${new Date().toISOString().slice(0, 10)}.json`);
520
+
521
+ /** Take a file, and say what came of it. */
522
+ const takeFile = async (event) => {
523
+ const file = event?.target?.files?.[0];
524
+ if (file === undefined || file === null) return;
525
+ setBusy(true);
526
+ try {
527
+ const parsed = parseImport(await file.text());
528
+ if (parsed.ok !== true) {
529
+ setReport({ failed: parsed.error });
530
+ return;
531
+ }
532
+ // Through the shared path, so a file and a pack cannot diverge in what they do with an
533
+ // entry — including the crafted revival that makes a term the user deleted stay revived.
534
+ setReport(await importEntries(store, parsed.entries, { includeDeleted, skipped: parsed.skipped ?? 0 }));
535
+ } catch (error) {
536
+ setReport({ failed: String(error?.message ?? error) });
537
+ } finally {
538
+ setBusy(false);
539
+ }
540
+ };
541
+
542
+ /** Tick or untick one category. */
543
+ const toggleDomain = (domain) => setChosen(chosen.includes(domain) ? chosen.filter((name) => name !== domain) : [...chosen, domain]);
544
+
545
+ return h(
546
+ "div",
547
+ { style: applyStyles.settingsPage, "data-term-dictionary": "transfer" },
548
+ //#region export
549
+ h("p", { style: applyStyles.settingLabel }, t("transferExportTitle")),
550
+ h("p", { style: applyStyles.settingHint }, t("transferExportHint")),
551
+ h(
552
+ "div",
553
+ { style: applyStyles.transferRow, role: "radiogroup", "aria-label": t("transferExportTitle") },
554
+ h(OptionBar, {
555
+ label: t("transferExportTitle"),
556
+ value: scope,
557
+ options: [
558
+ { value: "all", label: t("transferScopeAll") },
559
+ { value: "domains", label: t("transferScopeDomains") }
560
+ ],
561
+ onSelect: (value) => setScope(value)
562
+ })
563
+ ),
564
+ scope === "domains"
565
+ ? h(
566
+ "div",
567
+ null,
568
+ h(
569
+ "div",
570
+ { style: applyStyles.transferRow },
571
+ h("button", { type: "button", style: applyStyles.linkButton, onClick: () => setChosen(domains.map((domain) => domain.domain)) }, t("transferSelectAll")),
572
+ h("button", { type: "button", style: applyStyles.linkButton, onClick: () => setChosen([]) }, t("transferSelectNone"))
573
+ ),
574
+ h(CategoryChecklist, { domains, chosen, onToggle: toggleDomain, t })
575
+ )
576
+ : null,
577
+ h(
578
+ "div",
579
+ { style: applyStyles.transferRow },
580
+ h(Button, { label: t("transferDownload"), onClick: download }, t("transferDownload")),
581
+ h(Button, { label: t("transferCopy"), onClick: () => void globalThis.navigator?.clipboard?.writeText(textOf()) }, t("transferCopy"))
582
+ ),
583
+ //#endregion
584
+ //#region feedback report
585
+ //
586
+ // The other half of the feedback feature, and the half that can actually reach someone: the
587
+ // host's own channel records a remark into THIS machine's session log. A report is a file the
588
+ // user decides to send.
589
+ h("p", { style: applyStyles.settingLabel }, t("feedbackExportTitle")),
590
+ h("p", { style: applyStyles.settingHint }, t("feedbackExportHint", { count: String(flagged.length) })),
591
+ h(
592
+ "label",
593
+ { style: applyStyles.transferItem, "data-term-dictionary": "feedback-include-gloss" },
594
+ h("input", {
595
+ type: "checkbox",
596
+ style: applyStyles.checkbox,
597
+ checked: includeGloss,
598
+ onChange: () => setIncludeGloss(!includeGloss)
599
+ }),
600
+ h("span", { style: applyStyles.transferName }, t("feedbackIncludeGloss"))
601
+ ),
602
+ h(
603
+ "div",
604
+ { style: applyStyles.transferRow },
605
+ h(
606
+ Button,
607
+ { label: t("feedbackDownload"), disabled: flagged.length === 0, onClick: () => downloadText(feedbackTextOf(), `term-dictionary-feedback-${new Date().toISOString().slice(0, 10)}.json`) },
608
+ t("feedbackDownload")
609
+ ),
610
+ h(
611
+ Button,
612
+ { label: t("feedbackCopy"), disabled: flagged.length === 0, onClick: () => void globalThis.navigator?.clipboard?.writeText(feedbackTextOf()) },
613
+ t("feedbackCopy")
614
+ )
615
+ ),
616
+ //#region the host's own channel
617
+ //
618
+ // The third way to say something, and the only one that lands somewhere the plugin does not own:
619
+ // the host keeps it in THIS machine's session log. It cannot reach the plugin's author — nothing
620
+ // leaves the machine — which is exactly why the report above exists.
621
+ h("p", { style: applyStyles.settingHint }, t("feedbackRecordHint")),
622
+ h(
623
+ "div",
624
+ { style: applyStyles.transferRow },
625
+ h(
626
+ Button,
627
+ { label: t("feedbackRecord"), disabled: flagged.length === 0 || feedbackState === "sending", onClick: () => void sendFeedback() },
628
+ t("feedbackRecord")
629
+ ),
630
+ feedbackState === null
631
+ ? null
632
+ : h("span", { style: applyStyles.meta, "data-term-feedback-state": feedbackState }, t(`feedbackState_${feedbackState}`))
633
+ ),
634
+ //#endregion
635
+ //#region import
636
+ h("p", { style: applyStyles.settingLabel }, t("transferImportTitle")),
637
+ h("p", { style: applyStyles.settingHint }, t("transferImportHint")),
638
+ h(
639
+ "label",
640
+ { style: applyStyles.transferItem, "data-term-dictionary": "transfer-include-deleted" },
641
+ h("input", {
642
+ type: "checkbox",
643
+ style: applyStyles.checkbox,
644
+ checked: includeDeleted,
645
+ onChange: () => setIncludeDeleted(!includeDeleted)
646
+ }),
647
+ h("span", { style: applyStyles.transferName }, t("transferIncludeDeleted"))
648
+ ),
649
+ h("p", { style: applyStyles.settingHint }, t("transferIncludeDeletedHint")),
650
+ h("input", {
651
+ type: "file",
652
+ accept: ".json,application/json",
653
+ "aria-label": t("transferImportTitle"),
654
+ disabled: busy,
655
+ onChange: (event) => void takeFile(event),
656
+ style: applyStyles.transferFile
657
+ }),
658
+ report === null
659
+ ? null
660
+ : h(
661
+ "p",
662
+ { style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error },
663
+ report.failed === undefined
664
+ ? t("transferReport", { imported: report.imported, existing: report.existing, deleted: report.deleted, duplicates: report.duplicates })
665
+ : t("transferFailed", { message: report.failed })
666
+ )
667
+ //#endregion
668
+ );
669
+ }
670
+
671
+ /**
672
+ * Term packs: hand this dictionary to somebody, or take theirs.
673
+ *
674
+ * Three sections in the order the work happens — make a pack, read one out of a share code, browse a
675
+ * source. The page owns no rules: the pack shape, the source rule, the checksum and the import
676
+ * decisions live in `pack.js`, `transfer.js` and the host routes; what is here is the wiring, which is
677
+ * also why nothing on this page can publish a field the format refuses.
678
+ *
679
+ * `sources` is a preference (the settings store) rather than page state: a source the user added is
680
+ * configuration, and configuration that disappears when the panel closes is not configuration.
681
+ *
682
+ * Exported for the checks, for the same reason {@link TransferPage} is: the panel reaches it from the
683
+ * header, and a check that has to click the header is a check that tests the header.
684
+ *
685
+ * @param props - `store`, `t`, `settings` (the settings store), `packApi` (`{ build, decode, index,
686
+ * fetch }`), `setToast`.
687
+ * @returns the packs page.
688
+ */
689
+ function PacksPage(props) {
690
+ const { store, t, settings: settingsStore, packApi } = props;
691
+ const sources = settingsStore?.getSnapshot?.()?.packSources ?? [];
692
+ const entries = store.getState().entries ?? [];
693
+ const domains = domainsIn(entries);
694
+ const [name, setName] = React.useState("");
695
+ const [author, setAuthor] = React.useState("");
696
+ const [license, setLicense] = React.useState("");
697
+ const [scope, setScope] = React.useState("all");
698
+ const [chosen, setChosen] = React.useState([]);
699
+ /** What the last build produced: `{ pack, summary, sha256, bytes, code }`. */
700
+ const [share, setShare] = React.useState(null);
701
+ const [code, setCode] = React.useState("");
702
+ /** The pack a code or a source produced, waiting for the user to accept it. */
703
+ const [incoming, setIncoming] = React.useState(null);
704
+ const [draft, setDraft] = React.useState("");
705
+ const [sourceDraft, setSourceDraft] = React.useState("");
706
+ const [notice, setNotice] = React.useState("");
707
+ const [busy, setBusy] = React.useState(false);
708
+ const [report, setReport] = React.useState(null);
709
+ const [includeDeleted, setIncludeDeleted] = React.useState(false);
710
+ /** One row per source after a refresh: `{ url, ok, stale, error, index }`. */
711
+ const [listing, setListing] = React.useState(null);
712
+ const [packNote, setPackNote] = React.useState({});
713
+ /** Say something in the page's one notice line rather than in a dialog. */
714
+ const say = (message) => setNotice(message);
715
+
716
+ /** Build this dictionary into a pack — and, when asked, into a share code as well. */
717
+ const build = async (withCode) => {
718
+ if (typeof packApi?.build !== "function") return say(t("packNoHost"));
719
+ if (entries.length === 0) return say(t("packEmpty"));
720
+ setBusy(true);
721
+ setReport(null);
722
+ try {
723
+ const answer = await packApi.build({
724
+ id: name.trim() === "" ? "pack" : name.trim().toLowerCase().replace(/\s+/g, "-"),
725
+ name: name.trim(),
726
+ author: author.trim(),
727
+ license: license.trim(),
728
+ scope,
729
+ domains: chosen,
730
+ code: withCode
731
+ });
732
+ if (answer?.ok !== true) return say(t(`packError_${answer?.error ?? "unknown"}`));
733
+ setShare(answer);
734
+ setCode(typeof answer.code === "string" ? answer.code : "");
735
+ say(t("packBuilt", { count: String(answer.summary?.count ?? 0) }));
736
+ } finally {
737
+ setBusy(false);
738
+ }
739
+ };
740
+
741
+ /** Read a share code into a pack, without importing anything yet. */
742
+ const readCode = async () => {
743
+ if (typeof packApi?.decode !== "function") return say(t("packNoHost"));
744
+ setBusy(true);
745
+ setReport(null);
746
+ setIncoming(null);
747
+ try {
748
+ const answer = await packApi.decode(draft.trim());
749
+ if (answer?.ok !== true) return say(t(`packError_${answer?.error ?? "unknown"}`));
750
+ setIncoming({ pack: answer.pack, summary: answer.summary, label: answer.pack.name });
751
+ say(t("packRead", { count: String(answer.summary?.count ?? 0) }));
752
+ } finally {
753
+ setBusy(false);
754
+ }
755
+ };
756
+
757
+ /** Bring one pack in through the shared import path. */
758
+ const take = async (pack, label) => {
759
+ if (pack === null || pack === undefined) return;
760
+ setBusy(true);
761
+ try {
762
+ setReport({ ...(await importEntries(store, pack.entries, { includeDeleted })), label });
763
+ setIncoming(null);
764
+ } catch (error) {
765
+ setReport({ failed: String(error?.message ?? error) });
766
+ } finally {
767
+ setBusy(false);
768
+ }
769
+ };
770
+
771
+ /** Ask every source what it offers. Each one is independent: a dead source does not hide the rest. */
772
+ const refresh = async () => {
773
+ if (typeof packApi?.index !== "function") return say(t("packNoHost"));
774
+ setBusy(true);
775
+ setListing(null);
776
+ try {
777
+ const rows = [];
778
+ for (const url of sources) {
779
+ const answer = await packApi.index(url, true);
780
+ rows.push({ url, ...(answer ?? { ok: false, error: "unreachable" }) });
781
+ }
782
+ setListing(rows);
783
+ say(rows.length === 0 ? t("packNoSources") : t("packRefreshed", { count: String(rows.filter((row) => row.ok === true).length) }));
784
+ } finally {
785
+ setBusy(false);
786
+ }
787
+ };
788
+
789
+ /** Fetch one listed pack and offer it for import. */
790
+ const preview = async (row, listed) => {
791
+ if (typeof packApi?.fetch !== "function") return say(t("packNoHost"));
792
+ setBusy(true);
793
+ setPackNote((current) => ({ ...current, [`${row.url}|${listed.id}`]: "loading" }));
794
+ try {
795
+ const answer = await packApi.fetch(listed.url, listed.sha256);
796
+ if (answer?.ok !== true) {
797
+ setPackNote((current) => ({ ...current, [`${row.url}|${listed.id}`]: `packError_${answer?.error ?? "unknown"}` }));
798
+ return;
799
+ }
800
+ setIncoming({ pack: answer.pack, summary: answer.summary, label: listed.name });
801
+ setPackNote((current) => ({ ...current, [`${row.url}|${listed.id}`]: "packReady" }));
802
+ } finally {
803
+ setBusy(false);
804
+ }
805
+ };
806
+
807
+ /** Add one source, refusing anything the format would refuse. */
808
+ const addSource = () => {
809
+ const url = sourceDraft.trim();
810
+ if (settingsStore === null || settingsStore === undefined) return say(t("packNoHost"));
811
+ const refusal = refuseUrl(url);
812
+ // The SAME rule the host applies before it fetches: a source that cannot be fetched is refused
813
+ // here, where the user can see why, rather than after a failed round trip.
814
+ if (refusal !== null) return say(t(`packError_${refusal}`));
815
+ if (sources.includes(url)) return say(t("packSourceKnown"));
816
+ settingsStore.set("packSources", [...sources, url]);
817
+ setSourceDraft("");
818
+ say(t("packSourceAdded"));
819
+ };
820
+
821
+ /** Tick or untick one category. */
822
+ const toggleDomain = (domain) => setChosen(chosen.includes(domain) ? chosen.filter((each) => each !== domain) : [...chosen, domain]);
823
+
824
+ /** One pack inside a source's listing. */
825
+ const packRow = (row, listed) => {
826
+ const note = packNote[`${row.url}|${listed.id}`];
827
+ return h(
828
+ "article",
829
+ { key: `${row.url}|${listed.id}`, style: applyStyles.row, "data-term-pack": listed.id },
830
+ h(
831
+ "div",
832
+ { style: applyStyles.rowHead },
833
+ h("span", { style: applyStyles.termPlain }, listed.name),
834
+ listed.author !== "" ? h("span", { style: applyStyles.zh }, listed.author) : null,
835
+ listed.license !== "" ? h("span", { style: applyStyles.domain }, listed.license) : null,
836
+ h("span", { style: applyStyles.spacer }),
837
+ h("span", { style: applyStyles.meta }, t("packCount", { count: String(listed.count) })),
838
+ h(Button, { label: t("packPreview"), disabled: busy, onClick: () => void preview(row, listed) }, t("packPreview"))
839
+ ),
840
+ listed.description !== "" ? h("p", { style: applyStyles.gloss }, listed.description) : null,
841
+ h(
842
+ "p",
843
+ { style: applyStyles.meta },
844
+ [
845
+ listed.domains.slice(0, 4).join(" · "),
846
+ listed.sha256 === "" ? t("packNoChecksum") : `${t("packChecksum")} ${listed.sha256.slice(0, 12)}`,
847
+ note === undefined ? "" : t(note === "loading" ? "packLoading" : "packReady")
848
+ ]
849
+ .filter((part) => part !== "")
850
+ .join(" · ")
851
+ )
852
+ );
853
+ };
854
+
855
+ return h(
856
+ "div",
857
+ { style: applyStyles.settingsPage, "data-term-dictionary": "packs" },
858
+ //#region make a pack
859
+ h("p", { style: applyStyles.settingLabel }, t("packShareTitle")),
860
+ h("p", { style: applyStyles.settingHint }, t("packShareHint")),
861
+ h(Field, { label: t("packNameLabel"), value: name, placeholder: t("packNamePlaceholder"), onChange: setName }),
862
+ h(Field, { label: t("packAuthorLabel"), value: author, placeholder: t("packAuthorPlaceholder"), onChange: setAuthor }),
863
+ h(Field, { label: t("packLicenseLabel"), value: license, placeholder: t("packLicensePlaceholder"), onChange: setLicense }),
864
+ h(OptionBar, {
865
+ label: t("transferScope"),
866
+ value: scope,
867
+ options: [
868
+ { value: "all", label: t("transferScopeAll") },
869
+ { value: "domains", label: t("transferScopeDomains") }
870
+ ],
871
+ onSelect: (value) => {
872
+ setScope(value);
873
+ if (value === "all") setChosen([]);
874
+ }
875
+ }),
876
+ scope === "domains" ? h(CategoryChecklist, { domains, chosen, onToggle: toggleDomain, t }) : null,
877
+ h(
878
+ "div",
879
+ { style: applyStyles.transferRow },
880
+ h(Button, { label: t("packMakeCode"), disabled: busy, onClick: () => void build(true) }, t("packMakeCode")),
881
+ h(Button, { label: t("packMakeFile"), disabled: busy, onClick: () => void build(false) }, t("packMakeFile")),
882
+ share === null
883
+ ? null
884
+ : h(Button, { label: t("packDownload"), disabled: busy, onClick: () => downloadJson(JSON.stringify(share.pack, null, 2), `term-pack-${share.pack.id}.json`, props.setToast, t) }, t("packDownload"))
885
+ ),
886
+ share === null
887
+ ? null
888
+ : h(
889
+ "div",
890
+ null,
891
+ h("p", { style: applyStyles.settingHint }, t("packDigest", { sha: share.sha256.slice(0, 16), bytes: String(share.bytes ?? 0) })),
892
+ code === ""
893
+ ? null
894
+ : h("textarea", {
895
+ readOnly: true,
896
+ value: code,
897
+ rows: 4,
898
+ "aria-label": t("packCodeLabel"),
899
+ "data-term-pack-code": "true",
900
+ style: { ...applyStyles.input, fontFamily: "monospace", fontSize: "11px" }
901
+ }),
902
+ code === ""
903
+ ? null
904
+ : h(Button, { label: t("packCopyCode"), onClick: () => void writeClipboard(code, props.setToast, t) }, t("packCopyCode"))
905
+ ),
906
+ //#endregion
907
+ //#region read a code
908
+ h("p", { style: applyStyles.settingLabel }, t("packReadTitle")),
909
+ h("p", { style: applyStyles.settingHint }, t("packReadHint")),
910
+ h(Area, { label: t("packCodeLabel"), value: draft, placeholder: t("packCodePlaceholder"), rows: 3, onChange: (value) => { setDraft(value); setNotice(""); } }),
911
+ h("div", { style: applyStyles.transferRow }, h(Button, { label: t("packRead"), disabled: busy || draft.trim() === "", onClick: () => void readCode() }, t("packRead"))),
912
+ //#endregion
913
+ //#region the preview, shared by both ways in
914
+ incoming === null
915
+ ? null
916
+ : h(
917
+ "div",
918
+ { style: applyStyles.notice, "data-term-pack-preview": "true" },
919
+ h("p", { style: applyStyles.settingLabel }, t("packPreviewTitle", { name: incoming.label })),
920
+ h(
921
+ "p",
922
+ { style: applyStyles.settingHint },
923
+ [
924
+ t("packCount", { count: String(incoming.summary?.count ?? 0) }),
925
+ incoming.summary?.domains?.length > 0 ? incoming.summary.domains.slice(0, 4).map((each) => each.domain).join(" · ") : "",
926
+ t("packExplained", { count: String(incoming.summary?.explained ?? 0) })
927
+ ]
928
+ .filter((part) => part !== "")
929
+ .join(" · ")
930
+ ),
931
+ h(
932
+ "div",
933
+ { style: applyStyles.transferRow },
934
+ h(Button, { label: t("packImport"), disabled: busy, onClick: () => void take(incoming.pack, incoming.label) }, t("packImport")),
935
+ h(Button, { label: t("cancel"), onClick: () => setIncoming(null) }, t("cancel"))
936
+ ),
937
+ // The opt-in sits with the action it modifies, and only here: a pack that contains a term
938
+ // the user deleted is the one case where "also take what I deleted" is the decision being
939
+ // made, and the tombstone list IS the blacklist.
940
+ h(
941
+ "label",
942
+ { style: applyStyles.transferItem, "data-term-dictionary": "pack-include-deleted" },
943
+ h("input", { type: "checkbox", style: applyStyles.checkbox, checked: includeDeleted, onChange: () => setIncludeDeleted(!includeDeleted) }),
944
+ h("span", { style: applyStyles.transferName }, t("transferIncludeDeleted"))
945
+ )
946
+ ),
947
+ //#endregion
948
+ //#region sources
949
+ h("p", { style: applyStyles.settingLabel }, t("packSourcesTitle")),
950
+ h("p", { style: applyStyles.settingHint }, t("packSourcesHint")),
951
+ sources.length === 0
952
+ ? h("p", { style: applyStyles.blank }, t("packNoSources"))
953
+ : h(
954
+ "div",
955
+ { style: applyStyles.transferList },
956
+ sources.map((url) =>
957
+ h(
958
+ "div",
959
+ { key: url, style: applyStyles.transferItem, "data-term-source": url },
960
+ h("span", { style: applyStyles.transferName }, url),
961
+ h(Button, { label: t("packRemoveSource"), onClick: () => settingsStore?.set?.("packSources", sources.filter((each) => each !== url)) }, t("packRemoveSource"))
962
+ )
963
+ )
964
+ ),
965
+ h(
966
+ "div",
967
+ { style: applyStyles.transferRow },
968
+ h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), onChange: setSourceDraft }),
969
+ h(Button, { label: t("packAddSource"), disabled: busy, onClick: addSource }, t("packAddSource")),
970
+ h(Button, { label: t("packRefresh"), disabled: busy || sources.length === 0, onClick: () => void refresh() }, t("packRefresh"))
971
+ ),
972
+ listing === null
973
+ ? null
974
+ : h(
975
+ "div",
976
+ { style: applyStyles.transferList },
977
+ listing.map((row) =>
978
+ h(
979
+ "div",
980
+ { key: row.url },
981
+ h(
982
+ "p",
983
+ { style: applyStyles.meta },
984
+ [
985
+ row.url,
986
+ row.ok === true ? (row.stale === true ? t("packSourceStale") : t("packSourceOk", { count: String(row.index?.packs?.length ?? 0) })) : t(`packError_${row.error ?? "unreachable"}`)
987
+ ].join(" · ")
988
+ ),
989
+ row.ok === true ? row.index.packs.map((listed) => packRow(row, listed)) : null
990
+ )
991
+ )
992
+ ),
993
+ //#endregion
994
+ notice === "" ? null : h("p", { style: applyStyles.settingHint, "data-term-notice": "true" }, notice),
995
+ report === null ? null : h("p", { style: applyStyles.settingHint, "data-term-report": "true" }, importReportText(report, t))
996
+ );
997
+ }
998
+
999
+ /**
1000
+ * What the user deleted, and the one action that matters here: take it back.
1001
+ *
1002
+ * The dictionary is a blacklist as well as a glossary — a struck-out term is refused by collection
1003
+ * and by import — and a blacklist nobody can read is one nobody can correct. Before this view a term
1004
+ * deleted by mistake (or by a mis-aimed click on a row) was gone for good: the tombstone was doing
1005
+ * its job with no way to retract it.
1006
+ *
1007
+ * The row shows the DELETION, not the entry, so it carries what the decision needs: which term, when
1008
+ * it was struck out, and the way back. It is deliberately not clickable — opening the editor here
1009
+ * would be a second, silent way to undo the deletion.
1010
+ *
1011
+ * Exported so the checks can render it directly, for the same reason {@link SettingsPage} is: the
1012
+ * panel only reaches it through the tab, and a check that has to simulate that click is a check that
1013
+ * tests the click.
1014
+ *
1015
+ * @param props - `entries` (the tombstones, newest first), `t`, `onRevive`.
1016
+ * @returns the deleted view.
1017
+ */
1018
+ function DeletedList(props) {
1019
+ const { entries, t, onRevive } = props;
1020
+ return h(
1021
+ "div",
1022
+ { style: applyStyles.deletedList, "data-term-dictionary": "deleted" },
1023
+ h("p", { style: applyStyles.settingHint }, t("deletedHint")),
1024
+ entries.length === 0
1025
+ ? h("p", { style: applyStyles.blank }, t("deletedEmpty"))
1026
+ : entries.map((entry) =>
1027
+ h(
1028
+ "article",
1029
+ { key: entry.id, style: applyStyles.row, "data-term-deleted": entry.key },
1030
+ h(
1031
+ "div",
1032
+ { style: applyStyles.rowHead },
1033
+ h("span", { style: applyStyles.deletedTerm }, entry.term),
1034
+ h("span", { style: applyStyles.spacer }),
1035
+ h("span", { style: applyStyles.meta }, t("deletedAt", { time: formatStamp(entry.deletedAt) })),
1036
+ h(Button, { label: t("revive"), onClick: () => onRevive(entry) }, t("revive"))
1037
+ )
1038
+ )
1039
+ )
1040
+ );
1041
+ }
1042
+
1043
+ /**
1044
+ * What the user said about entries, and the one action that matters here: take it back.
1045
+ *
1046
+ * The row shows the REMARK — its kind, the user's own words, and when — because that is the thing
1047
+ * being reviewed. Retracting is one press rather than a trip through the editor: a remark is easy to
1048
+ * make by accident (a stray click on a kind), and a correction that costs a form is a correction
1049
+ * nobody makes.
1050
+ *
1051
+ * Exported so the checks can render it directly, like {@link SettingsPage} and {@link DeletedList}.
1052
+ *
1053
+ * @param props - `entries` (the flagged ones), `t`, `onClear`.
1054
+ * @returns the flagged view.
1055
+ */
1056
+ function FlaggedList(props) {
1057
+ const { entries, t, onClear } = props;
1058
+ return h(
1059
+ "div",
1060
+ { style: applyStyles.flaggedList, "data-term-dictionary": "flagged" },
1061
+ h("p", { style: applyStyles.settingHint }, t("flaggedHint")),
1062
+ entries.length === 0
1063
+ ? h("p", { style: applyStyles.blank }, t("flaggedEmpty"))
1064
+ : entries.map((entry) =>
1065
+ h(
1066
+ "article",
1067
+ { key: entry.id, style: applyStyles.row, "data-term-flagged": entry.key },
1068
+ h(
1069
+ "div",
1070
+ { style: applyStyles.rowHead },
1071
+ h("span", { style: applyStyles.termPlain }, entry.term),
1072
+ entry.feedback !== null && entry.feedback !== undefined
1073
+ ? h("span", { style: applyStyles.badgeWarn }, t(`feedbackKind_${entry.feedback.kind}`))
1074
+ : null,
1075
+ entry.untrusted === true ? h("span", { style: applyStyles.badgeWarn }, t("flaggedUntrusted")) : null,
1076
+ h("span", { style: applyStyles.spacer }),
1077
+ h("span", { style: applyStyles.meta }, t("flaggedAt", { time: formatStamp(Math.max(entry.feedbackAt ?? 0, entry.untrustedAt ?? 0)) })),
1078
+ h(Button, { label: t("flaggedClear"), onClick: () => onClear(entry) }, t("flaggedClear"))
1079
+ ),
1080
+ entry.feedback !== null && entry.feedback !== undefined && entry.feedback.note !== ""
1081
+ ? h("p", { style: applyStyles.gloss }, entry.feedback.note)
1082
+ : null
1083
+ )
1084
+ )
1085
+ );
1086
+ }
1087
+
1088
+ /**
1089
+ * The plugin's preferences, as a page of its own inside the panel.
1090
+ *
1091
+ * Why a page rather than a row of chips under the tabs: a chip can only state its CURRENT value,
1092
+ * so the row read as a status line you could also poke, not as settings. A page has room for the
1093
+ * thing each preference actually does — and there are now nine of them, which is past what a row
1094
+ * can carry without becoming a puzzle.
1095
+ *
1096
+ * Every row is the same three parts: the preference's name (which never changes as you use it),
1097
+ * what it does, and the control its VALUE SHAPE calls for — a switch for two states, an option bar
1098
+ * for more, so the control itself says how many choices there are.
1099
+ *
1100
+ * Exported so it can be rendered and driven directly by the checks, the way the glyph and the
1101
+ * popup are: putting it behind the panel's own state would mean a check could only see it by
1102
+ * simulating a click first.
1103
+ *
1104
+ * @param props - `settings`, `setSwitch`, `t`.
1105
+ * @returns the settings page.
1106
+ */
1107
+ function SettingsPage(props) {
1108
+ const { settings, setSwitch, t } = props;
1109
+
1110
+ /**
1111
+ * One preference line.
1112
+ * @param key - React's list key.
1113
+ * @param label - the preference's name.
1114
+ * @param hint - what it does.
1115
+ * @param control - the switch or option bar.
1116
+ * @returns the row.
1117
+ */
1118
+ const row = (key, label, hint, control) =>
1119
+ h(
1120
+ "div",
1121
+ { style: applyStyles.settingRow, key },
1122
+ h("div", { style: applyStyles.settingText }, h("p", { style: applyStyles.settingLabel }, label), h("p", { style: applyStyles.settingHint }, hint)),
1123
+ control
1124
+ );
1125
+
1126
+ /**
1127
+ * A two-state row.
1128
+ * @param key - React's list key.
1129
+ * @param label - the name.
1130
+ * @param hint - what it does.
1131
+ * @param checked - the current state.
1132
+ * @param toggle - the setter.
1133
+ * @returns the row.
1134
+ */
1135
+ const flag = (key, label, hint, checked, toggle) => row(key, label, hint, h(ToggleSwitch, { checked, title: label, onToggle: toggle }));
1136
+
1137
+ return h(
1138
+ "div",
1139
+ { style: applyStyles.settingsPage, "data-term-dictionary": "settings" },
1140
+ flag("collect", t("autoDetectOn"), t("autoDetectHint"), settings.autoCollect, () => setSwitch("autoCollect", !settings.autoCollect)),
1141
+ flag("annotate", t("annotateOn"), t("annotateHint"), settings.autoAnnotate, () => setSwitch("autoAnnotate", !settings.autoAnnotate)),
1142
+ flag("explain", t("autoExplainOn"), t("autoExplainHint"), settings.autoExplain, () => setSwitch("autoExplain", !settings.autoExplain)),
1143
+ flag(
1144
+ "paragraph",
1145
+ t("explainParagraphOn"),
1146
+ t("explainParagraphHint"),
1147
+ settings.explainParagraph !== false,
1148
+ () => setSwitch("explainParagraph", settings.explainParagraph === false)
1149
+ ),
1150
+ row(
1151
+ "lang",
1152
+ t("explainLangName"),
1153
+ t("explainLangHint"),
1154
+ h(OptionBar, {
1155
+ label: t("explainLangName"),
1156
+ value: EXPLAIN_LANGS.includes(settings.explainLang) ? settings.explainLang : "auto",
1157
+ options: EXPLAIN_LANGS.map((value) => ({ value, label: t(`explainLang_${value}`) })),
1158
+ onSelect: (value) => setSwitch("explainLang", value)
1159
+ })
1160
+ ),
1161
+ row(
1162
+ "depth",
1163
+ t("explainDepthName"),
1164
+ t("explainDepthHint"),
1165
+ h(OptionBar, {
1166
+ label: t("explainDepthName"),
1167
+ value: EXPLAIN_DEPTHS.includes(settings.explainDepth) ? settings.explainDepth : "normal",
1168
+ options: EXPLAIN_DEPTHS.map((value) => ({ value, label: t(`explainDepth_${value}`) })),
1169
+ onSelect: (value) => setSwitch("explainDepth", value)
1170
+ })
1171
+ ),
1172
+ flag(
1173
+ "identifiers",
1174
+ t("collectIdentifiersOn"),
1175
+ t("collectIdentifiersHint"),
1176
+ settings.collectIdentifiers === true,
1177
+ () => setSwitch("collectIdentifiers", settings.collectIdentifiers !== true)
1178
+ ),
1179
+ row(
1180
+ "minLength",
1181
+ t("collectMinLengthName"),
1182
+ t("collectMinLengthHint"),
1183
+ h(OptionBar, {
1184
+ label: t("collectMinLengthName"),
1185
+ value: COLLECT_LENGTHS.includes(settings.collectMinLength) ? settings.collectMinLength : 4,
1186
+ options: COLLECT_LENGTHS.map((value) => ({ value, label: t("collectMinLengthOption", { count: value }) })),
1187
+ onSelect: (value) => setSwitch("collectMinLength", value)
1188
+ })
1189
+ ),
1190
+ flag("cjk", t("collectCjkOn"), t("collectCjkHint"), settings.collectCjk !== false, () => setSwitch("collectCjk", settings.collectCjk === false)),
1191
+ // The hover delays. Both are numbers in a range, so both are slider + box; the exit delay
1192
+ // sits right under the entry delay because they are read together.
1193
+ row(
1194
+ "hoverIn",
1195
+ t("hoverInName"),
1196
+ t("hoverInHint"),
1197
+ h(DelayField, {
1198
+ label: t("hoverInName"),
1199
+ value: settings.hoverInMs,
1200
+ min: HOVER_DELAY_MIN,
1201
+ max: HOVER_DELAY_MAX,
1202
+ step: HOVER_DELAY_STEP,
1203
+ unit: t("hoverUnit"),
1204
+ onChange: (value) => setSwitch("hoverInMs", value)
1205
+ })
1206
+ ),
1207
+ row(
1208
+ "hoverOut",
1209
+ t("hoverOutName"),
1210
+ t("hoverOutHint"),
1211
+ h(DelayField, {
1212
+ label: t("hoverOutName"),
1213
+ value: settings.hoverOutMs,
1214
+ min: HOVER_DELAY_MIN,
1215
+ max: HOVER_DELAY_MAX,
1216
+ step: HOVER_DELAY_STEP,
1217
+ unit: t("hoverUnit"),
1218
+ onChange: (value) => setSwitch("hoverOutMs", value)
1219
+ })
1220
+ )
1221
+ );
1222
+ }
1223
+
1224
+ /** One clickable icon button, sized like the host's 28px controls. */
1225
+ function IconButton(props) {
1226
+ return h(
1227
+ "button",
1228
+ {
1229
+ type: "button",
1230
+ title: props.title,
1231
+ "aria-label": props.title,
1232
+ "aria-pressed": props.pressed,
1233
+ disabled: props.disabled === true,
1234
+ onClick: props.onClick,
1235
+ style: {
1236
+ ...applyStyles.iconButton,
1237
+ ...(props.pressed === true ? applyStyles.iconButtonActive : null),
1238
+ ...(props.disabled === true ? { opacity: 0.45, cursor: "default" } : null)
1239
+ }
1240
+ },
1241
+ props.children
1242
+ );
1243
+ }
1244
+
1245
+ /** A labelled text input. */
1246
+ function Field(props) {
1247
+ return h(
1248
+ "label",
1249
+ { style: applyStyles.field },
1250
+ h("span", { style: applyStyles.fieldLabel }, props.label),
1251
+ h("input", {
1252
+ type: "text",
1253
+ value: props.value,
1254
+ placeholder: props.placeholder,
1255
+ onChange: (event) => props.onChange(event.target.value),
1256
+ style: applyStyles.input,
1257
+ "aria-label": props.label
1258
+ })
1259
+ );
1260
+ }
1261
+
1262
+ /** A labelled multi-line text area. */
1263
+ function Area(props) {
1264
+ return h(
1265
+ "label",
1266
+ { style: applyStyles.field },
1267
+ h("span", { style: applyStyles.fieldLabel }, props.label),
1268
+ h("textarea", {
1269
+ value: props.value,
1270
+ placeholder: props.placeholder,
1271
+ rows: props.rows ?? 3,
1272
+ onChange: (event) => props.onChange(event.target.value),
1273
+ style: { ...applyStyles.input, resize: "vertical", minHeight: `${(props.rows ?? 3) * 1.5}em`, lineHeight: 1.5 },
1274
+ "aria-label": props.label
1275
+ })
1276
+ );
1277
+ }
1278
+
1279
+ /** A primary or secondary button. */
1280
+ function Button(props) {
1281
+ return h(
1282
+ "button",
1283
+ {
1284
+ type: "button",
1285
+ disabled: props.disabled === true,
1286
+ onClick: props.onClick,
1287
+ "aria-label": props.label,
1288
+ style: {
1289
+ ...(props.variant === "primary" ? applyStyles.primaryButton : applyStyles.button),
1290
+ ...(props.disabled === true ? { opacity: 0.5, cursor: "default" } : null),
1291
+ ...(props.extra ?? null)
1292
+ }
1293
+ },
1294
+ props.children
1295
+ );
1296
+ }
1297
+
1298
+ /**
1299
+ * The dictionary panel body, rendered as the `main` center region once the user
1300
+ * picks the Dictionary row in the sidebar.
1301
+ *
1302
+ * @param props - injected `store`, `t`, plus the theme/font context the host
1303
+ * provides to a main panel; it uses no session data.
1304
+ * @returns the panel element tree.
1305
+ */
1306
+ /** @type {null | { term: string, context: string, id?: string, zh: string, gloss: string, usage: string, notes: string, domain: string, aliases: string, pinned: boolean, isNew: boolean }} */
1307
+ function DictionaryPanel(props) {
1308
+ const { store, t, bus } = props;
1309
+ const settingsStore = props.settings ?? null;
1310
+ const view = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
1311
+ // The three-argument form is used with a stable fallback so the hook order does not
1312
+ // depend on whether this deployment passed a settings store.
1313
+ const settings = React.useSyncExternalStore(
1314
+ settingsStore === null ? NO_SUBSCRIPTION : settingsStore.subscribe,
1315
+ settingsStore === null ? () => FALLBACK_SETTINGS : settingsStore.getSnapshot,
1316
+ settingsStore === null ? () => FALLBACK_SETTINGS : settingsStore.getSnapshot
1317
+ );
1318
+ const [query, setQuery] = React.useState("");
1319
+ const [filter, setFilter] = React.useState("all");
1320
+ const [editing, setEditing] = React.useState(null);
1321
+ /** Whether the panel is showing its settings page instead of the entry list. */
1322
+ const [settingsOpen, setSettingsOpen] = React.useState(false);
1323
+ /** Whether it is showing the import/export page instead. */
1324
+ const [transferOpen, setTransferOpen] = React.useState(false);
1325
+ /** Whether it is showing the term-pack page instead. */
1326
+ const [packsOpen, setPacksOpen] = React.useState(false);
1327
+ const [busy, setBusy] = React.useState(false);
1328
+ /** Whether rows are being picked for a batch action. */
1329
+ const [picking, setPicking] = React.useState(false);
1330
+ /** The ids of the picked rows. */
1331
+ const [picked, setPicked] = React.useState([]);
1332
+ /** The entry a transcript click asked to reveal, held long enough to be noticed. */
1333
+ const [revealedKey, setRevealedKey] = React.useState(null);
1334
+ const revealedRef = React.useRef(null);
1335
+ const [status, setStatus] = React.useState(() => store.getStatus());
1336
+ const [syncError, setSyncError] = React.useState(() => store.getSyncError());
1337
+
1338
+ /** Turn one switch over. The settings store is what persists and broadcasts it. */
1339
+ const setSwitch = (key, value) => {
1340
+ if (settingsStore === null) return;
1341
+ settingsStore.set(key, value);
1342
+ };
1343
+
1344
+ /** Add or remove one row from the picked set. */
1345
+ const togglePicked = (id) => {
1346
+ setPicked((current) => (current.includes(id) ? current.filter((each) => each !== id) : [...current, id]));
1347
+ };
1348
+
1349
+ // The store is the single owner of the document, so the panel only needs to
1350
+ // observe when hydration finishes and when the host rejected a write.
1351
+ React.useEffect(() => {
1352
+ let alive = true;
1353
+ const sync = () => {
1354
+ if (!alive) return;
1355
+ setStatus(store.getStatus());
1356
+ setSyncError(store.getSyncError());
1357
+ };
1358
+ const unsubscribe = store.subscribe(sync);
1359
+ sync();
1360
+ return () => {
1361
+ alive = false;
1362
+ unsubscribe();
1363
+ };
1364
+ }, [store]);
1365
+
1366
+ const visible = React.useMemo(() => store.list(query, filter), [view, query, filter]);
1367
+ const current = view.state.entries;
1368
+ /**
1369
+ * Whether the panel is showing what was deleted.
1370
+ *
1371
+ * This is not a fourth predicate over the same list: tombstones live in `backing`, so the deleted
1372
+ * view is a different list, rendered by {@link DeletedList} — and the batch-selection bar, whose
1373
+ * checkboxes only exist on live rows, has nothing to act on here.
1374
+ */
1375
+ const showingDeleted = filter === "deleted";
1376
+ /**
1377
+ * Whether the panel is showing what the user said about entries.
1378
+ *
1379
+ * A third list rather than a predicate: these rows are about the REMARK, so they read differently
1380
+ * (kind, note, when) and their one action is to take it back. The batch bar is hidden for the same
1381
+ * reason as in the deleted view — these rows have no checkbox.
1382
+ */
1383
+ const showingFlagged = filter === "flagged";
1384
+
1385
+ /** Open the editor for a blank entry, prefilled from an optional term. */
1386
+ const startCreate = (term = "", context = "") => {
1387
+ setEditing({
1388
+ term,
1389
+ zh: "",
1390
+ gloss: "",
1391
+ usage: "",
1392
+ notes: "",
1393
+ domain: "",
1394
+ aliases: "",
1395
+ pinned: false,
1396
+ // Nothing to say yet, and `feedbackTouched` is what tells a save apart from a retraction:
1397
+ // an untouched pair of empty fields must not create an empty remark.
1398
+ feedbackKind: "wrong-gloss",
1399
+ feedbackNote: "",
1400
+ feedbackTouched: false,
1401
+ untrusted: false,
1402
+ context,
1403
+ isNew: true
1404
+ });
1405
+ };
1406
+
1407
+ /** Open the editor on an existing entry. */
1408
+ const startEdit = (entry) => {
1409
+ setEditing({
1410
+ id: entry.id,
1411
+ term: entry.term,
1412
+ zh: entry.definition.zh,
1413
+ gloss: entry.definition.gloss,
1414
+ usage: entry.definition.usage,
1415
+ notes: entry.definition.notes,
1416
+ domain: entry.domain,
1417
+ aliases: entry.aliases.join(", "),
1418
+ pinned: entry.pinned,
1419
+ // Seeded from the entry, and `touched` starts true when there is already a remark: saving
1420
+ // the form again must not read as "the user retracted it".
1421
+ feedbackKind: entry.feedback?.kind ?? "wrong-gloss",
1422
+ feedbackNote: entry.feedback?.note ?? "",
1423
+ feedbackTouched: entry.feedback !== null && entry.feedback !== undefined,
1424
+ untrusted: entry.untrusted === true,
1425
+ context: entry.context,
1426
+ isNew: false
1427
+ });
1428
+ };
1429
+
1430
+ // A click on a term in the transcript reaches the editor through the shell's
1431
+ // command bus, so the transcript never has to know the panel's shape.
1432
+ React.useEffect(() => {
1433
+ if (bus === undefined || bus === null) return undefined;
1434
+ const consume = () => {
1435
+ const command = bus.consumeCreate();
1436
+ if (command !== null) {
1437
+ setRevealedKey(null);
1438
+ startCreate(command.term, command.context);
1439
+ }
1440
+ const reveal = typeof bus.consumeFocus === "function" ? bus.consumeFocus() : null;
1441
+ if (reveal === null || reveal === undefined) return;
1442
+ // The entry exists, so the panel's job is to show it: drop whatever narrows
1443
+ // the list and whatever replaces it, or the row the user asked for would be
1444
+ // filtered out or hidden behind an open editor.
1445
+ setEditing(null);
1446
+ setQuery("");
1447
+ setFilter("all");
1448
+ setRevealedKey(reveal.key);
1449
+ };
1450
+ consume();
1451
+ return bus.subscribe(consume);
1452
+ }, [bus]);
1453
+
1454
+ // The mark is a cue, not a mode: it fades on its own so the list goes back to
1455
+ // reading as a list.
1456
+ React.useEffect(() => {
1457
+ if (revealedKey === null) return undefined;
1458
+ const timer = setTimeout(() => setRevealedKey(null), REVEAL_FLASH_MS);
1459
+ return () => clearTimeout(timer);
1460
+ }, [revealedKey]);
1461
+
1462
+ // Bring the revealed row into view. The list scrolls, so a row below the fold
1463
+ // would otherwise be "revealed" out of sight.
1464
+ React.useLayoutEffect(() => {
1465
+ const element = revealedRef.current;
1466
+ if (element === null || revealedKey === null) return;
1467
+ if (typeof element.scrollIntoView === "function") element.scrollIntoView({ block: "center" });
1468
+ }, [revealedKey, query, filter]);
1469
+
1470
+ /** Persist the editor's fields. */
1471
+ const commitEditing = async () => {
1472
+ if (editing === null || editing.term.trim() === "" || busy) return;
1473
+ setBusy(true);
1474
+ try {
1475
+ await store.saveEntry(editing.term, {
1476
+ definition: { zh: editing.zh, gloss: editing.gloss, usage: editing.usage, notes: editing.notes },
1477
+ domain: editing.domain,
1478
+ aliases: editing.aliases
1479
+ .split(",")
1480
+ .map((alias) => alias.trim())
1481
+ .filter((alias) => alias !== ""),
1482
+ pinned: editing.pinned,
1483
+ // What the two feedback fields say, and nothing when the user never touched them — an
1484
+ // untouched pair of empty fields means "no remark", not "the user retracted one", which
1485
+ // is why a save that only fixes a typo in the gloss leaves the remark alone.
1486
+ feedback: editing.feedbackTouched ? { kind: editing.feedbackKind, note: editing.feedbackNote } : null,
1487
+ untrusted: editing.untrusted
1488
+ });
1489
+ setEditing(null);
1490
+ } finally {
1491
+ setBusy(false);
1492
+ }
1493
+ };
1494
+
1495
+ /** Ask the host's model for an explanation of the term being edited. */
1496
+ const generate = async () => {
1497
+ if (editing === null || editing.term.trim() === "" || busy) return;
1498
+ if (typeof props.explain !== "function") {
1499
+ setSyncError(t("generateNoHost"));
1500
+ return;
1501
+ }
1502
+ setBusy(true);
1503
+ try {
1504
+ // `retry` when the user has called the current explanation untrustworthy: the host appends
1505
+ // one fixed sentence saying the last answer was rejected. The user's own note is never sent.
1506
+ const answer = await props.explain(editing.term.trim(), editing.context ?? "", { retry: editing.untrusted === true });
1507
+ if (answer !== null && answer !== undefined && answer.ok === true && answer.definition !== null && typeof answer.definition === "object") {
1508
+ setEditing((previous) =>
1509
+ previous === null
1510
+ ? previous
1511
+ : {
1512
+ ...previous,
1513
+ gloss: typeof answer.definition.gloss === "string" && answer.definition.gloss !== "" ? answer.definition.gloss : previous.gloss,
1514
+ zh: typeof answer.definition.zh === "string" && answer.definition.zh !== "" ? answer.definition.zh : previous.zh,
1515
+ usage: typeof answer.definition.usage === "string" && answer.definition.usage !== "" ? answer.definition.usage : previous.usage,
1516
+ domain: typeof answer.definition.domain === "string" && answer.definition.domain !== "" ? answer.definition.domain : previous.domain
1517
+ }
1518
+ );
1519
+ setSyncError("");
1520
+ } else {
1521
+ setSyncError(t("generateFailed", { message: answer?.error ?? "unknown" }));
1522
+ }
1523
+ } finally {
1524
+ setBusy(false);
1525
+ }
1526
+ };
1527
+
1528
+ /**
1529
+ * Copy one entry as text.
1530
+ *
1531
+ * No selection involved, which is the whole point: the list re-sorts itself as new sightings arrive
1532
+ * and the click that ends a selection used to open the editor over it. A button cannot be
1533
+ * interrupted by either.
1534
+ *
1535
+ * @param entry - the entry to copy.
1536
+ */
1537
+ const copyEntry = async (entry) => {
1538
+ const text = entryAsText(entry);
1539
+ if (text === "") return;
1540
+ try {
1541
+ if (typeof navigator?.clipboard?.writeText !== "function") {
1542
+ setSyncError(t("copyUnavailable"));
1543
+ return;
1544
+ }
1545
+ await navigator.clipboard.writeText(text);
1546
+ props.setToast?.({ kind: "auto", text: t("toastCopied", { term: entry.term }), undoable: false });
1547
+ } catch (error) {
1548
+ void error;
1549
+ setSyncError(t("copyUnavailable"));
1550
+ }
1551
+ };
1552
+
1553
+ /** Copy the whole dictionary as JSON to the clipboard. */
1554
+ const exportJson = async () => {
1555
+ try {
1556
+ if (typeof navigator?.clipboard?.writeText !== "function") {
1557
+ setSyncError(t("exportFailed"));
1558
+ return;
1559
+ }
1560
+ const text = JSON.stringify({ schemaVersion: 1, entries: current }, null, 2);
1561
+ await navigator.clipboard.writeText(text);
1562
+ setSyncError(t("exportDone"));
1563
+ } catch (error) {
1564
+ setSyncError(t("exportFailed"));
1565
+ }
1566
+ };
1567
+
1568
+ /**
1569
+ * Delete every picked row in one document write.
1570
+ *
1571
+ * One `store.deleteEntries` call rather than a loop of `deleteEntry`: each delete
1572
+ * persists the whole document and pushes it to the host, so twenty rows done one at a
1573
+ * time would mean twenty round trips. The tombstones are still one per entry, which is
1574
+ * what stops the host's copy from bringing them back.
1575
+ */
1576
+ const deletePicked = async () => {
1577
+ if (picked.length === 0) return;
1578
+ if (globalThis.confirm?.(t("deleteSelectedConfirm", { count: String(picked.length) })) === false) return;
1579
+ await store.deleteEntries(picked);
1580
+ setPicked([]);
1581
+ setPicking(false);
1582
+ };
1583
+
1584
+ /**
1585
+ * Take one deletion back.
1586
+ *
1587
+ * The toast is the confirmation, and it is not decoration: the revived row LEAVES this list, so
1588
+ * without a word about where it went, a click on 撤回 looks like a row that simply disappeared.
1589
+ */
1590
+ const revive = async (entry) => {
1591
+ const restored = await store.reviveEntry(entry.id);
1592
+ if (restored !== null) props.setToast?.({ kind: "auto", text: t("toastRevived", { term: entry.term }), undoable: false });
1593
+ };
1594
+
1595
+ /**
1596
+ * Take a remark back.
1597
+ *
1598
+ * Clears BOTH things the user said — the remark and the verdict on the explanation — because the
1599
+ * row's one press is "never mind", and leaving the entry marked untrustworthy while its remark
1600
+ * disappeared would keep the automatic explainer away with nothing on screen to explain why.
1601
+ */
1602
+ const clearFlag = async (entry) => {
1603
+ const cleared = await store.markEntry(entry.id, { feedback: null, untrusted: false });
1604
+ if (cleared !== null) props.setToast?.({ kind: "auto", text: t("toastFlagCleared", { term: entry.term }), undoable: false });
1605
+ };
1606
+
1607
+ if (editing !== null) {
1608
+ return h(
1609
+ "div",
1610
+ { style: applyStyles.panel, "data-term-dictionary": "editor" },
1611
+ h(
1612
+ "header",
1613
+ { style: applyStyles.header },
1614
+ h("div", { style: applyStyles.headerText },
1615
+ h("h2", { style: applyStyles.title }, editing.isNew ? t("editorTitle") : t("editorTitleEdit")),
1616
+ editing.context !== "" ? h("p", { style: applyStyles.context }, editing.context) : null
1617
+ ),
1618
+ h(IconButton, { title: t("close"), onClick: () => setEditing(null) }, Icons.close(14))
1619
+ ),
1620
+ h(
1621
+ "div",
1622
+ { style: applyStyles.editorBody },
1623
+ h(Field, {
1624
+ label: t("termLabel"),
1625
+ value: editing.term,
1626
+ placeholder: t("termPlaceholder"),
1627
+ onChange: (value) => setEditing({ ...editing, term: value })
1628
+ }),
1629
+ h(Field, {
1630
+ label: t("translationLabel"),
1631
+ value: editing.zh,
1632
+ placeholder: t("zhPlaceholder"),
1633
+ onChange: (value) => setEditing({ ...editing, zh: value })
1634
+ }),
1635
+ h(Area, {
1636
+ label: t("glossLabel"),
1637
+ value: editing.gloss,
1638
+ placeholder: t("glossPlaceholder"),
1639
+ rows: 4,
1640
+ onChange: (value) => setEditing({ ...editing, gloss: value })
1641
+ }),
1642
+ h(Area, {
1643
+ label: t("usageLabel"),
1644
+ value: editing.usage,
1645
+ placeholder: t("usagePlaceholder"),
1646
+ rows: 2,
1647
+ onChange: (value) => setEditing({ ...editing, usage: value })
1648
+ }),
1649
+ h(Field, {
1650
+ label: t("domainLabel"),
1651
+ value: editing.domain,
1652
+ placeholder: t("domainPlaceholder"),
1653
+ onChange: (value) => setEditing({ ...editing, domain: value })
1654
+ }),
1655
+ h(Area, {
1656
+ label: t("notesLabel"),
1657
+ value: editing.notes,
1658
+ placeholder: t("notesPlaceholder"),
1659
+ rows: 2,
1660
+ onChange: (value) => setEditing({ ...editing, notes: value })
1661
+ }),
1662
+ //#region feedback
1663
+ //
1664
+ // The remark lives HERE, on the entry's own page, rather than behind a separate "report
1665
+ // a problem" dialog. Two reasons: a report and a fix are the same visit — the person who
1666
+ // says the explanation is wrong is looking right at the field they would correct it in —
1667
+ // and the editor is where an entry's state is edited, so a second place to change the
1668
+ // same entry is exactly the drift this plugin keeps designing out.
1669
+ h("p", { style: applyStyles.settingLabel }, t("feedbackTitle")),
1670
+ h("p", { style: applyStyles.settingHint }, t("feedbackHint")),
1671
+ h(
1672
+ "div",
1673
+ { style: applyStyles.settingRow },
1674
+ h("div", { style: applyStyles.settingText }, h("span", { style: applyStyles.settingLabel }, t("feedbackKindName"))),
1675
+ h(OptionBar, {
1676
+ label: t("feedbackKindName"),
1677
+ value: editing.feedbackKind,
1678
+ options: FEEDBACK_KINDS.map((kind) => ({ value: kind, label: t(`feedbackKind_${kind}`) })),
1679
+ // Picking a kind IS making a remark, so the pair stops being "untouched" here.
1680
+ onSelect: (value) => setEditing({ ...editing, feedbackKind: value, feedbackTouched: true })
1681
+ })
1682
+ ),
1683
+ h(
1684
+ "div",
1685
+ { style: applyStyles.settingRow },
1686
+ h(
1687
+ "div",
1688
+ { style: applyStyles.settingText },
1689
+ h("span", { style: applyStyles.settingLabel }, t("feedbackUntrustedOn")),
1690
+ h("span", { style: applyStyles.settingHint }, t("feedbackUntrustedHint"))
1691
+ ),
1692
+ h(ToggleSwitch, {
1693
+ title: t("feedbackUntrustedOn"),
1694
+ checked: editing.untrusted === true,
1695
+ onToggle: () => setEditing({ ...editing, untrusted: !editing.untrusted })
1696
+ })
1697
+ ),
1698
+ h(Area, {
1699
+ label: t("feedbackNoteLabel"),
1700
+ value: editing.feedbackNote,
1701
+ placeholder: t("feedbackNotePlaceholder"),
1702
+ rows: 2,
1703
+ onChange: (value) => setEditing({ ...editing, feedbackNote: value, feedbackTouched: true })
1704
+ }),
1705
+ //#endregion
1706
+ syncError !== "" ? h("p", { style: applyStyles.error }, syncError) : null,
1707
+ h(
1708
+ "div",
1709
+ { style: applyStyles.actions },
1710
+ h(Button, { variant: "primary", label: t("saveAndClose"), disabled: busy || editing.term.trim() === "", onClick: commitEditing }, t("save")),
1711
+ h(Button, { label: t("generate"), disabled: busy || editing.term.trim() === "", onClick: generate }, busy ? t("generating") : t("generate")),
1712
+ // Copy what is on the FORM, not what is stored: the editor is where somebody has just
1713
+ // written the text they want to paste somewhere, and it may not be saved yet.
1714
+ h(
1715
+ Button,
1716
+ {
1717
+ label: t("copyEntry"),
1718
+ disabled: editing.term.trim() === "",
1719
+ onClick: () =>
1720
+ void copyEntry({
1721
+ term: editing.term.trim(),
1722
+ definition: { zh: editing.zh, gloss: editing.gloss, usage: editing.usage }
1723
+ })
1724
+ },
1725
+ t("copyEntry")
1726
+ ),
1727
+ h(Button, { label: t("cancel"), onClick: () => setEditing(null) }, t("cancel"))
1728
+ )
1729
+ )
1730
+ );
1731
+ }
1732
+
1733
+ if (packsOpen) {
1734
+ return h(
1735
+ "div",
1736
+ { style: applyStyles.panel, "data-term-dictionary": "panel" },
1737
+ h(
1738
+ "header",
1739
+ { style: applyStyles.header },
1740
+ h(
1741
+ "div",
1742
+ { style: applyStyles.headerText },
1743
+ h("h2", { style: applyStyles.title }, t("packsTitle")),
1744
+ h("p", { style: applyStyles.subtitle }, t("packsSubtitle"))
1745
+ ),
1746
+ h(Button, { label: t("settingsBack"), onClick: () => setPacksOpen(false) }, t("settingsBack"))
1747
+ ),
1748
+ h(PacksPage, { store, t, settings: props.settings, packApi: props.packApi, setToast: props.setToast })
1749
+ );
1750
+ }
1751
+
1752
+ if (transferOpen) {
1753
+ return h(
1754
+ "div",
1755
+ { style: applyStyles.panel, "data-term-dictionary": "panel" },
1756
+ h(
1757
+ "header",
1758
+ { style: applyStyles.header },
1759
+ h(
1760
+ "div",
1761
+ { style: applyStyles.headerText },
1762
+ h("h2", { style: applyStyles.title }, t("transferTitle")),
1763
+ h("p", { style: applyStyles.subtitle }, t("transferSubtitle"))
1764
+ ),
1765
+ h(Button, { label: t("settingsBack"), onClick: () => setTransferOpen(false) }, t("settingsBack"))
1766
+ ),
1767
+ h(TransferPage, { store, t, setToast: props.setToast })
1768
+ );
1769
+ }
1770
+
1771
+ if (settingsOpen) {
1772
+ return h(
1773
+ "div",
1774
+ { style: applyStyles.panel, "data-term-dictionary": "panel" },
1775
+ h(
1776
+ "header",
1777
+ { style: applyStyles.header },
1778
+ h(
1779
+ "div",
1780
+ { style: applyStyles.headerText },
1781
+ h("h2", { style: applyStyles.title }, t("settingsTitle")),
1782
+ h("p", { style: applyStyles.subtitle }, t("settingsSubtitle"))
1783
+ ),
1784
+ h(Button, { label: t("settingsBack"), onClick: () => setSettingsOpen(false) }, t("settingsBack"))
1785
+ ),
1786
+ h(SettingsPage, { settings, setSwitch, t })
1787
+ );
1788
+ }
1789
+
1790
+ return h(
1791
+ "div",
1792
+ { style: applyStyles.panel, "data-term-dictionary": "panel" },
1793
+ h(
1794
+ "header",
1795
+ { style: applyStyles.header },
1796
+ h(
1797
+ "div",
1798
+ { style: applyStyles.headerText },
1799
+ h("h2", { style: applyStyles.title }, t("title")),
1800
+ h("p", { style: applyStyles.subtitle }, status === "loading" ? t("loading") : t("count", { total: view.stats.total, unexplained: view.stats.unexplained }))
1801
+ ),
1802
+ h(Button, { label: t("settingsOpen"), onClick: () => setSettingsOpen(true) }, t("settingsOpen")),
1803
+ h(Button, { label: t("transferTitle"), onClick: () => setTransferOpen(true) }, t("transferTitle")),
1804
+ h(Button, { label: t("packsTitle"), onClick: () => setPacksOpen(true) }, t("packsTitle")),
1805
+ h(IconButton, { title: t("addTerm"), onClick: () => startCreate() }, Icons.plus(14)),
1806
+ h(
1807
+ IconButton,
1808
+ {
1809
+ title: picking ? t("selectExit") : t("selectEnter"),
1810
+ pressed: picking,
1811
+ onClick: () => {
1812
+ setPicking(!picking);
1813
+ setPicked([]);
1814
+ }
1815
+ },
1816
+ Icons.select(14)
1817
+ )
1818
+ ),
1819
+ h(
1820
+ "div",
1821
+ { style: applyStyles.searchRow },
1822
+ h("span", { style: applyStyles.searchIcon, "aria-hidden": "true" }, Icons.search(14)),
1823
+ h("input", {
1824
+ type: "search",
1825
+ value: query,
1826
+ placeholder: t("search"),
1827
+ "aria-label": t("search"),
1828
+ onChange: (event) => setQuery(event.target.value),
1829
+ style: SEARCH_INPUT_STYLE
1830
+ })
1831
+ ),
1832
+ h(
1833
+ "div",
1834
+ { style: applyStyles.tabs, role: "tablist" },
1835
+ [
1836
+ ["all", t("filterAll")],
1837
+ ["unexplained", t("filterUnexplained")],
1838
+ ["pinned", t("filterPinned")],
1839
+ ["flagged", t("filterFlagged")],
1840
+ ["deleted", t("filterDeleted")]
1841
+ ].map(([id, label]) =>
1842
+ h(
1843
+ "button",
1844
+ {
1845
+ key: id,
1846
+ type: "button",
1847
+ role: "tab",
1848
+ "aria-selected": filter === id,
1849
+ onClick: () => setFilter(id),
1850
+ style: { ...applyStyles.tab, ...(filter === id ? applyStyles.tabActive : null) }
1851
+ },
1852
+ label
1853
+ )
1854
+ )
1855
+ ),
1856
+ // The preferences are NOT a row here any more. A row of chips under the tabs could only
1857
+ // state each value, never explain it, and at nine controls it read as a puzzle — so they
1858
+ // moved to their own page ({@link SettingsPage}), reached from the header. One place per
1859
+ // preference: two places to change the same thing is how the two drift apart.
1860
+ picking && !showingDeleted && !showingFlagged
1861
+ ? h(
1862
+ "div",
1863
+ { style: applyStyles.selectBar, "data-term-dictionary": "select-bar" },
1864
+ h("span", null, t("selectedCount", { count: String(picked.length) })),
1865
+ h(
1866
+ "button",
1867
+ {
1868
+ type: "button",
1869
+ style: applyStyles.linkButton,
1870
+ onClick: () => setPicked(picked.length === visible.length ? [] : visible.map((entry) => entry.id))
1871
+ },
1872
+ picked.length === visible.length && visible.length > 0 ? t("selectNone") : t("selectAll")
1873
+ ),
1874
+ h("span", { style: applyStyles.spacer }),
1875
+ h(
1876
+ "button",
1877
+ {
1878
+ type: "button",
1879
+ style: { ...applyStyles.button, ...(picked.length === 0 ? { opacity: 0.5, cursor: "default" } : null) },
1880
+ disabled: picked.length === 0,
1881
+ onClick: deletePicked
1882
+ },
1883
+ t("deleteSelected")
1884
+ )
1885
+ )
1886
+ : null,
1887
+ syncError !== "" ? h(
1888
+ "div",
1889
+ { style: applyStyles.notice },
1890
+ h("span", null, syncError),
1891
+ h("button", { type: "button", onClick: () => store.retrySync(), style: applyStyles.linkButton }, t("retrySync"))
1892
+ ) : null,
1893
+ h(
1894
+ "div",
1895
+ { style: applyStyles.list },
1896
+ showingDeleted
1897
+ ? h(DeletedList, { entries: visible, t, onRevive: (entry) => void revive(entry) })
1898
+ : showingFlagged
1899
+ ? h(FlaggedList, { entries: visible, t, onClear: (entry) => void clearFlag(entry) })
1900
+ : visible.length === 0
1901
+ ? h("p", { style: applyStyles.blank }, current.length === 0 ? t("empty") : t("searchEmpty"))
1902
+ : visible.map((entry) =>
1903
+ picking
1904
+ ? h(
1905
+ "article",
1906
+ {
1907
+ key: entry.id,
1908
+ style: applyStyles.rowPicking,
1909
+ "data-term-entry": entry.key,
1910
+ "data-term-picked": picked.includes(entry.id) ? "true" : undefined
1911
+ },
1912
+ h("input", {
1913
+ type: "checkbox",
1914
+ style: applyStyles.checkbox,
1915
+ checked: picked.includes(entry.id),
1916
+ "aria-label": entry.term,
1917
+ onChange: () => togglePicked(entry.id)
1918
+ }),
1919
+ h("button", {
1920
+ type: "button",
1921
+ style: applyStyles.termButton,
1922
+ onClick: () => togglePicked(entry.id),
1923
+ title: t("selectEnter")
1924
+ }, entry.term),
1925
+ entry.definition.zh !== "" ? h("span", { style: applyStyles.zh }, entry.definition.zh) : null,
1926
+ entry.pinned ? h("span", { style: applyStyles.badge }, t("pinned")) : null,
1927
+ h("span", { style: applyStyles.spacer }),
1928
+ h("span", { style: applyStyles.domain }, entry.domain)
1929
+ )
1930
+ : h(
1931
+ "article",
1932
+ {
1933
+ key: entry.id,
1934
+ ref: revealedKey === entry.key ? revealedRef : null,
1935
+ style: revealedKey === entry.key ? { ...applyStyles.row, ...applyStyles.rowRevealed } : applyStyles.row,
1936
+ "data-term-entry": entry.key,
1937
+ "data-term-revealed": revealedKey === entry.key ? "true" : undefined,
1938
+ // The whole BLOCK opens the entry, not only its name.
1939
+ //
1940
+ // A pointer convenience, deliberately not a control: the term-name button
1941
+ // inside is the accessible one (focusable, labelled), so this adds no role,
1942
+ // no tab stop and no key handler — and it must not, because the row holds
1943
+ // several buttons of its own and a `role="button"` wrapped around them would
1944
+ // report the whole row as a single control.
1945
+ onClick: (event) => {
1946
+ // The buttons inside keep their own meaning: pressing pin, copy, edit or
1947
+ // delete must not also open the editor behind it.
1948
+ if (typeof event?.target?.closest === "function" && event.target.closest("button") !== null) return;
1949
+ // And a click that just ENDED a text selection is a selection gesture: this
1950
+ // used to open the editor on top of the text somebody was selecting to copy.
1951
+ if (selectionInside(event?.currentTarget)) return;
1952
+ startEdit(entry);
1953
+ }
1954
+ },
1955
+ h(
1956
+ "div",
1957
+ { style: applyStyles.rowHead },
1958
+ h("button", {
1959
+ type: "button",
1960
+ style: applyStyles.termButton,
1961
+ onClick: () => startEdit(entry),
1962
+ title: t("edit")
1963
+ }, entry.term),
1964
+ entry.definition.zh !== "" ? h("span", { style: applyStyles.zh }, entry.definition.zh) : null,
1965
+ entry.pinned ? h("span", { style: applyStyles.badge }, t("pinned")) : null,
1966
+ entry.definition.gloss === "" && entry.definition.zh === "" ? h("span", { style: applyStyles.badgeWarn }, t("unexplained")) : null,
1967
+ // A flagged entry says so in the list, not only in its own view: the badge is
1968
+ // how a reader learns there is something to read before opening anything.
1969
+ entry.feedback !== null && entry.feedback !== undefined
1970
+ ? h("span", { style: applyStyles.badgeWarn, "data-term-badge": "feedback" }, t(`feedbackKind_${entry.feedback.kind}`))
1971
+ : null,
1972
+ entry.untrusted === true ? h("span", { style: applyStyles.badgeWarn, "data-term-badge": "untrusted" }, t("flaggedUntrusted")) : null,
1973
+ h("span", { style: applyStyles.spacer }),
1974
+ entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null,
1975
+ h(IconButton, { title: entry.pinned ? t("unpin") : t("pin"), pressed: entry.pinned, onClick: () => store.togglePinned(entry.id) }, Icons.pin(13)),
1976
+ h(IconButton, { title: t("copyEntry"), onClick: () => void copyEntry(entry) }, Icons.copy(13)),
1977
+ h(IconButton, { title: t("feedbackTitle"), pressed: entry.feedback !== null && entry.feedback !== undefined, onClick: () => startEdit(entry) }, Icons.flag(13)),
1978
+ h(IconButton, { title: t("edit"), onClick: () => startEdit(entry) }, Icons.edit(13)),
1979
+ h(IconButton, { title: t("remove"), onClick: () => { if (globalThis.confirm?.(t("removeConfirm")) !== false) store.deleteEntry(entry.id); } }, Icons.trash(13))
1980
+ ),
1981
+ entry.definition.gloss !== "" ? h("p", { style: applyStyles.gloss }, entry.definition.gloss) : h("p", { style: applyStyles.glossMuted }, t("noDefinition")),
1982
+ entry.definition.usage !== "" ? h("p", { style: applyStyles.usage }, entry.definition.usage) : null,
1983
+ h(
1984
+ "p",
1985
+ { style: applyStyles.meta },
1986
+ [
1987
+ t("seenTimes", { count: entry.seen }),
1988
+ entry.lastSeenAt > 0 ? t("lastSeen", { time: formatStamp(entry.lastSeenAt) }) : "",
1989
+ sourceLabel(entry.source, t)
1990
+ ].filter((part) => part !== "").join(" · ")
1991
+ )
1992
+ )
1993
+ )
1994
+ ),
1995
+ storageNote(view.hostInfo, t),
1996
+ h(
1997
+ "footer",
1998
+ { style: applyStyles.footer },
1999
+ h("span", null, t("countShort", { total: view.stats.total })),
2000
+ h("span", { style: applyStyles.spacer }),
2001
+ h("button", { type: "button", onClick: exportJson, style: applyStyles.linkButton }, t("exportJson")),
2002
+ h("button", {
2003
+ type: "button",
2004
+ onClick: () => {
2005
+ // Ask with the NUMBERS in the question. "Delete everything?" and "delete 8, keep the
2006
+ // 3 you pinned" are different offers, and only the second one is what happens —
2007
+ // a confirmation that under-describes its own effect is how a pinned entry gets
2008
+ // deleted by a user who believed it was safe.
2009
+ const all = store.getState().entries ?? [];
2010
+ const kept = all.filter((entry) => entry.pinned === true).length;
2011
+ if (globalThis.confirm?.(t("clearConfirm", { removed: all.length - kept, kept })) === true) store.clearAll();
2012
+ },
2013
+ style: applyStyles.linkButton
2014
+ }, t("clearAll"))
2015
+ )
2016
+ );
2017
+ }
2018
+
2019
+ /**
2020
+ * Where the dictionary is stored, when the host told the page.
2021
+ *
2022
+ * A read-only profile is the interesting case: the panel keeps working from local
2023
+ * storage, so the user needs to know the file is not being written rather than
2024
+ * discovering it after a restart.
2025
+ *
2026
+ * @param hostInfo - the `location` block from `GET /state`, or null.
2027
+ * @param t - the panel's localization function.
2028
+ * @returns the note element, or null when there is nothing to say.
2029
+ */
2030
+ function storageNote(hostInfo, t) {
2031
+ if (hostInfo === null || hostInfo === undefined) return null;
2032
+ const writable = hostInfo.writable !== false;
2033
+ return h(
2034
+ "p",
2035
+ { style: writable ? applyStyles.storageNote : applyStyles.error, "data-term-dictionary": "storage-note" },
2036
+ writable ? t("storageAt", { path: hostInfo.directory ?? "" }) : t("storageReadOnly")
2037
+ );
2038
+ }
2039
+
2040
+ /** Localized label for an entry's provenance. */
2041
+ function sourceLabel(source, t) {
2042
+ switch (source) {
2043
+ case "user":
2044
+ return t("popupSourceUser");
2045
+ case "llm":
2046
+ return t("popupSourceLlm");
2047
+ case "heuristic":
2048
+ return t("popupSourceHeuristic");
2049
+ case "import":
2050
+ return t("popupSourceImport");
2051
+ default:
2052
+ return t("popupSourceAuto");
2053
+ }
2054
+ }
2055
+
2056
+ module.exports = { DictionaryGlyph, DictionaryPanel, DeletedList, FlaggedList, PacksPage, SettingsPage, TransferPage, ToggleSwitch, OptionBar, Icons, IconButton, Button, sourceLabel };