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/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
- await store.saveEntry(entry.term, {
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 say what came of it. */
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
- // Through the shared path, so a file and a pack cannot diverge in what they do with an
533
- // entry — including the crafted revival that makes a term the user deleted stay revived.
534
- setReport(await importEntries(store, parsed.entries, { includeDeleted, skipped: parsed.skipped ?? 0 }));
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
- onChange: () => setIncludeDeleted(!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
+ }
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
- const visible = React.useMemo(() => store.list(query, filter), [view, query, filter]);
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
- { style: applyStyles.list },
1896
- showingDeleted
1897
- ? h(DeletedList, { entries: visible, t, onRevive: (entry) => void revive(entry) })
1898
- : showingFlagged
1899
- ? h(FlaggedList, { entries: visible, t, onClear: (entry) => void clearFlag(entry) })
1900
- : visible.length === 0
1901
- ? h("p", { style: applyStyles.blank }, current.length === 0 ? t("empty") : t("searchEmpty"))
1902
- : visible.map((entry) =>
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
- h(IconButton, { title: entry.pinned ? t("unpin") : t("pin"), pressed: entry.pinned, onClick: () => store.togglePinned(entry.id) }, Icons.pin(13)),
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(