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/client.js CHANGED
@@ -369,6 +369,16 @@ window.__ModuleLoader__.load({
369
369
  explainLang_en: "英文",
370
370
  explainDepthName: "详细程度",
371
371
  explainDepthHint: "解释写多详细",
372
+ entryColumnsName: "词条排列",
373
+ entryColumnsHint: "一列排下去很长;「自动」按面板宽度铺成多列,窄了就回到一列。",
374
+ entryColumns_auto: "自动",
375
+ entryColumns_1: "1 列",
376
+ entryColumns_2: "2 列",
377
+ entryColumns_3: "3 列",
378
+ entryRowsName: "词条行高",
379
+ entryRowsHint: "多列排版时,同一行的卡片要不要一样高。统一高度不会在矮卡片下面留出空条;各按各自更紧凑。",
380
+ entryRows_uniform: "统一高度",
381
+ entryRows_compact: "各按各自",
372
382
  explainDepth_brief: "一句话",
373
383
  explainDepth_normal: "标准",
374
384
  explainDepth_detailed: "详细",
@@ -401,6 +411,20 @@ window.__ModuleLoader__.load({
401
411
  transferImportHint: "选择一个 JSON 文件。已存在的词条会原样保留;你删除过的词条默认不复活;导入完成后会报告各自的条数。",
402
412
  transferIncludeDeleted: "导入我删除过的词条",
403
413
  transferIncludeDeletedHint: "勾选后,文件里那些你删除过的词条会被导入,并从「已删除」里撤回。不勾选则它们留在删除列表里。",
414
+ transferConfirmTitle: "「{name}」里有什么",
415
+ transferWillImport: "将导入 {count} 条",
416
+ transferWillSkipExisting: "已有 {count} 条(跳过)",
417
+ transferWillSkipDeleted: "你删过 {count} 条(默认跳过)",
418
+ transferConfirm: "确认导入",
419
+ transferShown: "已切到「全部」并清空搜索,好让刚导入的词条就在眼前。",
420
+ transferFolderTitle: "导入文件夹",
421
+ transferFolderHint: "选一个文件夹,把里面的 .json 全部读一遍(导出的词典、下载的包文件都认)。不是 JSON 的文件直接忽略;读不了的会点名列出。",
422
+ transferFolderAsGroup: "按文件夹建立分组(文件夹名就是组名)",
423
+ transferFolderLabel: "文件夹里的 {count} 个文件",
424
+ transferFolderFound: "文件夹里 {files} 个 .json,用上 {used} 个",
425
+ transferFolderSkipped: "跳过 {count} 个:",
426
+ transferFolderAllSkipped: "这个文件夹里 {count} 个 .json 都读不了",
427
+ transferNoJson: "这个文件夹里没有 .json 文件",
404
428
  transferReport: "导入 {imported} 条;跳过:已存在 {existing} 条、你删除过 {deleted} 条、重复或无效 {duplicates} 条。",
405
429
  transferFailed: "操作失败:{message}",
406
430
  settingsBack: "返回词条列表",
@@ -492,6 +516,7 @@ window.__ModuleLoader__.load({
492
516
  packSourcesTitle: "源",
493
517
  packSourcesHint: "源就是别人托管的一个静态 index.json。只接受 https(http、file、带账号密码的 URL 都会被拒)。",
494
518
  packNoSources: "还没有添加源",
519
+ packBuiltInSource: "内置",
495
520
  packRemoveSource: "移除",
496
521
  packAddSourceLabel: "添加源",
497
522
  packAddSourcePlaceholder: "https://cdn.jsdelivr.net/gh/…/index.json",
@@ -554,6 +579,25 @@ window.__ModuleLoader__.load({
554
579
  usageLabel: "用法示例",
555
580
  notesLabel: "备注",
556
581
  domainLabel: "领域",
582
+ groupLabel: "分组",
583
+ groupOpen: "进入这个分组",
584
+ groupGo: "回到「{name}」",
585
+ groupCount: "{count} 条",
586
+ groupEmpty: "这一层只有分组,还没有词条。",
587
+ groupSampleJoin: "、",
588
+ groupSampleMore: " 等 {count} 条",
589
+ groupRename: "重命名或移动",
590
+ groupRenameTo: "改到:",
591
+ groupRenameApply: "改名 / 移动",
592
+ groupDelete: "删除分组",
593
+ groupRenamed: "已移动 {count} 条词条。",
594
+ groupDeleted: "已删除 {count} 条词条,它们都在「已删除」里,可以撤回。",
595
+ groupDeleteConfirm: "删除分组「{name}」里的 {count} 条词条?它们会进入「已删除」,之后可以逐条撤回。",
596
+ "groupError_no-group": "那个分组不存在。",
597
+ "groupError_no-name": "分组名不能为空。",
598
+ "groupError_same": "新旧名字一样,没有需要动的地方。",
599
+ "groupError_into-itself": "不能把分组移动到它自己里面。",
600
+ groupPlaceholder: "留空 = 顶层;用 / 分层,例如 backend/net",
557
601
  termPlaceholder: "例如 event sourcing",
558
602
  glossPlaceholder: "用一两句话说明它的含义",
559
603
  usagePlaceholder: "可选:一句例子或典型用法",
@@ -639,6 +683,16 @@ window.__ModuleLoader__.load({
639
683
  explainLang_en: "English",
640
684
  explainDepthName: "Explanation depth",
641
685
  explainDepthHint: "How much the explanation says",
686
+ entryColumnsName: "Entry layout",
687
+ entryColumnsHint: "One column makes a very long strip; 自动 fills the panel width with as many columns as fit, and falls back to one when it is narrow.",
688
+ entryColumns_auto: "Auto",
689
+ entryColumns_1: "1 column",
690
+ entryColumns_2: "2 columns",
691
+ entryColumns_3: "3 columns",
692
+ entryRowsName: "Row height",
693
+ entryRowsHint: "In a multi-column layout, whether the cards in one row share a height. Uniform leaves no empty strip under the short ones; compact keeps each card tight.",
694
+ entryRows_uniform: "Uniform",
695
+ entryRows_compact: "Compact",
642
696
  explainDepth_brief: "One line",
643
697
  explainDepth_normal: "Normal",
644
698
  explainDepth_detailed: "Detailed",
@@ -686,6 +740,7 @@ window.__ModuleLoader__.load({
686
740
  packSourcesTitle: "Sources",
687
741
  packSourcesHint: "A source is somebody's static index.json. Only https is accepted (http, file and URLs carrying credentials are refused).",
688
742
  packNoSources: "No sources yet",
743
+ packBuiltInSource: "built in",
689
744
  packRemoveSource: "Remove",
690
745
  packAddSourceLabel: "Add a source",
691
746
  packAddSourcePlaceholder: "https://cdn.jsdelivr.net/gh/…/index.json",
@@ -741,6 +796,20 @@ window.__ModuleLoader__.load({
741
796
  transferImportHint: "Choose a JSON file. Entries you already have are left as they are, and entries you deleted stay deleted unless you say otherwise below; the counts are reported when it finishes.",
742
797
  transferIncludeDeleted: "Import entries I deleted",
743
798
  transferIncludeDeletedHint: "When ticked, entries in the file that you had deleted are imported and retracted from the deleted list. Unticked, they stay there.",
799
+ transferConfirmTitle: "What “{name}” holds",
800
+ transferWillImport: "{count} entries will be imported",
801
+ transferWillSkipExisting: "{count} already here (skipped)",
802
+ transferWillSkipDeleted: "{count} you deleted (skipped by default)",
803
+ transferConfirm: "Import them",
804
+ transferShown: "Moved to All and cleared the search, so what was imported is in front of you.",
805
+ transferFolderTitle: "Import a folder",
806
+ transferFolderHint: "Pick a folder and every .json inside it is read: exported dictionaries and downloaded pack files both. Files that are not JSON are ignored, and any that cannot be read are named.",
807
+ transferFolderAsGroup: "Build groups from the folders (the folder name is the group)",
808
+ transferFolderLabel: "{count} files in the folder",
809
+ transferFolderFound: "{files} .json files in the folder, {used} of them usable",
810
+ transferFolderSkipped: "{count} skipped:",
811
+ transferFolderAllSkipped: "none of the {count} .json files in that folder could be read",
812
+ transferNoJson: "that folder holds no .json files",
744
813
  transferReport: "Imported {imported}; skipped {existing} already here, {deleted} you deleted, {duplicates} duplicate or unusable.",
745
814
  transferFailed: "That did not work: {message}",
746
815
  settingsBack: "Back to the entries",
@@ -825,6 +894,25 @@ window.__ModuleLoader__.load({
825
894
  usageLabel: "Usage",
826
895
  notesLabel: "Notes",
827
896
  domainLabel: "Domain",
897
+ groupLabel: "Group",
898
+ groupOpen: "Open this group",
899
+ groupGo: "Go back to {name}",
900
+ groupCount: "{count} entries",
901
+ groupEmpty: "This level holds only groups so far.",
902
+ groupSampleJoin: ", ",
903
+ groupSampleMore: " and {count} more",
904
+ groupRename: "Rename or move",
905
+ groupRenameTo: "Move to:",
906
+ groupRenameApply: "Rename / move",
907
+ groupDelete: "Delete group",
908
+ groupRenamed: "Moved {count} entries.",
909
+ groupDeleted: "Deleted {count} entries. They are on the deleted list and can be restored one by one.",
910
+ groupDeleteConfirm: "Delete the {count} entries in “{name}”? They go to the deleted list, where each can be restored.",
911
+ "groupError_no-group": "There is no such group.",
912
+ "groupError_no-name": "A group needs a name.",
913
+ "groupError_same": "The name is unchanged, so there is nothing to move.",
914
+ "groupError_into-itself": "A group cannot be moved inside itself.",
915
+ groupPlaceholder: "empty = top level; use / to nest, e.g. backend/net",
828
916
  termPlaceholder: "e.g. event sourcing",
829
917
  glossPlaceholder: "Say what it means in one or two sentences",
830
918
  usagePlaceholder: "Optional: an example or typical use",
@@ -934,6 +1022,15 @@ window.__ModuleLoader__.load({
934
1022
  /** Upper bound on a feedback note, which is a remark rather than an explanation. */
935
1023
  const MAX_NOTE_CHARS = 200;
936
1024
 
1025
+ /**
1026
+ * Upper bound on an entry's group path.
1027
+ *
1028
+ * A path rather than a name: a group can hold groups, because that is what a directory does and what a
1029
+ * folder import produces. One string covers any depth, which is why nothing else had to be added to the
1030
+ * document to get nesting.
1031
+ */
1032
+ const MAX_GROUP_CHARS = 120;
1033
+
937
1034
  /**
938
1035
  * What a user can say is wrong with an entry.
939
1036
  *
@@ -1023,6 +1120,25 @@ window.__ModuleLoader__.load({
1023
1120
  notes: clampText(definition.notes, MAX_DEFINITION_CHARS)
1024
1121
  },
1025
1122
  domain: clampText(value.domain, 24),
1123
+ /**
1124
+ * The group (folder) this entry lives in: `""` for the top level, or a `/`-separated path.
1125
+ *
1126
+ * A CONTAINER, not a topic: `domain` is a semantic label the reader assigns, while this is where
1127
+ * the entry was put. It needs its own stamp (§ `groupAt`) because a move is a decision — the first
1128
+ * attempt let it ride on the content merge, and a merge of one entry from two windows then wiped
1129
+ * the group order-dependently.
1130
+ */
1131
+ group: clampText(value.group, MAX_GROUP_CHARS),
1132
+ // When the group was last decided, epoch milliseconds, 0 for "never moved".
1133
+ //
1134
+ // The third use of this pattern, and for the reason the other two exist: a decision the user can
1135
+ // make and unmake cannot be carried by content-merging rules.
1136
+ // When the user restored this term, epoch milliseconds, 0 for never.
1137
+ //
1138
+ // The named evidence a merge weighs against a deletion, so a restoration does not have to win a
1139
+ // contest over a clock — see `restoreEntry`.
1140
+ restoredAt: typeof value.restoredAt === "number" && Number.isFinite(value.restoredAt) && value.restoredAt > 0 ? Math.floor(value.restoredAt) : 0,
1141
+ groupAt: typeof value.groupAt === "number" && Number.isFinite(value.groupAt) && value.groupAt > 0 ? Math.floor(value.groupAt) : 0,
1026
1142
  source: SOURCES.includes(value.source) ? value.source : "auto",
1027
1143
  confidence: typeof value.confidence === "number" && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : 0.5,
1028
1144
  createdAt: typeof value.createdAt === "number" && value.createdAt > 0 ? value.createdAt : now,
@@ -1204,6 +1320,12 @@ window.__ModuleLoader__.load({
1204
1320
  aliases: [...new Set([...current.aliases, ...incoming.aliases])].slice(0, 8),
1205
1321
  definition: takeDefinition ? incoming.definition : current.definition,
1206
1322
  domain: takeDefinition && incoming.domain !== "" ? incoming.domain : current.domain,
1323
+ // Unlike the domain, an empty incoming group is taken when the content changed: moving an entry back
1324
+ // to the top level is a move, and the old rule ("an empty value never overwrites") would make the
1325
+ // top level unreachable for anything that had ever been filed.
1326
+ group: (incoming.groupAt ?? 0) > (current.groupAt ?? 0) ? incoming.group : current.group,
1327
+ groupAt: Math.max(incoming.groupAt ?? 0, current.groupAt ?? 0),
1328
+ restoredAt: Math.max(incoming.restoredAt ?? 0, current.restoredAt ?? 0),
1207
1329
  // The STRONGEST provenance survives, not the arriving one. Taking the arriving
1208
1330
  // source let a later automatic copy of an already-curated term downgrade it to
1209
1331
  // `auto`, which then denied that term its own revival — and let its source be
@@ -1307,6 +1429,7 @@ window.__ModuleLoader__.load({
1307
1429
  MAX_DEFINITION_CHARS,
1308
1430
  MAX_CONTEXT_CHARS,
1309
1431
  MAX_NOTE_CHARS,
1432
+ MAX_GROUP_CHARS,
1310
1433
  normalizeEntry,
1311
1434
  normalizeFeedback,
1312
1435
  entryAsText,
@@ -1610,6 +1733,9 @@ window.__ModuleLoader__.load({
1610
1733
  //
1611
1734
  // So the comparison uses the newest time a record the USER asked for carries, and
1612
1735
  // being strictly newer is what revives. Time alone is not intent; provenance is.
1736
+ // A NAMED restoration first: `restoreEntry` records that the user asked for this term back, which
1737
+ // is not an inference from a clock. The comparison below stays for the paths that revive by editing.
1738
+ if ((record.restoredAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
1613
1739
  if ((options?.craftedAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
1614
1740
  const alreadyDeleted = record.deletedAt ?? 0;
1615
1741
  return { ...record, deletedAt: Math.max(alreadyDeleted, notice.deletedAt) };
@@ -1892,6 +2018,10 @@ window.__ModuleLoader__.load({
1892
2018
  const feedbackStamp = mentionsFeedback ? Math.max(stamp, (base.feedbackAt ?? 0) + 1) : base.feedbackAt ?? 0;
1893
2019
  const untrustedPatch = typeof patch?.untrusted === "boolean" ? patch.untrusted : null;
1894
2020
  const untrustedStamp = untrustedPatch === null ? base.untrustedAt ?? 0 : Math.max(stamp, (base.untrustedAt ?? 0) + 1);
2021
+ // A move is a decision like a pin or a remark, and it is stamped strictly past the last one so that two
2022
+ // moves inside the same millisecond still have an order.
2023
+ const mentionsGroup = patch !== null && patch !== undefined && Object.prototype.hasOwnProperty.call(patch, "group");
2024
+ const groupStamp = mentionsGroup ? Math.max(stamp, (base.groupAt ?? 0) + 1) : base.groupAt ?? 0;
1895
2025
  const candidate = normalizeEntry(
1896
2026
  {
1897
2027
  ...base,
@@ -1900,6 +2030,11 @@ window.__ModuleLoader__.load({
1900
2030
  deletedAt: 0,
1901
2031
  aliases: patch?.aliases ?? base.aliases,
1902
2032
  domain: patch?.domain ?? base.domain,
2033
+ // `patch.group` may legitimately be `""` — that is how an entry is moved back to the top level —
2034
+ // so this reads with `hasOwnProperty` rather than with `??`: a patch that says nothing about the
2035
+ // group must leave it, and its stamp, exactly where they were.
2036
+ group: mentionsGroup ? patch.group : base.group,
2037
+ groupAt: groupStamp,
1903
2038
  definition: { ...base.definition, ...(patch?.definition ?? {}) },
1904
2039
  source: "user",
1905
2040
  confidence: 1,
@@ -1917,6 +2052,60 @@ window.__ModuleLoader__.load({
1917
2052
  return upsertEntry(state, { ...candidate, source: "user", deletedAt: 0 }, { now: stamp });
1918
2053
  }
1919
2054
 
2055
+ /**
2056
+ * Bring a deleted term back, as an explicit act rather than as a side effect of an edit.
2057
+ *
2058
+ * This exists because "restore this deleted term" is a decision, and until now it was expressed as "write
2059
+ * an edit and hope the merge weighs it right": `editEntry` produced a crafted timestamp, and a merge
2060
+ * revived the record when that timestamp beat the tombstone's. That works — there is a check for it — but
2061
+ * it means the import path, the editor, a sighting and a second window all revive through one inferred
2062
+ * comparison, and a term the user had collected and deleted came back as "nothing happened".
2063
+ *
2064
+ * So a restoration is its own thing:
2065
+ *
2066
+ * - `restoredAt` is the evidence, weighed against `notice.deletedAt` like `pinnedAt` is weighed against
2067
+ * silence. It is what the merge reads, and it exists even when the tombstone's clock ran ahead;
2068
+ * - the tombstone is WITHDRAWN from the document this returns — the key leaves `deletedKeys` and the
2069
+ * record leaves `backing` — so a reader of this document is not told the term is deleted at all. Being
2070
+ * merely outvoted is what let a later merge put the deletion back;
2071
+ * - the text is kept: restoring is not the same as rewriting, so the patch is applied on top of whatever
2072
+ * the tombstone still remembers.
2073
+ *
2074
+ * @param state - the current document.
2075
+ * @param idOrTerm - the entry id, term or alias.
2076
+ * @param patch - `definition`, `domain`, `group`, `aliases`, `pinned`.
2077
+ * @param options - `now` overrides the restoration time.
2078
+ * @returns `{ state, entry, restored }`; `restored` is false when the term was not deleted.
2079
+ */
2080
+ function restoreEntry(state, idOrTerm, patch, options) {
2081
+ const found = findEntryIncludingDeleted(state, idOrTerm);
2082
+ if (found === undefined) {
2083
+ // Nothing to restore: the caller wanted a new entry, and `editEntry` is the path for that.
2084
+ const created = editEntry(state, typeof idOrTerm === "string" ? idOrTerm : "", patch, options);
2085
+ return { ...created, restored: false };
2086
+ }
2087
+ if ((found.deletedAt ?? 0) === 0) {
2088
+ // Already live: a restoration must not disturb it, and saying "restored" would be a lie the caller
2089
+ // might act on (the import reports it).
2090
+ return { state, entry: found, restored: false };
2091
+ }
2092
+ // The stamp is strictly past the deletion for the same reason `editEntry`'s is: a clock that ran ahead
2093
+ // must not let the tombstone win a comparison it lost the argument for.
2094
+ const now = options?.now ?? Date.now();
2095
+ const stamp = Math.max(now, (found.deletedAt ?? 0) + 1);
2096
+ const edited = editEntry(state, found.term, { ...patch, definition: { ...found.definition, ...(patch?.definition ?? {}) } }, { now: stamp });
2097
+ const key = found.key;
2098
+ // The withdrawal, in the document itself: the record is rewritten live and the whole thing is re-sealed,
2099
+ // so `deletedKeys` and `backing` are derived fresh and no longer announce the deletion. (An earlier
2100
+ // version also filtered them by hand first, which the re-seal made redundant — a mutation proved it by
2101
+ // not biting, so the dead work is gone.)
2102
+ const records = recordsOf(edited.state).map((entry) =>
2103
+ entry.id === found.id || entry.key === key ? { ...entry, deletedAt: 0, restoredAt: stamp, source: "user", updatedAt: stamp, lastSeenAt: stamp } : entry
2104
+ );
2105
+ const next = sealRecords(records, { now: stamp });
2106
+ return { state: next, entry: next.entries.find((entry) => entry.key === key) ?? edited.entry, restored: true };
2107
+ }
2108
+
1920
2109
  /**
1921
2110
  * Record one sighting of a term without replacing its meaning: the counters move
1922
2111
  * and a missing definition is filled from the glossary, but a curated definition
@@ -2226,16 +2415,139 @@ window.__ModuleLoader__.load({
2226
2415
  }
2227
2416
 
2228
2417
  /**
2229
- * The panel's visible list: filter by query, then order pinned first, then most
2230
- * recently seen.
2418
+ * Move a group — and everything under it — to another path.
2419
+ *
2420
+ * Renaming and moving are the same act, because a group IS a path: renaming `backend` to `服务端` and moving
2421
+ * `net` out of `backend` into `infra` are both "these entries live somewhere else now". One primitive, two
2422
+ * names in the UI, so the two cannot drift apart.
2423
+ *
2424
+ * Each entry is moved through {@link editEntry}, so each carries the stamped decision (`groupAt`) a merge
2425
+ * weighs — a bulk move is not an exception to "a move is a decision".
2426
+ *
2427
+ * Refused: an empty target, a target inside the group being moved (which would put it inside itself), and a
2428
+ * target that leaves everything where it is. Renaming ONTO an existing group is allowed and merges the two,
2429
+ * which is what a reader asking for it means.
2430
+ *
2431
+ * @param state - the current document.
2432
+ * @param from - the path to move.
2433
+ * @param to - the path to move it to.
2434
+ * @param options - `now` overrides the timestamps.
2435
+ * @returns `{ state, moved, error }`.
2436
+ */
2437
+ function renameGroup(state, from, to, options) {
2438
+ const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
2439
+ const target = typeof to === "string" ? to.replace(/^\/+|\/+$/g, "") : "";
2440
+ if (source === "") return { state, moved: 0, error: "no-group" };
2441
+ if (target === "") return { state, moved: 0, error: "no-name" };
2442
+ if (target === source) return { state, moved: 0, error: "same" };
2443
+ if (target.startsWith(`${source}/`)) return { state, moved: 0, error: "into-itself" };
2444
+ const now = options?.now ?? Date.now();
2445
+ let next = state;
2446
+ let moved = 0;
2447
+ let stamp = now;
2448
+ for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
2449
+ stamp += 1;
2450
+ const rest = entry.group === source ? "" : entry.group.slice(source.length + 1);
2451
+ next = editEntry(next, entry.term, { group: rest === "" ? target : `${target}/${rest}` }, { now: stamp }).state;
2452
+ moved += 1;
2453
+ }
2454
+ return { state: next, moved, error: null };
2455
+ }
2456
+
2457
+ /**
2458
+ * Delete a group: every entry under it goes to the deleted list.
2459
+ *
2460
+ * Soft, like every other deletion here — the tombstones make it reversible from 「已删除」, and they are also
2461
+ * what stops a passing pack from putting the terms straight back. A group therefore disappears when the last
2462
+ * entry in it does, which is the same rule that made it appear.
2463
+ *
2464
+ * @param state - the current document.
2465
+ * @param from - the path to empty.
2466
+ * @param options - `now` overrides the timestamps.
2467
+ * @returns `{ state, removed, error }`.
2468
+ */
2469
+ function deleteGroup(state, from, options) {
2470
+ const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
2471
+ if (source === "") return { state, removed: 0, error: "no-group" };
2472
+ const now = options?.now ?? Date.now();
2473
+ let next = state;
2474
+ let removed = 0;
2475
+ let stamp = now;
2476
+ for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
2477
+ stamp += 1;
2478
+ next = removeEntry(next, entry.id, { now: stamp }).state;
2479
+ removed += 1;
2480
+ }
2481
+ return { state: next, removed, error: null };
2482
+ }
2483
+
2484
+ /**
2485
+ * The groups immediately inside one path, with how many entries each holds.
2486
+ *
2487
+ * Only the immediate children: the list shows one level at a time, and a count covers everything under
2488
+ * that child, however deep. A group exists as long as something is in it — there is no separate list of
2489
+ * empty groups to keep in step with the entries, which is the trade this makes deliberately.
2490
+ *
2491
+ * Each group also carries a SAMPLE of the terms inside it. A folder row with only a name and a count is a
2492
+ * row of empty space next to entries full of text — and a preview answers the question the row raises
2493
+ * without making the reader open it to find out.
2494
+ *
2495
+ * @param entries - every live entry.
2496
+ * @param prefix - the path to look inside, `""` for the top level.
2497
+ * @returns `[{ group, name, count, sample }]`, alphabetical.
2498
+ */
2499
+ /**
2500
+ * How many terms a group's preview names before it stops.
2501
+ *
2502
+ * Three: enough to say what kind of thing is in there, few enough that the line stays one line in a
2503
+ * multi-column layout.
2504
+ */
2505
+ const GROUP_SAMPLE = 3;
2506
+
2507
+ function groupsIn(entries, prefix) {
2508
+ const base = prefix === undefined || prefix === null ? "" : String(prefix);
2509
+ const counts = new Map();
2510
+ const samples = new Map();
2511
+ // Newest first, the order the list itself uses, so the preview is the top of what you would see inside.
2512
+ const ordered = (Array.isArray(entries) ? entries : [])
2513
+ .filter((entry) => (entry?.deletedAt ?? 0) === 0 && typeof entry?.term === "string" && entry.term !== "")
2514
+ .slice()
2515
+ .sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
2516
+ for (const entry of ordered) {
2517
+ if ((entry?.deletedAt ?? 0) !== 0) continue;
2518
+ const group = typeof entry?.group === "string" ? entry.group : "";
2519
+ if (group === "" || group === base) continue;
2520
+ let inside = null;
2521
+ if (base === "") inside = group;
2522
+ else if (group.startsWith(`${base}/`)) inside = group.slice(base.length + 1);
2523
+ if (inside === null || inside === "") continue;
2524
+ const name = inside.split("/")[0];
2525
+ counts.set(name, (counts.get(name) ?? 0) + 1);
2526
+ const sample = samples.get(name) ?? [];
2527
+ if (sample.length < GROUP_SAMPLE) sample.push(entry.term);
2528
+ samples.set(name, sample);
2529
+ }
2530
+ return [...counts.entries()]
2531
+ .map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count, sample: samples.get(name) ?? [] }))
2532
+ .sort((left, right) => left.name.localeCompare(right.name));
2533
+ }
2534
+
2535
+ /**
2536
+ * The panel's visible list: one level of the group tree, filtered by query, then ordered by when each
2537
+ * term was last seen.
2231
2538
  * @param state - the document.
2232
2539
  * @param query - search text.
2233
2540
  * @param filter - `all`, `unexplained`, `pinned` or `flagged`.
2541
+ * @param group - the group path to look inside, `""` for the top level.
2234
2542
  * @returns the ordered entries.
2235
2543
  */
2236
- function listEntries(state, query, filter) {
2544
+ function listEntries(state, query, filter, group) {
2545
+ const base = group === undefined || group === null ? "" : String(group);
2237
2546
  const matched = state.entries.filter((entry) => {
2238
2547
  if (entry.deletedAt !== 0) return false;
2548
+ // The list shows the level you are standing in, not everything below it: a group is a directory,
2549
+ // and what is inside it is what you see after entering it.
2550
+ if ((typeof entry.group === "string" ? entry.group : "") !== base) return false;
2239
2551
  if (!matchesQuery(entry, query)) return false;
2240
2552
  if (filter === "unexplained") return isUnexplained(entry);
2241
2553
  if (filter === "pinned") return entry.pinned;
@@ -2245,13 +2557,14 @@ window.__ModuleLoader__.load({
2245
2557
  if (filter === "flagged") return isFlagged(entry);
2246
2558
  return true;
2247
2559
  });
2248
- return matched.sort(
2249
- (left, right) =>
2250
- Number(right.pinned) - Number(left.pinned) ||
2251
- Number(isUnexplained(left)) - Number(isUnexplained(right)) ||
2252
- right.lastSeenAt - left.lastSeenAt ||
2253
- left.term.localeCompare(right.term)
2254
- );
2560
+ // Newest term first, and NOTHING ELSE.
2561
+ //
2562
+ // It used to sort pinned first and unexplained first, and it then sorted by when each term was last
2563
+ // SEEN — every one of which moves a row under the reader's hand: pin a term and it jumped to the top,
2564
+ // answer a prompt and it jumped, and the transcript merely mentioning a term again shuffled the list.
2565
+ // Creation time is the one thing no action changes, so the list is still while the reader reads it.
2566
+ // The pin is shown on the row itself and 「已钉选」 gathers the pinned ones, so ordering has no job left.
2567
+ return matched.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
2255
2568
  }
2256
2569
 
2257
2570
  /**
@@ -2393,6 +2706,9 @@ window.__ModuleLoader__.load({
2393
2706
  tombstonesOf,
2394
2707
  upsertEntry,
2395
2708
  editEntry,
2709
+ restoreEntry,
2710
+ renameGroup,
2711
+ deleteGroup,
2396
2712
  recordSighting,
2397
2713
  removeEntry,
2398
2714
  reviveEntry,
@@ -2402,6 +2718,7 @@ window.__ModuleLoader__.load({
2402
2718
  mergeState,
2403
2719
  summarize,
2404
2720
  listEntries,
2721
+ groupsIn,
2405
2722
  listDeleted,
2406
2723
  createFileStore,
2407
2724
  createBrowserStore
@@ -4295,6 +4612,44 @@ window.__ModuleLoader__.load({
4295
4612
  return result.entry;
4296
4613
  },
4297
4614
 
4615
+ /**
4616
+ * Move a group, and everything under it, to another path.
4617
+ * @param from - the path to move.
4618
+ * @param to - the path to move it to.
4619
+ * @returns a promise resolving to `{ moved, error }`.
4620
+ */
4621
+ async renameGroup(from, to) {
4622
+ const result = dictionary.renameGroup(state, from, to, { now: now() });
4623
+ if (result.moved > 0) await commit(result.state);
4624
+ return { moved: result.moved, error: result.error };
4625
+ },
4626
+
4627
+ /**
4628
+ * Delete a group by deleting everything inside it, which is the only way a group can be deleted.
4629
+ * @param from - the path to empty.
4630
+ * @returns a promise resolving to `{ removed, error }`.
4631
+ */
4632
+ async deleteGroup(from) {
4633
+ const result = dictionary.deleteGroup(state, from, { now: now() });
4634
+ if (result.removed > 0) await commit(result.state);
4635
+ return { removed: result.removed, error: result.error };
4636
+ },
4637
+
4638
+ /**
4639
+ * Bring a deleted term back, as an explicit act.
4640
+ *
4641
+ * See `restoreEntry` in the core: a restoration is a decision with its own evidence and it
4642
+ * withdraws the tombstone, rather than an edit that has to win a comparison on the way to the host.
4643
+ * @param idOrTerm - the entry id, term or alias.
4644
+ * @param patch - the text to restore it with, if any.
4645
+ * @returns a promise resolving to `{ entry, restored }`.
4646
+ */
4647
+ async restoreEntry(idOrTerm, patch) {
4648
+ const result = dictionary.restoreEntry(state, idOrTerm, patch, { now: now() });
4649
+ if (result.restored) await commit(result.state);
4650
+ return { entry: result.entry, restored: result.restored };
4651
+ },
4652
+
4298
4653
  /**
4299
4654
  * Record that a term appeared in a message, without overwriting a definition
4300
4655
  * the user wrote.
@@ -4421,17 +4776,29 @@ window.__ModuleLoader__.load({
4421
4776
  return this.saveEntry(found.term, patch);
4422
4777
  },
4423
4778
 
4779
+ /**
4780
+ * The groups directly inside one path, each with how many entries it holds.
4781
+ * @param prefix - the path to look inside, `""` for the top level.
4782
+ * @returns `[{ group, name, count }]`.
4783
+ */
4784
+ groups(prefix) {
4785
+ return dictionary.groupsIn(state.entries, prefix);
4786
+ },
4787
+
4424
4788
  /**
4425
4789
  * The visible list for the panel.
4426
4790
  * @param query - search text.
4427
4791
  * @param filter - `all`, `unexplained`, `pinned`, `deleted` or `flagged`.
4792
+ * @param group - the group path to look inside, `""` for the top level.
4428
4793
  * @returns the ordered entries. For `deleted`, the tombstones.
4429
4794
  */
4430
- list(query, filter) {
4795
+ list(query, filter, group) {
4796
+ const at = group === undefined || group === null ? "" : String(group);
4431
4797
  // The deleted view is a different LIST rather than a different filter: tombstones live in
4432
- // `backing`, not in `entries`, so no predicate over the live list could ever show one.
4433
- if (filter === "deleted") return dictionary.listDeleted(state, query);
4434
- return listEntries(state, query, filter);
4798
+ // `backing`, not in `entries`, so no predicate over the live list could ever show one. The group
4799
+ // still applies — standing in a folder and asking what was deleted there means that folder.
4800
+ if (filter === "deleted") return dictionary.listDeleted(state, query).filter((entry) => (typeof entry.group === "string" ? entry.group : "") === at);
4801
+ return listEntries(state, query, filter, at);
4435
4802
  },
4436
4803
 
4437
4804
  /**
@@ -4491,12 +4858,34 @@ window.__ModuleLoader__.load({
4491
4858
  /** The most sources the list will hold. A UI bound, not a security one. */
4492
4859
  const MAX_SOURCES = 12;
4493
4860
 
4861
+ /**
4862
+ * The source a fresh install starts with: this plugin's own repository, as a static file.
4863
+ *
4864
+ * Why ship one at all: without it the packs page opens on an empty list, and a first-time reader has
4865
+ * to be TOLD a URL before they can see what a term pack even is. The alternative — fetching from a
4866
+ * server we run — is the thing this design refuses to have, so the default is an ordinary source like
4867
+ * any other: visible in the list, removable in one press, and fetched only when the reader asks (the
4868
+ * page refreshes it on first open, which is a request to the source THEY have configured).
4869
+ *
4870
+ * `@main` rather than a tag because a default should follow the packs the repository actually has:
4871
+ * pinned to a tag, a new pack would need a plugin release before anyone could see it.
4872
+ */
4873
+ const DEFAULT_PACK_SOURCE = "https://cdn.jsdelivr.net/gh/lakerian/dsh-plugin-term-dictionary@main/packs/index.json";
4874
+
4494
4875
  /**
4495
4876
  * The URL rule, imported rather than restated: the settings store, the page and the host's transport
4496
4877
  * must agree on what a usable source is, and two copies of that rule is how one of them drifts.
4497
4878
  */
4498
4879
  const { refuseUrl } = require("./pack.js");
4499
4880
 
4881
+ const ENTRY_COLUMNS = ["auto", "1", "2", "3"];
4882
+
4883
+ /** The two row-height readings: equal cards, or each card as tall as its own text. */
4884
+ const ENTRY_ROWS = ["uniform", "compact"];
4885
+
4886
+ /** The narrowest a column may be when the layout is choosing. Below this a gloss wraps every other word. */
4887
+ const MIN_COLUMN_PX = 260;
4888
+
4500
4889
  /**
4501
4890
  * The three switches and their defaults.
4502
4891
  *
@@ -4548,6 +4937,23 @@ window.__ModuleLoader__.load({
4548
4937
  * the pointer had gone. Anyone who wants a grace period may have one; nobody gets it by accident.
4549
4938
  */
4550
4939
  hoverOutMs: 0,
4940
+ /**
4941
+ * How many columns the entry list is laid out in.
4942
+ *
4943
+ * `auto` fits as many as the panel is wide enough for, and is the default: a dictionary row is
4944
+ * short, a single column of them is a very long strip to read, and a narrow panel simply gets one
4945
+ * column back. The fixed values exist for a reader who wants the layout to stop moving under them.
4946
+ */
4947
+ entryColumns: "auto",
4948
+ /**
4949
+ * How tall a row of the entry list is.
4950
+ *
4951
+ * `uniform` gives every card in a grid row the height of the tallest, so the cards line up and no
4952
+ * empty strip is left under the short ones. `compact` lets each keep its own height, which reads
4953
+ * tighter in a single column and leaves exactly those strips in a grid. Both are defensible, which
4954
+ * is why it is a preference rather than a constant.
4955
+ */
4956
+ entryRows: "uniform",
4551
4957
  /**
4552
4958
  * The term-pack sources the user added: https URLs of static `index.json` files.
4553
4959
  *
@@ -4556,8 +4962,13 @@ window.__ModuleLoader__.load({
4556
4962
  * is not configuration. Validated by the same rule the host applies before it fetches anything
4557
4963
  * ({@link module:core/pack.refuseUrl}), so the page and the transport cannot disagree about what a
4558
4964
  * usable source is.
4965
+ *
4966
+ * The default is not empty: see {@link DEFAULT_PACK_SOURCE}. An ABSENT key means "never touched, use
4967
+ * the default"; an empty array means "the reader removed them all", which is honoured (§
4968
+ * `normalizeSettings`) — the two are different statements and a store that conflated them would
4969
+ * resurrect the default every time somebody cleared the list.
4559
4970
  */
4560
- packSources: []
4971
+ packSources: [DEFAULT_PACK_SOURCE]
4561
4972
  };
4562
4973
 
4563
4974
  /** The accepted minimum term lengths, for the panel's cycling control. */
@@ -4663,7 +5074,13 @@ window.__ModuleLoader__.load({
4663
5074
  collectCjk: readFlag(value.collectCjk, DEFAULT_SETTINGS.collectCjk),
4664
5075
  hoverInMs: readDelay(value.hoverInMs, DEFAULT_SETTINGS.hoverInMs),
4665
5076
  hoverOutMs: readDelay(value.hoverOutMs, DEFAULT_SETTINGS.hoverOutMs),
4666
- packSources: readSources(value.packSources)
5077
+ entryColumns: readChoice(value.entryColumns, ENTRY_COLUMNS, DEFAULT_SETTINGS.entryColumns),
5078
+ entryRows: readChoice(value.entryRows, ENTRY_ROWS, DEFAULT_SETTINGS.entryRows),
5079
+ // Absent means "never touched" and gets the shipped default; an empty array means the reader
5080
+ // removed every source, and is kept as it is. A fresh array either way: a snapshot is compared by
5081
+ // identity, so handing back the shared default array would make one page's edit appear in
5082
+ // another page's defaults.
5083
+ packSources: value.packSources === undefined ? [...DEFAULT_SETTINGS.packSources] : readSources(value.packSources)
4667
5084
  };
4668
5085
  }
4669
5086
 
@@ -4771,6 +5188,10 @@ window.__ModuleLoader__.load({
4771
5188
  DEFAULT_SETTINGS,
4772
5189
  normalizeSettings,
4773
5190
  MAX_SOURCES,
5191
+ DEFAULT_PACK_SOURCE,
5192
+ ENTRY_COLUMNS,
5193
+ ENTRY_ROWS,
5194
+ MIN_COLUMN_PX,
4774
5195
  EXPLAIN_LANGS,
4775
5196
  EXPLAIN_DEPTHS,
4776
5197
  COLLECT_LENGTHS,
@@ -4854,6 +5275,9 @@ window.__ModuleLoader__.load({
4854
5275
  }
4855
5276
  };
4856
5277
  if (typeof entry?.domain === "string" && entry.domain !== "") record.domain = entry.domain;
5278
+ // A group travels through an export, so a backup restores the structure it was filed under; it does NOT
5279
+ // travel in a pack, where the reader organizes entries their own way (see `pack.js`'s allowlist).
5280
+ if (typeof entry?.group === "string" && entry.group !== "") record.group = entry.group;
4857
5281
  if (Array.isArray(entry?.aliases) && entry.aliases.length > 0) record.aliases = [...entry.aliases];
4858
5282
  if (entry?.pinned === true) record.pinned = true;
4859
5283
  if (typeof entry?.source === "string" && entry.source !== "") record.source = entry.source;
@@ -4935,6 +5359,9 @@ window.__ModuleLoader__.load({
4935
5359
  usage: typeof definition.usage === "string" ? definition.usage : ""
4936
5360
  },
4937
5361
  domain: typeof raw.domain === "string" ? raw.domain.trim() : "",
5362
+ // A group is read back from a file like any other label the user assigned — that is what makes an
5363
+ // export a backup of the STRUCTURE and not only of the words.
5364
+ group: typeof raw.group === "string" ? raw.group.trim() : "",
4938
5365
  aliases: Array.isArray(raw.aliases) ? raw.aliases.filter((alias) => typeof alias === "string" && alias.trim() !== "") : [],
4939
5366
  pinned: raw.pinned === true
4940
5367
  };
@@ -4975,6 +5402,64 @@ window.__ModuleLoader__.load({
4975
5402
  return { ok: true, entries, skipped };
4976
5403
  }
4977
5404
 
5405
+ /**
5406
+ * The folder path a file sits in, as a group path.
5407
+ *
5408
+ * The picked folder's own name IS the first group: choosing a folder called `packs` is how you say "file
5409
+ * these under `packs`", and dropping the name would put everything at the top level — which is precisely
5410
+ * the structure the user just picked a folder to express.
5411
+ *
5412
+ * @param path - a `relative/path/file.json`, as `webkitRelativePath` reports it.
5413
+ * @returns the group path.
5414
+ */
5415
+ function folderPathOf(path) {
5416
+ const parts = String(path)
5417
+ .split("/")
5418
+ .filter((part) => part !== "");
5419
+ // Everything but the file name.
5420
+ return parts.slice(0, -1).join("/");
5421
+ }
5422
+
5423
+ /**
5424
+ * Read a whole directory into one import.
5425
+ *
5426
+ * A folder is a mixed bag: several exported dictionaries, somebody's downloaded pack files, an
5427
+ * `index.json` that lists packs rather than holding entries, and often a README or a screenshot. So this
5428
+ * reports what it could NOT use instead of refusing the folder — the alternative is a user with twenty
5429
+ * files and one bad one, told "no" with no idea which file it was. Files that are not JSON are not
5430
+ * reported at all: they are not mistakes.
5431
+ *
5432
+ * @param files - `[{ path, text }]`, the directory's files as the browser hands them over.
5433
+ * @param options - `useFolderAsGroup` files each entry under the folder it came from, which is the shape
5434
+ * people actually keep: one folder per topic, and the reason the option exists at all.
5435
+ * @returns `{ entries, files, used, skipped }`, where `skipped` is `[{ name, error }]`.
5436
+ */
5437
+ function collectFolderImport(files, options) {
5438
+ const list = Array.isArray(files) ? files : [];
5439
+ const entries = [];
5440
+ const skipped = [];
5441
+ const useFolder = options?.useFolderAsGroup === true;
5442
+ let used = 0;
5443
+ let jsonFiles = 0;
5444
+ for (const file of list) {
5445
+ const path = typeof file?.path === "string" ? file.path : "";
5446
+ if (!/\.json$/i.test(path)) continue;
5447
+ jsonFiles++;
5448
+ const parsed = parseImport(typeof file?.text === "string" ? file.text : "");
5449
+ if (parsed.ok !== true) {
5450
+ skipped.push({ name: path, error: parsed.error });
5451
+ continue;
5452
+ }
5453
+ used++;
5454
+ const folder = useFolder ? folderPathOf(path) : "";
5455
+ for (const entry of parsed.entries) {
5456
+ const hasGroup = typeof entry.group === "string" && entry.group !== "";
5457
+ entries.push(folder !== "" && !hasGroup ? { ...entry, group: folder } : entry);
5458
+ }
5459
+ }
5460
+ return { entries, files: jsonFiles, used, skipped };
5461
+ }
5462
+
4978
5463
  /**
4979
5464
  * Decide what an import would do, without doing any of it.
4980
5465
  *
@@ -5020,10 +5505,15 @@ window.__ModuleLoader__.load({
5020
5505
  // Opting in does not empty the `deleted` bucket by accident: it moves those entries into
5021
5506
  // `accepted`, and the bucket is then genuinely empty, so the report says "0 you deleted" and
5022
5507
  // means it.
5508
+ //
5509
+ // `revived` names the accepted entries that were previously DELETED, separately from the ones that are
5510
+ // simply new. The caller must not treat the two the same: a new term is a write, while a deleted term is
5511
+ // a RESTORATION — a decision that has to beat the tombstone, not an edit that hopes to. Running both
5512
+ // through the same write is how "a term that was collected and then deleted cannot be restored" happened.
5023
5513
  const retract = options?.includeDeleted === true;
5024
5514
  return retract
5025
- ? { accepted: [...accepted, ...wasDeleted], existing: alreadyHave, deleted: [], duplicates }
5026
- : { accepted, existing: alreadyHave, deleted: wasDeleted, duplicates };
5515
+ ? { accepted: [...accepted, ...wasDeleted], revived: wasDeleted, existing: alreadyHave, deleted: [], duplicates }
5516
+ : { accepted, revived: [], existing: alreadyHave, deleted: wasDeleted, duplicates };
5027
5517
  }
5028
5518
 
5029
5519
  /**
@@ -5085,6 +5575,7 @@ window.__ModuleLoader__.load({
5085
5575
  buildExport,
5086
5576
  buildFeedbackReport,
5087
5577
  parseImport,
5578
+ collectFolderImport,
5088
5579
  planImport,
5089
5580
  domainsIn,
5090
5581
  toRecord,
@@ -5260,28 +5751,41 @@ window.__ModuleLoader__.load({
5260
5751
  }
5261
5752
 
5262
5753
  /**
5263
- * Read a pack from whatever arrived: a fetched file, or a decoded share code.
5754
+ * Read a pack from whatever arrived: a fetched file, a decoded share code, or the text of either.
5264
5755
  *
5265
5756
  * Tolerant about unknown keys in the envelope, strict about the two things that make it a pack: its
5266
5757
  * marker and at least one usable entry. Every record goes through the same validator an imported file
5267
5758
  * does, so a record that cannot be imported is a record this refuses.
5268
5759
  *
5269
- * @param raw - the parsed value.
5760
+ * Accepts TEXT as well as a parsed object, because text is what actually arrives: the host fetches the
5761
+ * file and passes the body on. Accepting only an object made every source and every pack answer
5762
+ * `not-an-object` — a feature that refuses everything, in the shape of a bad URL.
5763
+ *
5764
+ * @param raw - the fetched body, the decoded text, or the parsed value.
5270
5765
  * @returns `{ ok: true, pack }`, or `{ ok: false, error }`.
5271
5766
  */
5272
5767
  function parsePack(raw) {
5273
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, error: "not-an-object" };
5274
- if (raw.kind !== PACK_KIND) return { ok: false, error: "not-a-pack" };
5275
- if (raw.version !== PACK_VERSION) return { ok: false, error: "unsupported-version" };
5276
- if (!Array.isArray(raw.entries)) return { ok: false, error: "no-entries" };
5277
- if (raw.entries.length > MAX_PACK_ENTRIES) return { ok: false, error: "too-many" };
5768
+ let value = raw;
5769
+ if (typeof raw === "string") {
5770
+ try {
5771
+ value = JSON.parse(raw);
5772
+ } catch (error) {
5773
+ return { ok: false, error: "not-json" };
5774
+ }
5775
+ }
5776
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "not-an-object" };
5777
+ const raw2 = value;
5778
+ if (raw2.kind !== PACK_KIND) return { ok: false, error: "not-a-pack" };
5779
+ if (raw2.version !== PACK_VERSION) return { ok: false, error: "unsupported-version" };
5780
+ if (!Array.isArray(raw2.entries)) return { ok: false, error: "no-entries" };
5781
+ if (raw2.entries.length > MAX_PACK_ENTRIES) return { ok: false, error: "too-many" };
5278
5782
  const entries = [];
5279
- for (const record of raw.entries) {
5783
+ for (const record of raw2.entries) {
5280
5784
  const entry = transfer.toEntry(record);
5281
5785
  if (entry !== null) entries.push(record);
5282
5786
  }
5283
5787
  if (entries.length === 0) return { ok: false, error: "no-usable-entries" };
5284
- const scope = raw.scope !== null && typeof raw.scope === "object" && raw.scope.kind === "domains" ? "domains" : "all";
5788
+ const scope = raw2.scope !== null && typeof raw2.scope === "object" && raw2.scope.kind === "domains" ? "domains" : "all";
5285
5789
  return {
5286
5790
  ok: true,
5287
5791
  pack: {
@@ -5380,17 +5884,31 @@ window.__ModuleLoader__.load({
5380
5884
  * in somebody's index must not hide the other forty packs. An index with nothing usable left is a
5381
5885
  * refusal, because a source that offers nothing is a source that is misconfigured or moved.
5382
5886
  *
5383
- * @param raw - the parsed value.
5887
+ * Takes TEXT or a parsed object, like {@link parsePack} — and the text form is the one production uses:
5888
+ * the host fetches the source and hands the body straight over. Requiring an object here while the host
5889
+ * sends text meant every source answered `not-an-object`, which is a whole feature answering "no" in a
5890
+ * way that looks like the user's URL was wrong.
5891
+ *
5892
+ * @param raw - the fetched body, or the parsed value.
5384
5893
  * @returns `{ ok: true, index, dropped }`, or `{ ok: false, error }`.
5385
5894
  */
5386
5895
  function parseIndex(raw) {
5387
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, error: "not-an-object" };
5388
- if (raw.kind !== INDEX_KIND) return { ok: false, error: "not-an-index" };
5389
- if (raw.version !== INDEX_VERSION) return { ok: false, error: "unsupported-version" };
5390
- if (!Array.isArray(raw.packs)) return { ok: false, error: "no-packs" };
5896
+ let value = raw;
5897
+ if (typeof raw === "string") {
5898
+ try {
5899
+ value = JSON.parse(raw);
5900
+ } catch (error) {
5901
+ return { ok: false, error: "not-json" };
5902
+ }
5903
+ }
5904
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "not-an-object" };
5905
+ const raw2 = value;
5906
+ if (raw2.kind !== INDEX_KIND) return { ok: false, error: "not-an-index" };
5907
+ if (raw2.version !== INDEX_VERSION) return { ok: false, error: "unsupported-version" };
5908
+ if (!Array.isArray(raw2.packs)) return { ok: false, error: "no-packs" };
5391
5909
  const packs = [];
5392
5910
  let dropped = 0;
5393
- for (const pack of raw.packs) {
5911
+ for (const pack of raw2.packs) {
5394
5912
  if (pack === null || typeof pack !== "object") {
5395
5913
  dropped++;
5396
5914
  continue;
@@ -8022,6 +8540,45 @@ window.__ModuleLoader__.load({
8022
8540
  return null;
8023
8541
  }
8024
8542
 
8543
+ /**
8544
+ * How far left of the caret a pointer may still count as being on the text.
8545
+ *
8546
+ * The space before a word is enough: landing in it resolves the caret to the word's own start, and a
8547
+ * reader aiming at the first letter often lands there. A paragraph's left margin is tens of pixels, which
8548
+ * is what this has to reject.
8549
+ */
8550
+ const LEFT_TOLERANCE_PX = 8;
8551
+
8552
+ /**
8553
+ * Whether the caret a point resolved to actually sits at that point.
8554
+ *
8555
+ * `caretRangeFromPoint` CLAMPS horizontally: a point in the left margin of a line resolves to the line's
8556
+ * start, offset 0. When a term begins that line, offset 0 IS that term — so every point from the panel's
8557
+ * left edge to the first character read as "on the term", and the card opened from anywhere in the margin,
8558
+ * with a click to match. That is how this was reported.
8559
+ *
8560
+ * The caret's own rectangle is the live proof of where the text is: it is measured from the document at
8561
+ * this instant, so unlike a block snapshot it cannot go stale. One measurement per event, and only once a
8562
+ * caret has been found at all.
8563
+ *
8564
+ * @param x - viewport x of the pointer.
8565
+ * @param caret - the Range the point resolved to.
8566
+ * @returns true when the caret is at the pointer rather than clamped to the start of a line.
8567
+ */
8568
+ function caretIsAt(x, caret) {
8569
+ let rect = null;
8570
+ try {
8571
+ rect = typeof caret?.getBoundingClientRect === "function" ? caret.getBoundingClientRect() : null;
8572
+ } catch (error) {
8573
+ rect = null;
8574
+ }
8575
+ // A browser (or a fixture) that cannot answer is trusted: refusing every hover because one optional
8576
+ // measurement is missing would be a worse failure than the one being fixed. An all-zero rectangle says
8577
+ // the same thing in another shape — a detached or not-yet-laid-out range.
8578
+ if (rect === null || (rect.left === 0 && rect.top === 0 && rect.width === 0 && rect.height === 0)) return true;
8579
+ return rect.left <= x + LEFT_TOLERANCE_PX;
8580
+ }
8581
+
8025
8582
  /**
8026
8583
  * The marked occurrence under a point.
8027
8584
  *
@@ -8036,6 +8593,7 @@ window.__ModuleLoader__.load({
8036
8593
  function matchAt(x, y) {
8037
8594
  const caret = caretAt(x, y);
8038
8595
  if (caret === null) return null;
8596
+ if (!caretIsAt(x, caret)) return null;
8039
8597
  const at = resolveCaret(caret, doc);
8040
8598
  if (at === null) return null;
8041
8599
  let best = null;
@@ -8787,6 +9345,25 @@ window.__ModuleLoader__.load({
8787
9345
  boxShadow: "0 0 0 1px var(--dsw-alias-brand-primary, #4d6bfe)"
8788
9346
  },
8789
9347
  rowHead: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" },
9348
+ /** Where you are, and the way back up: a directory you can only enter is a trap. */
9349
+ breadcrumb: { display: "flex", alignItems: "center", gap: "2px", flexWrap: "wrap", padding: "2px 0 6px", fontSize: "12px" },
9350
+ breadcrumbPart: { display: "inline-flex", alignItems: "center", gap: "2px" },
9351
+ breadcrumbSep: { color: "var(--dsw-alias-label-tertiary)", margin: "0 2px" },
9352
+ breadcrumbButton: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-brand-primary, #4d6bfe)", cursor: "pointer" },
9353
+ breadcrumbHere: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-label-secondary)", cursor: "default" },
9354
+ /** The group row's own bits: the mark that says "not an entry", and the chevron that says "opens". */
9355
+ groupIcon: { display: "inline-flex", alignItems: "center", color: "var(--dsw-alias-label-secondary)" },
9356
+ groupEnter: { color: "var(--dsw-alias-label-tertiary)", fontSize: "14px", lineHeight: 1 },
9357
+ /** One line saying what is inside a group, so the card is not a name over a blank space. */
9358
+ groupPreview: { margin: 0, fontSize: "12px", lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
9359
+ /**
9360
+ * The row's action toolbar: the second line, always.
9361
+ *
9362
+ * On the title line the controls moved from row to row with the length of the term and the number of
9363
+ * badges; a line of their own puts every row's controls in the same place and keeps them out of the
9364
+ * way of the text they act on.
9365
+ */
9366
+ rowActions: { display: "flex", alignItems: "center", gap: "6px", marginTop: "2px" },
8790
9367
  termButton: {
8791
9368
  appearance: "none",
8792
9369
  background: "transparent",
@@ -9293,6 +9870,13 @@ window.__ModuleLoader__.load({
9293
9870
  { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
9294
9871
  h("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round" })
9295
9872
  ),
9873
+ // The group row's icon: the one thing on screen that is not an entry, so it says so without a label.
9874
+ folder: (size) =>
9875
+ h(
9876
+ "svg",
9877
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
9878
+ 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" })
9879
+ ),
9296
9880
  pin: (size) =>
9297
9881
  h(
9298
9882
  "svg",
@@ -9366,11 +9950,11 @@ window.__ModuleLoader__.load({
9366
9950
  };
9367
9951
 
9368
9952
  /** Settings used when the panel is rendered without a settings store (tests, odd hosts). */
9369
- const FALLBACK_SETTINGS = { autoCollect: true, autoAnnotate: true, autoExplain: true, explainLang: "auto", explainDepth: "normal", explainParagraph: true, collectMinLength: 4, collectIdentifiers: false, collectCjk: true };
9953
+ 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: [] };
9370
9954
 
9371
9955
  /** The accepted preference values, so the page offers exactly what the store validates. */
9372
- const { EXPLAIN_LANGS, EXPLAIN_DEPTHS, COLLECT_LENGTHS, HOVER_DELAY_MIN, HOVER_DELAY_MAX, HOVER_DELAY_STEP } = require("./settings.js");
9373
- const { buildExport, buildFeedbackReport, parseImport, planImport, domainsIn } = require("./transfer.js");
9956
+ 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");
9957
+ const { buildExport, buildFeedbackReport, parseImport, collectFolderImport, planImport, domainsIn } = require("./transfer.js");
9374
9958
  const { FEEDBACK_KINDS, isFlagged, entryAsText } = require("./entries.js");
9375
9959
  const { refuseUrl } = require("./pack.js");
9376
9960
  const { selectionInside } = require("./selection.js");
@@ -9544,13 +10128,20 @@ window.__ModuleLoader__.load({
9544
10128
  const plan = planImport(store.getState().entries ?? [], incoming, store.getState().deletedKeys ?? new Set(), {
9545
10129
  includeDeleted: options?.includeDeleted === true
9546
10130
  });
10131
+ // Two different acts, deliberately: a term the dictionary never had is a WRITE, while a term the user
10132
+ // deleted is a RESTORATION. `store.restoreEntry` withdraws the tombstone and records the decision; a
10133
+ // write would instead have to BEAT the tombstone in a merge, and it can lose that comparison — which is
10134
+ // exactly "a term that was collected and then deleted could not be restored".
10135
+ const revived = new Set((plan.revived ?? []).map((entry) => entry.key));
9547
10136
  for (const entry of plan.accepted) {
9548
- await store.saveEntry(entry.term, {
10137
+ const patch = {
9549
10138
  definition: entry.definition,
9550
10139
  domain: entry.domain,
9551
10140
  aliases: entry.aliases,
9552
10141
  pinned: entry.pinned
9553
- });
10142
+ };
10143
+ if (revived.has(entry.key)) await store.restoreEntry(entry.term, patch);
10144
+ else await store.saveEntry(entry.term, patch);
9554
10145
  }
9555
10146
  return {
9556
10147
  imported: plan.accepted.length,
@@ -9658,6 +10249,21 @@ window.__ModuleLoader__.load({
9658
10249
  * choice this page offers.
9659
10250
  */
9660
10251
  const [includeGloss, setIncludeGloss] = React.useState(false);
10252
+ /**
10253
+ * A file that has been read but not imported yet: `{ name, entries, skippedCount, plan }`.
10254
+ *
10255
+ * Reading a file and writing it are two decisions, and this state is the gap between them. It exists
10256
+ * because the second decision used to have no moment of its own: picking a file imported it outright,
10257
+ * so the only way to see what was in it was the report afterwards.
10258
+ */
10259
+ const [pending, setPending] = React.useState(null);
10260
+ /**
10261
+ * Whether a folder import names each entry's category after the folder it came from.
10262
+ *
10263
+ * Off by default: it writes a value the user did not type. On is for the shape people actually keep —
10264
+ * one folder per topic.
10265
+ */
10266
+ const [useFolderAsGroup, setUseFolderAsGroup] = React.useState(true);
9661
10267
  /**
9662
10268
  * What came back from the host's own feedback channel, or null when nothing was sent yet.
9663
10269
  *
@@ -9742,7 +10348,7 @@ window.__ModuleLoader__.load({
9742
10348
  /** Save the export as a file. */
9743
10349
  const download = () => downloadText(textOf(), `term-dictionary-${new Date().toISOString().slice(0, 10)}.json`);
9744
10350
 
9745
- /** Take a file, and say what came of it. */
10351
+ /** Take a file: read it, show what is in it, and write nothing until the user says so. */
9746
10352
  const takeFile = async (event) => {
9747
10353
  const file = event?.target?.files?.[0];
9748
10354
  if (file === undefined || file === null) return;
@@ -9753,9 +10359,76 @@ window.__ModuleLoader__.load({
9753
10359
  setReport({ failed: parsed.error });
9754
10360
  return;
9755
10361
  }
9756
- // Through the shared path, so a file and a pack cannot diverge in what they do with an
9757
- // entry — including the crafted revival that makes a term the user deleted stay revived.
9758
- setReport(await importEntries(store, parsed.entries, { includeDeleted, skipped: parsed.skipped ?? 0 }));
10362
+ // The previous import's report goes away when a new file is read: it describes something that
10363
+ // has already happened, and leaving it under a fresh confirmation reads as if it described
10364
+ // this one.
10365
+ setReport(null);
10366
+ // The plan is a DRY RUN: `planImport` is pure, so the page can say what would happen without
10367
+ // any of it happening. The write path recomputes it at the moment of the write, so a preview
10368
+ // that went stale (another window deleted something) cannot import the stale answer.
10369
+ setPending({ name: file.name, entries: parsed.entries, skippedCount: parsed.skipped ?? 0, plan: planFor(parsed.entries, includeDeleted) });
10370
+ } catch (error) {
10371
+ setReport({ failed: String(error?.message ?? error) });
10372
+ } finally {
10373
+ setBusy(false);
10374
+ }
10375
+ };
10376
+
10377
+ /** The dry-run plan for a set of incoming records, used by the preview and by the write. */
10378
+ function planFor(incoming, withDeleted) {
10379
+ return planImport(store.getState().entries ?? [], incoming, store.getState().deletedKeys ?? new Set(), { includeDeleted: withDeleted === true });
10380
+ }
10381
+
10382
+ /** The confirmation the user actually presses. */
10383
+ const confirmImport = async () => {
10384
+ if (pending === null || busy) return;
10385
+ setBusy(true);
10386
+ try {
10387
+ const result = await importEntries(store, pending.entries, { includeDeleted, skipped: pending.skippedCount });
10388
+ // The panel owns the view, so IT decides what has to move to show what was just written — and
10389
+ // says whether it had to. An import that lands while the list is filtered to 「已删除」 or
10390
+ // narrowed by a search looks exactly like an import that did nothing, which is how this was
10391
+ // reported once ("the imported entries do not appear").
10392
+ const moved = typeof props.onImported === "function" && props.onImported() === true;
10393
+ setReport(moved ? { ...result, moved: true } : result);
10394
+ setPending(null);
10395
+ } catch (error) {
10396
+ setReport({ failed: String(error?.message ?? error) });
10397
+ } finally {
10398
+ setBusy(false);
10399
+ }
10400
+ };
10401
+
10402
+ /**
10403
+ * Read a whole folder: every `*.json` in it, as one import.
10404
+ *
10405
+ * A directory reaches the page through `webkitdirectory`, which is the OS's own directory browser —
10406
+ * so this needs no new service dependency, and nothing can park the plugin waiting for one. The
10407
+ * browser supplies `webkitRelativePath`, which is what names a category after its folder.
10408
+ */
10409
+ const takeFolder = async (event) => {
10410
+ const files = [...(event?.target?.files ?? [])];
10411
+ if (files.length === 0) return;
10412
+ setBusy(true);
10413
+ try {
10414
+ const read = [];
10415
+ for (const file of files) {
10416
+ const path = typeof file.webkitRelativePath === "string" && file.webkitRelativePath !== "" ? file.webkitRelativePath : file.name;
10417
+ read.push({ path, text: await file.text() });
10418
+ }
10419
+ const collected = collectFolderImport(read, { useFolderAsGroup });
10420
+ if (collected.entries.length === 0) {
10421
+ setReport({ failed: collected.skipped.length === 0 ? t("transferNoJson") : t("transferFolderAllSkipped", { count: String(collected.skipped.length) }) });
10422
+ return;
10423
+ }
10424
+ setReport(null);
10425
+ setPending({
10426
+ name: t("transferFolderLabel", { count: String(collected.used) }),
10427
+ entries: collected.entries,
10428
+ skippedCount: 0,
10429
+ plan: planFor(collected.entries, includeDeleted),
10430
+ folder: { files: collected.files, used: collected.used, skipped: collected.skipped }
10431
+ });
9759
10432
  } catch (error) {
9760
10433
  setReport({ failed: String(error?.message ?? error) });
9761
10434
  } finally {
@@ -9866,7 +10539,13 @@ window.__ModuleLoader__.load({
9866
10539
  type: "checkbox",
9867
10540
  style: applyStyles.checkbox,
9868
10541
  checked: includeDeleted,
9869
- onChange: () => setIncludeDeleted(!includeDeleted)
10542
+ // Toggling this re-plans the preview rather than leaving it: the number the user is about
10543
+ // to confirm is the number this opt-in produces, or the confirmation is a lie.
10544
+ onChange: () => {
10545
+ const next = !includeDeleted;
10546
+ setIncludeDeleted(next);
10547
+ setPending((current) => (current === null ? null : { ...current, plan: planFor(current.entries, next) }));
10548
+ }
9870
10549
  }),
9871
10550
  h("span", { style: applyStyles.transferName }, t("transferIncludeDeleted"))
9872
10551
  ),
@@ -9879,19 +10558,114 @@ window.__ModuleLoader__.load({
9879
10558
  onChange: (event) => void takeFile(event),
9880
10559
  style: applyStyles.transferFile
9881
10560
  }),
10561
+ // The folder half. `webkitdirectory` opens the OS's directory browser and hands over every file
10562
+ // inside it, with the path — which is the only directory browser a page needs, and the one that
10563
+ // arrives without a service the plugin could end up parked on.
10564
+ h("p", { style: applyStyles.settingLabel }, t("transferFolderTitle")),
10565
+ h("p", { style: applyStyles.settingHint }, t("transferFolderHint")),
10566
+ h("input", {
10567
+ type: "file",
10568
+ webkitdirectory: "true",
10569
+ directory: "true",
10570
+ multiple: true,
10571
+ "aria-label": t("transferFolderTitle"),
10572
+ disabled: busy,
10573
+ onChange: (event) => void takeFolder(event),
10574
+ style: applyStyles.transferFile,
10575
+ "data-term-dictionary": "transfer-folder"
10576
+ }),
10577
+ h(
10578
+ "label",
10579
+ { style: applyStyles.transferItem, "data-term-dictionary": "transfer-folder-group" },
10580
+ h("input", { type: "checkbox", style: applyStyles.checkbox, checked: useFolderAsGroup, onChange: () => setUseFolderAsGroup(!useFolderAsGroup) }),
10581
+ h("span", { style: applyStyles.transferName }, t("transferFolderAsGroup"))
10582
+ ),
10583
+ // The confirmation. Picking a file used to import it, which made "what is in this file" a
10584
+ // question you could only answer afterwards; the plan below is a dry run, so the answer comes
10585
+ // first and the write waits for a second press.
10586
+ pending === null
10587
+ ? null
10588
+ : h(
10589
+ "div",
10590
+ { style: applyStyles.notice, "data-term-import-pending": "true" },
10591
+ h("p", { style: applyStyles.settingLabel }, t("transferConfirmTitle", { name: pending.name })),
10592
+ h(
10593
+ "p",
10594
+ { style: applyStyles.settingHint },
10595
+ [
10596
+ t("transferWillImport", { count: String(pending.plan.accepted.length) }),
10597
+ pending.plan.existing.length > 0 ? t("transferWillSkipExisting", { count: String(pending.plan.existing.length) }) : "",
10598
+ pending.plan.deleted.length > 0 ? t("transferWillSkipDeleted", { count: String(pending.plan.deleted.length) }) : "",
10599
+ pending.plan.duplicates + pending.skippedCount > 0 ? t("transferDuplicates", { count: String(pending.plan.duplicates + pending.skippedCount) }) : ""
10600
+ ]
10601
+ .filter((part) => part !== "")
10602
+ .join(" · ")
10603
+ ),
10604
+ // A folder says what it found and what it had to pass over — by name, because "one file was
10605
+ // skipped" with twenty files in the folder is not an answer.
10606
+ pending.folder === undefined
10607
+ ? null
10608
+ : h(
10609
+ "div",
10610
+ null,
10611
+ h("p", { style: applyStyles.settingHint }, t("transferFolderFound", { files: String(pending.folder.files), used: String(pending.folder.used) })),
10612
+ pending.folder.skipped.length === 0
10613
+ ? null
10614
+ : h("p", { style: applyStyles.error }, `${t("transferFolderSkipped", { count: String(pending.folder.skipped.length) })} ${pending.folder.skipped.map((each) => `${each.name}(${each.error})`).join("、")}`)
10615
+ ),
10616
+ h(
10617
+ "div",
10618
+ { style: applyStyles.transferRow },
10619
+ h(Button, { label: t("transferConfirm"), disabled: busy, onClick: () => void confirmImport() }, t("transferConfirm")),
10620
+ h(Button, { label: t("cancel"), onClick: () => setPending(null) }, t("cancel"))
10621
+ )
10622
+ ),
9882
10623
  report === null
9883
10624
  ? null
9884
10625
  : h(
9885
10626
  "p",
9886
- { style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error },
10627
+ { style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error, "data-term-report": "true" },
9887
10628
  report.failed === undefined
9888
- ? t("transferReport", { imported: report.imported, existing: report.existing, deleted: report.deleted, duplicates: report.duplicates })
10629
+ ? [t("transferReport", { imported: report.imported, existing: report.existing, deleted: report.deleted, duplicates: report.duplicates }), report.moved === true ? t("transferShown") : ""]
10630
+ .filter((part) => part !== "")
10631
+ .join(" ")
9889
10632
  : t("transferFailed", { message: report.failed })
9890
10633
  )
9891
10634
  //#endregion
9892
10635
  );
9893
10636
  }
9894
10637
 
10638
+ /**
10639
+ * The entry list's layout, given the column preference.
10640
+ *
10641
+ * A module-level function rather than an inline style, and pure, so the rule can be asserted without a
10642
+ * browser: `auto` asks the grid for as many columns as fit at a readable width, a number pins the
10643
+ * layout, and `1` is the plain column the panel had before any of this existed.
10644
+ *
10645
+ * @param columns - `"auto"`, `"1"`, `"2"` or `"3"` (anything else behaves as `auto`).
10646
+ * @returns the style for the list container.
10647
+ */
10648
+ function listStyle(columns, rowLayout) {
10649
+ const fixed = Number.parseInt(String(columns), 10);
10650
+ const gridTemplateColumns =
10651
+ Number.isFinite(fixed) && fixed > 1
10652
+ ? `repeat(${fixed}, minmax(0, 1fr))`
10653
+ : `repeat(auto-fill, minmax(${MIN_COLUMN_PX}px, 1fr))`;
10654
+ // `stretch` is the default because the complaint was the GAPS: with each cell as tall as its track,
10655
+ // cards line up and no empty strip appears under the short ones. `compact` lets every card keep its
10656
+ // own height instead, which is tidier in a column but leaves those strips — so it is a choice.
10657
+ return {
10658
+ ...applyStyles.list,
10659
+ display: "grid",
10660
+ // Rows are as tall as their content, and the leftover height stays at the bottom. Without this a
10661
+ // grid STRETCHES its rows to fill the container, so two cards in a tall panel became two 700px
10662
+ // boxes — the opposite of what "equal cards" was asking for.
10663
+ alignContent: "start",
10664
+ alignItems: rowLayout === "compact" ? "start" : "stretch",
10665
+ gridTemplateColumns
10666
+ };
10667
+ }
10668
+
9895
10669
  /**
9896
10670
  * Term packs: hand this dictionary to somebody, or take theirs.
9897
10671
  *
@@ -9934,6 +10708,7 @@ window.__ModuleLoader__.load({
9934
10708
  /** One row per source after a refresh: `{ url, ok, stale, error, index }`. */
9935
10709
  const [listing, setListing] = React.useState(null);
9936
10710
  const [packNote, setPackNote] = React.useState({});
10711
+ const [autoRefreshed, setAutoRefreshed] = React.useState(false);
9937
10712
  /** Say something in the page's one notice line rather than in a dialog. */
9938
10713
  const say = (message) => setNotice(message);
9939
10714
 
@@ -10010,6 +10785,25 @@ window.__ModuleLoader__.load({
10010
10785
  }
10011
10786
  };
10012
10787
 
10788
+ /**
10789
+ * Ask the reader's own sources what they hold, once, when the page first opens.
10790
+ *
10791
+ * Without it a fresh install shows a configured source and nothing else, and "press 刷新" is a step
10792
+ * whose purpose nobody can guess. The request goes to the sources THEY have (the shipped default
10793
+ * included), the host does the fetching and caches it for an hour, and the list stays theirs to clear
10794
+ * — a source removed here is not added back, which is why this looks at the length rather than at
10795
+ * whether the list is the default one.
10796
+ *
10797
+ * Declared after `refresh` on purpose: the effect callback runs on the first render in some
10798
+ * harnesses (and in React's own test renderers), and a callback closing over a `const` below it
10799
+ * would read an uninitialised binding.
10800
+ */
10801
+ React.useEffect(() => {
10802
+ if (autoRefreshed || sources.length === 0) return;
10803
+ setAutoRefreshed(true);
10804
+ void refresh();
10805
+ }, [autoRefreshed, sources.length]);
10806
+
10013
10807
  /** Fetch one listed pack and offer it for import. */
10014
10808
  const preview = async (row, listed) => {
10015
10809
  if (typeof packApi?.fetch !== "function") return say(t("packNoHost"));
@@ -10181,6 +10975,9 @@ window.__ModuleLoader__.load({
10181
10975
  h(
10182
10976
  "div",
10183
10977
  { key: url, style: applyStyles.transferItem, "data-term-source": url },
10978
+ // The shipped one says so: a source nobody remembers adding is a source nobody dares
10979
+ // remove, and this one is removable like any other.
10980
+ url === DEFAULT_PACK_SOURCE ? h("span", { style: applyStyles.badge }, t("packBuiltInSource")) : null,
10184
10981
  h("span", { style: applyStyles.transferName }, url),
10185
10982
  h(Button, { label: t("packRemoveSource"), onClick: () => settingsStore?.set?.("packSources", sources.filter((each) => each !== url)) }, t("packRemoveSource"))
10186
10983
  )
@@ -10189,7 +10986,7 @@ window.__ModuleLoader__.load({
10189
10986
  h(
10190
10987
  "div",
10191
10988
  { style: applyStyles.transferRow },
10192
- h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), onChange: setSourceDraft }),
10989
+ h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), width: "30em", onChange: setSourceDraft }),
10193
10990
  h(Button, { label: t("packAddSource"), disabled: busy, onClick: addSource }, t("packAddSource")),
10194
10991
  h(Button, { label: t("packRefresh"), disabled: busy || sources.length === 0, onClick: () => void refresh() }, t("packRefresh"))
10195
10992
  ),
@@ -10393,6 +11190,28 @@ window.__ModuleLoader__.load({
10393
11190
  onSelect: (value) => setSwitch("explainDepth", value)
10394
11191
  })
10395
11192
  ),
11193
+ row(
11194
+ "rows",
11195
+ t("entryRowsName"),
11196
+ t("entryRowsHint"),
11197
+ h(OptionBar, {
11198
+ label: t("entryRowsName"),
11199
+ value: ENTRY_ROWS.includes(settings.entryRows) ? settings.entryRows : "uniform",
11200
+ options: ENTRY_ROWS.map((value) => ({ value, label: t(`entryRows_${value}`) })),
11201
+ onSelect: (value) => setSwitch("entryRows", value)
11202
+ })
11203
+ ),
11204
+ row(
11205
+ "columns",
11206
+ t("entryColumnsName"),
11207
+ t("entryColumnsHint"),
11208
+ h(OptionBar, {
11209
+ label: t("entryColumnsName"),
11210
+ value: ENTRY_COLUMNS.includes(settings.entryColumns) ? settings.entryColumns : "auto",
11211
+ options: ENTRY_COLUMNS.map((value) => ({ value, label: t(`entryColumns_${value}`) })),
11212
+ onSelect: (value) => setSwitch("entryColumns", value)
11213
+ })
11214
+ ),
10396
11215
  flag(
10397
11216
  "identifiers",
10398
11217
  t("collectIdentifiersOn"),
@@ -10467,6 +11286,15 @@ window.__ModuleLoader__.load({
10467
11286
  }
10468
11287
 
10469
11288
  /** A labelled text input. */
11289
+ /**
11290
+ * A labelled text field.
11291
+ *
11292
+ * One width for every single-line field, because a form of differently-sized boxes reads as a mistake
11293
+ * rather than as a decision. `width` overrides the cap for the rare field whose content really is longer
11294
+ * (a URL, a path), and a multi-line field is an {@link Area} and takes the full width instead.
11295
+ */
11296
+ const FIELD_WIDTH = "22em";
11297
+
10470
11298
  function Field(props) {
10471
11299
  return h(
10472
11300
  "label",
@@ -10477,7 +11305,7 @@ window.__ModuleLoader__.load({
10477
11305
  value: props.value,
10478
11306
  placeholder: props.placeholder,
10479
11307
  onChange: (event) => props.onChange(event.target.value),
10480
- style: applyStyles.input,
11308
+ style: { ...applyStyles.input, maxWidth: props.width ?? FIELD_WIDTH },
10481
11309
  "aria-label": props.label
10482
11310
  })
10483
11311
  );
@@ -10541,6 +11369,8 @@ window.__ModuleLoader__.load({
10541
11369
  );
10542
11370
  const [query, setQuery] = React.useState("");
10543
11371
  const [filter, setFilter] = React.useState("all");
11372
+ /** How the entry list is laid out; the settings store owns the value, this only reads it. */
11373
+ const columns = settings.entryColumns ?? FALLBACK_SETTINGS.entryColumns;
10544
11374
  const [editing, setEditing] = React.useState(null);
10545
11375
  /** Whether the panel is showing its settings page instead of the entry list. */
10546
11376
  const [settingsOpen, setSettingsOpen] = React.useState(false);
@@ -10587,7 +11417,28 @@ window.__ModuleLoader__.load({
10587
11417
  };
10588
11418
  }, [store]);
10589
11419
 
10590
- const visible = React.useMemo(() => store.list(query, filter), [view, query, filter]);
11420
+ /**
11421
+ * Which group the list is showing: `""` is the top level, otherwise a path like `backend/net`.
11422
+ *
11423
+ * Standing somewhere is a property of the VIEW, not of the dictionary — nothing about an entry changes
11424
+ * because you looked at it — so it is panel state, next to the search box and the tab.
11425
+ */
11426
+ const [group, setGroup] = React.useState("");
11427
+ /**
11428
+ * The group being renamed or moved, as `{ group, value }`, or null.
11429
+ *
11430
+ * An inline form rather than `prompt()`: Electron does not implement `prompt`, so a rename dialog
11431
+ * built on it would do nothing at all in the app this plugin runs in.
11432
+ */
11433
+ const [renaming, setRenaming] = React.useState(null);
11434
+ const visible = React.useMemo(() => store.list(query, filter, group), [view, query, filter, group]);
11435
+ /**
11436
+ * The groups directly inside the current one, each with how many entries it holds.
11437
+ *
11438
+ * They are ROWS of the list rather than a sidebar or a header: a group is the same kind of thing as an
11439
+ * entry — something you open — and the point of the shape is that both are the same size on screen.
11440
+ */
11441
+ const subGroups = React.useMemo(() => store.groups(group), [view, group]);
10591
11442
  const current = view.state.entries;
10592
11443
  /**
10593
11444
  * Whether the panel is showing what was deleted.
@@ -10606,6 +11457,59 @@ window.__ModuleLoader__.load({
10606
11457
  */
10607
11458
  const showingFlagged = filter === "flagged";
10608
11459
 
11460
+ /**
11461
+ * Make what an import just wrote visible.
11462
+ *
11463
+ * The panel owns the view, so this is the panel's job and not the import page's. It returns whether it
11464
+ * had to move anything, because a report that says "12 imported" while the list is still filtered to
11465
+ * 「已删除」 is a report nobody can check.
11466
+ *
11467
+ * @returns true when the search box or the tab had to change.
11468
+ */
11469
+ const showImported = () => {
11470
+ const moved = query !== "" || filter !== "all";
11471
+ setQuery("");
11472
+ setFilter("all");
11473
+ return moved;
11474
+ };
11475
+
11476
+ /**
11477
+ * Rename or move a group, and follow it if the panel was standing inside it.
11478
+ *
11479
+ * Following is the point: a reader who renames the folder they are in must still be looking at it
11480
+ * afterwards, not dropped back at the top wondering where it went.
11481
+ */
11482
+ const commitRename = async () => {
11483
+ if (renaming === null) return;
11484
+ const result = await store.renameGroup(renaming.group, renaming.value);
11485
+ if (result.error !== null) {
11486
+ props.setToast?.(t(`groupError_${result.error}`));
11487
+ return;
11488
+ }
11489
+ const target = renaming.value.replace(/^\/+|\/+$/g, "");
11490
+ if (group === renaming.group || group.startsWith(`${renaming.group}/`)) {
11491
+ setGroup(group === renaming.group ? target : `${target}${group.slice(renaming.group.length)}`);
11492
+ }
11493
+ setRenaming(null);
11494
+ props.setToast?.(t("groupRenamed", { count: String(result.moved) }));
11495
+ };
11496
+
11497
+ /**
11498
+ * Delete a group by deleting what is in it, after saying how much that is.
11499
+ *
11500
+ * The entries go to 「已删除」 rather than away, so this is reversible — and the count is in the
11501
+ * question, because "delete this group?" and "delete 12 entries?" are different offers.
11502
+ */
11503
+ const removeGroup = async (sub) => {
11504
+ if (globalThis.confirm?.(t("groupDeleteConfirm", { name: sub.name, count: String(sub.count) })) !== true) return;
11505
+ const result = await store.deleteGroup(sub.group);
11506
+ if (result.error !== null) {
11507
+ props.setToast?.(t(`groupError_${result.error}`));
11508
+ return;
11509
+ }
11510
+ props.setToast?.(t("groupDeleted", { count: String(result.removed) }));
11511
+ };
11512
+
10609
11513
  /** Open the editor for a blank entry, prefilled from an optional term. */
10610
11514
  const startCreate = (term = "", context = "") => {
10611
11515
  setEditing({
@@ -10615,6 +11519,9 @@ window.__ModuleLoader__.load({
10615
11519
  usage: "",
10616
11520
  notes: "",
10617
11521
  domain: "",
11522
+ // A new entry made while standing in a group belongs to that group: that is what standing there
11523
+ // meant, and asking again in the form would be a question with one sensible answer.
11524
+ group,
10618
11525
  aliases: "",
10619
11526
  pinned: false,
10620
11527
  // Nothing to say yet, and `feedbackTouched` is what tells a save apart from a retraction:
@@ -10638,6 +11545,7 @@ window.__ModuleLoader__.load({
10638
11545
  usage: entry.definition.usage,
10639
11546
  notes: entry.definition.notes,
10640
11547
  domain: entry.domain,
11548
+ group: entry.group,
10641
11549
  aliases: entry.aliases.join(", "),
10642
11550
  pinned: entry.pinned,
10643
11551
  // Seeded from the entry, and `touched` starts true when there is already a remark: saving
@@ -10699,6 +11607,7 @@ window.__ModuleLoader__.load({
10699
11607
  await store.saveEntry(editing.term, {
10700
11608
  definition: { zh: editing.zh, gloss: editing.gloss, usage: editing.usage, notes: editing.notes },
10701
11609
  domain: editing.domain,
11610
+ group: editing.group,
10702
11611
  aliases: editing.aliases
10703
11612
  .split(",")
10704
11613
  .map((alias) => alias.trim())
@@ -10856,6 +11765,12 @@ window.__ModuleLoader__.load({
10856
11765
  placeholder: t("zhPlaceholder"),
10857
11766
  onChange: (value) => setEditing({ ...editing, zh: value })
10858
11767
  }),
11768
+ h(Field, {
11769
+ label: t("groupLabel"),
11770
+ value: editing.group,
11771
+ placeholder: t("groupPlaceholder"),
11772
+ onChange: (value) => setEditing({ ...editing, group: value })
11773
+ }),
10859
11774
  h(Area, {
10860
11775
  label: t("glossLabel"),
10861
11776
  value: editing.gloss,
@@ -10988,7 +11903,7 @@ window.__ModuleLoader__.load({
10988
11903
  ),
10989
11904
  h(Button, { label: t("settingsBack"), onClick: () => setTransferOpen(false) }, t("settingsBack"))
10990
11905
  ),
10991
- h(TransferPage, { store, t, setToast: props.setToast })
11906
+ h(TransferPage, { store, t, setToast: props.setToast, onImported: showImported })
10992
11907
  );
10993
11908
  }
10994
11909
 
@@ -11116,14 +12031,118 @@ window.__ModuleLoader__.load({
11116
12031
  ) : null,
11117
12032
  h(
11118
12033
  "div",
11119
- { style: applyStyles.list },
11120
- showingDeleted
11121
- ? h(DeletedList, { entries: visible, t, onRevive: (entry) => void revive(entry) })
11122
- : showingFlagged
11123
- ? h(FlaggedList, { entries: visible, t, onClear: (entry) => void clearFlag(entry) })
11124
- : visible.length === 0
11125
- ? h("p", { style: applyStyles.blank }, current.length === 0 ? t("empty") : t("searchEmpty"))
11126
- : visible.map((entry) =>
12034
+ // The entry list is the one that gets columns. The deleted and flagged views are lists of
12035
+ // DECISIONS rather than of terms — shorter rows, read one at a time — and the picking mode
12036
+ // pairs every row with a checkbox, so laying those out in a grid would be a change nobody
12037
+ // asked for.
12038
+ {
12039
+ style: showingDeleted || showingFlagged || picking ? applyStyles.list : listStyle(columns, settings.entryRows),
12040
+ "data-term-columns": showingDeleted || showingFlagged || picking ? undefined : columns
12041
+ },
12042
+ // The groups come first, then the entries at this level — one list, one scroll area, one kind of
12043
+ // row. A breadcrumb says where you are and is the way back up, because a directory you can only
12044
+ // enter is a trap.
12045
+ [
12046
+ renaming === null ? null : h(
12047
+ "div",
12048
+ { style: { ...applyStyles.transferRow, gridColumn: "1 / -1" }, "data-term-group-form": renaming.group },
12049
+ h("span", { style: applyStyles.fieldLabel }, t("groupRenameTo")),
12050
+ h("input", {
12051
+ type: "text",
12052
+ value: renaming.value,
12053
+ "aria-label": t("groupRename"),
12054
+ onChange: (event) => setRenaming({ ...renaming, value: event.target.value }),
12055
+ onKeyDown: (event) => {
12056
+ if (event.key === "Enter") void commitRename();
12057
+ if (event.key === "Escape") setRenaming(null);
12058
+ },
12059
+ style: { ...applyStyles.input, maxWidth: "18em" }
12060
+ }),
12061
+ h(Button, { label: t("groupRenameApply"), onClick: () => void commitRename() }, t("groupRenameApply")),
12062
+ h(Button, { label: t("cancel"), onClick: () => setRenaming(null) }, t("cancel"))
12063
+ ),
12064
+ group === "" ? null : h(
12065
+ "div",
12066
+ { style: { ...applyStyles.breadcrumb, gridColumn: "1 / -1" }, "data-term-breadcrumb": group },
12067
+ [
12068
+ { path: "", label: t("filterAll") },
12069
+ ...group.split("/").map((name, index, all) => ({ path: all.slice(0, index + 1).join("/"), label: name }))
12070
+ ].map((crumb, index) =>
12071
+ h(
12072
+ "span",
12073
+ { key: crumb.path === "" ? "root" : crumb.path, style: applyStyles.breadcrumbPart },
12074
+ index === 0 ? null : h("span", { style: applyStyles.breadcrumbSep }, "›"),
12075
+ h(
12076
+ "button",
12077
+ {
12078
+ type: "button",
12079
+ style: index === group.split("/").length ? applyStyles.breadcrumbHere : applyStyles.breadcrumbButton,
12080
+ onClick: () => setGroup(crumb.path),
12081
+ title: t("groupGo", { name: crumb.label })
12082
+ },
12083
+ crumb.label
12084
+ )
12085
+ )
12086
+ )
12087
+ ),
12088
+ ...subGroups.map((sub) =>
12089
+ h(
12090
+ "article",
12091
+ {
12092
+ key: `group:${sub.group}`,
12093
+ // It fills its row again, now that the height carries something: with the preview below, the
12094
+ // space that used to be emptiness is the first thing you would see on opening it.
12095
+ style: applyStyles.row,
12096
+ "data-term-group": sub.group,
12097
+ // The WHOLE row opens the group, exactly as the whole row of an entry opens the entry.
12098
+ // Only the label being live is what "the group cannot be clicked into" meant: a reader
12099
+ // clicks the row, the same gesture that works one line below, and nothing happened.
12100
+ // The label stays a button so it is reachable by keyboard and named for a screen reader.
12101
+ onClick: (event) => {
12102
+ // The buttons inside keep their own meaning, exactly as they do on an entry row.
12103
+ if (typeof event?.target?.closest === "function" && event.target.closest("button") !== null) return;
12104
+ setGroup(sub.group);
12105
+ }
12106
+ },
12107
+ h("div", { style: applyStyles.rowHead },
12108
+ h("div", { style: applyStyles.groupIcon, "aria-hidden": "true" }, Icons.folder(14)),
12109
+ h("button", {
12110
+ type: "button",
12111
+ style: applyStyles.termButton,
12112
+ onClick: () => setGroup(sub.group),
12113
+ title: t("groupOpen")
12114
+ }, sub.name),
12115
+ h("span", { style: applyStyles.badge }, t("groupCount", { count: String(sub.count) })),
12116
+ h("span", { style: applyStyles.spacer }),
12117
+ h("span", { style: applyStyles.groupEnter, "aria-hidden": "true" }, "›")
12118
+ ),
12119
+ // The same shape an entry row has: a title line, a line of actions, then the body.
12120
+ h(
12121
+ "div",
12122
+ { style: applyStyles.rowActions, "data-term-group-actions": sub.group },
12123
+ h(IconButton, { title: t("groupRename"), onClick: () => setRenaming({ group: sub.group, value: sub.group }) }, Icons.edit(13)),
12124
+ h(IconButton, { title: t("groupDelete"), onClick: () => void removeGroup(sub) }, Icons.trash(13))
12125
+ ),
12126
+ // What is inside, in one line: the row stops being a name over a blank card.
12127
+ h(
12128
+ "p",
12129
+ { style: applyStyles.groupPreview, "data-term-group-sample": sub.group },
12130
+ [
12131
+ sub.sample.join(t("groupSampleJoin")),
12132
+ sub.count > sub.sample.length ? t("groupSampleMore", { count: String(sub.count - sub.sample.length) }) : ""
12133
+ ]
12134
+ .filter((part) => part !== "")
12135
+ .join("")
12136
+ )
12137
+ )
12138
+ ),
12139
+ showingDeleted
12140
+ ? h(DeletedList, { entries: visible, t, onRevive: (entry) => void revive(entry) })
12141
+ : showingFlagged
12142
+ ? h(FlaggedList, { entries: visible, t, onClear: (entry) => void clearFlag(entry) })
12143
+ : visible.length === 0
12144
+ ? h("p", { style: { ...applyStyles.blank, gridColumn: "1 / -1" } }, current.length === 0 ? t("empty") : subGroups.length > 0 ? t("groupEmpty") : t("searchEmpty"))
12145
+ : visible.map((entry) =>
11127
12146
  picking
11128
12147
  ? h(
11129
12148
  "article",
@@ -11179,6 +12198,10 @@ window.__ModuleLoader__.load({
11179
12198
  h(
11180
12199
  "div",
11181
12200
  { style: applyStyles.rowHead },
12201
+ // The pin comes FIRST, on the title line: it is a state of the entry rather than an
12202
+ // action on it, and the list no longer re-sorts when it changes, so this is where
12203
+ // the state has to be readable.
12204
+ h(IconButton, { title: entry.pinned ? t("unpin") : t("pin"), pressed: entry.pinned, onClick: () => store.togglePinned(entry.id) }, Icons.pin(13)),
11182
12205
  h("button", {
11183
12206
  type: "button",
11184
12207
  style: applyStyles.termButton,
@@ -11195,8 +12218,16 @@ window.__ModuleLoader__.load({
11195
12218
  : null,
11196
12219
  entry.untrusted === true ? h("span", { style: applyStyles.badgeWarn, "data-term-badge": "untrusted" }, t("flaggedUntrusted")) : null,
11197
12220
  h("span", { style: applyStyles.spacer }),
11198
- entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null,
11199
- h(IconButton, { title: entry.pinned ? t("unpin") : t("pin"), pressed: entry.pinned, onClick: () => store.togglePinned(entry.id) }, Icons.pin(13)),
12221
+ entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null
12222
+ ),
12223
+ // Every action on its own line, and always in the same place.
12224
+ //
12225
+ // The title line used to carry them, so their position depended on how long the term
12226
+ // was, which badges it had and whether it had a domain — the controls wandered from
12227
+ // row to row. A toolbar that is always the second line does not.
12228
+ h(
12229
+ "div",
12230
+ { style: applyStyles.rowActions, "data-term-actions": entry.key },
11200
12231
  h(IconButton, { title: t("copyEntry"), onClick: () => void copyEntry(entry) }, Icons.copy(13)),
11201
12232
  h(IconButton, { title: t("feedbackTitle"), pressed: entry.feedback !== null && entry.feedback !== undefined, onClick: () => startEdit(entry) }, Icons.flag(13)),
11202
12233
  h(IconButton, { title: t("edit"), onClick: () => startEdit(entry) }, Icons.edit(13)),
@@ -11215,6 +12246,7 @@ window.__ModuleLoader__.load({
11215
12246
  )
11216
12247
  )
11217
12248
  )
12249
+ ]
11218
12250
  ),
11219
12251
  storageNote(view.hostInfo, t),
11220
12252
  h(