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/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,21 @@ 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
+ 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
- { 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) =>
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
- h(IconButton, { title: entry.pinned ? t("unpin") : t("pin"), pressed: entry.pinned, onClick: () => store.togglePinned(entry.id) }, Icons.pin(13)),
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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-term-dictionary",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "术语词典:自动识别对话中的生僻术语并建立词条,左侧插件区提供词典面板。已收录的词在回复中被划出,鼠标靠近显示简短解释,点击进入词典对应词条;选中词句可一键添加词条。",
5
5
  "icon": "./icon.svg",
6
6
  "keywords": [