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