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