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