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/CHANGELOG.md +117 -23
- package/README.md +8 -4
- package/lib/client.js +1091 -59
- package/lib/core/copy.js +88 -0
- package/lib/core/dictionary.js +204 -10
- package/lib/core/entries.js +35 -0
- package/lib/core/hover.js +40 -0
- package/lib/core/pack.js +42 -15
- package/lib/core/settings.js +56 -2
- package/lib/core/store.js +54 -4
- package/lib/core/styles.js +19 -0
- package/lib/core/transfer.js +72 -2
- package/lib/core/views.js +481 -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,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",
|
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,139 @@ function summarize(state) {
|
|
|
901
967
|
}
|
|
902
968
|
|
|
903
969
|
/**
|
|
904
|
-
*
|
|
905
|
-
*
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
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/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,
|
|
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;
|