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/CHANGELOG.md +84 -23
- package/README.md +8 -4
- package/lib/client.js +827 -59
- package/lib/core/copy.js +62 -0
- package/lib/core/dictionary.js +114 -10
- package/lib/core/entries.js +35 -0
- package/lib/core/pack.js +42 -15
- package/lib/core/settings.js +56 -2
- package/lib/core/store.js +31 -4
- package/lib/core/styles.js +17 -0
- package/lib/core/transfer.js +72 -2
- package/lib/core/views.js +398 -26
- package/package.json +1 -1
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",
|
package/lib/core/dictionary.js
CHANGED
|
@@ -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
|
|
905
|
-
*
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
package/lib/core/entries.js
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
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
|
-
|
|
174
|
-
if (raw
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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 =
|
|
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
|
-
* @
|
|
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
|
-
|
|
288
|
-
if (raw
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
320
|
+
for (const pack of raw2.packs) {
|
|
294
321
|
if (pack === null || typeof pack !== "object") {
|
|
295
322
|
dropped++;
|
|
296
323
|
continue;
|
package/lib/core/settings.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
340
|
-
return
|
|
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
|
/**
|
package/lib/core/styles.js
CHANGED
|
@@ -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",
|