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/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,12 @@ 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
+ groupPlaceholder: "留空 = 顶层;用 / 分层,例如 backend/net",
557
588
  termPlaceholder: "例如 event sourcing",
558
589
  glossPlaceholder: "用一两句话说明它的含义",
559
590
  usagePlaceholder: "可选:一句例子或典型用法",
@@ -639,6 +670,16 @@ window.__ModuleLoader__.load({
639
670
  explainLang_en: "English",
640
671
  explainDepthName: "Explanation depth",
641
672
  explainDepthHint: "How much the explanation says",
673
+ entryColumnsName: "Entry layout",
674
+ 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.",
675
+ entryColumns_auto: "Auto",
676
+ entryColumns_1: "1 column",
677
+ entryColumns_2: "2 columns",
678
+ entryColumns_3: "3 columns",
679
+ entryRowsName: "Row height",
680
+ 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.",
681
+ entryRows_uniform: "Uniform",
682
+ entryRows_compact: "Compact",
642
683
  explainDepth_brief: "One line",
643
684
  explainDepth_normal: "Normal",
644
685
  explainDepth_detailed: "Detailed",
@@ -686,6 +727,7 @@ window.__ModuleLoader__.load({
686
727
  packSourcesTitle: "Sources",
687
728
  packSourcesHint: "A source is somebody's static index.json. Only https is accepted (http, file and URLs carrying credentials are refused).",
688
729
  packNoSources: "No sources yet",
730
+ packBuiltInSource: "built in",
689
731
  packRemoveSource: "Remove",
690
732
  packAddSourceLabel: "Add a source",
691
733
  packAddSourcePlaceholder: "https://cdn.jsdelivr.net/gh/…/index.json",
@@ -741,6 +783,20 @@ window.__ModuleLoader__.load({
741
783
  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
784
  transferIncludeDeleted: "Import entries I deleted",
743
785
  transferIncludeDeletedHint: "When ticked, entries in the file that you had deleted are imported and retracted from the deleted list. Unticked, they stay there.",
786
+ transferConfirmTitle: "What “{name}” holds",
787
+ transferWillImport: "{count} entries will be imported",
788
+ transferWillSkipExisting: "{count} already here (skipped)",
789
+ transferWillSkipDeleted: "{count} you deleted (skipped by default)",
790
+ transferConfirm: "Import them",
791
+ transferShown: "Moved to All and cleared the search, so what was imported is in front of you.",
792
+ transferFolderTitle: "Import a folder",
793
+ 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.",
794
+ transferFolderAsGroup: "Build groups from the folders (the folder name is the group)",
795
+ transferFolderLabel: "{count} files in the folder",
796
+ transferFolderFound: "{files} .json files in the folder, {used} of them usable",
797
+ transferFolderSkipped: "{count} skipped:",
798
+ transferFolderAllSkipped: "none of the {count} .json files in that folder could be read",
799
+ transferNoJson: "that folder holds no .json files",
744
800
  transferReport: "Imported {imported}; skipped {existing} already here, {deleted} you deleted, {duplicates} duplicate or unusable.",
745
801
  transferFailed: "That did not work: {message}",
746
802
  settingsBack: "Back to the entries",
@@ -825,6 +881,12 @@ window.__ModuleLoader__.load({
825
881
  usageLabel: "Usage",
826
882
  notesLabel: "Notes",
827
883
  domainLabel: "Domain",
884
+ groupLabel: "Group",
885
+ groupOpen: "Open this group",
886
+ groupGo: "Go back to {name}",
887
+ groupCount: "{count} entries",
888
+ groupEmpty: "This level holds only groups so far.",
889
+ groupPlaceholder: "empty = top level; use / to nest, e.g. backend/net",
828
890
  termPlaceholder: "e.g. event sourcing",
829
891
  glossPlaceholder: "Say what it means in one or two sentences",
830
892
  usagePlaceholder: "Optional: an example or typical use",
@@ -934,6 +996,15 @@ window.__ModuleLoader__.load({
934
996
  /** Upper bound on a feedback note, which is a remark rather than an explanation. */
935
997
  const MAX_NOTE_CHARS = 200;
936
998
 
999
+ /**
1000
+ * Upper bound on an entry's group path.
1001
+ *
1002
+ * A path rather than a name: a group can hold groups, because that is what a directory does and what a
1003
+ * folder import produces. One string covers any depth, which is why nothing else had to be added to the
1004
+ * document to get nesting.
1005
+ */
1006
+ const MAX_GROUP_CHARS = 120;
1007
+
937
1008
  /**
938
1009
  * What a user can say is wrong with an entry.
939
1010
  *
@@ -1023,6 +1094,25 @@ window.__ModuleLoader__.load({
1023
1094
  notes: clampText(definition.notes, MAX_DEFINITION_CHARS)
1024
1095
  },
1025
1096
  domain: clampText(value.domain, 24),
1097
+ /**
1098
+ * The group (folder) this entry lives in: `""` for the top level, or a `/`-separated path.
1099
+ *
1100
+ * A CONTAINER, not a topic: `domain` is a semantic label the reader assigns, while this is where
1101
+ * the entry was put. It needs its own stamp (§ `groupAt`) because a move is a decision — the first
1102
+ * attempt let it ride on the content merge, and a merge of one entry from two windows then wiped
1103
+ * the group order-dependently.
1104
+ */
1105
+ group: clampText(value.group, MAX_GROUP_CHARS),
1106
+ // When the group was last decided, epoch milliseconds, 0 for "never moved".
1107
+ //
1108
+ // The third use of this pattern, and for the reason the other two exist: a decision the user can
1109
+ // make and unmake cannot be carried by content-merging rules.
1110
+ // When the user restored this term, epoch milliseconds, 0 for never.
1111
+ //
1112
+ // The named evidence a merge weighs against a deletion, so a restoration does not have to win a
1113
+ // contest over a clock — see `restoreEntry`.
1114
+ restoredAt: typeof value.restoredAt === "number" && Number.isFinite(value.restoredAt) && value.restoredAt > 0 ? Math.floor(value.restoredAt) : 0,
1115
+ groupAt: typeof value.groupAt === "number" && Number.isFinite(value.groupAt) && value.groupAt > 0 ? Math.floor(value.groupAt) : 0,
1026
1116
  source: SOURCES.includes(value.source) ? value.source : "auto",
1027
1117
  confidence: typeof value.confidence === "number" && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : 0.5,
1028
1118
  createdAt: typeof value.createdAt === "number" && value.createdAt > 0 ? value.createdAt : now,
@@ -1204,6 +1294,12 @@ window.__ModuleLoader__.load({
1204
1294
  aliases: [...new Set([...current.aliases, ...incoming.aliases])].slice(0, 8),
1205
1295
  definition: takeDefinition ? incoming.definition : current.definition,
1206
1296
  domain: takeDefinition && incoming.domain !== "" ? incoming.domain : current.domain,
1297
+ // Unlike the domain, an empty incoming group is taken when the content changed: moving an entry back
1298
+ // to the top level is a move, and the old rule ("an empty value never overwrites") would make the
1299
+ // top level unreachable for anything that had ever been filed.
1300
+ group: (incoming.groupAt ?? 0) > (current.groupAt ?? 0) ? incoming.group : current.group,
1301
+ groupAt: Math.max(incoming.groupAt ?? 0, current.groupAt ?? 0),
1302
+ restoredAt: Math.max(incoming.restoredAt ?? 0, current.restoredAt ?? 0),
1207
1303
  // The STRONGEST provenance survives, not the arriving one. Taking the arriving
1208
1304
  // source let a later automatic copy of an already-curated term downgrade it to
1209
1305
  // `auto`, which then denied that term its own revival — and let its source be
@@ -1307,6 +1403,7 @@ window.__ModuleLoader__.load({
1307
1403
  MAX_DEFINITION_CHARS,
1308
1404
  MAX_CONTEXT_CHARS,
1309
1405
  MAX_NOTE_CHARS,
1406
+ MAX_GROUP_CHARS,
1310
1407
  normalizeEntry,
1311
1408
  normalizeFeedback,
1312
1409
  entryAsText,
@@ -1610,6 +1707,9 @@ window.__ModuleLoader__.load({
1610
1707
  //
1611
1708
  // So the comparison uses the newest time a record the USER asked for carries, and
1612
1709
  // being strictly newer is what revives. Time alone is not intent; provenance is.
1710
+ // A NAMED restoration first: `restoreEntry` records that the user asked for this term back, which
1711
+ // is not an inference from a clock. The comparison below stays for the paths that revive by editing.
1712
+ if ((record.restoredAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
1613
1713
  if ((options?.craftedAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
1614
1714
  const alreadyDeleted = record.deletedAt ?? 0;
1615
1715
  return { ...record, deletedAt: Math.max(alreadyDeleted, notice.deletedAt) };
@@ -1892,6 +1992,10 @@ window.__ModuleLoader__.load({
1892
1992
  const feedbackStamp = mentionsFeedback ? Math.max(stamp, (base.feedbackAt ?? 0) + 1) : base.feedbackAt ?? 0;
1893
1993
  const untrustedPatch = typeof patch?.untrusted === "boolean" ? patch.untrusted : null;
1894
1994
  const untrustedStamp = untrustedPatch === null ? base.untrustedAt ?? 0 : Math.max(stamp, (base.untrustedAt ?? 0) + 1);
1995
+ // A move is a decision like a pin or a remark, and it is stamped strictly past the last one so that two
1996
+ // moves inside the same millisecond still have an order.
1997
+ const mentionsGroup = patch !== null && patch !== undefined && Object.prototype.hasOwnProperty.call(patch, "group");
1998
+ const groupStamp = mentionsGroup ? Math.max(stamp, (base.groupAt ?? 0) + 1) : base.groupAt ?? 0;
1895
1999
  const candidate = normalizeEntry(
1896
2000
  {
1897
2001
  ...base,
@@ -1900,6 +2004,11 @@ window.__ModuleLoader__.load({
1900
2004
  deletedAt: 0,
1901
2005
  aliases: patch?.aliases ?? base.aliases,
1902
2006
  domain: patch?.domain ?? base.domain,
2007
+ // `patch.group` may legitimately be `""` — that is how an entry is moved back to the top level —
2008
+ // so this reads with `hasOwnProperty` rather than with `??`: a patch that says nothing about the
2009
+ // group must leave it, and its stamp, exactly where they were.
2010
+ group: mentionsGroup ? patch.group : base.group,
2011
+ groupAt: groupStamp,
1903
2012
  definition: { ...base.definition, ...(patch?.definition ?? {}) },
1904
2013
  source: "user",
1905
2014
  confidence: 1,
@@ -1917,6 +2026,60 @@ window.__ModuleLoader__.load({
1917
2026
  return upsertEntry(state, { ...candidate, source: "user", deletedAt: 0 }, { now: stamp });
1918
2027
  }
1919
2028
 
2029
+ /**
2030
+ * Bring a deleted term back, as an explicit act rather than as a side effect of an edit.
2031
+ *
2032
+ * This exists because "restore this deleted term" is a decision, and until now it was expressed as "write
2033
+ * an edit and hope the merge weighs it right": `editEntry` produced a crafted timestamp, and a merge
2034
+ * revived the record when that timestamp beat the tombstone's. That works — there is a check for it — but
2035
+ * it means the import path, the editor, a sighting and a second window all revive through one inferred
2036
+ * comparison, and a term the user had collected and deleted came back as "nothing happened".
2037
+ *
2038
+ * So a restoration is its own thing:
2039
+ *
2040
+ * - `restoredAt` is the evidence, weighed against `notice.deletedAt` like `pinnedAt` is weighed against
2041
+ * silence. It is what the merge reads, and it exists even when the tombstone's clock ran ahead;
2042
+ * - the tombstone is WITHDRAWN from the document this returns — the key leaves `deletedKeys` and the
2043
+ * record leaves `backing` — so a reader of this document is not told the term is deleted at all. Being
2044
+ * merely outvoted is what let a later merge put the deletion back;
2045
+ * - the text is kept: restoring is not the same as rewriting, so the patch is applied on top of whatever
2046
+ * the tombstone still remembers.
2047
+ *
2048
+ * @param state - the current document.
2049
+ * @param idOrTerm - the entry id, term or alias.
2050
+ * @param patch - `definition`, `domain`, `group`, `aliases`, `pinned`.
2051
+ * @param options - `now` overrides the restoration time.
2052
+ * @returns `{ state, entry, restored }`; `restored` is false when the term was not deleted.
2053
+ */
2054
+ function restoreEntry(state, idOrTerm, patch, options) {
2055
+ const found = findEntryIncludingDeleted(state, idOrTerm);
2056
+ if (found === undefined) {
2057
+ // Nothing to restore: the caller wanted a new entry, and `editEntry` is the path for that.
2058
+ const created = editEntry(state, typeof idOrTerm === "string" ? idOrTerm : "", patch, options);
2059
+ return { ...created, restored: false };
2060
+ }
2061
+ if ((found.deletedAt ?? 0) === 0) {
2062
+ // Already live: a restoration must not disturb it, and saying "restored" would be a lie the caller
2063
+ // might act on (the import reports it).
2064
+ return { state, entry: found, restored: false };
2065
+ }
2066
+ // The stamp is strictly past the deletion for the same reason `editEntry`'s is: a clock that ran ahead
2067
+ // must not let the tombstone win a comparison it lost the argument for.
2068
+ const now = options?.now ?? Date.now();
2069
+ const stamp = Math.max(now, (found.deletedAt ?? 0) + 1);
2070
+ const edited = editEntry(state, found.term, { ...patch, definition: { ...found.definition, ...(patch?.definition ?? {}) } }, { now: stamp });
2071
+ const key = found.key;
2072
+ // The withdrawal, in the document itself: the record is rewritten live and the whole thing is re-sealed,
2073
+ // so `deletedKeys` and `backing` are derived fresh and no longer announce the deletion. (An earlier
2074
+ // version also filtered them by hand first, which the re-seal made redundant — a mutation proved it by
2075
+ // not biting, so the dead work is gone.)
2076
+ const records = recordsOf(edited.state).map((entry) =>
2077
+ entry.id === found.id || entry.key === key ? { ...entry, deletedAt: 0, restoredAt: stamp, source: "user", updatedAt: stamp, lastSeenAt: stamp } : entry
2078
+ );
2079
+ const next = sealRecords(records, { now: stamp });
2080
+ return { state: next, entry: next.entries.find((entry) => entry.key === key) ?? edited.entry, restored: true };
2081
+ }
2082
+
1920
2083
  /**
1921
2084
  * Record one sighting of a term without replacing its meaning: the counters move
1922
2085
  * and a missing definition is filled from the glossary, but a curated definition
@@ -2226,16 +2389,51 @@ window.__ModuleLoader__.load({
2226
2389
  }
2227
2390
 
2228
2391
  /**
2229
- * The panel's visible list: filter by query, then order pinned first, then most
2230
- * recently seen.
2392
+ * The groups immediately inside one path, with how many entries each holds.
2393
+ *
2394
+ * Only the immediate children: the list shows one level at a time, and a count covers everything under
2395
+ * that child, however deep. A group exists as long as something is in it — there is no separate list of
2396
+ * empty groups to keep in step with the entries, which is the trade this makes deliberately.
2397
+ *
2398
+ * @param entries - every live entry.
2399
+ * @param prefix - the path to look inside, `""` for the top level.
2400
+ * @returns `[{ group, name, count }]`, alphabetical.
2401
+ */
2402
+ function groupsIn(entries, prefix) {
2403
+ const base = prefix === undefined || prefix === null ? "" : String(prefix);
2404
+ const counts = new Map();
2405
+ for (const entry of Array.isArray(entries) ? entries : []) {
2406
+ if ((entry?.deletedAt ?? 0) !== 0) continue;
2407
+ const group = typeof entry?.group === "string" ? entry.group : "";
2408
+ if (group === "" || group === base) continue;
2409
+ let inside = null;
2410
+ if (base === "") inside = group;
2411
+ else if (group.startsWith(`${base}/`)) inside = group.slice(base.length + 1);
2412
+ if (inside === null || inside === "") continue;
2413
+ const name = inside.split("/")[0];
2414
+ counts.set(name, (counts.get(name) ?? 0) + 1);
2415
+ }
2416
+ return [...counts.entries()]
2417
+ .map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count }))
2418
+ .sort((left, right) => left.name.localeCompare(right.name));
2419
+ }
2420
+
2421
+ /**
2422
+ * The panel's visible list: one level of the group tree, filtered by query, then ordered by when each
2423
+ * term was last seen.
2231
2424
  * @param state - the document.
2232
2425
  * @param query - search text.
2233
2426
  * @param filter - `all`, `unexplained`, `pinned` or `flagged`.
2427
+ * @param group - the group path to look inside, `""` for the top level.
2234
2428
  * @returns the ordered entries.
2235
2429
  */
2236
- function listEntries(state, query, filter) {
2430
+ function listEntries(state, query, filter, group) {
2431
+ const base = group === undefined || group === null ? "" : String(group);
2237
2432
  const matched = state.entries.filter((entry) => {
2238
2433
  if (entry.deletedAt !== 0) return false;
2434
+ // The list shows the level you are standing in, not everything below it: a group is a directory,
2435
+ // and what is inside it is what you see after entering it.
2436
+ if ((typeof entry.group === "string" ? entry.group : "") !== base) return false;
2239
2437
  if (!matchesQuery(entry, query)) return false;
2240
2438
  if (filter === "unexplained") return isUnexplained(entry);
2241
2439
  if (filter === "pinned") return entry.pinned;
@@ -2245,13 +2443,14 @@ window.__ModuleLoader__.load({
2245
2443
  if (filter === "flagged") return isFlagged(entry);
2246
2444
  return true;
2247
2445
  });
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
- );
2446
+ // Newest term first, and NOTHING ELSE.
2447
+ //
2448
+ // It used to sort pinned first and unexplained first, and it then sorted by when each term was last
2449
+ // SEEN — every one of which moves a row under the reader's hand: pin a term and it jumped to the top,
2450
+ // answer a prompt and it jumped, and the transcript merely mentioning a term again shuffled the list.
2451
+ // Creation time is the one thing no action changes, so the list is still while the reader reads it.
2452
+ // The pin is shown on the row itself and 「已钉选」 gathers the pinned ones, so ordering has no job left.
2453
+ return matched.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
2255
2454
  }
2256
2455
 
2257
2456
  /**
@@ -2393,6 +2592,7 @@ window.__ModuleLoader__.load({
2393
2592
  tombstonesOf,
2394
2593
  upsertEntry,
2395
2594
  editEntry,
2595
+ restoreEntry,
2396
2596
  recordSighting,
2397
2597
  removeEntry,
2398
2598
  reviveEntry,
@@ -2402,6 +2602,7 @@ window.__ModuleLoader__.load({
2402
2602
  mergeState,
2403
2603
  summarize,
2404
2604
  listEntries,
2605
+ groupsIn,
2405
2606
  listDeleted,
2406
2607
  createFileStore,
2407
2608
  createBrowserStore
@@ -4295,6 +4496,21 @@ window.__ModuleLoader__.load({
4295
4496
  return result.entry;
4296
4497
  },
4297
4498
 
4499
+ /**
4500
+ * Bring a deleted term back, as an explicit act.
4501
+ *
4502
+ * See `restoreEntry` in the core: a restoration is a decision with its own evidence and it
4503
+ * withdraws the tombstone, rather than an edit that has to win a comparison on the way to the host.
4504
+ * @param idOrTerm - the entry id, term or alias.
4505
+ * @param patch - the text to restore it with, if any.
4506
+ * @returns a promise resolving to `{ entry, restored }`.
4507
+ */
4508
+ async restoreEntry(idOrTerm, patch) {
4509
+ const result = dictionary.restoreEntry(state, idOrTerm, patch, { now: now() });
4510
+ if (result.restored) await commit(result.state);
4511
+ return { entry: result.entry, restored: result.restored };
4512
+ },
4513
+
4298
4514
  /**
4299
4515
  * Record that a term appeared in a message, without overwriting a definition
4300
4516
  * the user wrote.
@@ -4421,17 +4637,29 @@ window.__ModuleLoader__.load({
4421
4637
  return this.saveEntry(found.term, patch);
4422
4638
  },
4423
4639
 
4640
+ /**
4641
+ * The groups directly inside one path, each with how many entries it holds.
4642
+ * @param prefix - the path to look inside, `""` for the top level.
4643
+ * @returns `[{ group, name, count }]`.
4644
+ */
4645
+ groups(prefix) {
4646
+ return dictionary.groupsIn(state.entries, prefix);
4647
+ },
4648
+
4424
4649
  /**
4425
4650
  * The visible list for the panel.
4426
4651
  * @param query - search text.
4427
4652
  * @param filter - `all`, `unexplained`, `pinned`, `deleted` or `flagged`.
4653
+ * @param group - the group path to look inside, `""` for the top level.
4428
4654
  * @returns the ordered entries. For `deleted`, the tombstones.
4429
4655
  */
4430
- list(query, filter) {
4656
+ list(query, filter, group) {
4657
+ const at = group === undefined || group === null ? "" : String(group);
4431
4658
  // 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);
4659
+ // `backing`, not in `entries`, so no predicate over the live list could ever show one. The group
4660
+ // still applies — standing in a folder and asking what was deleted there means that folder.
4661
+ if (filter === "deleted") return dictionary.listDeleted(state, query).filter((entry) => (typeof entry.group === "string" ? entry.group : "") === at);
4662
+ return listEntries(state, query, filter, at);
4435
4663
  },
4436
4664
 
4437
4665
  /**
@@ -4491,12 +4719,34 @@ window.__ModuleLoader__.load({
4491
4719
  /** The most sources the list will hold. A UI bound, not a security one. */
4492
4720
  const MAX_SOURCES = 12;
4493
4721
 
4722
+ /**
4723
+ * The source a fresh install starts with: this plugin's own repository, as a static file.
4724
+ *
4725
+ * Why ship one at all: without it the packs page opens on an empty list, and a first-time reader has
4726
+ * to be TOLD a URL before they can see what a term pack even is. The alternative — fetching from a
4727
+ * server we run — is the thing this design refuses to have, so the default is an ordinary source like
4728
+ * any other: visible in the list, removable in one press, and fetched only when the reader asks (the
4729
+ * page refreshes it on first open, which is a request to the source THEY have configured).
4730
+ *
4731
+ * `@main` rather than a tag because a default should follow the packs the repository actually has:
4732
+ * pinned to a tag, a new pack would need a plugin release before anyone could see it.
4733
+ */
4734
+ const DEFAULT_PACK_SOURCE = "https://cdn.jsdelivr.net/gh/lakerian/dsh-plugin-term-dictionary@main/packs/index.json";
4735
+
4494
4736
  /**
4495
4737
  * The URL rule, imported rather than restated: the settings store, the page and the host's transport
4496
4738
  * must agree on what a usable source is, and two copies of that rule is how one of them drifts.
4497
4739
  */
4498
4740
  const { refuseUrl } = require("./pack.js");
4499
4741
 
4742
+ const ENTRY_COLUMNS = ["auto", "1", "2", "3"];
4743
+
4744
+ /** The two row-height readings: equal cards, or each card as tall as its own text. */
4745
+ const ENTRY_ROWS = ["uniform", "compact"];
4746
+
4747
+ /** The narrowest a column may be when the layout is choosing. Below this a gloss wraps every other word. */
4748
+ const MIN_COLUMN_PX = 260;
4749
+
4500
4750
  /**
4501
4751
  * The three switches and their defaults.
4502
4752
  *
@@ -4548,6 +4798,23 @@ window.__ModuleLoader__.load({
4548
4798
  * the pointer had gone. Anyone who wants a grace period may have one; nobody gets it by accident.
4549
4799
  */
4550
4800
  hoverOutMs: 0,
4801
+ /**
4802
+ * How many columns the entry list is laid out in.
4803
+ *
4804
+ * `auto` fits as many as the panel is wide enough for, and is the default: a dictionary row is
4805
+ * short, a single column of them is a very long strip to read, and a narrow panel simply gets one
4806
+ * column back. The fixed values exist for a reader who wants the layout to stop moving under them.
4807
+ */
4808
+ entryColumns: "auto",
4809
+ /**
4810
+ * How tall a row of the entry list is.
4811
+ *
4812
+ * `uniform` gives every card in a grid row the height of the tallest, so the cards line up and no
4813
+ * empty strip is left under the short ones. `compact` lets each keep its own height, which reads
4814
+ * tighter in a single column and leaves exactly those strips in a grid. Both are defensible, which
4815
+ * is why it is a preference rather than a constant.
4816
+ */
4817
+ entryRows: "uniform",
4551
4818
  /**
4552
4819
  * The term-pack sources the user added: https URLs of static `index.json` files.
4553
4820
  *
@@ -4556,8 +4823,13 @@ window.__ModuleLoader__.load({
4556
4823
  * is not configuration. Validated by the same rule the host applies before it fetches anything
4557
4824
  * ({@link module:core/pack.refuseUrl}), so the page and the transport cannot disagree about what a
4558
4825
  * usable source is.
4826
+ *
4827
+ * The default is not empty: see {@link DEFAULT_PACK_SOURCE}. An ABSENT key means "never touched, use
4828
+ * the default"; an empty array means "the reader removed them all", which is honoured (§
4829
+ * `normalizeSettings`) — the two are different statements and a store that conflated them would
4830
+ * resurrect the default every time somebody cleared the list.
4559
4831
  */
4560
- packSources: []
4832
+ packSources: [DEFAULT_PACK_SOURCE]
4561
4833
  };
4562
4834
 
4563
4835
  /** The accepted minimum term lengths, for the panel's cycling control. */
@@ -4663,7 +4935,13 @@ window.__ModuleLoader__.load({
4663
4935
  collectCjk: readFlag(value.collectCjk, DEFAULT_SETTINGS.collectCjk),
4664
4936
  hoverInMs: readDelay(value.hoverInMs, DEFAULT_SETTINGS.hoverInMs),
4665
4937
  hoverOutMs: readDelay(value.hoverOutMs, DEFAULT_SETTINGS.hoverOutMs),
4666
- packSources: readSources(value.packSources)
4938
+ entryColumns: readChoice(value.entryColumns, ENTRY_COLUMNS, DEFAULT_SETTINGS.entryColumns),
4939
+ entryRows: readChoice(value.entryRows, ENTRY_ROWS, DEFAULT_SETTINGS.entryRows),
4940
+ // Absent means "never touched" and gets the shipped default; an empty array means the reader
4941
+ // removed every source, and is kept as it is. A fresh array either way: a snapshot is compared by
4942
+ // identity, so handing back the shared default array would make one page's edit appear in
4943
+ // another page's defaults.
4944
+ packSources: value.packSources === undefined ? [...DEFAULT_SETTINGS.packSources] : readSources(value.packSources)
4667
4945
  };
4668
4946
  }
4669
4947
 
@@ -4771,6 +5049,10 @@ window.__ModuleLoader__.load({
4771
5049
  DEFAULT_SETTINGS,
4772
5050
  normalizeSettings,
4773
5051
  MAX_SOURCES,
5052
+ DEFAULT_PACK_SOURCE,
5053
+ ENTRY_COLUMNS,
5054
+ ENTRY_ROWS,
5055
+ MIN_COLUMN_PX,
4774
5056
  EXPLAIN_LANGS,
4775
5057
  EXPLAIN_DEPTHS,
4776
5058
  COLLECT_LENGTHS,
@@ -4854,6 +5136,9 @@ window.__ModuleLoader__.load({
4854
5136
  }
4855
5137
  };
4856
5138
  if (typeof entry?.domain === "string" && entry.domain !== "") record.domain = entry.domain;
5139
+ // A group travels through an export, so a backup restores the structure it was filed under; it does NOT
5140
+ // travel in a pack, where the reader organizes entries their own way (see `pack.js`'s allowlist).
5141
+ if (typeof entry?.group === "string" && entry.group !== "") record.group = entry.group;
4857
5142
  if (Array.isArray(entry?.aliases) && entry.aliases.length > 0) record.aliases = [...entry.aliases];
4858
5143
  if (entry?.pinned === true) record.pinned = true;
4859
5144
  if (typeof entry?.source === "string" && entry.source !== "") record.source = entry.source;
@@ -4935,6 +5220,9 @@ window.__ModuleLoader__.load({
4935
5220
  usage: typeof definition.usage === "string" ? definition.usage : ""
4936
5221
  },
4937
5222
  domain: typeof raw.domain === "string" ? raw.domain.trim() : "",
5223
+ // A group is read back from a file like any other label the user assigned — that is what makes an
5224
+ // export a backup of the STRUCTURE and not only of the words.
5225
+ group: typeof raw.group === "string" ? raw.group.trim() : "",
4938
5226
  aliases: Array.isArray(raw.aliases) ? raw.aliases.filter((alias) => typeof alias === "string" && alias.trim() !== "") : [],
4939
5227
  pinned: raw.pinned === true
4940
5228
  };
@@ -4975,6 +5263,64 @@ window.__ModuleLoader__.load({
4975
5263
  return { ok: true, entries, skipped };
4976
5264
  }
4977
5265
 
5266
+ /**
5267
+ * The folder path a file sits in, as a group path.
5268
+ *
5269
+ * The picked folder's own name IS the first group: choosing a folder called `packs` is how you say "file
5270
+ * these under `packs`", and dropping the name would put everything at the top level — which is precisely
5271
+ * the structure the user just picked a folder to express.
5272
+ *
5273
+ * @param path - a `relative/path/file.json`, as `webkitRelativePath` reports it.
5274
+ * @returns the group path.
5275
+ */
5276
+ function folderPathOf(path) {
5277
+ const parts = String(path)
5278
+ .split("/")
5279
+ .filter((part) => part !== "");
5280
+ // Everything but the file name.
5281
+ return parts.slice(0, -1).join("/");
5282
+ }
5283
+
5284
+ /**
5285
+ * Read a whole directory into one import.
5286
+ *
5287
+ * A folder is a mixed bag: several exported dictionaries, somebody's downloaded pack files, an
5288
+ * `index.json` that lists packs rather than holding entries, and often a README or a screenshot. So this
5289
+ * reports what it could NOT use instead of refusing the folder — the alternative is a user with twenty
5290
+ * files and one bad one, told "no" with no idea which file it was. Files that are not JSON are not
5291
+ * reported at all: they are not mistakes.
5292
+ *
5293
+ * @param files - `[{ path, text }]`, the directory's files as the browser hands them over.
5294
+ * @param options - `useFolderAsGroup` files each entry under the folder it came from, which is the shape
5295
+ * people actually keep: one folder per topic, and the reason the option exists at all.
5296
+ * @returns `{ entries, files, used, skipped }`, where `skipped` is `[{ name, error }]`.
5297
+ */
5298
+ function collectFolderImport(files, options) {
5299
+ const list = Array.isArray(files) ? files : [];
5300
+ const entries = [];
5301
+ const skipped = [];
5302
+ const useFolder = options?.useFolderAsGroup === true;
5303
+ let used = 0;
5304
+ let jsonFiles = 0;
5305
+ for (const file of list) {
5306
+ const path = typeof file?.path === "string" ? file.path : "";
5307
+ if (!/\.json$/i.test(path)) continue;
5308
+ jsonFiles++;
5309
+ const parsed = parseImport(typeof file?.text === "string" ? file.text : "");
5310
+ if (parsed.ok !== true) {
5311
+ skipped.push({ name: path, error: parsed.error });
5312
+ continue;
5313
+ }
5314
+ used++;
5315
+ const folder = useFolder ? folderPathOf(path) : "";
5316
+ for (const entry of parsed.entries) {
5317
+ const hasGroup = typeof entry.group === "string" && entry.group !== "";
5318
+ entries.push(folder !== "" && !hasGroup ? { ...entry, group: folder } : entry);
5319
+ }
5320
+ }
5321
+ return { entries, files: jsonFiles, used, skipped };
5322
+ }
5323
+
4978
5324
  /**
4979
5325
  * Decide what an import would do, without doing any of it.
4980
5326
  *
@@ -5020,10 +5366,15 @@ window.__ModuleLoader__.load({
5020
5366
  // Opting in does not empty the `deleted` bucket by accident: it moves those entries into
5021
5367
  // `accepted`, and the bucket is then genuinely empty, so the report says "0 you deleted" and
5022
5368
  // means it.
5369
+ //
5370
+ // `revived` names the accepted entries that were previously DELETED, separately from the ones that are
5371
+ // simply new. The caller must not treat the two the same: a new term is a write, while a deleted term is
5372
+ // a RESTORATION — a decision that has to beat the tombstone, not an edit that hopes to. Running both
5373
+ // through the same write is how "a term that was collected and then deleted cannot be restored" happened.
5023
5374
  const retract = options?.includeDeleted === true;
5024
5375
  return retract
5025
- ? { accepted: [...accepted, ...wasDeleted], existing: alreadyHave, deleted: [], duplicates }
5026
- : { accepted, existing: alreadyHave, deleted: wasDeleted, duplicates };
5376
+ ? { accepted: [...accepted, ...wasDeleted], revived: wasDeleted, existing: alreadyHave, deleted: [], duplicates }
5377
+ : { accepted, revived: [], existing: alreadyHave, deleted: wasDeleted, duplicates };
5027
5378
  }
5028
5379
 
5029
5380
  /**
@@ -5085,6 +5436,7 @@ window.__ModuleLoader__.load({
5085
5436
  buildExport,
5086
5437
  buildFeedbackReport,
5087
5438
  parseImport,
5439
+ collectFolderImport,
5088
5440
  planImport,
5089
5441
  domainsIn,
5090
5442
  toRecord,
@@ -5260,28 +5612,41 @@ window.__ModuleLoader__.load({
5260
5612
  }
5261
5613
 
5262
5614
  /**
5263
- * Read a pack from whatever arrived: a fetched file, or a decoded share code.
5615
+ * Read a pack from whatever arrived: a fetched file, a decoded share code, or the text of either.
5264
5616
  *
5265
5617
  * Tolerant about unknown keys in the envelope, strict about the two things that make it a pack: its
5266
5618
  * marker and at least one usable entry. Every record goes through the same validator an imported file
5267
5619
  * does, so a record that cannot be imported is a record this refuses.
5268
5620
  *
5269
- * @param raw - the parsed value.
5621
+ * Accepts TEXT as well as a parsed object, because text is what actually arrives: the host fetches the
5622
+ * file and passes the body on. Accepting only an object made every source and every pack answer
5623
+ * `not-an-object` — a feature that refuses everything, in the shape of a bad URL.
5624
+ *
5625
+ * @param raw - the fetched body, the decoded text, or the parsed value.
5270
5626
  * @returns `{ ok: true, pack }`, or `{ ok: false, error }`.
5271
5627
  */
5272
5628
  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" };
5629
+ let value = raw;
5630
+ if (typeof raw === "string") {
5631
+ try {
5632
+ value = JSON.parse(raw);
5633
+ } catch (error) {
5634
+ return { ok: false, error: "not-json" };
5635
+ }
5636
+ }
5637
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "not-an-object" };
5638
+ const raw2 = value;
5639
+ if (raw2.kind !== PACK_KIND) return { ok: false, error: "not-a-pack" };
5640
+ if (raw2.version !== PACK_VERSION) return { ok: false, error: "unsupported-version" };
5641
+ if (!Array.isArray(raw2.entries)) return { ok: false, error: "no-entries" };
5642
+ if (raw2.entries.length > MAX_PACK_ENTRIES) return { ok: false, error: "too-many" };
5278
5643
  const entries = [];
5279
- for (const record of raw.entries) {
5644
+ for (const record of raw2.entries) {
5280
5645
  const entry = transfer.toEntry(record);
5281
5646
  if (entry !== null) entries.push(record);
5282
5647
  }
5283
5648
  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";
5649
+ const scope = raw2.scope !== null && typeof raw2.scope === "object" && raw2.scope.kind === "domains" ? "domains" : "all";
5285
5650
  return {
5286
5651
  ok: true,
5287
5652
  pack: {
@@ -5380,17 +5745,31 @@ window.__ModuleLoader__.load({
5380
5745
  * in somebody's index must not hide the other forty packs. An index with nothing usable left is a
5381
5746
  * refusal, because a source that offers nothing is a source that is misconfigured or moved.
5382
5747
  *
5383
- * @param raw - the parsed value.
5748
+ * Takes TEXT or a parsed object, like {@link parsePack} — and the text form is the one production uses:
5749
+ * the host fetches the source and hands the body straight over. Requiring an object here while the host
5750
+ * sends text meant every source answered `not-an-object`, which is a whole feature answering "no" in a
5751
+ * way that looks like the user's URL was wrong.
5752
+ *
5753
+ * @param raw - the fetched body, or the parsed value.
5384
5754
  * @returns `{ ok: true, index, dropped }`, or `{ ok: false, error }`.
5385
5755
  */
5386
5756
  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" };
5757
+ let value = raw;
5758
+ if (typeof raw === "string") {
5759
+ try {
5760
+ value = JSON.parse(raw);
5761
+ } catch (error) {
5762
+ return { ok: false, error: "not-json" };
5763
+ }
5764
+ }
5765
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "not-an-object" };
5766
+ const raw2 = value;
5767
+ if (raw2.kind !== INDEX_KIND) return { ok: false, error: "not-an-index" };
5768
+ if (raw2.version !== INDEX_VERSION) return { ok: false, error: "unsupported-version" };
5769
+ if (!Array.isArray(raw2.packs)) return { ok: false, error: "no-packs" };
5391
5770
  const packs = [];
5392
5771
  let dropped = 0;
5393
- for (const pack of raw.packs) {
5772
+ for (const pack of raw2.packs) {
5394
5773
  if (pack === null || typeof pack !== "object") {
5395
5774
  dropped++;
5396
5775
  continue;
@@ -8787,6 +9166,23 @@ window.__ModuleLoader__.load({
8787
9166
  boxShadow: "0 0 0 1px var(--dsw-alias-brand-primary, #4d6bfe)"
8788
9167
  },
8789
9168
  rowHead: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" },
9169
+ /** Where you are, and the way back up: a directory you can only enter is a trap. */
9170
+ breadcrumb: { display: "flex", alignItems: "center", gap: "2px", flexWrap: "wrap", padding: "2px 0 6px", fontSize: "12px" },
9171
+ breadcrumbPart: { display: "inline-flex", alignItems: "center", gap: "2px" },
9172
+ breadcrumbSep: { color: "var(--dsw-alias-label-tertiary)", margin: "0 2px" },
9173
+ breadcrumbButton: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-brand-primary, #4d6bfe)", cursor: "pointer" },
9174
+ breadcrumbHere: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-label-secondary)", cursor: "default" },
9175
+ /** The group row's own bits: the mark that says "not an entry", and the chevron that says "opens". */
9176
+ groupIcon: { display: "inline-flex", alignItems: "center", color: "var(--dsw-alias-label-secondary)" },
9177
+ groupEnter: { color: "var(--dsw-alias-label-tertiary)", fontSize: "14px", lineHeight: 1 },
9178
+ /**
9179
+ * The row's action toolbar: the second line, always.
9180
+ *
9181
+ * On the title line the controls moved from row to row with the length of the term and the number of
9182
+ * badges; a line of their own puts every row's controls in the same place and keeps them out of the
9183
+ * way of the text they act on.
9184
+ */
9185
+ rowActions: { display: "flex", alignItems: "center", gap: "6px", marginTop: "2px" },
8790
9186
  termButton: {
8791
9187
  appearance: "none",
8792
9188
  background: "transparent",
@@ -9293,6 +9689,13 @@ window.__ModuleLoader__.load({
9293
9689
  { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
9294
9690
  h("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round" })
9295
9691
  ),
9692
+ // The group row's icon: the one thing on screen that is not an entry, so it says so without a label.
9693
+ folder: (size) =>
9694
+ h(
9695
+ "svg",
9696
+ { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
9697
+ 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" })
9698
+ ),
9296
9699
  pin: (size) =>
9297
9700
  h(
9298
9701
  "svg",
@@ -9366,11 +9769,11 @@ window.__ModuleLoader__.load({
9366
9769
  };
9367
9770
 
9368
9771
  /** 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 };
9772
+ 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
9773
 
9371
9774
  /** 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");
9775
+ 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");
9776
+ const { buildExport, buildFeedbackReport, parseImport, collectFolderImport, planImport, domainsIn } = require("./transfer.js");
9374
9777
  const { FEEDBACK_KINDS, isFlagged, entryAsText } = require("./entries.js");
9375
9778
  const { refuseUrl } = require("./pack.js");
9376
9779
  const { selectionInside } = require("./selection.js");
@@ -9544,13 +9947,20 @@ window.__ModuleLoader__.load({
9544
9947
  const plan = planImport(store.getState().entries ?? [], incoming, store.getState().deletedKeys ?? new Set(), {
9545
9948
  includeDeleted: options?.includeDeleted === true
9546
9949
  });
9950
+ // Two different acts, deliberately: a term the dictionary never had is a WRITE, while a term the user
9951
+ // deleted is a RESTORATION. `store.restoreEntry` withdraws the tombstone and records the decision; a
9952
+ // write would instead have to BEAT the tombstone in a merge, and it can lose that comparison — which is
9953
+ // exactly "a term that was collected and then deleted could not be restored".
9954
+ const revived = new Set((plan.revived ?? []).map((entry) => entry.key));
9547
9955
  for (const entry of plan.accepted) {
9548
- await store.saveEntry(entry.term, {
9956
+ const patch = {
9549
9957
  definition: entry.definition,
9550
9958
  domain: entry.domain,
9551
9959
  aliases: entry.aliases,
9552
9960
  pinned: entry.pinned
9553
- });
9961
+ };
9962
+ if (revived.has(entry.key)) await store.restoreEntry(entry.term, patch);
9963
+ else await store.saveEntry(entry.term, patch);
9554
9964
  }
9555
9965
  return {
9556
9966
  imported: plan.accepted.length,
@@ -9658,6 +10068,21 @@ window.__ModuleLoader__.load({
9658
10068
  * choice this page offers.
9659
10069
  */
9660
10070
  const [includeGloss, setIncludeGloss] = React.useState(false);
10071
+ /**
10072
+ * A file that has been read but not imported yet: `{ name, entries, skippedCount, plan }`.
10073
+ *
10074
+ * Reading a file and writing it are two decisions, and this state is the gap between them. It exists
10075
+ * because the second decision used to have no moment of its own: picking a file imported it outright,
10076
+ * so the only way to see what was in it was the report afterwards.
10077
+ */
10078
+ const [pending, setPending] = React.useState(null);
10079
+ /**
10080
+ * Whether a folder import names each entry's category after the folder it came from.
10081
+ *
10082
+ * Off by default: it writes a value the user did not type. On is for the shape people actually keep —
10083
+ * one folder per topic.
10084
+ */
10085
+ const [useFolderAsGroup, setUseFolderAsGroup] = React.useState(true);
9661
10086
  /**
9662
10087
  * What came back from the host's own feedback channel, or null when nothing was sent yet.
9663
10088
  *
@@ -9742,7 +10167,7 @@ window.__ModuleLoader__.load({
9742
10167
  /** Save the export as a file. */
9743
10168
  const download = () => downloadText(textOf(), `term-dictionary-${new Date().toISOString().slice(0, 10)}.json`);
9744
10169
 
9745
- /** Take a file, and say what came of it. */
10170
+ /** Take a file: read it, show what is in it, and write nothing until the user says so. */
9746
10171
  const takeFile = async (event) => {
9747
10172
  const file = event?.target?.files?.[0];
9748
10173
  if (file === undefined || file === null) return;
@@ -9753,9 +10178,76 @@ window.__ModuleLoader__.load({
9753
10178
  setReport({ failed: parsed.error });
9754
10179
  return;
9755
10180
  }
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 }));
10181
+ // The previous import's report goes away when a new file is read: it describes something that
10182
+ // has already happened, and leaving it under a fresh confirmation reads as if it described
10183
+ // this one.
10184
+ setReport(null);
10185
+ // The plan is a DRY RUN: `planImport` is pure, so the page can say what would happen without
10186
+ // any of it happening. The write path recomputes it at the moment of the write, so a preview
10187
+ // that went stale (another window deleted something) cannot import the stale answer.
10188
+ setPending({ name: file.name, entries: parsed.entries, skippedCount: parsed.skipped ?? 0, plan: planFor(parsed.entries, includeDeleted) });
10189
+ } catch (error) {
10190
+ setReport({ failed: String(error?.message ?? error) });
10191
+ } finally {
10192
+ setBusy(false);
10193
+ }
10194
+ };
10195
+
10196
+ /** The dry-run plan for a set of incoming records, used by the preview and by the write. */
10197
+ function planFor(incoming, withDeleted) {
10198
+ return planImport(store.getState().entries ?? [], incoming, store.getState().deletedKeys ?? new Set(), { includeDeleted: withDeleted === true });
10199
+ }
10200
+
10201
+ /** The confirmation the user actually presses. */
10202
+ const confirmImport = async () => {
10203
+ if (pending === null || busy) return;
10204
+ setBusy(true);
10205
+ try {
10206
+ const result = await importEntries(store, pending.entries, { includeDeleted, skipped: pending.skippedCount });
10207
+ // The panel owns the view, so IT decides what has to move to show what was just written — and
10208
+ // says whether it had to. An import that lands while the list is filtered to 「已删除」 or
10209
+ // narrowed by a search looks exactly like an import that did nothing, which is how this was
10210
+ // reported once ("the imported entries do not appear").
10211
+ const moved = typeof props.onImported === "function" && props.onImported() === true;
10212
+ setReport(moved ? { ...result, moved: true } : result);
10213
+ setPending(null);
10214
+ } catch (error) {
10215
+ setReport({ failed: String(error?.message ?? error) });
10216
+ } finally {
10217
+ setBusy(false);
10218
+ }
10219
+ };
10220
+
10221
+ /**
10222
+ * Read a whole folder: every `*.json` in it, as one import.
10223
+ *
10224
+ * A directory reaches the page through `webkitdirectory`, which is the OS's own directory browser —
10225
+ * so this needs no new service dependency, and nothing can park the plugin waiting for one. The
10226
+ * browser supplies `webkitRelativePath`, which is what names a category after its folder.
10227
+ */
10228
+ const takeFolder = async (event) => {
10229
+ const files = [...(event?.target?.files ?? [])];
10230
+ if (files.length === 0) return;
10231
+ setBusy(true);
10232
+ try {
10233
+ const read = [];
10234
+ for (const file of files) {
10235
+ const path = typeof file.webkitRelativePath === "string" && file.webkitRelativePath !== "" ? file.webkitRelativePath : file.name;
10236
+ read.push({ path, text: await file.text() });
10237
+ }
10238
+ const collected = collectFolderImport(read, { useFolderAsGroup });
10239
+ if (collected.entries.length === 0) {
10240
+ setReport({ failed: collected.skipped.length === 0 ? t("transferNoJson") : t("transferFolderAllSkipped", { count: String(collected.skipped.length) }) });
10241
+ return;
10242
+ }
10243
+ setReport(null);
10244
+ setPending({
10245
+ name: t("transferFolderLabel", { count: String(collected.used) }),
10246
+ entries: collected.entries,
10247
+ skippedCount: 0,
10248
+ plan: planFor(collected.entries, includeDeleted),
10249
+ folder: { files: collected.files, used: collected.used, skipped: collected.skipped }
10250
+ });
9759
10251
  } catch (error) {
9760
10252
  setReport({ failed: String(error?.message ?? error) });
9761
10253
  } finally {
@@ -9866,7 +10358,13 @@ window.__ModuleLoader__.load({
9866
10358
  type: "checkbox",
9867
10359
  style: applyStyles.checkbox,
9868
10360
  checked: includeDeleted,
9869
- onChange: () => setIncludeDeleted(!includeDeleted)
10361
+ // Toggling this re-plans the preview rather than leaving it: the number the user is about
10362
+ // to confirm is the number this opt-in produces, or the confirmation is a lie.
10363
+ onChange: () => {
10364
+ const next = !includeDeleted;
10365
+ setIncludeDeleted(next);
10366
+ setPending((current) => (current === null ? null : { ...current, plan: planFor(current.entries, next) }));
10367
+ }
9870
10368
  }),
9871
10369
  h("span", { style: applyStyles.transferName }, t("transferIncludeDeleted"))
9872
10370
  ),
@@ -9879,19 +10377,114 @@ window.__ModuleLoader__.load({
9879
10377
  onChange: (event) => void takeFile(event),
9880
10378
  style: applyStyles.transferFile
9881
10379
  }),
10380
+ // The folder half. `webkitdirectory` opens the OS's directory browser and hands over every file
10381
+ // inside it, with the path — which is the only directory browser a page needs, and the one that
10382
+ // arrives without a service the plugin could end up parked on.
10383
+ h("p", { style: applyStyles.settingLabel }, t("transferFolderTitle")),
10384
+ h("p", { style: applyStyles.settingHint }, t("transferFolderHint")),
10385
+ h("input", {
10386
+ type: "file",
10387
+ webkitdirectory: "true",
10388
+ directory: "true",
10389
+ multiple: true,
10390
+ "aria-label": t("transferFolderTitle"),
10391
+ disabled: busy,
10392
+ onChange: (event) => void takeFolder(event),
10393
+ style: applyStyles.transferFile,
10394
+ "data-term-dictionary": "transfer-folder"
10395
+ }),
10396
+ h(
10397
+ "label",
10398
+ { style: applyStyles.transferItem, "data-term-dictionary": "transfer-folder-group" },
10399
+ h("input", { type: "checkbox", style: applyStyles.checkbox, checked: useFolderAsGroup, onChange: () => setUseFolderAsGroup(!useFolderAsGroup) }),
10400
+ h("span", { style: applyStyles.transferName }, t("transferFolderAsGroup"))
10401
+ ),
10402
+ // The confirmation. Picking a file used to import it, which made "what is in this file" a
10403
+ // question you could only answer afterwards; the plan below is a dry run, so the answer comes
10404
+ // first and the write waits for a second press.
10405
+ pending === null
10406
+ ? null
10407
+ : h(
10408
+ "div",
10409
+ { style: applyStyles.notice, "data-term-import-pending": "true" },
10410
+ h("p", { style: applyStyles.settingLabel }, t("transferConfirmTitle", { name: pending.name })),
10411
+ h(
10412
+ "p",
10413
+ { style: applyStyles.settingHint },
10414
+ [
10415
+ t("transferWillImport", { count: String(pending.plan.accepted.length) }),
10416
+ pending.plan.existing.length > 0 ? t("transferWillSkipExisting", { count: String(pending.plan.existing.length) }) : "",
10417
+ pending.plan.deleted.length > 0 ? t("transferWillSkipDeleted", { count: String(pending.plan.deleted.length) }) : "",
10418
+ pending.plan.duplicates + pending.skippedCount > 0 ? t("transferDuplicates", { count: String(pending.plan.duplicates + pending.skippedCount) }) : ""
10419
+ ]
10420
+ .filter((part) => part !== "")
10421
+ .join(" · ")
10422
+ ),
10423
+ // A folder says what it found and what it had to pass over — by name, because "one file was
10424
+ // skipped" with twenty files in the folder is not an answer.
10425
+ pending.folder === undefined
10426
+ ? null
10427
+ : h(
10428
+ "div",
10429
+ null,
10430
+ h("p", { style: applyStyles.settingHint }, t("transferFolderFound", { files: String(pending.folder.files), used: String(pending.folder.used) })),
10431
+ pending.folder.skipped.length === 0
10432
+ ? null
10433
+ : h("p", { style: applyStyles.error }, `${t("transferFolderSkipped", { count: String(pending.folder.skipped.length) })} ${pending.folder.skipped.map((each) => `${each.name}(${each.error})`).join("、")}`)
10434
+ ),
10435
+ h(
10436
+ "div",
10437
+ { style: applyStyles.transferRow },
10438
+ h(Button, { label: t("transferConfirm"), disabled: busy, onClick: () => void confirmImport() }, t("transferConfirm")),
10439
+ h(Button, { label: t("cancel"), onClick: () => setPending(null) }, t("cancel"))
10440
+ )
10441
+ ),
9882
10442
  report === null
9883
10443
  ? null
9884
10444
  : h(
9885
10445
  "p",
9886
- { style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error },
10446
+ { style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error, "data-term-report": "true" },
9887
10447
  report.failed === undefined
9888
- ? t("transferReport", { imported: report.imported, existing: report.existing, deleted: report.deleted, duplicates: report.duplicates })
10448
+ ? [t("transferReport", { imported: report.imported, existing: report.existing, deleted: report.deleted, duplicates: report.duplicates }), report.moved === true ? t("transferShown") : ""]
10449
+ .filter((part) => part !== "")
10450
+ .join(" ")
9889
10451
  : t("transferFailed", { message: report.failed })
9890
10452
  )
9891
10453
  //#endregion
9892
10454
  );
9893
10455
  }
9894
10456
 
10457
+ /**
10458
+ * The entry list's layout, given the column preference.
10459
+ *
10460
+ * A module-level function rather than an inline style, and pure, so the rule can be asserted without a
10461
+ * browser: `auto` asks the grid for as many columns as fit at a readable width, a number pins the
10462
+ * layout, and `1` is the plain column the panel had before any of this existed.
10463
+ *
10464
+ * @param columns - `"auto"`, `"1"`, `"2"` or `"3"` (anything else behaves as `auto`).
10465
+ * @returns the style for the list container.
10466
+ */
10467
+ function listStyle(columns, rowLayout) {
10468
+ const fixed = Number.parseInt(String(columns), 10);
10469
+ const gridTemplateColumns =
10470
+ Number.isFinite(fixed) && fixed > 1
10471
+ ? `repeat(${fixed}, minmax(0, 1fr))`
10472
+ : `repeat(auto-fill, minmax(${MIN_COLUMN_PX}px, 1fr))`;
10473
+ // `stretch` is the default because the complaint was the GAPS: with each cell as tall as its track,
10474
+ // cards line up and no empty strip appears under the short ones. `compact` lets every card keep its
10475
+ // own height instead, which is tidier in a column but leaves those strips — so it is a choice.
10476
+ return {
10477
+ ...applyStyles.list,
10478
+ display: "grid",
10479
+ // Rows are as tall as their content, and the leftover height stays at the bottom. Without this a
10480
+ // grid STRETCHES its rows to fill the container, so two cards in a tall panel became two 700px
10481
+ // boxes — the opposite of what "equal cards" was asking for.
10482
+ alignContent: "start",
10483
+ alignItems: rowLayout === "compact" ? "start" : "stretch",
10484
+ gridTemplateColumns
10485
+ };
10486
+ }
10487
+
9895
10488
  /**
9896
10489
  * Term packs: hand this dictionary to somebody, or take theirs.
9897
10490
  *
@@ -9934,6 +10527,7 @@ window.__ModuleLoader__.load({
9934
10527
  /** One row per source after a refresh: `{ url, ok, stale, error, index }`. */
9935
10528
  const [listing, setListing] = React.useState(null);
9936
10529
  const [packNote, setPackNote] = React.useState({});
10530
+ const [autoRefreshed, setAutoRefreshed] = React.useState(false);
9937
10531
  /** Say something in the page's one notice line rather than in a dialog. */
9938
10532
  const say = (message) => setNotice(message);
9939
10533
 
@@ -10010,6 +10604,25 @@ window.__ModuleLoader__.load({
10010
10604
  }
10011
10605
  };
10012
10606
 
10607
+ /**
10608
+ * Ask the reader's own sources what they hold, once, when the page first opens.
10609
+ *
10610
+ * Without it a fresh install shows a configured source and nothing else, and "press 刷新" is a step
10611
+ * whose purpose nobody can guess. The request goes to the sources THEY have (the shipped default
10612
+ * included), the host does the fetching and caches it for an hour, and the list stays theirs to clear
10613
+ * — a source removed here is not added back, which is why this looks at the length rather than at
10614
+ * whether the list is the default one.
10615
+ *
10616
+ * Declared after `refresh` on purpose: the effect callback runs on the first render in some
10617
+ * harnesses (and in React's own test renderers), and a callback closing over a `const` below it
10618
+ * would read an uninitialised binding.
10619
+ */
10620
+ React.useEffect(() => {
10621
+ if (autoRefreshed || sources.length === 0) return;
10622
+ setAutoRefreshed(true);
10623
+ void refresh();
10624
+ }, [autoRefreshed, sources.length]);
10625
+
10013
10626
  /** Fetch one listed pack and offer it for import. */
10014
10627
  const preview = async (row, listed) => {
10015
10628
  if (typeof packApi?.fetch !== "function") return say(t("packNoHost"));
@@ -10181,6 +10794,9 @@ window.__ModuleLoader__.load({
10181
10794
  h(
10182
10795
  "div",
10183
10796
  { key: url, style: applyStyles.transferItem, "data-term-source": url },
10797
+ // The shipped one says so: a source nobody remembers adding is a source nobody dares
10798
+ // remove, and this one is removable like any other.
10799
+ url === DEFAULT_PACK_SOURCE ? h("span", { style: applyStyles.badge }, t("packBuiltInSource")) : null,
10184
10800
  h("span", { style: applyStyles.transferName }, url),
10185
10801
  h(Button, { label: t("packRemoveSource"), onClick: () => settingsStore?.set?.("packSources", sources.filter((each) => each !== url)) }, t("packRemoveSource"))
10186
10802
  )
@@ -10189,7 +10805,7 @@ window.__ModuleLoader__.load({
10189
10805
  h(
10190
10806
  "div",
10191
10807
  { style: applyStyles.transferRow },
10192
- h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), onChange: setSourceDraft }),
10808
+ h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), width: "30em", onChange: setSourceDraft }),
10193
10809
  h(Button, { label: t("packAddSource"), disabled: busy, onClick: addSource }, t("packAddSource")),
10194
10810
  h(Button, { label: t("packRefresh"), disabled: busy || sources.length === 0, onClick: () => void refresh() }, t("packRefresh"))
10195
10811
  ),
@@ -10393,6 +11009,28 @@ window.__ModuleLoader__.load({
10393
11009
  onSelect: (value) => setSwitch("explainDepth", value)
10394
11010
  })
10395
11011
  ),
11012
+ row(
11013
+ "rows",
11014
+ t("entryRowsName"),
11015
+ t("entryRowsHint"),
11016
+ h(OptionBar, {
11017
+ label: t("entryRowsName"),
11018
+ value: ENTRY_ROWS.includes(settings.entryRows) ? settings.entryRows : "uniform",
11019
+ options: ENTRY_ROWS.map((value) => ({ value, label: t(`entryRows_${value}`) })),
11020
+ onSelect: (value) => setSwitch("entryRows", value)
11021
+ })
11022
+ ),
11023
+ row(
11024
+ "columns",
11025
+ t("entryColumnsName"),
11026
+ t("entryColumnsHint"),
11027
+ h(OptionBar, {
11028
+ label: t("entryColumnsName"),
11029
+ value: ENTRY_COLUMNS.includes(settings.entryColumns) ? settings.entryColumns : "auto",
11030
+ options: ENTRY_COLUMNS.map((value) => ({ value, label: t(`entryColumns_${value}`) })),
11031
+ onSelect: (value) => setSwitch("entryColumns", value)
11032
+ })
11033
+ ),
10396
11034
  flag(
10397
11035
  "identifiers",
10398
11036
  t("collectIdentifiersOn"),
@@ -10467,6 +11105,15 @@ window.__ModuleLoader__.load({
10467
11105
  }
10468
11106
 
10469
11107
  /** A labelled text input. */
11108
+ /**
11109
+ * A labelled text field.
11110
+ *
11111
+ * One width for every single-line field, because a form of differently-sized boxes reads as a mistake
11112
+ * rather than as a decision. `width` overrides the cap for the rare field whose content really is longer
11113
+ * (a URL, a path), and a multi-line field is an {@link Area} and takes the full width instead.
11114
+ */
11115
+ const FIELD_WIDTH = "22em";
11116
+
10470
11117
  function Field(props) {
10471
11118
  return h(
10472
11119
  "label",
@@ -10477,7 +11124,7 @@ window.__ModuleLoader__.load({
10477
11124
  value: props.value,
10478
11125
  placeholder: props.placeholder,
10479
11126
  onChange: (event) => props.onChange(event.target.value),
10480
- style: applyStyles.input,
11127
+ style: { ...applyStyles.input, maxWidth: props.width ?? FIELD_WIDTH },
10481
11128
  "aria-label": props.label
10482
11129
  })
10483
11130
  );
@@ -10541,6 +11188,8 @@ window.__ModuleLoader__.load({
10541
11188
  );
10542
11189
  const [query, setQuery] = React.useState("");
10543
11190
  const [filter, setFilter] = React.useState("all");
11191
+ /** How the entry list is laid out; the settings store owns the value, this only reads it. */
11192
+ const columns = settings.entryColumns ?? FALLBACK_SETTINGS.entryColumns;
10544
11193
  const [editing, setEditing] = React.useState(null);
10545
11194
  /** Whether the panel is showing its settings page instead of the entry list. */
10546
11195
  const [settingsOpen, setSettingsOpen] = React.useState(false);
@@ -10587,7 +11236,21 @@ window.__ModuleLoader__.load({
10587
11236
  };
10588
11237
  }, [store]);
10589
11238
 
10590
- const visible = React.useMemo(() => store.list(query, filter), [view, query, filter]);
11239
+ /**
11240
+ * Which group the list is showing: `""` is the top level, otherwise a path like `backend/net`.
11241
+ *
11242
+ * Standing somewhere is a property of the VIEW, not of the dictionary — nothing about an entry changes
11243
+ * because you looked at it — so it is panel state, next to the search box and the tab.
11244
+ */
11245
+ const [group, setGroup] = React.useState("");
11246
+ const visible = React.useMemo(() => store.list(query, filter, group), [view, query, filter, group]);
11247
+ /**
11248
+ * The groups directly inside the current one, each with how many entries it holds.
11249
+ *
11250
+ * They are ROWS of the list rather than a sidebar or a header: a group is the same kind of thing as an
11251
+ * entry — something you open — and the point of the shape is that both are the same size on screen.
11252
+ */
11253
+ const subGroups = React.useMemo(() => store.groups(group), [view, group]);
10591
11254
  const current = view.state.entries;
10592
11255
  /**
10593
11256
  * Whether the panel is showing what was deleted.
@@ -10606,6 +11269,22 @@ window.__ModuleLoader__.load({
10606
11269
  */
10607
11270
  const showingFlagged = filter === "flagged";
10608
11271
 
11272
+ /**
11273
+ * Make what an import just wrote visible.
11274
+ *
11275
+ * The panel owns the view, so this is the panel's job and not the import page's. It returns whether it
11276
+ * had to move anything, because a report that says "12 imported" while the list is still filtered to
11277
+ * 「已删除」 is a report nobody can check.
11278
+ *
11279
+ * @returns true when the search box or the tab had to change.
11280
+ */
11281
+ const showImported = () => {
11282
+ const moved = query !== "" || filter !== "all";
11283
+ setQuery("");
11284
+ setFilter("all");
11285
+ return moved;
11286
+ };
11287
+
10609
11288
  /** Open the editor for a blank entry, prefilled from an optional term. */
10610
11289
  const startCreate = (term = "", context = "") => {
10611
11290
  setEditing({
@@ -10615,6 +11294,9 @@ window.__ModuleLoader__.load({
10615
11294
  usage: "",
10616
11295
  notes: "",
10617
11296
  domain: "",
11297
+ // A new entry made while standing in a group belongs to that group: that is what standing there
11298
+ // meant, and asking again in the form would be a question with one sensible answer.
11299
+ group,
10618
11300
  aliases: "",
10619
11301
  pinned: false,
10620
11302
  // Nothing to say yet, and `feedbackTouched` is what tells a save apart from a retraction:
@@ -10638,6 +11320,7 @@ window.__ModuleLoader__.load({
10638
11320
  usage: entry.definition.usage,
10639
11321
  notes: entry.definition.notes,
10640
11322
  domain: entry.domain,
11323
+ group: entry.group,
10641
11324
  aliases: entry.aliases.join(", "),
10642
11325
  pinned: entry.pinned,
10643
11326
  // Seeded from the entry, and `touched` starts true when there is already a remark: saving
@@ -10699,6 +11382,7 @@ window.__ModuleLoader__.load({
10699
11382
  await store.saveEntry(editing.term, {
10700
11383
  definition: { zh: editing.zh, gloss: editing.gloss, usage: editing.usage, notes: editing.notes },
10701
11384
  domain: editing.domain,
11385
+ group: editing.group,
10702
11386
  aliases: editing.aliases
10703
11387
  .split(",")
10704
11388
  .map((alias) => alias.trim())
@@ -10856,6 +11540,12 @@ window.__ModuleLoader__.load({
10856
11540
  placeholder: t("zhPlaceholder"),
10857
11541
  onChange: (value) => setEditing({ ...editing, zh: value })
10858
11542
  }),
11543
+ h(Field, {
11544
+ label: t("groupLabel"),
11545
+ value: editing.group,
11546
+ placeholder: t("groupPlaceholder"),
11547
+ onChange: (value) => setEditing({ ...editing, group: value })
11548
+ }),
10859
11549
  h(Area, {
10860
11550
  label: t("glossLabel"),
10861
11551
  value: editing.gloss,
@@ -10988,7 +11678,7 @@ window.__ModuleLoader__.load({
10988
11678
  ),
10989
11679
  h(Button, { label: t("settingsBack"), onClick: () => setTransferOpen(false) }, t("settingsBack"))
10990
11680
  ),
10991
- h(TransferPage, { store, t, setToast: props.setToast })
11681
+ h(TransferPage, { store, t, setToast: props.setToast, onImported: showImported })
10992
11682
  );
10993
11683
  }
10994
11684
 
@@ -11116,14 +11806,79 @@ window.__ModuleLoader__.load({
11116
11806
  ) : null,
11117
11807
  h(
11118
11808
  "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) =>
11809
+ // The entry list is the one that gets columns. The deleted and flagged views are lists of
11810
+ // DECISIONS rather than of terms — shorter rows, read one at a time — and the picking mode
11811
+ // pairs every row with a checkbox, so laying those out in a grid would be a change nobody
11812
+ // asked for.
11813
+ {
11814
+ style: showingDeleted || showingFlagged || picking ? applyStyles.list : listStyle(columns, settings.entryRows),
11815
+ "data-term-columns": showingDeleted || showingFlagged || picking ? undefined : columns
11816
+ },
11817
+ // The groups come first, then the entries at this level — one list, one scroll area, one kind of
11818
+ // row. A breadcrumb says where you are and is the way back up, because a directory you can only
11819
+ // enter is a trap.
11820
+ [
11821
+ group === "" ? null : h(
11822
+ "div",
11823
+ { style: { ...applyStyles.breadcrumb, gridColumn: "1 / -1" }, "data-term-breadcrumb": group },
11824
+ [
11825
+ { path: "", label: t("filterAll") },
11826
+ ...group.split("/").map((name, index, all) => ({ path: all.slice(0, index + 1).join("/"), label: name }))
11827
+ ].map((crumb, index) =>
11828
+ h(
11829
+ "span",
11830
+ { key: crumb.path === "" ? "root" : crumb.path, style: applyStyles.breadcrumbPart },
11831
+ index === 0 ? null : h("span", { style: applyStyles.breadcrumbSep }, "›"),
11832
+ h(
11833
+ "button",
11834
+ {
11835
+ type: "button",
11836
+ style: index === group.split("/").length ? applyStyles.breadcrumbHere : applyStyles.breadcrumbButton,
11837
+ onClick: () => setGroup(crumb.path),
11838
+ title: t("groupGo", { name: crumb.label })
11839
+ },
11840
+ crumb.label
11841
+ )
11842
+ )
11843
+ )
11844
+ ),
11845
+ ...subGroups.map((sub) =>
11846
+ h(
11847
+ "article",
11848
+ {
11849
+ key: `group:${sub.group}`,
11850
+ // Its own height, not the track's: a group has no body text, so stretching it to match a
11851
+ // three-line entry would leave an empty box the size of the entry — which is what it looked
11852
+ // like. Equal KIND of row is the point; equal height only reads as equal among rows with text.
11853
+ style: { ...applyStyles.row, alignSelf: "start" },
11854
+ "data-term-group": sub.group,
11855
+ // The WHOLE row opens the group, exactly as the whole row of an entry opens the entry.
11856
+ // Only the label being live is what "the group cannot be clicked into" meant: a reader
11857
+ // clicks the row, the same gesture that works one line below, and nothing happened.
11858
+ // The label stays a button so it is reachable by keyboard and named for a screen reader.
11859
+ onClick: () => setGroup(sub.group)
11860
+ },
11861
+ h("div", { style: applyStyles.rowHead },
11862
+ h("div", { style: applyStyles.groupIcon, "aria-hidden": "true" }, Icons.folder(14)),
11863
+ h("button", {
11864
+ type: "button",
11865
+ style: applyStyles.termButton,
11866
+ onClick: () => setGroup(sub.group),
11867
+ title: t("groupOpen")
11868
+ }, sub.name),
11869
+ h("span", { style: applyStyles.badge }, t("groupCount", { count: String(sub.count) })),
11870
+ h("span", { style: applyStyles.spacer }),
11871
+ h("span", { style: applyStyles.groupEnter, "aria-hidden": "true" }, "›")
11872
+ )
11873
+ )
11874
+ ),
11875
+ showingDeleted
11876
+ ? h(DeletedList, { entries: visible, t, onRevive: (entry) => void revive(entry) })
11877
+ : showingFlagged
11878
+ ? h(FlaggedList, { entries: visible, t, onClear: (entry) => void clearFlag(entry) })
11879
+ : visible.length === 0
11880
+ ? h("p", { style: { ...applyStyles.blank, gridColumn: "1 / -1" } }, current.length === 0 ? t("empty") : subGroups.length > 0 ? t("groupEmpty") : t("searchEmpty"))
11881
+ : visible.map((entry) =>
11127
11882
  picking
11128
11883
  ? h(
11129
11884
  "article",
@@ -11179,6 +11934,10 @@ window.__ModuleLoader__.load({
11179
11934
  h(
11180
11935
  "div",
11181
11936
  { style: applyStyles.rowHead },
11937
+ // The pin comes FIRST, on the title line: it is a state of the entry rather than an
11938
+ // action on it, and the list no longer re-sorts when it changes, so this is where
11939
+ // the state has to be readable.
11940
+ h(IconButton, { title: entry.pinned ? t("unpin") : t("pin"), pressed: entry.pinned, onClick: () => store.togglePinned(entry.id) }, Icons.pin(13)),
11182
11941
  h("button", {
11183
11942
  type: "button",
11184
11943
  style: applyStyles.termButton,
@@ -11195,8 +11954,16 @@ window.__ModuleLoader__.load({
11195
11954
  : null,
11196
11955
  entry.untrusted === true ? h("span", { style: applyStyles.badgeWarn, "data-term-badge": "untrusted" }, t("flaggedUntrusted")) : null,
11197
11956
  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)),
11957
+ entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null
11958
+ ),
11959
+ // Every action on its own line, and always in the same place.
11960
+ //
11961
+ // The title line used to carry them, so their position depended on how long the term
11962
+ // was, which badges it had and whether it had a domain — the controls wandered from
11963
+ // row to row. A toolbar that is always the second line does not.
11964
+ h(
11965
+ "div",
11966
+ { style: applyStyles.rowActions, "data-term-actions": entry.key },
11200
11967
  h(IconButton, { title: t("copyEntry"), onClick: () => void copyEntry(entry) }, Icons.copy(13)),
11201
11968
  h(IconButton, { title: t("feedbackTitle"), pressed: entry.feedback !== null && entry.feedback !== undefined, onClick: () => startEdit(entry) }, Icons.flag(13)),
11202
11969
  h(IconButton, { title: t("edit"), onClick: () => startEdit(entry) }, Icons.edit(13)),
@@ -11215,6 +11982,7 @@ window.__ModuleLoader__.load({
11215
11982
  )
11216
11983
  )
11217
11984
  )
11985
+ ]
11218
11986
  ),
11219
11987
  storageNote(view.hostInfo, t),
11220
11988
  h(