dsh-plugin-term-dictionary 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core/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,12 @@ const zh = {
208
233
  usageLabel: "用法示例",
209
234
  notesLabel: "备注",
210
235
  domainLabel: "领域",
236
+ groupLabel: "分组",
237
+ groupOpen: "进入这个分组",
238
+ groupGo: "回到「{name}」",
239
+ groupCount: "{count} 条",
240
+ groupEmpty: "这一层只有分组,还没有词条。",
241
+ groupPlaceholder: "留空 = 顶层;用 / 分层,例如 backend/net",
211
242
  termPlaceholder: "例如 event sourcing",
212
243
  glossPlaceholder: "用一两句话说明它的含义",
213
244
  usagePlaceholder: "可选:一句例子或典型用法",
@@ -293,6 +324,16 @@ const en = {
293
324
  explainLang_en: "English",
294
325
  explainDepthName: "Explanation depth",
295
326
  explainDepthHint: "How much the explanation says",
327
+ entryColumnsName: "Entry layout",
328
+ 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.",
329
+ entryColumns_auto: "Auto",
330
+ entryColumns_1: "1 column",
331
+ entryColumns_2: "2 columns",
332
+ entryColumns_3: "3 columns",
333
+ entryRowsName: "Row height",
334
+ 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.",
335
+ entryRows_uniform: "Uniform",
336
+ entryRows_compact: "Compact",
296
337
  explainDepth_brief: "One line",
297
338
  explainDepth_normal: "Normal",
298
339
  explainDepth_detailed: "Detailed",
@@ -340,6 +381,7 @@ const en = {
340
381
  packSourcesTitle: "Sources",
341
382
  packSourcesHint: "A source is somebody's static index.json. Only https is accepted (http, file and URLs carrying credentials are refused).",
342
383
  packNoSources: "No sources yet",
384
+ packBuiltInSource: "built in",
343
385
  packRemoveSource: "Remove",
344
386
  packAddSourceLabel: "Add a source",
345
387
  packAddSourcePlaceholder: "https://cdn.jsdelivr.net/gh/…/index.json",
@@ -395,6 +437,20 @@ const en = {
395
437
  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
438
  transferIncludeDeleted: "Import entries I deleted",
397
439
  transferIncludeDeletedHint: "When ticked, entries in the file that you had deleted are imported and retracted from the deleted list. Unticked, they stay there.",
440
+ transferConfirmTitle: "What “{name}” holds",
441
+ transferWillImport: "{count} entries will be imported",
442
+ transferWillSkipExisting: "{count} already here (skipped)",
443
+ transferWillSkipDeleted: "{count} you deleted (skipped by default)",
444
+ transferConfirm: "Import them",
445
+ transferShown: "Moved to All and cleared the search, so what was imported is in front of you.",
446
+ transferFolderTitle: "Import a folder",
447
+ 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.",
448
+ transferFolderAsGroup: "Build groups from the folders (the folder name is the group)",
449
+ transferFolderLabel: "{count} files in the folder",
450
+ transferFolderFound: "{files} .json files in the folder, {used} of them usable",
451
+ transferFolderSkipped: "{count} skipped:",
452
+ transferFolderAllSkipped: "none of the {count} .json files in that folder could be read",
453
+ transferNoJson: "that folder holds no .json files",
398
454
  transferReport: "Imported {imported}; skipped {existing} already here, {deleted} you deleted, {duplicates} duplicate or unusable.",
399
455
  transferFailed: "That did not work: {message}",
400
456
  settingsBack: "Back to the entries",
@@ -479,6 +535,12 @@ const en = {
479
535
  usageLabel: "Usage",
480
536
  notesLabel: "Notes",
481
537
  domainLabel: "Domain",
538
+ groupLabel: "Group",
539
+ groupOpen: "Open this group",
540
+ groupGo: "Go back to {name}",
541
+ groupCount: "{count} entries",
542
+ groupEmpty: "This level holds only groups so far.",
543
+ groupPlaceholder: "empty = top level; use / to nest, e.g. backend/net",
482
544
  termPlaceholder: "e.g. event sourcing",
483
545
  glossPlaceholder: "Say what it means in one or two sentences",
484
546
  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,51 @@ 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
+ * The groups immediately inside one path, with how many entries each holds.
971
+ *
972
+ * Only the immediate children: the list shows one level at a time, and a count covers everything under
973
+ * that child, however deep. A group exists as long as something is in it — there is no separate list of
974
+ * empty groups to keep in step with the entries, which is the trade this makes deliberately.
975
+ *
976
+ * @param entries - every live entry.
977
+ * @param prefix - the path to look inside, `""` for the top level.
978
+ * @returns `[{ group, name, count }]`, alphabetical.
979
+ */
980
+ function groupsIn(entries, prefix) {
981
+ const base = prefix === undefined || prefix === null ? "" : String(prefix);
982
+ const counts = new Map();
983
+ for (const entry of Array.isArray(entries) ? entries : []) {
984
+ if ((entry?.deletedAt ?? 0) !== 0) continue;
985
+ const group = typeof entry?.group === "string" ? entry.group : "";
986
+ if (group === "" || group === base) continue;
987
+ let inside = null;
988
+ if (base === "") inside = group;
989
+ else if (group.startsWith(`${base}/`)) inside = group.slice(base.length + 1);
990
+ if (inside === null || inside === "") continue;
991
+ const name = inside.split("/")[0];
992
+ counts.set(name, (counts.get(name) ?? 0) + 1);
993
+ }
994
+ return [...counts.entries()]
995
+ .map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count }))
996
+ .sort((left, right) => left.name.localeCompare(right.name));
997
+ }
998
+
999
+ /**
1000
+ * The panel's visible list: one level of the group tree, filtered by query, then ordered by when each
1001
+ * term was last seen.
906
1002
  * @param state - the document.
907
1003
  * @param query - search text.
908
1004
  * @param filter - `all`, `unexplained`, `pinned` or `flagged`.
1005
+ * @param group - the group path to look inside, `""` for the top level.
909
1006
  * @returns the ordered entries.
910
1007
  */
911
- function listEntries(state, query, filter) {
1008
+ function listEntries(state, query, filter, group) {
1009
+ const base = group === undefined || group === null ? "" : String(group);
912
1010
  const matched = state.entries.filter((entry) => {
913
1011
  if (entry.deletedAt !== 0) return false;
1012
+ // The list shows the level you are standing in, not everything below it: a group is a directory,
1013
+ // and what is inside it is what you see after entering it.
1014
+ if ((typeof entry.group === "string" ? entry.group : "") !== base) return false;
914
1015
  if (!matchesQuery(entry, query)) return false;
915
1016
  if (filter === "unexplained") return isUnexplained(entry);
916
1017
  if (filter === "pinned") return entry.pinned;
@@ -920,13 +1021,14 @@ function listEntries(state, query, filter) {
920
1021
  if (filter === "flagged") return isFlagged(entry);
921
1022
  return true;
922
1023
  });
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
- );
1024
+ // Newest term first, and NOTHING ELSE.
1025
+ //
1026
+ // It used to sort pinned first and unexplained first, and it then sorted by when each term was last
1027
+ // SEEN — every one of which moves a row under the reader's hand: pin a term and it jumped to the top,
1028
+ // answer a prompt and it jumped, and the transcript merely mentioning a term again shuffled the list.
1029
+ // Creation time is the one thing no action changes, so the list is still while the reader reads it.
1030
+ // The pin is shown on the row itself and 「已钉选」 gathers the pinned ones, so ordering has no job left.
1031
+ return matched.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
930
1032
  }
931
1033
 
932
1034
  /**
@@ -1068,6 +1170,7 @@ module.exports = {
1068
1170
  tombstonesOf,
1069
1171
  upsertEntry,
1070
1172
  editEntry,
1173
+ restoreEntry,
1071
1174
  recordSighting,
1072
1175
  removeEntry,
1073
1176
  reviveEntry,
@@ -1077,6 +1180,7 @@ module.exports = {
1077
1180
  mergeState,
1078
1181
  summarize,
1079
1182
  listEntries,
1183
+ groupsIn,
1080
1184
  listDeleted,
1081
1185
  createFileStore,
1082
1186
  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/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;
@@ -23,12 +23,34 @@ const SETTINGS_KEY = "dsh-plugin-term-dictionary:settings:v1";
23
23
  /** The most sources the list will hold. A UI bound, not a security one. */
24
24
  const MAX_SOURCES = 12;
25
25
 
26
+ /**
27
+ * The source a fresh install starts with: this plugin's own repository, as a static file.
28
+ *
29
+ * Why ship one at all: without it the packs page opens on an empty list, and a first-time reader has
30
+ * to be TOLD a URL before they can see what a term pack even is. The alternative — fetching from a
31
+ * server we run — is the thing this design refuses to have, so the default is an ordinary source like
32
+ * any other: visible in the list, removable in one press, and fetched only when the reader asks (the
33
+ * page refreshes it on first open, which is a request to the source THEY have configured).
34
+ *
35
+ * `@main` rather than a tag because a default should follow the packs the repository actually has:
36
+ * pinned to a tag, a new pack would need a plugin release before anyone could see it.
37
+ */
38
+ const DEFAULT_PACK_SOURCE = "https://cdn.jsdelivr.net/gh/lakerian/dsh-plugin-term-dictionary@main/packs/index.json";
39
+
26
40
  /**
27
41
  * The URL rule, imported rather than restated: the settings store, the page and the host's transport
28
42
  * must agree on what a usable source is, and two copies of that rule is how one of them drifts.
29
43
  */
30
44
  const { refuseUrl } = require("./pack.js");
31
45
 
46
+ const ENTRY_COLUMNS = ["auto", "1", "2", "3"];
47
+
48
+ /** The two row-height readings: equal cards, or each card as tall as its own text. */
49
+ const ENTRY_ROWS = ["uniform", "compact"];
50
+
51
+ /** The narrowest a column may be when the layout is choosing. Below this a gloss wraps every other word. */
52
+ const MIN_COLUMN_PX = 260;
53
+
32
54
  /**
33
55
  * The three switches and their defaults.
34
56
  *
@@ -80,6 +102,23 @@ const DEFAULT_SETTINGS = {
80
102
  * the pointer had gone. Anyone who wants a grace period may have one; nobody gets it by accident.
81
103
  */
82
104
  hoverOutMs: 0,
105
+ /**
106
+ * How many columns the entry list is laid out in.
107
+ *
108
+ * `auto` fits as many as the panel is wide enough for, and is the default: a dictionary row is
109
+ * short, a single column of them is a very long strip to read, and a narrow panel simply gets one
110
+ * column back. The fixed values exist for a reader who wants the layout to stop moving under them.
111
+ */
112
+ entryColumns: "auto",
113
+ /**
114
+ * How tall a row of the entry list is.
115
+ *
116
+ * `uniform` gives every card in a grid row the height of the tallest, so the cards line up and no
117
+ * empty strip is left under the short ones. `compact` lets each keep its own height, which reads
118
+ * tighter in a single column and leaves exactly those strips in a grid. Both are defensible, which
119
+ * is why it is a preference rather than a constant.
120
+ */
121
+ entryRows: "uniform",
83
122
  /**
84
123
  * The term-pack sources the user added: https URLs of static `index.json` files.
85
124
  *
@@ -88,8 +127,13 @@ const DEFAULT_SETTINGS = {
88
127
  * is not configuration. Validated by the same rule the host applies before it fetches anything
89
128
  * ({@link module:core/pack.refuseUrl}), so the page and the transport cannot disagree about what a
90
129
  * usable source is.
130
+ *
131
+ * The default is not empty: see {@link DEFAULT_PACK_SOURCE}. An ABSENT key means "never touched, use
132
+ * the default"; an empty array means "the reader removed them all", which is honoured (§
133
+ * `normalizeSettings`) — the two are different statements and a store that conflated them would
134
+ * resurrect the default every time somebody cleared the list.
91
135
  */
92
- packSources: []
136
+ packSources: [DEFAULT_PACK_SOURCE]
93
137
  };
94
138
 
95
139
  /** The accepted minimum term lengths, for the panel's cycling control. */
@@ -195,7 +239,13 @@ function normalizeSettings(raw) {
195
239
  collectCjk: readFlag(value.collectCjk, DEFAULT_SETTINGS.collectCjk),
196
240
  hoverInMs: readDelay(value.hoverInMs, DEFAULT_SETTINGS.hoverInMs),
197
241
  hoverOutMs: readDelay(value.hoverOutMs, DEFAULT_SETTINGS.hoverOutMs),
198
- packSources: readSources(value.packSources)
242
+ entryColumns: readChoice(value.entryColumns, ENTRY_COLUMNS, DEFAULT_SETTINGS.entryColumns),
243
+ entryRows: readChoice(value.entryRows, ENTRY_ROWS, DEFAULT_SETTINGS.entryRows),
244
+ // Absent means "never touched" and gets the shipped default; an empty array means the reader
245
+ // removed every source, and is kept as it is. A fresh array either way: a snapshot is compared by
246
+ // identity, so handing back the shared default array would make one page's edit appear in
247
+ // another page's defaults.
248
+ packSources: value.packSources === undefined ? [...DEFAULT_SETTINGS.packSources] : readSources(value.packSources)
199
249
  };
200
250
  }
201
251
 
@@ -303,6 +353,10 @@ module.exports = {
303
353
  DEFAULT_SETTINGS,
304
354
  normalizeSettings,
305
355
  MAX_SOURCES,
356
+ DEFAULT_PACK_SOURCE,
357
+ ENTRY_COLUMNS,
358
+ ENTRY_ROWS,
359
+ MIN_COLUMN_PX,
306
360
  EXPLAIN_LANGS,
307
361
  EXPLAIN_DEPTHS,
308
362
  COLLECT_LENGTHS,
package/lib/core/store.js CHANGED
@@ -201,6 +201,21 @@ function createDictionaryStore(options) {
201
201
  return result.entry;
202
202
  },
203
203
 
204
+ /**
205
+ * Bring a deleted term back, as an explicit act.
206
+ *
207
+ * See `restoreEntry` in the core: a restoration is a decision with its own evidence and it
208
+ * withdraws the tombstone, rather than an edit that has to win a comparison on the way to the host.
209
+ * @param idOrTerm - the entry id, term or alias.
210
+ * @param patch - the text to restore it with, if any.
211
+ * @returns a promise resolving to `{ entry, restored }`.
212
+ */
213
+ async restoreEntry(idOrTerm, patch) {
214
+ const result = dictionary.restoreEntry(state, idOrTerm, patch, { now: now() });
215
+ if (result.restored) await commit(result.state);
216
+ return { entry: result.entry, restored: result.restored };
217
+ },
218
+
204
219
  /**
205
220
  * Record that a term appeared in a message, without overwriting a definition
206
221
  * the user wrote.
@@ -327,17 +342,29 @@ function createDictionaryStore(options) {
327
342
  return this.saveEntry(found.term, patch);
328
343
  },
329
344
 
345
+ /**
346
+ * The groups directly inside one path, each with how many entries it holds.
347
+ * @param prefix - the path to look inside, `""` for the top level.
348
+ * @returns `[{ group, name, count }]`.
349
+ */
350
+ groups(prefix) {
351
+ return dictionary.groupsIn(state.entries, prefix);
352
+ },
353
+
330
354
  /**
331
355
  * The visible list for the panel.
332
356
  * @param query - search text.
333
357
  * @param filter - `all`, `unexplained`, `pinned`, `deleted` or `flagged`.
358
+ * @param group - the group path to look inside, `""` for the top level.
334
359
  * @returns the ordered entries. For `deleted`, the tombstones.
335
360
  */
336
- list(query, filter) {
361
+ list(query, filter, group) {
362
+ const at = group === undefined || group === null ? "" : String(group);
337
363
  // The deleted view is a different LIST rather than a different filter: tombstones live in
338
- // `backing`, not in `entries`, so no predicate over the live list could ever show one.
339
- if (filter === "deleted") return dictionary.listDeleted(state, query);
340
- return listEntries(state, query, filter);
364
+ // `backing`, not in `entries`, so no predicate over the live list could ever show one. The group
365
+ // still applies — standing in a folder and asking what was deleted there means that folder.
366
+ if (filter === "deleted") return dictionary.listDeleted(state, query).filter((entry) => (typeof entry.group === "string" ? entry.group : "") === at);
367
+ return listEntries(state, query, filter, at);
341
368
  },
342
369
 
343
370
  /**
@@ -124,6 +124,23 @@ const applyStyles = {
124
124
  boxShadow: "0 0 0 1px var(--dsw-alias-brand-primary, #4d6bfe)"
125
125
  },
126
126
  rowHead: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" },
127
+ /** Where you are, and the way back up: a directory you can only enter is a trap. */
128
+ breadcrumb: { display: "flex", alignItems: "center", gap: "2px", flexWrap: "wrap", padding: "2px 0 6px", fontSize: "12px" },
129
+ breadcrumbPart: { display: "inline-flex", alignItems: "center", gap: "2px" },
130
+ breadcrumbSep: { color: "var(--dsw-alias-label-tertiary)", margin: "0 2px" },
131
+ breadcrumbButton: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-brand-primary, #4d6bfe)", cursor: "pointer" },
132
+ breadcrumbHere: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-label-secondary)", cursor: "default" },
133
+ /** The group row's own bits: the mark that says "not an entry", and the chevron that says "opens". */
134
+ groupIcon: { display: "inline-flex", alignItems: "center", color: "var(--dsw-alias-label-secondary)" },
135
+ groupEnter: { color: "var(--dsw-alias-label-tertiary)", fontSize: "14px", lineHeight: 1 },
136
+ /**
137
+ * The row's action toolbar: the second line, always.
138
+ *
139
+ * On the title line the controls moved from row to row with the length of the term and the number of
140
+ * badges; a line of their own puts every row's controls in the same place and keeps them out of the
141
+ * way of the text they act on.
142
+ */
143
+ rowActions: { display: "flex", alignItems: "center", gap: "6px", marginTop: "2px" },
127
144
  termButton: {
128
145
  appearance: "none",
129
146
  background: "transparent",