dsh-plugin-term-dictionary 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core/copy.js CHANGED
@@ -23,6 +23,16 @@ const zh = {
23
23
  explainLang_en: "英文",
24
24
  explainDepthName: "详细程度",
25
25
  explainDepthHint: "解释写多详细",
26
+ entryColumnsName: "词条排列",
27
+ entryColumnsHint: "一列排下去很长;「自动」按面板宽度铺成多列,窄了就回到一列。",
28
+ entryColumns_auto: "自动",
29
+ entryColumns_1: "1 列",
30
+ entryColumns_2: "2 列",
31
+ entryColumns_3: "3 列",
32
+ entryRowsName: "词条行高",
33
+ entryRowsHint: "多列排版时,同一行的卡片要不要一样高。统一高度不会在矮卡片下面留出空条;各按各自更紧凑。",
34
+ entryRows_uniform: "统一高度",
35
+ entryRows_compact: "各按各自",
26
36
  explainDepth_brief: "一句话",
27
37
  explainDepth_normal: "标准",
28
38
  explainDepth_detailed: "详细",
@@ -55,6 +65,20 @@ const zh = {
55
65
  transferImportHint: "选择一个 JSON 文件。已存在的词条会原样保留;你删除过的词条默认不复活;导入完成后会报告各自的条数。",
56
66
  transferIncludeDeleted: "导入我删除过的词条",
57
67
  transferIncludeDeletedHint: "勾选后,文件里那些你删除过的词条会被导入,并从「已删除」里撤回。不勾选则它们留在删除列表里。",
68
+ transferConfirmTitle: "「{name}」里有什么",
69
+ transferWillImport: "将导入 {count} 条",
70
+ transferWillSkipExisting: "已有 {count} 条(跳过)",
71
+ transferWillSkipDeleted: "你删过 {count} 条(默认跳过)",
72
+ transferConfirm: "确认导入",
73
+ transferShown: "已切到「全部」并清空搜索,好让刚导入的词条就在眼前。",
74
+ transferFolderTitle: "导入文件夹",
75
+ transferFolderHint: "选一个文件夹,把里面的 .json 全部读一遍(导出的词典、下载的包文件都认)。不是 JSON 的文件直接忽略;读不了的会点名列出。",
76
+ transferFolderAsGroup: "按文件夹建立分组(文件夹名就是组名)",
77
+ transferFolderLabel: "文件夹里的 {count} 个文件",
78
+ transferFolderFound: "文件夹里 {files} 个 .json,用上 {used} 个",
79
+ transferFolderSkipped: "跳过 {count} 个:",
80
+ transferFolderAllSkipped: "这个文件夹里 {count} 个 .json 都读不了",
81
+ transferNoJson: "这个文件夹里没有 .json 文件",
58
82
  transferReport: "导入 {imported} 条;跳过:已存在 {existing} 条、你删除过 {deleted} 条、重复或无效 {duplicates} 条。",
59
83
  transferFailed: "操作失败:{message}",
60
84
  settingsBack: "返回词条列表",
@@ -146,6 +170,7 @@ const zh = {
146
170
  packSourcesTitle: "源",
147
171
  packSourcesHint: "源就是别人托管的一个静态 index.json。只接受 https(http、file、带账号密码的 URL 都会被拒)。",
148
172
  packNoSources: "还没有添加源",
173
+ packBuiltInSource: "内置",
149
174
  packRemoveSource: "移除",
150
175
  packAddSourceLabel: "添加源",
151
176
  packAddSourcePlaceholder: "https://cdn.jsdelivr.net/gh/…/index.json",
@@ -208,6 +233,25 @@ const zh = {
208
233
  usageLabel: "用法示例",
209
234
  notesLabel: "备注",
210
235
  domainLabel: "领域",
236
+ groupLabel: "分组",
237
+ groupOpen: "进入这个分组",
238
+ groupGo: "回到「{name}」",
239
+ groupCount: "{count} 条",
240
+ groupEmpty: "这一层只有分组,还没有词条。",
241
+ groupSampleJoin: "、",
242
+ groupSampleMore: " 等 {count} 条",
243
+ groupRename: "重命名或移动",
244
+ groupRenameTo: "改到:",
245
+ groupRenameApply: "改名 / 移动",
246
+ groupDelete: "删除分组",
247
+ groupRenamed: "已移动 {count} 条词条。",
248
+ groupDeleted: "已删除 {count} 条词条,它们都在「已删除」里,可以撤回。",
249
+ groupDeleteConfirm: "删除分组「{name}」里的 {count} 条词条?它们会进入「已删除」,之后可以逐条撤回。",
250
+ "groupError_no-group": "那个分组不存在。",
251
+ "groupError_no-name": "分组名不能为空。",
252
+ "groupError_same": "新旧名字一样,没有需要动的地方。",
253
+ "groupError_into-itself": "不能把分组移动到它自己里面。",
254
+ groupPlaceholder: "留空 = 顶层;用 / 分层,例如 backend/net",
211
255
  termPlaceholder: "例如 event sourcing",
212
256
  glossPlaceholder: "用一两句话说明它的含义",
213
257
  usagePlaceholder: "可选:一句例子或典型用法",
@@ -293,6 +337,16 @@ const en = {
293
337
  explainLang_en: "English",
294
338
  explainDepthName: "Explanation depth",
295
339
  explainDepthHint: "How much the explanation says",
340
+ entryColumnsName: "Entry layout",
341
+ 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.",
342
+ entryColumns_auto: "Auto",
343
+ entryColumns_1: "1 column",
344
+ entryColumns_2: "2 columns",
345
+ entryColumns_3: "3 columns",
346
+ entryRowsName: "Row height",
347
+ 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.",
348
+ entryRows_uniform: "Uniform",
349
+ entryRows_compact: "Compact",
296
350
  explainDepth_brief: "One line",
297
351
  explainDepth_normal: "Normal",
298
352
  explainDepth_detailed: "Detailed",
@@ -340,6 +394,7 @@ const en = {
340
394
  packSourcesTitle: "Sources",
341
395
  packSourcesHint: "A source is somebody's static index.json. Only https is accepted (http, file and URLs carrying credentials are refused).",
342
396
  packNoSources: "No sources yet",
397
+ packBuiltInSource: "built in",
343
398
  packRemoveSource: "Remove",
344
399
  packAddSourceLabel: "Add a source",
345
400
  packAddSourcePlaceholder: "https://cdn.jsdelivr.net/gh/…/index.json",
@@ -395,6 +450,20 @@ const en = {
395
450
  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.",
396
451
  transferIncludeDeleted: "Import entries I deleted",
397
452
  transferIncludeDeletedHint: "When ticked, entries in the file that you had deleted are imported and retracted from the deleted list. Unticked, they stay there.",
453
+ transferConfirmTitle: "What “{name}” holds",
454
+ transferWillImport: "{count} entries will be imported",
455
+ transferWillSkipExisting: "{count} already here (skipped)",
456
+ transferWillSkipDeleted: "{count} you deleted (skipped by default)",
457
+ transferConfirm: "Import them",
458
+ transferShown: "Moved to All and cleared the search, so what was imported is in front of you.",
459
+ transferFolderTitle: "Import a folder",
460
+ 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.",
461
+ transferFolderAsGroup: "Build groups from the folders (the folder name is the group)",
462
+ transferFolderLabel: "{count} files in the folder",
463
+ transferFolderFound: "{files} .json files in the folder, {used} of them usable",
464
+ transferFolderSkipped: "{count} skipped:",
465
+ transferFolderAllSkipped: "none of the {count} .json files in that folder could be read",
466
+ transferNoJson: "that folder holds no .json files",
398
467
  transferReport: "Imported {imported}; skipped {existing} already here, {deleted} you deleted, {duplicates} duplicate or unusable.",
399
468
  transferFailed: "That did not work: {message}",
400
469
  settingsBack: "Back to the entries",
@@ -479,6 +548,25 @@ const en = {
479
548
  usageLabel: "Usage",
480
549
  notesLabel: "Notes",
481
550
  domainLabel: "Domain",
551
+ groupLabel: "Group",
552
+ groupOpen: "Open this group",
553
+ groupGo: "Go back to {name}",
554
+ groupCount: "{count} entries",
555
+ groupEmpty: "This level holds only groups so far.",
556
+ groupSampleJoin: ", ",
557
+ groupSampleMore: " and {count} more",
558
+ groupRename: "Rename or move",
559
+ groupRenameTo: "Move to:",
560
+ groupRenameApply: "Rename / move",
561
+ groupDelete: "Delete group",
562
+ groupRenamed: "Moved {count} entries.",
563
+ groupDeleted: "Deleted {count} entries. They are on the deleted list and can be restored one by one.",
564
+ groupDeleteConfirm: "Delete the {count} entries in “{name}”? They go to the deleted list, where each can be restored.",
565
+ "groupError_no-group": "There is no such group.",
566
+ "groupError_no-name": "A group needs a name.",
567
+ "groupError_same": "The name is unchanged, so there is nothing to move.",
568
+ "groupError_into-itself": "A group cannot be moved inside itself.",
569
+ groupPlaceholder: "empty = top level; use / to nest, e.g. backend/net",
482
570
  termPlaceholder: "e.g. event sourcing",
483
571
  glossPlaceholder: "Say what it means in one or two sentences",
484
572
  usagePlaceholder: "Optional: an example or typical use",
@@ -285,6 +285,9 @@ function mergeRecords(record, notice, options) {
285
285
  //
286
286
  // So the comparison uses the newest time a record the USER asked for carries, and
287
287
  // being strictly newer is what revives. Time alone is not intent; provenance is.
288
+ // A NAMED restoration first: `restoreEntry` records that the user asked for this term back, which
289
+ // is not an inference from a clock. The comparison below stays for the paths that revive by editing.
290
+ if ((record.restoredAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
288
291
  if ((options?.craftedAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
289
292
  const alreadyDeleted = record.deletedAt ?? 0;
290
293
  return { ...record, deletedAt: Math.max(alreadyDeleted, notice.deletedAt) };
@@ -567,6 +570,10 @@ function editEntry(state, term, patch, options) {
567
570
  const feedbackStamp = mentionsFeedback ? Math.max(stamp, (base.feedbackAt ?? 0) + 1) : base.feedbackAt ?? 0;
568
571
  const untrustedPatch = typeof patch?.untrusted === "boolean" ? patch.untrusted : null;
569
572
  const untrustedStamp = untrustedPatch === null ? base.untrustedAt ?? 0 : Math.max(stamp, (base.untrustedAt ?? 0) + 1);
573
+ // A move is a decision like a pin or a remark, and it is stamped strictly past the last one so that two
574
+ // moves inside the same millisecond still have an order.
575
+ const mentionsGroup = patch !== null && patch !== undefined && Object.prototype.hasOwnProperty.call(patch, "group");
576
+ const groupStamp = mentionsGroup ? Math.max(stamp, (base.groupAt ?? 0) + 1) : base.groupAt ?? 0;
570
577
  const candidate = normalizeEntry(
571
578
  {
572
579
  ...base,
@@ -575,6 +582,11 @@ function editEntry(state, term, patch, options) {
575
582
  deletedAt: 0,
576
583
  aliases: patch?.aliases ?? base.aliases,
577
584
  domain: patch?.domain ?? base.domain,
585
+ // `patch.group` may legitimately be `""` — that is how an entry is moved back to the top level —
586
+ // so this reads with `hasOwnProperty` rather than with `??`: a patch that says nothing about the
587
+ // group must leave it, and its stamp, exactly where they were.
588
+ group: mentionsGroup ? patch.group : base.group,
589
+ groupAt: groupStamp,
578
590
  definition: { ...base.definition, ...(patch?.definition ?? {}) },
579
591
  source: "user",
580
592
  confidence: 1,
@@ -592,6 +604,60 @@ function editEntry(state, term, patch, options) {
592
604
  return upsertEntry(state, { ...candidate, source: "user", deletedAt: 0 }, { now: stamp });
593
605
  }
594
606
 
607
+ /**
608
+ * Bring a deleted term back, as an explicit act rather than as a side effect of an edit.
609
+ *
610
+ * This exists because "restore this deleted term" is a decision, and until now it was expressed as "write
611
+ * an edit and hope the merge weighs it right": `editEntry` produced a crafted timestamp, and a merge
612
+ * revived the record when that timestamp beat the tombstone's. That works — there is a check for it — but
613
+ * it means the import path, the editor, a sighting and a second window all revive through one inferred
614
+ * comparison, and a term the user had collected and deleted came back as "nothing happened".
615
+ *
616
+ * So a restoration is its own thing:
617
+ *
618
+ * - `restoredAt` is the evidence, weighed against `notice.deletedAt` like `pinnedAt` is weighed against
619
+ * silence. It is what the merge reads, and it exists even when the tombstone's clock ran ahead;
620
+ * - the tombstone is WITHDRAWN from the document this returns — the key leaves `deletedKeys` and the
621
+ * record leaves `backing` — so a reader of this document is not told the term is deleted at all. Being
622
+ * merely outvoted is what let a later merge put the deletion back;
623
+ * - the text is kept: restoring is not the same as rewriting, so the patch is applied on top of whatever
624
+ * the tombstone still remembers.
625
+ *
626
+ * @param state - the current document.
627
+ * @param idOrTerm - the entry id, term or alias.
628
+ * @param patch - `definition`, `domain`, `group`, `aliases`, `pinned`.
629
+ * @param options - `now` overrides the restoration time.
630
+ * @returns `{ state, entry, restored }`; `restored` is false when the term was not deleted.
631
+ */
632
+ function restoreEntry(state, idOrTerm, patch, options) {
633
+ const found = findEntryIncludingDeleted(state, idOrTerm);
634
+ if (found === undefined) {
635
+ // Nothing to restore: the caller wanted a new entry, and `editEntry` is the path for that.
636
+ const created = editEntry(state, typeof idOrTerm === "string" ? idOrTerm : "", patch, options);
637
+ return { ...created, restored: false };
638
+ }
639
+ if ((found.deletedAt ?? 0) === 0) {
640
+ // Already live: a restoration must not disturb it, and saying "restored" would be a lie the caller
641
+ // might act on (the import reports it).
642
+ return { state, entry: found, restored: false };
643
+ }
644
+ // The stamp is strictly past the deletion for the same reason `editEntry`'s is: a clock that ran ahead
645
+ // must not let the tombstone win a comparison it lost the argument for.
646
+ const now = options?.now ?? Date.now();
647
+ const stamp = Math.max(now, (found.deletedAt ?? 0) + 1);
648
+ const edited = editEntry(state, found.term, { ...patch, definition: { ...found.definition, ...(patch?.definition ?? {}) } }, { now: stamp });
649
+ const key = found.key;
650
+ // The withdrawal, in the document itself: the record is rewritten live and the whole thing is re-sealed,
651
+ // so `deletedKeys` and `backing` are derived fresh and no longer announce the deletion. (An earlier
652
+ // version also filtered them by hand first, which the re-seal made redundant — a mutation proved it by
653
+ // not biting, so the dead work is gone.)
654
+ const records = recordsOf(edited.state).map((entry) =>
655
+ entry.id === found.id || entry.key === key ? { ...entry, deletedAt: 0, restoredAt: stamp, source: "user", updatedAt: stamp, lastSeenAt: stamp } : entry
656
+ );
657
+ const next = sealRecords(records, { now: stamp });
658
+ return { state: next, entry: next.entries.find((entry) => entry.key === key) ?? edited.entry, restored: true };
659
+ }
660
+
595
661
  /**
596
662
  * Record one sighting of a term without replacing its meaning: the counters move
597
663
  * and a missing definition is filled from the glossary, but a curated definition
@@ -901,16 +967,139 @@ function summarize(state) {
901
967
  }
902
968
 
903
969
  /**
904
- * The panel's visible list: filter by query, then order pinned first, then most
905
- * recently seen.
970
+ * Move a group — and everything under it — to another path.
971
+ *
972
+ * Renaming and moving are the same act, because a group IS a path: renaming `backend` to `服务端` and moving
973
+ * `net` out of `backend` into `infra` are both "these entries live somewhere else now". One primitive, two
974
+ * names in the UI, so the two cannot drift apart.
975
+ *
976
+ * Each entry is moved through {@link editEntry}, so each carries the stamped decision (`groupAt`) a merge
977
+ * weighs — a bulk move is not an exception to "a move is a decision".
978
+ *
979
+ * Refused: an empty target, a target inside the group being moved (which would put it inside itself), and a
980
+ * target that leaves everything where it is. Renaming ONTO an existing group is allowed and merges the two,
981
+ * which is what a reader asking for it means.
982
+ *
983
+ * @param state - the current document.
984
+ * @param from - the path to move.
985
+ * @param to - the path to move it to.
986
+ * @param options - `now` overrides the timestamps.
987
+ * @returns `{ state, moved, error }`.
988
+ */
989
+ function renameGroup(state, from, to, options) {
990
+ const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
991
+ const target = typeof to === "string" ? to.replace(/^\/+|\/+$/g, "") : "";
992
+ if (source === "") return { state, moved: 0, error: "no-group" };
993
+ if (target === "") return { state, moved: 0, error: "no-name" };
994
+ if (target === source) return { state, moved: 0, error: "same" };
995
+ if (target.startsWith(`${source}/`)) return { state, moved: 0, error: "into-itself" };
996
+ const now = options?.now ?? Date.now();
997
+ let next = state;
998
+ let moved = 0;
999
+ let stamp = now;
1000
+ for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
1001
+ stamp += 1;
1002
+ const rest = entry.group === source ? "" : entry.group.slice(source.length + 1);
1003
+ next = editEntry(next, entry.term, { group: rest === "" ? target : `${target}/${rest}` }, { now: stamp }).state;
1004
+ moved += 1;
1005
+ }
1006
+ return { state: next, moved, error: null };
1007
+ }
1008
+
1009
+ /**
1010
+ * Delete a group: every entry under it goes to the deleted list.
1011
+ *
1012
+ * Soft, like every other deletion here — the tombstones make it reversible from 「已删除」, and they are also
1013
+ * what stops a passing pack from putting the terms straight back. A group therefore disappears when the last
1014
+ * entry in it does, which is the same rule that made it appear.
1015
+ *
1016
+ * @param state - the current document.
1017
+ * @param from - the path to empty.
1018
+ * @param options - `now` overrides the timestamps.
1019
+ * @returns `{ state, removed, error }`.
1020
+ */
1021
+ function deleteGroup(state, from, options) {
1022
+ const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
1023
+ if (source === "") return { state, removed: 0, error: "no-group" };
1024
+ const now = options?.now ?? Date.now();
1025
+ let next = state;
1026
+ let removed = 0;
1027
+ let stamp = now;
1028
+ for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
1029
+ stamp += 1;
1030
+ next = removeEntry(next, entry.id, { now: stamp }).state;
1031
+ removed += 1;
1032
+ }
1033
+ return { state: next, removed, error: null };
1034
+ }
1035
+
1036
+ /**
1037
+ * The groups immediately inside one path, with how many entries each holds.
1038
+ *
1039
+ * Only the immediate children: the list shows one level at a time, and a count covers everything under
1040
+ * that child, however deep. A group exists as long as something is in it — there is no separate list of
1041
+ * empty groups to keep in step with the entries, which is the trade this makes deliberately.
1042
+ *
1043
+ * Each group also carries a SAMPLE of the terms inside it. A folder row with only a name and a count is a
1044
+ * row of empty space next to entries full of text — and a preview answers the question the row raises
1045
+ * without making the reader open it to find out.
1046
+ *
1047
+ * @param entries - every live entry.
1048
+ * @param prefix - the path to look inside, `""` for the top level.
1049
+ * @returns `[{ group, name, count, sample }]`, alphabetical.
1050
+ */
1051
+ /**
1052
+ * How many terms a group's preview names before it stops.
1053
+ *
1054
+ * Three: enough to say what kind of thing is in there, few enough that the line stays one line in a
1055
+ * multi-column layout.
1056
+ */
1057
+ const GROUP_SAMPLE = 3;
1058
+
1059
+ function groupsIn(entries, prefix) {
1060
+ const base = prefix === undefined || prefix === null ? "" : String(prefix);
1061
+ const counts = new Map();
1062
+ const samples = new Map();
1063
+ // Newest first, the order the list itself uses, so the preview is the top of what you would see inside.
1064
+ const ordered = (Array.isArray(entries) ? entries : [])
1065
+ .filter((entry) => (entry?.deletedAt ?? 0) === 0 && typeof entry?.term === "string" && entry.term !== "")
1066
+ .slice()
1067
+ .sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
1068
+ for (const entry of ordered) {
1069
+ if ((entry?.deletedAt ?? 0) !== 0) continue;
1070
+ const group = typeof entry?.group === "string" ? entry.group : "";
1071
+ if (group === "" || group === base) continue;
1072
+ let inside = null;
1073
+ if (base === "") inside = group;
1074
+ else if (group.startsWith(`${base}/`)) inside = group.slice(base.length + 1);
1075
+ if (inside === null || inside === "") continue;
1076
+ const name = inside.split("/")[0];
1077
+ counts.set(name, (counts.get(name) ?? 0) + 1);
1078
+ const sample = samples.get(name) ?? [];
1079
+ if (sample.length < GROUP_SAMPLE) sample.push(entry.term);
1080
+ samples.set(name, sample);
1081
+ }
1082
+ return [...counts.entries()]
1083
+ .map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count, sample: samples.get(name) ?? [] }))
1084
+ .sort((left, right) => left.name.localeCompare(right.name));
1085
+ }
1086
+
1087
+ /**
1088
+ * The panel's visible list: one level of the group tree, filtered by query, then ordered by when each
1089
+ * term was last seen.
906
1090
  * @param state - the document.
907
1091
  * @param query - search text.
908
1092
  * @param filter - `all`, `unexplained`, `pinned` or `flagged`.
1093
+ * @param group - the group path to look inside, `""` for the top level.
909
1094
  * @returns the ordered entries.
910
1095
  */
911
- function listEntries(state, query, filter) {
1096
+ function listEntries(state, query, filter, group) {
1097
+ const base = group === undefined || group === null ? "" : String(group);
912
1098
  const matched = state.entries.filter((entry) => {
913
1099
  if (entry.deletedAt !== 0) return false;
1100
+ // The list shows the level you are standing in, not everything below it: a group is a directory,
1101
+ // and what is inside it is what you see after entering it.
1102
+ if ((typeof entry.group === "string" ? entry.group : "") !== base) return false;
914
1103
  if (!matchesQuery(entry, query)) return false;
915
1104
  if (filter === "unexplained") return isUnexplained(entry);
916
1105
  if (filter === "pinned") return entry.pinned;
@@ -920,13 +1109,14 @@ function listEntries(state, query, filter) {
920
1109
  if (filter === "flagged") return isFlagged(entry);
921
1110
  return true;
922
1111
  });
923
- return matched.sort(
924
- (left, right) =>
925
- Number(right.pinned) - Number(left.pinned) ||
926
- Number(isUnexplained(left)) - Number(isUnexplained(right)) ||
927
- right.lastSeenAt - left.lastSeenAt ||
928
- left.term.localeCompare(right.term)
929
- );
1112
+ // Newest term first, and NOTHING ELSE.
1113
+ //
1114
+ // It used to sort pinned first and unexplained first, and it then sorted by when each term was last
1115
+ // SEEN — every one of which moves a row under the reader's hand: pin a term and it jumped to the top,
1116
+ // answer a prompt and it jumped, and the transcript merely mentioning a term again shuffled the list.
1117
+ // Creation time is the one thing no action changes, so the list is still while the reader reads it.
1118
+ // The pin is shown on the row itself and 「已钉选」 gathers the pinned ones, so ordering has no job left.
1119
+ return matched.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
930
1120
  }
931
1121
 
932
1122
  /**
@@ -1068,6 +1258,9 @@ module.exports = {
1068
1258
  tombstonesOf,
1069
1259
  upsertEntry,
1070
1260
  editEntry,
1261
+ restoreEntry,
1262
+ renameGroup,
1263
+ deleteGroup,
1071
1264
  recordSighting,
1072
1265
  removeEntry,
1073
1266
  reviveEntry,
@@ -1077,6 +1270,7 @@ module.exports = {
1077
1270
  mergeState,
1078
1271
  summarize,
1079
1272
  listEntries,
1273
+ groupsIn,
1080
1274
  listDeleted,
1081
1275
  createFileStore,
1082
1276
  createBrowserStore
@@ -32,6 +32,15 @@ const MAX_CONTEXT_CHARS = 400;
32
32
  /** Upper bound on a feedback note, which is a remark rather than an explanation. */
33
33
  const MAX_NOTE_CHARS = 200;
34
34
 
35
+ /**
36
+ * Upper bound on an entry's group path.
37
+ *
38
+ * A path rather than a name: a group can hold groups, because that is what a directory does and what a
39
+ * folder import produces. One string covers any depth, which is why nothing else had to be added to the
40
+ * document to get nesting.
41
+ */
42
+ const MAX_GROUP_CHARS = 120;
43
+
35
44
  /**
36
45
  * What a user can say is wrong with an entry.
37
46
  *
@@ -121,6 +130,25 @@ function normalizeEntry(value, options) {
121
130
  notes: clampText(definition.notes, MAX_DEFINITION_CHARS)
122
131
  },
123
132
  domain: clampText(value.domain, 24),
133
+ /**
134
+ * The group (folder) this entry lives in: `""` for the top level, or a `/`-separated path.
135
+ *
136
+ * A CONTAINER, not a topic: `domain` is a semantic label the reader assigns, while this is where
137
+ * the entry was put. It needs its own stamp (§ `groupAt`) because a move is a decision — the first
138
+ * attempt let it ride on the content merge, and a merge of one entry from two windows then wiped
139
+ * the group order-dependently.
140
+ */
141
+ group: clampText(value.group, MAX_GROUP_CHARS),
142
+ // When the group was last decided, epoch milliseconds, 0 for "never moved".
143
+ //
144
+ // The third use of this pattern, and for the reason the other two exist: a decision the user can
145
+ // make and unmake cannot be carried by content-merging rules.
146
+ // When the user restored this term, epoch milliseconds, 0 for never.
147
+ //
148
+ // The named evidence a merge weighs against a deletion, so a restoration does not have to win a
149
+ // contest over a clock — see `restoreEntry`.
150
+ restoredAt: typeof value.restoredAt === "number" && Number.isFinite(value.restoredAt) && value.restoredAt > 0 ? Math.floor(value.restoredAt) : 0,
151
+ groupAt: typeof value.groupAt === "number" && Number.isFinite(value.groupAt) && value.groupAt > 0 ? Math.floor(value.groupAt) : 0,
124
152
  source: SOURCES.includes(value.source) ? value.source : "auto",
125
153
  confidence: typeof value.confidence === "number" && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : 0.5,
126
154
  createdAt: typeof value.createdAt === "number" && value.createdAt > 0 ? value.createdAt : now,
@@ -302,6 +330,12 @@ function mergeEntry(current, incoming, options) {
302
330
  aliases: [...new Set([...current.aliases, ...incoming.aliases])].slice(0, 8),
303
331
  definition: takeDefinition ? incoming.definition : current.definition,
304
332
  domain: takeDefinition && incoming.domain !== "" ? incoming.domain : current.domain,
333
+ // Unlike the domain, an empty incoming group is taken when the content changed: moving an entry back
334
+ // to the top level is a move, and the old rule ("an empty value never overwrites") would make the
335
+ // top level unreachable for anything that had ever been filed.
336
+ group: (incoming.groupAt ?? 0) > (current.groupAt ?? 0) ? incoming.group : current.group,
337
+ groupAt: Math.max(incoming.groupAt ?? 0, current.groupAt ?? 0),
338
+ restoredAt: Math.max(incoming.restoredAt ?? 0, current.restoredAt ?? 0),
305
339
  // The STRONGEST provenance survives, not the arriving one. Taking the arriving
306
340
  // source let a later automatic copy of an already-curated term downgrade it to
307
341
  // `auto`, which then denied that term its own revival — and let its source be
@@ -405,6 +439,7 @@ module.exports = {
405
439
  MAX_DEFINITION_CHARS,
406
440
  MAX_CONTEXT_CHARS,
407
441
  MAX_NOTE_CHARS,
442
+ MAX_GROUP_CHARS,
408
443
  normalizeEntry,
409
444
  normalizeFeedback,
410
445
  entryAsText,
package/lib/core/hover.js CHANGED
@@ -199,6 +199,45 @@ function createHoverLayer(options) {
199
199
  return null;
200
200
  }
201
201
 
202
+ /**
203
+ * How far left of the caret a pointer may still count as being on the text.
204
+ *
205
+ * The space before a word is enough: landing in it resolves the caret to the word's own start, and a
206
+ * reader aiming at the first letter often lands there. A paragraph's left margin is tens of pixels, which
207
+ * is what this has to reject.
208
+ */
209
+ const LEFT_TOLERANCE_PX = 8;
210
+
211
+ /**
212
+ * Whether the caret a point resolved to actually sits at that point.
213
+ *
214
+ * `caretRangeFromPoint` CLAMPS horizontally: a point in the left margin of a line resolves to the line's
215
+ * start, offset 0. When a term begins that line, offset 0 IS that term — so every point from the panel's
216
+ * left edge to the first character read as "on the term", and the card opened from anywhere in the margin,
217
+ * with a click to match. That is how this was reported.
218
+ *
219
+ * The caret's own rectangle is the live proof of where the text is: it is measured from the document at
220
+ * this instant, so unlike a block snapshot it cannot go stale. One measurement per event, and only once a
221
+ * caret has been found at all.
222
+ *
223
+ * @param x - viewport x of the pointer.
224
+ * @param caret - the Range the point resolved to.
225
+ * @returns true when the caret is at the pointer rather than clamped to the start of a line.
226
+ */
227
+ function caretIsAt(x, caret) {
228
+ let rect = null;
229
+ try {
230
+ rect = typeof caret?.getBoundingClientRect === "function" ? caret.getBoundingClientRect() : null;
231
+ } catch (error) {
232
+ rect = null;
233
+ }
234
+ // A browser (or a fixture) that cannot answer is trusted: refusing every hover because one optional
235
+ // measurement is missing would be a worse failure than the one being fixed. An all-zero rectangle says
236
+ // the same thing in another shape — a detached or not-yet-laid-out range.
237
+ if (rect === null || (rect.left === 0 && rect.top === 0 && rect.width === 0 && rect.height === 0)) return true;
238
+ return rect.left <= x + LEFT_TOLERANCE_PX;
239
+ }
240
+
202
241
  /**
203
242
  * The marked occurrence under a point.
204
243
  *
@@ -213,6 +252,7 @@ function createHoverLayer(options) {
213
252
  function matchAt(x, y) {
214
253
  const caret = caretAt(x, y);
215
254
  if (caret === null) return null;
255
+ if (!caretIsAt(x, caret)) return null;
216
256
  const at = resolveCaret(caret, doc);
217
257
  if (at === null) return null;
218
258
  let best = null;
package/lib/core/pack.js CHANGED
@@ -160,28 +160,41 @@ function buildPack(entries, options) {
160
160
  }
161
161
 
162
162
  /**
163
- * Read a pack from whatever arrived: a fetched file, or a decoded share code.
163
+ * Read a pack from whatever arrived: a fetched file, a decoded share code, or the text of either.
164
164
  *
165
165
  * Tolerant about unknown keys in the envelope, strict about the two things that make it a pack: its
166
166
  * marker and at least one usable entry. Every record goes through the same validator an imported file
167
167
  * does, so a record that cannot be imported is a record this refuses.
168
168
  *
169
- * @param raw - the parsed value.
169
+ * Accepts TEXT as well as a parsed object, because text is what actually arrives: the host fetches the
170
+ * file and passes the body on. Accepting only an object made every source and every pack answer
171
+ * `not-an-object` — a feature that refuses everything, in the shape of a bad URL.
172
+ *
173
+ * @param raw - the fetched body, the decoded text, or the parsed value.
170
174
  * @returns `{ ok: true, pack }`, or `{ ok: false, error }`.
171
175
  */
172
176
  function parsePack(raw) {
173
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, error: "not-an-object" };
174
- if (raw.kind !== PACK_KIND) return { ok: false, error: "not-a-pack" };
175
- if (raw.version !== PACK_VERSION) return { ok: false, error: "unsupported-version" };
176
- if (!Array.isArray(raw.entries)) return { ok: false, error: "no-entries" };
177
- if (raw.entries.length > MAX_PACK_ENTRIES) return { ok: false, error: "too-many" };
177
+ let value = raw;
178
+ if (typeof raw === "string") {
179
+ try {
180
+ value = JSON.parse(raw);
181
+ } catch (error) {
182
+ return { ok: false, error: "not-json" };
183
+ }
184
+ }
185
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "not-an-object" };
186
+ const raw2 = value;
187
+ if (raw2.kind !== PACK_KIND) return { ok: false, error: "not-a-pack" };
188
+ if (raw2.version !== PACK_VERSION) return { ok: false, error: "unsupported-version" };
189
+ if (!Array.isArray(raw2.entries)) return { ok: false, error: "no-entries" };
190
+ if (raw2.entries.length > MAX_PACK_ENTRIES) return { ok: false, error: "too-many" };
178
191
  const entries = [];
179
- for (const record of raw.entries) {
192
+ for (const record of raw2.entries) {
180
193
  const entry = transfer.toEntry(record);
181
194
  if (entry !== null) entries.push(record);
182
195
  }
183
196
  if (entries.length === 0) return { ok: false, error: "no-usable-entries" };
184
- const scope = raw.scope !== null && typeof raw.scope === "object" && raw.scope.kind === "domains" ? "domains" : "all";
197
+ const scope = raw2.scope !== null && typeof raw2.scope === "object" && raw2.scope.kind === "domains" ? "domains" : "all";
185
198
  return {
186
199
  ok: true,
187
200
  pack: {
@@ -280,17 +293,31 @@ function buildIndex(packs, options) {
280
293
  * in somebody's index must not hide the other forty packs. An index with nothing usable left is a
281
294
  * refusal, because a source that offers nothing is a source that is misconfigured or moved.
282
295
  *
283
- * @param raw - the parsed value.
296
+ * Takes TEXT or a parsed object, like {@link parsePack} — and the text form is the one production uses:
297
+ * the host fetches the source and hands the body straight over. Requiring an object here while the host
298
+ * sends text meant every source answered `not-an-object`, which is a whole feature answering "no" in a
299
+ * way that looks like the user's URL was wrong.
300
+ *
301
+ * @param raw - the fetched body, or the parsed value.
284
302
  * @returns `{ ok: true, index, dropped }`, or `{ ok: false, error }`.
285
303
  */
286
304
  function parseIndex(raw) {
287
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, error: "not-an-object" };
288
- if (raw.kind !== INDEX_KIND) return { ok: false, error: "not-an-index" };
289
- if (raw.version !== INDEX_VERSION) return { ok: false, error: "unsupported-version" };
290
- if (!Array.isArray(raw.packs)) return { ok: false, error: "no-packs" };
305
+ let value = raw;
306
+ if (typeof raw === "string") {
307
+ try {
308
+ value = JSON.parse(raw);
309
+ } catch (error) {
310
+ return { ok: false, error: "not-json" };
311
+ }
312
+ }
313
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "not-an-object" };
314
+ const raw2 = value;
315
+ if (raw2.kind !== INDEX_KIND) return { ok: false, error: "not-an-index" };
316
+ if (raw2.version !== INDEX_VERSION) return { ok: false, error: "unsupported-version" };
317
+ if (!Array.isArray(raw2.packs)) return { ok: false, error: "no-packs" };
291
318
  const packs = [];
292
319
  let dropped = 0;
293
- for (const pack of raw.packs) {
320
+ for (const pack of raw2.packs) {
294
321
  if (pack === null || typeof pack !== "object") {
295
322
  dropped++;
296
323
  continue;