dsh-plugin-term-dictionary 1.0.0 → 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 +84 -23
- package/README.md +8 -4
- package/lib/client.js +827 -59
- package/lib/core/copy.js +62 -0
- package/lib/core/dictionary.js +114 -10
- package/lib/core/entries.js +35 -0
- package/lib/core/pack.js +42 -15
- package/lib/core/settings.js +56 -2
- package/lib/core/store.js +31 -4
- package/lib/core/styles.js +17 -0
- package/lib/core/transfer.js +72 -2
- package/lib/core/views.js +398 -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,21 @@ 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
|
+
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]);
|
|
1367
1634
|
const current = view.state.entries;
|
|
1368
1635
|
/**
|
|
1369
1636
|
* Whether the panel is showing what was deleted.
|
|
@@ -1382,6 +1649,22 @@ function DictionaryPanel(props) {
|
|
|
1382
1649
|
*/
|
|
1383
1650
|
const showingFlagged = filter === "flagged";
|
|
1384
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
|
+
|
|
1385
1668
|
/** Open the editor for a blank entry, prefilled from an optional term. */
|
|
1386
1669
|
const startCreate = (term = "", context = "") => {
|
|
1387
1670
|
setEditing({
|
|
@@ -1391,6 +1674,9 @@ function DictionaryPanel(props) {
|
|
|
1391
1674
|
usage: "",
|
|
1392
1675
|
notes: "",
|
|
1393
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,
|
|
1394
1680
|
aliases: "",
|
|
1395
1681
|
pinned: false,
|
|
1396
1682
|
// Nothing to say yet, and `feedbackTouched` is what tells a save apart from a retraction:
|
|
@@ -1414,6 +1700,7 @@ function DictionaryPanel(props) {
|
|
|
1414
1700
|
usage: entry.definition.usage,
|
|
1415
1701
|
notes: entry.definition.notes,
|
|
1416
1702
|
domain: entry.domain,
|
|
1703
|
+
group: entry.group,
|
|
1417
1704
|
aliases: entry.aliases.join(", "),
|
|
1418
1705
|
pinned: entry.pinned,
|
|
1419
1706
|
// Seeded from the entry, and `touched` starts true when there is already a remark: saving
|
|
@@ -1475,6 +1762,7 @@ function DictionaryPanel(props) {
|
|
|
1475
1762
|
await store.saveEntry(editing.term, {
|
|
1476
1763
|
definition: { zh: editing.zh, gloss: editing.gloss, usage: editing.usage, notes: editing.notes },
|
|
1477
1764
|
domain: editing.domain,
|
|
1765
|
+
group: editing.group,
|
|
1478
1766
|
aliases: editing.aliases
|
|
1479
1767
|
.split(",")
|
|
1480
1768
|
.map((alias) => alias.trim())
|
|
@@ -1632,6 +1920,12 @@ function DictionaryPanel(props) {
|
|
|
1632
1920
|
placeholder: t("zhPlaceholder"),
|
|
1633
1921
|
onChange: (value) => setEditing({ ...editing, zh: value })
|
|
1634
1922
|
}),
|
|
1923
|
+
h(Field, {
|
|
1924
|
+
label: t("groupLabel"),
|
|
1925
|
+
value: editing.group,
|
|
1926
|
+
placeholder: t("groupPlaceholder"),
|
|
1927
|
+
onChange: (value) => setEditing({ ...editing, group: value })
|
|
1928
|
+
}),
|
|
1635
1929
|
h(Area, {
|
|
1636
1930
|
label: t("glossLabel"),
|
|
1637
1931
|
value: editing.gloss,
|
|
@@ -1764,7 +2058,7 @@ function DictionaryPanel(props) {
|
|
|
1764
2058
|
),
|
|
1765
2059
|
h(Button, { label: t("settingsBack"), onClick: () => setTransferOpen(false) }, t("settingsBack"))
|
|
1766
2060
|
),
|
|
1767
|
-
h(TransferPage, { store, t, setToast: props.setToast })
|
|
2061
|
+
h(TransferPage, { store, t, setToast: props.setToast, onImported: showImported })
|
|
1768
2062
|
);
|
|
1769
2063
|
}
|
|
1770
2064
|
|
|
@@ -1892,14 +2186,79 @@ function DictionaryPanel(props) {
|
|
|
1892
2186
|
) : null,
|
|
1893
2187
|
h(
|
|
1894
2188
|
"div",
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
:
|
|
1901
|
-
|
|
1902
|
-
|
|
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) =>
|
|
1903
2262
|
picking
|
|
1904
2263
|
? h(
|
|
1905
2264
|
"article",
|
|
@@ -1955,6 +2314,10 @@ function DictionaryPanel(props) {
|
|
|
1955
2314
|
h(
|
|
1956
2315
|
"div",
|
|
1957
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)),
|
|
1958
2321
|
h("button", {
|
|
1959
2322
|
type: "button",
|
|
1960
2323
|
style: applyStyles.termButton,
|
|
@@ -1971,8 +2334,16 @@ function DictionaryPanel(props) {
|
|
|
1971
2334
|
: null,
|
|
1972
2335
|
entry.untrusted === true ? h("span", { style: applyStyles.badgeWarn, "data-term-badge": "untrusted" }, t("flaggedUntrusted")) : null,
|
|
1973
2336
|
h("span", { style: applyStyles.spacer }),
|
|
1974
|
-
entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null
|
|
1975
|
-
|
|
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 },
|
|
1976
2347
|
h(IconButton, { title: t("copyEntry"), onClick: () => void copyEntry(entry) }, Icons.copy(13)),
|
|
1977
2348
|
h(IconButton, { title: t("feedbackTitle"), pressed: entry.feedback !== null && entry.feedback !== undefined, onClick: () => startEdit(entry) }, Icons.flag(13)),
|
|
1978
2349
|
h(IconButton, { title: t("edit"), onClick: () => startEdit(entry) }, Icons.edit(13)),
|
|
@@ -1991,6 +2362,7 @@ function DictionaryPanel(props) {
|
|
|
1991
2362
|
)
|
|
1992
2363
|
)
|
|
1993
2364
|
)
|
|
2365
|
+
]
|
|
1994
2366
|
),
|
|
1995
2367
|
storageNote(view.hostInfo, t),
|
|
1996
2368
|
h(
|