dsh-plugin-term-dictionary 1.0.0 → 1.1.1
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 +117 -23
- package/README.md +8 -4
- package/lib/client.js +1091 -59
- package/lib/core/copy.js +88 -0
- package/lib/core/dictionary.js +204 -10
- package/lib/core/entries.js +35 -0
- package/lib/core/hover.js +40 -0
- package/lib/core/pack.js +42 -15
- package/lib/core/settings.js +56 -2
- package/lib/core/store.js +54 -4
- package/lib/core/styles.js +19 -0
- package/lib/core/transfer.js +72 -2
- package/lib/core/views.js +481 -26
- package/package.json +1 -1
package/lib/core/views.js
CHANGED
|
@@ -69,6 +69,13 @@ const Icons = {
|
|
|
69
69
|
{ width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
|
|
70
70
|
h("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round" })
|
|
71
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
|
+
),
|
|
72
79
|
pin: (size) =>
|
|
73
80
|
h(
|
|
74
81
|
"svg",
|
|
@@ -142,11 +149,11 @@ const Icons = {
|
|
|
142
149
|
};
|
|
143
150
|
|
|
144
151
|
/** Settings used when the panel is rendered without a settings store (tests, odd hosts). */
|
|
145
|
-
const FALLBACK_SETTINGS = { autoCollect: true, autoAnnotate: true, autoExplain: true, explainLang: "auto", explainDepth: "normal", explainParagraph: true, collectMinLength: 4, collectIdentifiers: false, collectCjk: true };
|
|
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: [] };
|
|
146
153
|
|
|
147
154
|
/** The accepted preference values, so the page offers exactly what the store validates. */
|
|
148
|
-
const { EXPLAIN_LANGS, EXPLAIN_DEPTHS, COLLECT_LENGTHS, HOVER_DELAY_MIN, HOVER_DELAY_MAX, HOVER_DELAY_STEP } = require("./settings.js");
|
|
149
|
-
const { buildExport, buildFeedbackReport, parseImport, planImport, domainsIn } = require("./transfer.js");
|
|
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");
|
|
150
157
|
const { FEEDBACK_KINDS, isFlagged, entryAsText } = require("./entries.js");
|
|
151
158
|
const { refuseUrl } = require("./pack.js");
|
|
152
159
|
const { selectionInside } = require("./selection.js");
|
|
@@ -320,13 +327,20 @@ async function importEntries(store, incoming, options) {
|
|
|
320
327
|
const plan = planImport(store.getState().entries ?? [], incoming, store.getState().deletedKeys ?? new Set(), {
|
|
321
328
|
includeDeleted: options?.includeDeleted === true
|
|
322
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));
|
|
323
335
|
for (const entry of plan.accepted) {
|
|
324
|
-
|
|
336
|
+
const patch = {
|
|
325
337
|
definition: entry.definition,
|
|
326
338
|
domain: entry.domain,
|
|
327
339
|
aliases: entry.aliases,
|
|
328
340
|
pinned: entry.pinned
|
|
329
|
-
}
|
|
341
|
+
};
|
|
342
|
+
if (revived.has(entry.key)) await store.restoreEntry(entry.term, patch);
|
|
343
|
+
else await store.saveEntry(entry.term, patch);
|
|
330
344
|
}
|
|
331
345
|
return {
|
|
332
346
|
imported: plan.accepted.length,
|
|
@@ -434,6 +448,21 @@ function TransferPage(props) {
|
|
|
434
448
|
* choice this page offers.
|
|
435
449
|
*/
|
|
436
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);
|
|
437
466
|
/**
|
|
438
467
|
* What came back from the host's own feedback channel, or null when nothing was sent yet.
|
|
439
468
|
*
|
|
@@ -518,7 +547,7 @@ function TransferPage(props) {
|
|
|
518
547
|
/** Save the export as a file. */
|
|
519
548
|
const download = () => downloadText(textOf(), `term-dictionary-${new Date().toISOString().slice(0, 10)}.json`);
|
|
520
549
|
|
|
521
|
-
/** Take a file, and
|
|
550
|
+
/** Take a file: read it, show what is in it, and write nothing until the user says so. */
|
|
522
551
|
const takeFile = async (event) => {
|
|
523
552
|
const file = event?.target?.files?.[0];
|
|
524
553
|
if (file === undefined || file === null) return;
|
|
@@ -529,9 +558,76 @@ function TransferPage(props) {
|
|
|
529
558
|
setReport({ failed: parsed.error });
|
|
530
559
|
return;
|
|
531
560
|
}
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
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
|
+
});
|
|
535
631
|
} catch (error) {
|
|
536
632
|
setReport({ failed: String(error?.message ?? error) });
|
|
537
633
|
} finally {
|
|
@@ -642,7 +738,13 @@ function TransferPage(props) {
|
|
|
642
738
|
type: "checkbox",
|
|
643
739
|
style: applyStyles.checkbox,
|
|
644
740
|
checked: includeDeleted,
|
|
645
|
-
|
|
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
|
+
}
|
|
646
748
|
}),
|
|
647
749
|
h("span", { style: applyStyles.transferName }, t("transferIncludeDeleted"))
|
|
648
750
|
),
|
|
@@ -655,19 +757,114 @@ function TransferPage(props) {
|
|
|
655
757
|
onChange: (event) => void takeFile(event),
|
|
656
758
|
style: applyStyles.transferFile
|
|
657
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
|
+
),
|
|
658
822
|
report === null
|
|
659
823
|
? null
|
|
660
824
|
: h(
|
|
661
825
|
"p",
|
|
662
|
-
{ style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error },
|
|
826
|
+
{ style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error, "data-term-report": "true" },
|
|
663
827
|
report.failed === undefined
|
|
664
|
-
? t("transferReport", { imported: report.imported, existing: report.existing, deleted: report.deleted, duplicates: report.duplicates })
|
|
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(" ")
|
|
665
831
|
: t("transferFailed", { message: report.failed })
|
|
666
832
|
)
|
|
667
833
|
//#endregion
|
|
668
834
|
);
|
|
669
835
|
}
|
|
670
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
|
+
|
|
671
868
|
/**
|
|
672
869
|
* Term packs: hand this dictionary to somebody, or take theirs.
|
|
673
870
|
*
|
|
@@ -710,6 +907,7 @@ function PacksPage(props) {
|
|
|
710
907
|
/** One row per source after a refresh: `{ url, ok, stale, error, index }`. */
|
|
711
908
|
const [listing, setListing] = React.useState(null);
|
|
712
909
|
const [packNote, setPackNote] = React.useState({});
|
|
910
|
+
const [autoRefreshed, setAutoRefreshed] = React.useState(false);
|
|
713
911
|
/** Say something in the page's one notice line rather than in a dialog. */
|
|
714
912
|
const say = (message) => setNotice(message);
|
|
715
913
|
|
|
@@ -786,6 +984,25 @@ function PacksPage(props) {
|
|
|
786
984
|
}
|
|
787
985
|
};
|
|
788
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
|
+
|
|
789
1006
|
/** Fetch one listed pack and offer it for import. */
|
|
790
1007
|
const preview = async (row, listed) => {
|
|
791
1008
|
if (typeof packApi?.fetch !== "function") return say(t("packNoHost"));
|
|
@@ -957,6 +1174,9 @@ function PacksPage(props) {
|
|
|
957
1174
|
h(
|
|
958
1175
|
"div",
|
|
959
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,
|
|
960
1180
|
h("span", { style: applyStyles.transferName }, url),
|
|
961
1181
|
h(Button, { label: t("packRemoveSource"), onClick: () => settingsStore?.set?.("packSources", sources.filter((each) => each !== url)) }, t("packRemoveSource"))
|
|
962
1182
|
)
|
|
@@ -965,7 +1185,7 @@ function PacksPage(props) {
|
|
|
965
1185
|
h(
|
|
966
1186
|
"div",
|
|
967
1187
|
{ style: applyStyles.transferRow },
|
|
968
|
-
h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), onChange: setSourceDraft }),
|
|
1188
|
+
h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), width: "30em", onChange: setSourceDraft }),
|
|
969
1189
|
h(Button, { label: t("packAddSource"), disabled: busy, onClick: addSource }, t("packAddSource")),
|
|
970
1190
|
h(Button, { label: t("packRefresh"), disabled: busy || sources.length === 0, onClick: () => void refresh() }, t("packRefresh"))
|
|
971
1191
|
),
|
|
@@ -1169,6 +1389,28 @@ function SettingsPage(props) {
|
|
|
1169
1389
|
onSelect: (value) => setSwitch("explainDepth", value)
|
|
1170
1390
|
})
|
|
1171
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
|
+
),
|
|
1172
1414
|
flag(
|
|
1173
1415
|
"identifiers",
|
|
1174
1416
|
t("collectIdentifiersOn"),
|
|
@@ -1243,6 +1485,15 @@ function IconButton(props) {
|
|
|
1243
1485
|
}
|
|
1244
1486
|
|
|
1245
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
|
+
|
|
1246
1497
|
function Field(props) {
|
|
1247
1498
|
return h(
|
|
1248
1499
|
"label",
|
|
@@ -1253,7 +1504,7 @@ function Field(props) {
|
|
|
1253
1504
|
value: props.value,
|
|
1254
1505
|
placeholder: props.placeholder,
|
|
1255
1506
|
onChange: (event) => props.onChange(event.target.value),
|
|
1256
|
-
style: applyStyles.input,
|
|
1507
|
+
style: { ...applyStyles.input, maxWidth: props.width ?? FIELD_WIDTH },
|
|
1257
1508
|
"aria-label": props.label
|
|
1258
1509
|
})
|
|
1259
1510
|
);
|
|
@@ -1317,6 +1568,8 @@ function DictionaryPanel(props) {
|
|
|
1317
1568
|
);
|
|
1318
1569
|
const [query, setQuery] = React.useState("");
|
|
1319
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;
|
|
1320
1573
|
const [editing, setEditing] = React.useState(null);
|
|
1321
1574
|
/** Whether the panel is showing its settings page instead of the entry list. */
|
|
1322
1575
|
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
|
@@ -1363,7 +1616,28 @@ function DictionaryPanel(props) {
|
|
|
1363
1616
|
};
|
|
1364
1617
|
}, [store]);
|
|
1365
1618
|
|
|
1366
|
-
|
|
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
|
+
/**
|
|
1627
|
+
* The group being renamed or moved, as `{ group, value }`, or null.
|
|
1628
|
+
*
|
|
1629
|
+
* An inline form rather than `prompt()`: Electron does not implement `prompt`, so a rename dialog
|
|
1630
|
+
* built on it would do nothing at all in the app this plugin runs in.
|
|
1631
|
+
*/
|
|
1632
|
+
const [renaming, setRenaming] = React.useState(null);
|
|
1633
|
+
const visible = React.useMemo(() => store.list(query, filter, group), [view, query, filter, group]);
|
|
1634
|
+
/**
|
|
1635
|
+
* The groups directly inside the current one, each with how many entries it holds.
|
|
1636
|
+
*
|
|
1637
|
+
* They are ROWS of the list rather than a sidebar or a header: a group is the same kind of thing as an
|
|
1638
|
+
* entry — something you open — and the point of the shape is that both are the same size on screen.
|
|
1639
|
+
*/
|
|
1640
|
+
const subGroups = React.useMemo(() => store.groups(group), [view, group]);
|
|
1367
1641
|
const current = view.state.entries;
|
|
1368
1642
|
/**
|
|
1369
1643
|
* Whether the panel is showing what was deleted.
|
|
@@ -1382,6 +1656,59 @@ function DictionaryPanel(props) {
|
|
|
1382
1656
|
*/
|
|
1383
1657
|
const showingFlagged = filter === "flagged";
|
|
1384
1658
|
|
|
1659
|
+
/**
|
|
1660
|
+
* Make what an import just wrote visible.
|
|
1661
|
+
*
|
|
1662
|
+
* The panel owns the view, so this is the panel's job and not the import page's. It returns whether it
|
|
1663
|
+
* had to move anything, because a report that says "12 imported" while the list is still filtered to
|
|
1664
|
+
* 「已删除」 is a report nobody can check.
|
|
1665
|
+
*
|
|
1666
|
+
* @returns true when the search box or the tab had to change.
|
|
1667
|
+
*/
|
|
1668
|
+
const showImported = () => {
|
|
1669
|
+
const moved = query !== "" || filter !== "all";
|
|
1670
|
+
setQuery("");
|
|
1671
|
+
setFilter("all");
|
|
1672
|
+
return moved;
|
|
1673
|
+
};
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Rename or move a group, and follow it if the panel was standing inside it.
|
|
1677
|
+
*
|
|
1678
|
+
* Following is the point: a reader who renames the folder they are in must still be looking at it
|
|
1679
|
+
* afterwards, not dropped back at the top wondering where it went.
|
|
1680
|
+
*/
|
|
1681
|
+
const commitRename = async () => {
|
|
1682
|
+
if (renaming === null) return;
|
|
1683
|
+
const result = await store.renameGroup(renaming.group, renaming.value);
|
|
1684
|
+
if (result.error !== null) {
|
|
1685
|
+
props.setToast?.(t(`groupError_${result.error}`));
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
const target = renaming.value.replace(/^\/+|\/+$/g, "");
|
|
1689
|
+
if (group === renaming.group || group.startsWith(`${renaming.group}/`)) {
|
|
1690
|
+
setGroup(group === renaming.group ? target : `${target}${group.slice(renaming.group.length)}`);
|
|
1691
|
+
}
|
|
1692
|
+
setRenaming(null);
|
|
1693
|
+
props.setToast?.(t("groupRenamed", { count: String(result.moved) }));
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Delete a group by deleting what is in it, after saying how much that is.
|
|
1698
|
+
*
|
|
1699
|
+
* The entries go to 「已删除」 rather than away, so this is reversible — and the count is in the
|
|
1700
|
+
* question, because "delete this group?" and "delete 12 entries?" are different offers.
|
|
1701
|
+
*/
|
|
1702
|
+
const removeGroup = async (sub) => {
|
|
1703
|
+
if (globalThis.confirm?.(t("groupDeleteConfirm", { name: sub.name, count: String(sub.count) })) !== true) return;
|
|
1704
|
+
const result = await store.deleteGroup(sub.group);
|
|
1705
|
+
if (result.error !== null) {
|
|
1706
|
+
props.setToast?.(t(`groupError_${result.error}`));
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
props.setToast?.(t("groupDeleted", { count: String(result.removed) }));
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1385
1712
|
/** Open the editor for a blank entry, prefilled from an optional term. */
|
|
1386
1713
|
const startCreate = (term = "", context = "") => {
|
|
1387
1714
|
setEditing({
|
|
@@ -1391,6 +1718,9 @@ function DictionaryPanel(props) {
|
|
|
1391
1718
|
usage: "",
|
|
1392
1719
|
notes: "",
|
|
1393
1720
|
domain: "",
|
|
1721
|
+
// A new entry made while standing in a group belongs to that group: that is what standing there
|
|
1722
|
+
// meant, and asking again in the form would be a question with one sensible answer.
|
|
1723
|
+
group,
|
|
1394
1724
|
aliases: "",
|
|
1395
1725
|
pinned: false,
|
|
1396
1726
|
// Nothing to say yet, and `feedbackTouched` is what tells a save apart from a retraction:
|
|
@@ -1414,6 +1744,7 @@ function DictionaryPanel(props) {
|
|
|
1414
1744
|
usage: entry.definition.usage,
|
|
1415
1745
|
notes: entry.definition.notes,
|
|
1416
1746
|
domain: entry.domain,
|
|
1747
|
+
group: entry.group,
|
|
1417
1748
|
aliases: entry.aliases.join(", "),
|
|
1418
1749
|
pinned: entry.pinned,
|
|
1419
1750
|
// Seeded from the entry, and `touched` starts true when there is already a remark: saving
|
|
@@ -1475,6 +1806,7 @@ function DictionaryPanel(props) {
|
|
|
1475
1806
|
await store.saveEntry(editing.term, {
|
|
1476
1807
|
definition: { zh: editing.zh, gloss: editing.gloss, usage: editing.usage, notes: editing.notes },
|
|
1477
1808
|
domain: editing.domain,
|
|
1809
|
+
group: editing.group,
|
|
1478
1810
|
aliases: editing.aliases
|
|
1479
1811
|
.split(",")
|
|
1480
1812
|
.map((alias) => alias.trim())
|
|
@@ -1632,6 +1964,12 @@ function DictionaryPanel(props) {
|
|
|
1632
1964
|
placeholder: t("zhPlaceholder"),
|
|
1633
1965
|
onChange: (value) => setEditing({ ...editing, zh: value })
|
|
1634
1966
|
}),
|
|
1967
|
+
h(Field, {
|
|
1968
|
+
label: t("groupLabel"),
|
|
1969
|
+
value: editing.group,
|
|
1970
|
+
placeholder: t("groupPlaceholder"),
|
|
1971
|
+
onChange: (value) => setEditing({ ...editing, group: value })
|
|
1972
|
+
}),
|
|
1635
1973
|
h(Area, {
|
|
1636
1974
|
label: t("glossLabel"),
|
|
1637
1975
|
value: editing.gloss,
|
|
@@ -1764,7 +2102,7 @@ function DictionaryPanel(props) {
|
|
|
1764
2102
|
),
|
|
1765
2103
|
h(Button, { label: t("settingsBack"), onClick: () => setTransferOpen(false) }, t("settingsBack"))
|
|
1766
2104
|
),
|
|
1767
|
-
h(TransferPage, { store, t, setToast: props.setToast })
|
|
2105
|
+
h(TransferPage, { store, t, setToast: props.setToast, onImported: showImported })
|
|
1768
2106
|
);
|
|
1769
2107
|
}
|
|
1770
2108
|
|
|
@@ -1892,14 +2230,118 @@ function DictionaryPanel(props) {
|
|
|
1892
2230
|
) : null,
|
|
1893
2231
|
h(
|
|
1894
2232
|
"div",
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
:
|
|
1901
|
-
|
|
1902
|
-
|
|
2233
|
+
// The entry list is the one that gets columns. The deleted and flagged views are lists of
|
|
2234
|
+
// DECISIONS rather than of terms — shorter rows, read one at a time — and the picking mode
|
|
2235
|
+
// pairs every row with a checkbox, so laying those out in a grid would be a change nobody
|
|
2236
|
+
// asked for.
|
|
2237
|
+
{
|
|
2238
|
+
style: showingDeleted || showingFlagged || picking ? applyStyles.list : listStyle(columns, settings.entryRows),
|
|
2239
|
+
"data-term-columns": showingDeleted || showingFlagged || picking ? undefined : columns
|
|
2240
|
+
},
|
|
2241
|
+
// The groups come first, then the entries at this level — one list, one scroll area, one kind of
|
|
2242
|
+
// row. A breadcrumb says where you are and is the way back up, because a directory you can only
|
|
2243
|
+
// enter is a trap.
|
|
2244
|
+
[
|
|
2245
|
+
renaming === null ? null : h(
|
|
2246
|
+
"div",
|
|
2247
|
+
{ style: { ...applyStyles.transferRow, gridColumn: "1 / -1" }, "data-term-group-form": renaming.group },
|
|
2248
|
+
h("span", { style: applyStyles.fieldLabel }, t("groupRenameTo")),
|
|
2249
|
+
h("input", {
|
|
2250
|
+
type: "text",
|
|
2251
|
+
value: renaming.value,
|
|
2252
|
+
"aria-label": t("groupRename"),
|
|
2253
|
+
onChange: (event) => setRenaming({ ...renaming, value: event.target.value }),
|
|
2254
|
+
onKeyDown: (event) => {
|
|
2255
|
+
if (event.key === "Enter") void commitRename();
|
|
2256
|
+
if (event.key === "Escape") setRenaming(null);
|
|
2257
|
+
},
|
|
2258
|
+
style: { ...applyStyles.input, maxWidth: "18em" }
|
|
2259
|
+
}),
|
|
2260
|
+
h(Button, { label: t("groupRenameApply"), onClick: () => void commitRename() }, t("groupRenameApply")),
|
|
2261
|
+
h(Button, { label: t("cancel"), onClick: () => setRenaming(null) }, t("cancel"))
|
|
2262
|
+
),
|
|
2263
|
+
group === "" ? null : h(
|
|
2264
|
+
"div",
|
|
2265
|
+
{ style: { ...applyStyles.breadcrumb, gridColumn: "1 / -1" }, "data-term-breadcrumb": group },
|
|
2266
|
+
[
|
|
2267
|
+
{ path: "", label: t("filterAll") },
|
|
2268
|
+
...group.split("/").map((name, index, all) => ({ path: all.slice(0, index + 1).join("/"), label: name }))
|
|
2269
|
+
].map((crumb, index) =>
|
|
2270
|
+
h(
|
|
2271
|
+
"span",
|
|
2272
|
+
{ key: crumb.path === "" ? "root" : crumb.path, style: applyStyles.breadcrumbPart },
|
|
2273
|
+
index === 0 ? null : h("span", { style: applyStyles.breadcrumbSep }, "›"),
|
|
2274
|
+
h(
|
|
2275
|
+
"button",
|
|
2276
|
+
{
|
|
2277
|
+
type: "button",
|
|
2278
|
+
style: index === group.split("/").length ? applyStyles.breadcrumbHere : applyStyles.breadcrumbButton,
|
|
2279
|
+
onClick: () => setGroup(crumb.path),
|
|
2280
|
+
title: t("groupGo", { name: crumb.label })
|
|
2281
|
+
},
|
|
2282
|
+
crumb.label
|
|
2283
|
+
)
|
|
2284
|
+
)
|
|
2285
|
+
)
|
|
2286
|
+
),
|
|
2287
|
+
...subGroups.map((sub) =>
|
|
2288
|
+
h(
|
|
2289
|
+
"article",
|
|
2290
|
+
{
|
|
2291
|
+
key: `group:${sub.group}`,
|
|
2292
|
+
// It fills its row again, now that the height carries something: with the preview below, the
|
|
2293
|
+
// space that used to be emptiness is the first thing you would see on opening it.
|
|
2294
|
+
style: applyStyles.row,
|
|
2295
|
+
"data-term-group": sub.group,
|
|
2296
|
+
// The WHOLE row opens the group, exactly as the whole row of an entry opens the entry.
|
|
2297
|
+
// Only the label being live is what "the group cannot be clicked into" meant: a reader
|
|
2298
|
+
// clicks the row, the same gesture that works one line below, and nothing happened.
|
|
2299
|
+
// The label stays a button so it is reachable by keyboard and named for a screen reader.
|
|
2300
|
+
onClick: (event) => {
|
|
2301
|
+
// The buttons inside keep their own meaning, exactly as they do on an entry row.
|
|
2302
|
+
if (typeof event?.target?.closest === "function" && event.target.closest("button") !== null) return;
|
|
2303
|
+
setGroup(sub.group);
|
|
2304
|
+
}
|
|
2305
|
+
},
|
|
2306
|
+
h("div", { style: applyStyles.rowHead },
|
|
2307
|
+
h("div", { style: applyStyles.groupIcon, "aria-hidden": "true" }, Icons.folder(14)),
|
|
2308
|
+
h("button", {
|
|
2309
|
+
type: "button",
|
|
2310
|
+
style: applyStyles.termButton,
|
|
2311
|
+
onClick: () => setGroup(sub.group),
|
|
2312
|
+
title: t("groupOpen")
|
|
2313
|
+
}, sub.name),
|
|
2314
|
+
h("span", { style: applyStyles.badge }, t("groupCount", { count: String(sub.count) })),
|
|
2315
|
+
h("span", { style: applyStyles.spacer }),
|
|
2316
|
+
h("span", { style: applyStyles.groupEnter, "aria-hidden": "true" }, "›")
|
|
2317
|
+
),
|
|
2318
|
+
// The same shape an entry row has: a title line, a line of actions, then the body.
|
|
2319
|
+
h(
|
|
2320
|
+
"div",
|
|
2321
|
+
{ style: applyStyles.rowActions, "data-term-group-actions": sub.group },
|
|
2322
|
+
h(IconButton, { title: t("groupRename"), onClick: () => setRenaming({ group: sub.group, value: sub.group }) }, Icons.edit(13)),
|
|
2323
|
+
h(IconButton, { title: t("groupDelete"), onClick: () => void removeGroup(sub) }, Icons.trash(13))
|
|
2324
|
+
),
|
|
2325
|
+
// What is inside, in one line: the row stops being a name over a blank card.
|
|
2326
|
+
h(
|
|
2327
|
+
"p",
|
|
2328
|
+
{ style: applyStyles.groupPreview, "data-term-group-sample": sub.group },
|
|
2329
|
+
[
|
|
2330
|
+
sub.sample.join(t("groupSampleJoin")),
|
|
2331
|
+
sub.count > sub.sample.length ? t("groupSampleMore", { count: String(sub.count - sub.sample.length) }) : ""
|
|
2332
|
+
]
|
|
2333
|
+
.filter((part) => part !== "")
|
|
2334
|
+
.join("")
|
|
2335
|
+
)
|
|
2336
|
+
)
|
|
2337
|
+
),
|
|
2338
|
+
showingDeleted
|
|
2339
|
+
? h(DeletedList, { entries: visible, t, onRevive: (entry) => void revive(entry) })
|
|
2340
|
+
: showingFlagged
|
|
2341
|
+
? h(FlaggedList, { entries: visible, t, onClear: (entry) => void clearFlag(entry) })
|
|
2342
|
+
: visible.length === 0
|
|
2343
|
+
? h("p", { style: { ...applyStyles.blank, gridColumn: "1 / -1" } }, current.length === 0 ? t("empty") : subGroups.length > 0 ? t("groupEmpty") : t("searchEmpty"))
|
|
2344
|
+
: visible.map((entry) =>
|
|
1903
2345
|
picking
|
|
1904
2346
|
? h(
|
|
1905
2347
|
"article",
|
|
@@ -1955,6 +2397,10 @@ function DictionaryPanel(props) {
|
|
|
1955
2397
|
h(
|
|
1956
2398
|
"div",
|
|
1957
2399
|
{ style: applyStyles.rowHead },
|
|
2400
|
+
// The pin comes FIRST, on the title line: it is a state of the entry rather than an
|
|
2401
|
+
// action on it, and the list no longer re-sorts when it changes, so this is where
|
|
2402
|
+
// the state has to be readable.
|
|
2403
|
+
h(IconButton, { title: entry.pinned ? t("unpin") : t("pin"), pressed: entry.pinned, onClick: () => store.togglePinned(entry.id) }, Icons.pin(13)),
|
|
1958
2404
|
h("button", {
|
|
1959
2405
|
type: "button",
|
|
1960
2406
|
style: applyStyles.termButton,
|
|
@@ -1971,8 +2417,16 @@ function DictionaryPanel(props) {
|
|
|
1971
2417
|
: null,
|
|
1972
2418
|
entry.untrusted === true ? h("span", { style: applyStyles.badgeWarn, "data-term-badge": "untrusted" }, t("flaggedUntrusted")) : null,
|
|
1973
2419
|
h("span", { style: applyStyles.spacer }),
|
|
1974
|
-
entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null
|
|
1975
|
-
|
|
2420
|
+
entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null
|
|
2421
|
+
),
|
|
2422
|
+
// Every action on its own line, and always in the same place.
|
|
2423
|
+
//
|
|
2424
|
+
// The title line used to carry them, so their position depended on how long the term
|
|
2425
|
+
// was, which badges it had and whether it had a domain — the controls wandered from
|
|
2426
|
+
// row to row. A toolbar that is always the second line does not.
|
|
2427
|
+
h(
|
|
2428
|
+
"div",
|
|
2429
|
+
{ style: applyStyles.rowActions, "data-term-actions": entry.key },
|
|
1976
2430
|
h(IconButton, { title: t("copyEntry"), onClick: () => void copyEntry(entry) }, Icons.copy(13)),
|
|
1977
2431
|
h(IconButton, { title: t("feedbackTitle"), pressed: entry.feedback !== null && entry.feedback !== undefined, onClick: () => startEdit(entry) }, Icons.flag(13)),
|
|
1978
2432
|
h(IconButton, { title: t("edit"), onClick: () => startEdit(entry) }, Icons.edit(13)),
|
|
@@ -1991,6 +2445,7 @@ function DictionaryPanel(props) {
|
|
|
1991
2445
|
)
|
|
1992
2446
|
)
|
|
1993
2447
|
)
|
|
2448
|
+
]
|
|
1994
2449
|
),
|
|
1995
2450
|
storageNote(view.hostInfo, t),
|
|
1996
2451
|
h(
|