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/client.js
CHANGED
|
@@ -369,6 +369,16 @@ window.__ModuleLoader__.load({
|
|
|
369
369
|
explainLang_en: "英文",
|
|
370
370
|
explainDepthName: "详细程度",
|
|
371
371
|
explainDepthHint: "解释写多详细",
|
|
372
|
+
entryColumnsName: "词条排列",
|
|
373
|
+
entryColumnsHint: "一列排下去很长;「自动」按面板宽度铺成多列,窄了就回到一列。",
|
|
374
|
+
entryColumns_auto: "自动",
|
|
375
|
+
entryColumns_1: "1 列",
|
|
376
|
+
entryColumns_2: "2 列",
|
|
377
|
+
entryColumns_3: "3 列",
|
|
378
|
+
entryRowsName: "词条行高",
|
|
379
|
+
entryRowsHint: "多列排版时,同一行的卡片要不要一样高。统一高度不会在矮卡片下面留出空条;各按各自更紧凑。",
|
|
380
|
+
entryRows_uniform: "统一高度",
|
|
381
|
+
entryRows_compact: "各按各自",
|
|
372
382
|
explainDepth_brief: "一句话",
|
|
373
383
|
explainDepth_normal: "标准",
|
|
374
384
|
explainDepth_detailed: "详细",
|
|
@@ -401,6 +411,20 @@ window.__ModuleLoader__.load({
|
|
|
401
411
|
transferImportHint: "选择一个 JSON 文件。已存在的词条会原样保留;你删除过的词条默认不复活;导入完成后会报告各自的条数。",
|
|
402
412
|
transferIncludeDeleted: "导入我删除过的词条",
|
|
403
413
|
transferIncludeDeletedHint: "勾选后,文件里那些你删除过的词条会被导入,并从「已删除」里撤回。不勾选则它们留在删除列表里。",
|
|
414
|
+
transferConfirmTitle: "「{name}」里有什么",
|
|
415
|
+
transferWillImport: "将导入 {count} 条",
|
|
416
|
+
transferWillSkipExisting: "已有 {count} 条(跳过)",
|
|
417
|
+
transferWillSkipDeleted: "你删过 {count} 条(默认跳过)",
|
|
418
|
+
transferConfirm: "确认导入",
|
|
419
|
+
transferShown: "已切到「全部」并清空搜索,好让刚导入的词条就在眼前。",
|
|
420
|
+
transferFolderTitle: "导入文件夹",
|
|
421
|
+
transferFolderHint: "选一个文件夹,把里面的 .json 全部读一遍(导出的词典、下载的包文件都认)。不是 JSON 的文件直接忽略;读不了的会点名列出。",
|
|
422
|
+
transferFolderAsGroup: "按文件夹建立分组(文件夹名就是组名)",
|
|
423
|
+
transferFolderLabel: "文件夹里的 {count} 个文件",
|
|
424
|
+
transferFolderFound: "文件夹里 {files} 个 .json,用上 {used} 个",
|
|
425
|
+
transferFolderSkipped: "跳过 {count} 个:",
|
|
426
|
+
transferFolderAllSkipped: "这个文件夹里 {count} 个 .json 都读不了",
|
|
427
|
+
transferNoJson: "这个文件夹里没有 .json 文件",
|
|
404
428
|
transferReport: "导入 {imported} 条;跳过:已存在 {existing} 条、你删除过 {deleted} 条、重复或无效 {duplicates} 条。",
|
|
405
429
|
transferFailed: "操作失败:{message}",
|
|
406
430
|
settingsBack: "返回词条列表",
|
|
@@ -492,6 +516,7 @@ window.__ModuleLoader__.load({
|
|
|
492
516
|
packSourcesTitle: "源",
|
|
493
517
|
packSourcesHint: "源就是别人托管的一个静态 index.json。只接受 https(http、file、带账号密码的 URL 都会被拒)。",
|
|
494
518
|
packNoSources: "还没有添加源",
|
|
519
|
+
packBuiltInSource: "内置",
|
|
495
520
|
packRemoveSource: "移除",
|
|
496
521
|
packAddSourceLabel: "添加源",
|
|
497
522
|
packAddSourcePlaceholder: "https://cdn.jsdelivr.net/gh/…/index.json",
|
|
@@ -554,6 +579,25 @@ window.__ModuleLoader__.load({
|
|
|
554
579
|
usageLabel: "用法示例",
|
|
555
580
|
notesLabel: "备注",
|
|
556
581
|
domainLabel: "领域",
|
|
582
|
+
groupLabel: "分组",
|
|
583
|
+
groupOpen: "进入这个分组",
|
|
584
|
+
groupGo: "回到「{name}」",
|
|
585
|
+
groupCount: "{count} 条",
|
|
586
|
+
groupEmpty: "这一层只有分组,还没有词条。",
|
|
587
|
+
groupSampleJoin: "、",
|
|
588
|
+
groupSampleMore: " 等 {count} 条",
|
|
589
|
+
groupRename: "重命名或移动",
|
|
590
|
+
groupRenameTo: "改到:",
|
|
591
|
+
groupRenameApply: "改名 / 移动",
|
|
592
|
+
groupDelete: "删除分组",
|
|
593
|
+
groupRenamed: "已移动 {count} 条词条。",
|
|
594
|
+
groupDeleted: "已删除 {count} 条词条,它们都在「已删除」里,可以撤回。",
|
|
595
|
+
groupDeleteConfirm: "删除分组「{name}」里的 {count} 条词条?它们会进入「已删除」,之后可以逐条撤回。",
|
|
596
|
+
"groupError_no-group": "那个分组不存在。",
|
|
597
|
+
"groupError_no-name": "分组名不能为空。",
|
|
598
|
+
"groupError_same": "新旧名字一样,没有需要动的地方。",
|
|
599
|
+
"groupError_into-itself": "不能把分组移动到它自己里面。",
|
|
600
|
+
groupPlaceholder: "留空 = 顶层;用 / 分层,例如 backend/net",
|
|
557
601
|
termPlaceholder: "例如 event sourcing",
|
|
558
602
|
glossPlaceholder: "用一两句话说明它的含义",
|
|
559
603
|
usagePlaceholder: "可选:一句例子或典型用法",
|
|
@@ -639,6 +683,16 @@ window.__ModuleLoader__.load({
|
|
|
639
683
|
explainLang_en: "English",
|
|
640
684
|
explainDepthName: "Explanation depth",
|
|
641
685
|
explainDepthHint: "How much the explanation says",
|
|
686
|
+
entryColumnsName: "Entry layout",
|
|
687
|
+
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.",
|
|
688
|
+
entryColumns_auto: "Auto",
|
|
689
|
+
entryColumns_1: "1 column",
|
|
690
|
+
entryColumns_2: "2 columns",
|
|
691
|
+
entryColumns_3: "3 columns",
|
|
692
|
+
entryRowsName: "Row height",
|
|
693
|
+
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.",
|
|
694
|
+
entryRows_uniform: "Uniform",
|
|
695
|
+
entryRows_compact: "Compact",
|
|
642
696
|
explainDepth_brief: "One line",
|
|
643
697
|
explainDepth_normal: "Normal",
|
|
644
698
|
explainDepth_detailed: "Detailed",
|
|
@@ -686,6 +740,7 @@ window.__ModuleLoader__.load({
|
|
|
686
740
|
packSourcesTitle: "Sources",
|
|
687
741
|
packSourcesHint: "A source is somebody's static index.json. Only https is accepted (http, file and URLs carrying credentials are refused).",
|
|
688
742
|
packNoSources: "No sources yet",
|
|
743
|
+
packBuiltInSource: "built in",
|
|
689
744
|
packRemoveSource: "Remove",
|
|
690
745
|
packAddSourceLabel: "Add a source",
|
|
691
746
|
packAddSourcePlaceholder: "https://cdn.jsdelivr.net/gh/…/index.json",
|
|
@@ -741,6 +796,20 @@ window.__ModuleLoader__.load({
|
|
|
741
796
|
transferImportHint: "Choose a JSON file. Entries you already have are left as they are, and entries you deleted stay deleted unless you say otherwise below; the counts are reported when it finishes.",
|
|
742
797
|
transferIncludeDeleted: "Import entries I deleted",
|
|
743
798
|
transferIncludeDeletedHint: "When ticked, entries in the file that you had deleted are imported and retracted from the deleted list. Unticked, they stay there.",
|
|
799
|
+
transferConfirmTitle: "What “{name}” holds",
|
|
800
|
+
transferWillImport: "{count} entries will be imported",
|
|
801
|
+
transferWillSkipExisting: "{count} already here (skipped)",
|
|
802
|
+
transferWillSkipDeleted: "{count} you deleted (skipped by default)",
|
|
803
|
+
transferConfirm: "Import them",
|
|
804
|
+
transferShown: "Moved to All and cleared the search, so what was imported is in front of you.",
|
|
805
|
+
transferFolderTitle: "Import a folder",
|
|
806
|
+
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.",
|
|
807
|
+
transferFolderAsGroup: "Build groups from the folders (the folder name is the group)",
|
|
808
|
+
transferFolderLabel: "{count} files in the folder",
|
|
809
|
+
transferFolderFound: "{files} .json files in the folder, {used} of them usable",
|
|
810
|
+
transferFolderSkipped: "{count} skipped:",
|
|
811
|
+
transferFolderAllSkipped: "none of the {count} .json files in that folder could be read",
|
|
812
|
+
transferNoJson: "that folder holds no .json files",
|
|
744
813
|
transferReport: "Imported {imported}; skipped {existing} already here, {deleted} you deleted, {duplicates} duplicate or unusable.",
|
|
745
814
|
transferFailed: "That did not work: {message}",
|
|
746
815
|
settingsBack: "Back to the entries",
|
|
@@ -825,6 +894,25 @@ window.__ModuleLoader__.load({
|
|
|
825
894
|
usageLabel: "Usage",
|
|
826
895
|
notesLabel: "Notes",
|
|
827
896
|
domainLabel: "Domain",
|
|
897
|
+
groupLabel: "Group",
|
|
898
|
+
groupOpen: "Open this group",
|
|
899
|
+
groupGo: "Go back to {name}",
|
|
900
|
+
groupCount: "{count} entries",
|
|
901
|
+
groupEmpty: "This level holds only groups so far.",
|
|
902
|
+
groupSampleJoin: ", ",
|
|
903
|
+
groupSampleMore: " and {count} more",
|
|
904
|
+
groupRename: "Rename or move",
|
|
905
|
+
groupRenameTo: "Move to:",
|
|
906
|
+
groupRenameApply: "Rename / move",
|
|
907
|
+
groupDelete: "Delete group",
|
|
908
|
+
groupRenamed: "Moved {count} entries.",
|
|
909
|
+
groupDeleted: "Deleted {count} entries. They are on the deleted list and can be restored one by one.",
|
|
910
|
+
groupDeleteConfirm: "Delete the {count} entries in “{name}”? They go to the deleted list, where each can be restored.",
|
|
911
|
+
"groupError_no-group": "There is no such group.",
|
|
912
|
+
"groupError_no-name": "A group needs a name.",
|
|
913
|
+
"groupError_same": "The name is unchanged, so there is nothing to move.",
|
|
914
|
+
"groupError_into-itself": "A group cannot be moved inside itself.",
|
|
915
|
+
groupPlaceholder: "empty = top level; use / to nest, e.g. backend/net",
|
|
828
916
|
termPlaceholder: "e.g. event sourcing",
|
|
829
917
|
glossPlaceholder: "Say what it means in one or two sentences",
|
|
830
918
|
usagePlaceholder: "Optional: an example or typical use",
|
|
@@ -934,6 +1022,15 @@ window.__ModuleLoader__.load({
|
|
|
934
1022
|
/** Upper bound on a feedback note, which is a remark rather than an explanation. */
|
|
935
1023
|
const MAX_NOTE_CHARS = 200;
|
|
936
1024
|
|
|
1025
|
+
/**
|
|
1026
|
+
* Upper bound on an entry's group path.
|
|
1027
|
+
*
|
|
1028
|
+
* A path rather than a name: a group can hold groups, because that is what a directory does and what a
|
|
1029
|
+
* folder import produces. One string covers any depth, which is why nothing else had to be added to the
|
|
1030
|
+
* document to get nesting.
|
|
1031
|
+
*/
|
|
1032
|
+
const MAX_GROUP_CHARS = 120;
|
|
1033
|
+
|
|
937
1034
|
/**
|
|
938
1035
|
* What a user can say is wrong with an entry.
|
|
939
1036
|
*
|
|
@@ -1023,6 +1120,25 @@ window.__ModuleLoader__.load({
|
|
|
1023
1120
|
notes: clampText(definition.notes, MAX_DEFINITION_CHARS)
|
|
1024
1121
|
},
|
|
1025
1122
|
domain: clampText(value.domain, 24),
|
|
1123
|
+
/**
|
|
1124
|
+
* The group (folder) this entry lives in: `""` for the top level, or a `/`-separated path.
|
|
1125
|
+
*
|
|
1126
|
+
* A CONTAINER, not a topic: `domain` is a semantic label the reader assigns, while this is where
|
|
1127
|
+
* the entry was put. It needs its own stamp (§ `groupAt`) because a move is a decision — the first
|
|
1128
|
+
* attempt let it ride on the content merge, and a merge of one entry from two windows then wiped
|
|
1129
|
+
* the group order-dependently.
|
|
1130
|
+
*/
|
|
1131
|
+
group: clampText(value.group, MAX_GROUP_CHARS),
|
|
1132
|
+
// When the group was last decided, epoch milliseconds, 0 for "never moved".
|
|
1133
|
+
//
|
|
1134
|
+
// The third use of this pattern, and for the reason the other two exist: a decision the user can
|
|
1135
|
+
// make and unmake cannot be carried by content-merging rules.
|
|
1136
|
+
// When the user restored this term, epoch milliseconds, 0 for never.
|
|
1137
|
+
//
|
|
1138
|
+
// The named evidence a merge weighs against a deletion, so a restoration does not have to win a
|
|
1139
|
+
// contest over a clock — see `restoreEntry`.
|
|
1140
|
+
restoredAt: typeof value.restoredAt === "number" && Number.isFinite(value.restoredAt) && value.restoredAt > 0 ? Math.floor(value.restoredAt) : 0,
|
|
1141
|
+
groupAt: typeof value.groupAt === "number" && Number.isFinite(value.groupAt) && value.groupAt > 0 ? Math.floor(value.groupAt) : 0,
|
|
1026
1142
|
source: SOURCES.includes(value.source) ? value.source : "auto",
|
|
1027
1143
|
confidence: typeof value.confidence === "number" && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : 0.5,
|
|
1028
1144
|
createdAt: typeof value.createdAt === "number" && value.createdAt > 0 ? value.createdAt : now,
|
|
@@ -1204,6 +1320,12 @@ window.__ModuleLoader__.load({
|
|
|
1204
1320
|
aliases: [...new Set([...current.aliases, ...incoming.aliases])].slice(0, 8),
|
|
1205
1321
|
definition: takeDefinition ? incoming.definition : current.definition,
|
|
1206
1322
|
domain: takeDefinition && incoming.domain !== "" ? incoming.domain : current.domain,
|
|
1323
|
+
// Unlike the domain, an empty incoming group is taken when the content changed: moving an entry back
|
|
1324
|
+
// to the top level is a move, and the old rule ("an empty value never overwrites") would make the
|
|
1325
|
+
// top level unreachable for anything that had ever been filed.
|
|
1326
|
+
group: (incoming.groupAt ?? 0) > (current.groupAt ?? 0) ? incoming.group : current.group,
|
|
1327
|
+
groupAt: Math.max(incoming.groupAt ?? 0, current.groupAt ?? 0),
|
|
1328
|
+
restoredAt: Math.max(incoming.restoredAt ?? 0, current.restoredAt ?? 0),
|
|
1207
1329
|
// The STRONGEST provenance survives, not the arriving one. Taking the arriving
|
|
1208
1330
|
// source let a later automatic copy of an already-curated term downgrade it to
|
|
1209
1331
|
// `auto`, which then denied that term its own revival — and let its source be
|
|
@@ -1307,6 +1429,7 @@ window.__ModuleLoader__.load({
|
|
|
1307
1429
|
MAX_DEFINITION_CHARS,
|
|
1308
1430
|
MAX_CONTEXT_CHARS,
|
|
1309
1431
|
MAX_NOTE_CHARS,
|
|
1432
|
+
MAX_GROUP_CHARS,
|
|
1310
1433
|
normalizeEntry,
|
|
1311
1434
|
normalizeFeedback,
|
|
1312
1435
|
entryAsText,
|
|
@@ -1610,6 +1733,9 @@ window.__ModuleLoader__.load({
|
|
|
1610
1733
|
//
|
|
1611
1734
|
// So the comparison uses the newest time a record the USER asked for carries, and
|
|
1612
1735
|
// being strictly newer is what revives. Time alone is not intent; provenance is.
|
|
1736
|
+
// A NAMED restoration first: `restoreEntry` records that the user asked for this term back, which
|
|
1737
|
+
// is not an inference from a clock. The comparison below stays for the paths that revive by editing.
|
|
1738
|
+
if ((record.restoredAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
|
|
1613
1739
|
if ((options?.craftedAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
|
|
1614
1740
|
const alreadyDeleted = record.deletedAt ?? 0;
|
|
1615
1741
|
return { ...record, deletedAt: Math.max(alreadyDeleted, notice.deletedAt) };
|
|
@@ -1892,6 +2018,10 @@ window.__ModuleLoader__.load({
|
|
|
1892
2018
|
const feedbackStamp = mentionsFeedback ? Math.max(stamp, (base.feedbackAt ?? 0) + 1) : base.feedbackAt ?? 0;
|
|
1893
2019
|
const untrustedPatch = typeof patch?.untrusted === "boolean" ? patch.untrusted : null;
|
|
1894
2020
|
const untrustedStamp = untrustedPatch === null ? base.untrustedAt ?? 0 : Math.max(stamp, (base.untrustedAt ?? 0) + 1);
|
|
2021
|
+
// A move is a decision like a pin or a remark, and it is stamped strictly past the last one so that two
|
|
2022
|
+
// moves inside the same millisecond still have an order.
|
|
2023
|
+
const mentionsGroup = patch !== null && patch !== undefined && Object.prototype.hasOwnProperty.call(patch, "group");
|
|
2024
|
+
const groupStamp = mentionsGroup ? Math.max(stamp, (base.groupAt ?? 0) + 1) : base.groupAt ?? 0;
|
|
1895
2025
|
const candidate = normalizeEntry(
|
|
1896
2026
|
{
|
|
1897
2027
|
...base,
|
|
@@ -1900,6 +2030,11 @@ window.__ModuleLoader__.load({
|
|
|
1900
2030
|
deletedAt: 0,
|
|
1901
2031
|
aliases: patch?.aliases ?? base.aliases,
|
|
1902
2032
|
domain: patch?.domain ?? base.domain,
|
|
2033
|
+
// `patch.group` may legitimately be `""` — that is how an entry is moved back to the top level —
|
|
2034
|
+
// so this reads with `hasOwnProperty` rather than with `??`: a patch that says nothing about the
|
|
2035
|
+
// group must leave it, and its stamp, exactly where they were.
|
|
2036
|
+
group: mentionsGroup ? patch.group : base.group,
|
|
2037
|
+
groupAt: groupStamp,
|
|
1903
2038
|
definition: { ...base.definition, ...(patch?.definition ?? {}) },
|
|
1904
2039
|
source: "user",
|
|
1905
2040
|
confidence: 1,
|
|
@@ -1917,6 +2052,60 @@ window.__ModuleLoader__.load({
|
|
|
1917
2052
|
return upsertEntry(state, { ...candidate, source: "user", deletedAt: 0 }, { now: stamp });
|
|
1918
2053
|
}
|
|
1919
2054
|
|
|
2055
|
+
/**
|
|
2056
|
+
* Bring a deleted term back, as an explicit act rather than as a side effect of an edit.
|
|
2057
|
+
*
|
|
2058
|
+
* This exists because "restore this deleted term" is a decision, and until now it was expressed as "write
|
|
2059
|
+
* an edit and hope the merge weighs it right": `editEntry` produced a crafted timestamp, and a merge
|
|
2060
|
+
* revived the record when that timestamp beat the tombstone's. That works — there is a check for it — but
|
|
2061
|
+
* it means the import path, the editor, a sighting and a second window all revive through one inferred
|
|
2062
|
+
* comparison, and a term the user had collected and deleted came back as "nothing happened".
|
|
2063
|
+
*
|
|
2064
|
+
* So a restoration is its own thing:
|
|
2065
|
+
*
|
|
2066
|
+
* - `restoredAt` is the evidence, weighed against `notice.deletedAt` like `pinnedAt` is weighed against
|
|
2067
|
+
* silence. It is what the merge reads, and it exists even when the tombstone's clock ran ahead;
|
|
2068
|
+
* - the tombstone is WITHDRAWN from the document this returns — the key leaves `deletedKeys` and the
|
|
2069
|
+
* record leaves `backing` — so a reader of this document is not told the term is deleted at all. Being
|
|
2070
|
+
* merely outvoted is what let a later merge put the deletion back;
|
|
2071
|
+
* - the text is kept: restoring is not the same as rewriting, so the patch is applied on top of whatever
|
|
2072
|
+
* the tombstone still remembers.
|
|
2073
|
+
*
|
|
2074
|
+
* @param state - the current document.
|
|
2075
|
+
* @param idOrTerm - the entry id, term or alias.
|
|
2076
|
+
* @param patch - `definition`, `domain`, `group`, `aliases`, `pinned`.
|
|
2077
|
+
* @param options - `now` overrides the restoration time.
|
|
2078
|
+
* @returns `{ state, entry, restored }`; `restored` is false when the term was not deleted.
|
|
2079
|
+
*/
|
|
2080
|
+
function restoreEntry(state, idOrTerm, patch, options) {
|
|
2081
|
+
const found = findEntryIncludingDeleted(state, idOrTerm);
|
|
2082
|
+
if (found === undefined) {
|
|
2083
|
+
// Nothing to restore: the caller wanted a new entry, and `editEntry` is the path for that.
|
|
2084
|
+
const created = editEntry(state, typeof idOrTerm === "string" ? idOrTerm : "", patch, options);
|
|
2085
|
+
return { ...created, restored: false };
|
|
2086
|
+
}
|
|
2087
|
+
if ((found.deletedAt ?? 0) === 0) {
|
|
2088
|
+
// Already live: a restoration must not disturb it, and saying "restored" would be a lie the caller
|
|
2089
|
+
// might act on (the import reports it).
|
|
2090
|
+
return { state, entry: found, restored: false };
|
|
2091
|
+
}
|
|
2092
|
+
// The stamp is strictly past the deletion for the same reason `editEntry`'s is: a clock that ran ahead
|
|
2093
|
+
// must not let the tombstone win a comparison it lost the argument for.
|
|
2094
|
+
const now = options?.now ?? Date.now();
|
|
2095
|
+
const stamp = Math.max(now, (found.deletedAt ?? 0) + 1);
|
|
2096
|
+
const edited = editEntry(state, found.term, { ...patch, definition: { ...found.definition, ...(patch?.definition ?? {}) } }, { now: stamp });
|
|
2097
|
+
const key = found.key;
|
|
2098
|
+
// The withdrawal, in the document itself: the record is rewritten live and the whole thing is re-sealed,
|
|
2099
|
+
// so `deletedKeys` and `backing` are derived fresh and no longer announce the deletion. (An earlier
|
|
2100
|
+
// version also filtered them by hand first, which the re-seal made redundant — a mutation proved it by
|
|
2101
|
+
// not biting, so the dead work is gone.)
|
|
2102
|
+
const records = recordsOf(edited.state).map((entry) =>
|
|
2103
|
+
entry.id === found.id || entry.key === key ? { ...entry, deletedAt: 0, restoredAt: stamp, source: "user", updatedAt: stamp, lastSeenAt: stamp } : entry
|
|
2104
|
+
);
|
|
2105
|
+
const next = sealRecords(records, { now: stamp });
|
|
2106
|
+
return { state: next, entry: next.entries.find((entry) => entry.key === key) ?? edited.entry, restored: true };
|
|
2107
|
+
}
|
|
2108
|
+
|
|
1920
2109
|
/**
|
|
1921
2110
|
* Record one sighting of a term without replacing its meaning: the counters move
|
|
1922
2111
|
* and a missing definition is filled from the glossary, but a curated definition
|
|
@@ -2226,16 +2415,139 @@ window.__ModuleLoader__.load({
|
|
|
2226
2415
|
}
|
|
2227
2416
|
|
|
2228
2417
|
/**
|
|
2229
|
-
*
|
|
2230
|
-
*
|
|
2418
|
+
* Move a group — and everything under it — to another path.
|
|
2419
|
+
*
|
|
2420
|
+
* Renaming and moving are the same act, because a group IS a path: renaming `backend` to `服务端` and moving
|
|
2421
|
+
* `net` out of `backend` into `infra` are both "these entries live somewhere else now". One primitive, two
|
|
2422
|
+
* names in the UI, so the two cannot drift apart.
|
|
2423
|
+
*
|
|
2424
|
+
* Each entry is moved through {@link editEntry}, so each carries the stamped decision (`groupAt`) a merge
|
|
2425
|
+
* weighs — a bulk move is not an exception to "a move is a decision".
|
|
2426
|
+
*
|
|
2427
|
+
* Refused: an empty target, a target inside the group being moved (which would put it inside itself), and a
|
|
2428
|
+
* target that leaves everything where it is. Renaming ONTO an existing group is allowed and merges the two,
|
|
2429
|
+
* which is what a reader asking for it means.
|
|
2430
|
+
*
|
|
2431
|
+
* @param state - the current document.
|
|
2432
|
+
* @param from - the path to move.
|
|
2433
|
+
* @param to - the path to move it to.
|
|
2434
|
+
* @param options - `now` overrides the timestamps.
|
|
2435
|
+
* @returns `{ state, moved, error }`.
|
|
2436
|
+
*/
|
|
2437
|
+
function renameGroup(state, from, to, options) {
|
|
2438
|
+
const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
|
|
2439
|
+
const target = typeof to === "string" ? to.replace(/^\/+|\/+$/g, "") : "";
|
|
2440
|
+
if (source === "") return { state, moved: 0, error: "no-group" };
|
|
2441
|
+
if (target === "") return { state, moved: 0, error: "no-name" };
|
|
2442
|
+
if (target === source) return { state, moved: 0, error: "same" };
|
|
2443
|
+
if (target.startsWith(`${source}/`)) return { state, moved: 0, error: "into-itself" };
|
|
2444
|
+
const now = options?.now ?? Date.now();
|
|
2445
|
+
let next = state;
|
|
2446
|
+
let moved = 0;
|
|
2447
|
+
let stamp = now;
|
|
2448
|
+
for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
|
|
2449
|
+
stamp += 1;
|
|
2450
|
+
const rest = entry.group === source ? "" : entry.group.slice(source.length + 1);
|
|
2451
|
+
next = editEntry(next, entry.term, { group: rest === "" ? target : `${target}/${rest}` }, { now: stamp }).state;
|
|
2452
|
+
moved += 1;
|
|
2453
|
+
}
|
|
2454
|
+
return { state: next, moved, error: null };
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/**
|
|
2458
|
+
* Delete a group: every entry under it goes to the deleted list.
|
|
2459
|
+
*
|
|
2460
|
+
* Soft, like every other deletion here — the tombstones make it reversible from 「已删除」, and they are also
|
|
2461
|
+
* what stops a passing pack from putting the terms straight back. A group therefore disappears when the last
|
|
2462
|
+
* entry in it does, which is the same rule that made it appear.
|
|
2463
|
+
*
|
|
2464
|
+
* @param state - the current document.
|
|
2465
|
+
* @param from - the path to empty.
|
|
2466
|
+
* @param options - `now` overrides the timestamps.
|
|
2467
|
+
* @returns `{ state, removed, error }`.
|
|
2468
|
+
*/
|
|
2469
|
+
function deleteGroup(state, from, options) {
|
|
2470
|
+
const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
|
|
2471
|
+
if (source === "") return { state, removed: 0, error: "no-group" };
|
|
2472
|
+
const now = options?.now ?? Date.now();
|
|
2473
|
+
let next = state;
|
|
2474
|
+
let removed = 0;
|
|
2475
|
+
let stamp = now;
|
|
2476
|
+
for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
|
|
2477
|
+
stamp += 1;
|
|
2478
|
+
next = removeEntry(next, entry.id, { now: stamp }).state;
|
|
2479
|
+
removed += 1;
|
|
2480
|
+
}
|
|
2481
|
+
return { state: next, removed, error: null };
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
/**
|
|
2485
|
+
* The groups immediately inside one path, with how many entries each holds.
|
|
2486
|
+
*
|
|
2487
|
+
* Only the immediate children: the list shows one level at a time, and a count covers everything under
|
|
2488
|
+
* that child, however deep. A group exists as long as something is in it — there is no separate list of
|
|
2489
|
+
* empty groups to keep in step with the entries, which is the trade this makes deliberately.
|
|
2490
|
+
*
|
|
2491
|
+
* Each group also carries a SAMPLE of the terms inside it. A folder row with only a name and a count is a
|
|
2492
|
+
* row of empty space next to entries full of text — and a preview answers the question the row raises
|
|
2493
|
+
* without making the reader open it to find out.
|
|
2494
|
+
*
|
|
2495
|
+
* @param entries - every live entry.
|
|
2496
|
+
* @param prefix - the path to look inside, `""` for the top level.
|
|
2497
|
+
* @returns `[{ group, name, count, sample }]`, alphabetical.
|
|
2498
|
+
*/
|
|
2499
|
+
/**
|
|
2500
|
+
* How many terms a group's preview names before it stops.
|
|
2501
|
+
*
|
|
2502
|
+
* Three: enough to say what kind of thing is in there, few enough that the line stays one line in a
|
|
2503
|
+
* multi-column layout.
|
|
2504
|
+
*/
|
|
2505
|
+
const GROUP_SAMPLE = 3;
|
|
2506
|
+
|
|
2507
|
+
function groupsIn(entries, prefix) {
|
|
2508
|
+
const base = prefix === undefined || prefix === null ? "" : String(prefix);
|
|
2509
|
+
const counts = new Map();
|
|
2510
|
+
const samples = new Map();
|
|
2511
|
+
// Newest first, the order the list itself uses, so the preview is the top of what you would see inside.
|
|
2512
|
+
const ordered = (Array.isArray(entries) ? entries : [])
|
|
2513
|
+
.filter((entry) => (entry?.deletedAt ?? 0) === 0 && typeof entry?.term === "string" && entry.term !== "")
|
|
2514
|
+
.slice()
|
|
2515
|
+
.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
|
|
2516
|
+
for (const entry of ordered) {
|
|
2517
|
+
if ((entry?.deletedAt ?? 0) !== 0) continue;
|
|
2518
|
+
const group = typeof entry?.group === "string" ? entry.group : "";
|
|
2519
|
+
if (group === "" || group === base) continue;
|
|
2520
|
+
let inside = null;
|
|
2521
|
+
if (base === "") inside = group;
|
|
2522
|
+
else if (group.startsWith(`${base}/`)) inside = group.slice(base.length + 1);
|
|
2523
|
+
if (inside === null || inside === "") continue;
|
|
2524
|
+
const name = inside.split("/")[0];
|
|
2525
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
2526
|
+
const sample = samples.get(name) ?? [];
|
|
2527
|
+
if (sample.length < GROUP_SAMPLE) sample.push(entry.term);
|
|
2528
|
+
samples.set(name, sample);
|
|
2529
|
+
}
|
|
2530
|
+
return [...counts.entries()]
|
|
2531
|
+
.map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count, sample: samples.get(name) ?? [] }))
|
|
2532
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
/**
|
|
2536
|
+
* The panel's visible list: one level of the group tree, filtered by query, then ordered by when each
|
|
2537
|
+
* term was last seen.
|
|
2231
2538
|
* @param state - the document.
|
|
2232
2539
|
* @param query - search text.
|
|
2233
2540
|
* @param filter - `all`, `unexplained`, `pinned` or `flagged`.
|
|
2541
|
+
* @param group - the group path to look inside, `""` for the top level.
|
|
2234
2542
|
* @returns the ordered entries.
|
|
2235
2543
|
*/
|
|
2236
|
-
function listEntries(state, query, filter) {
|
|
2544
|
+
function listEntries(state, query, filter, group) {
|
|
2545
|
+
const base = group === undefined || group === null ? "" : String(group);
|
|
2237
2546
|
const matched = state.entries.filter((entry) => {
|
|
2238
2547
|
if (entry.deletedAt !== 0) return false;
|
|
2548
|
+
// The list shows the level you are standing in, not everything below it: a group is a directory,
|
|
2549
|
+
// and what is inside it is what you see after entering it.
|
|
2550
|
+
if ((typeof entry.group === "string" ? entry.group : "") !== base) return false;
|
|
2239
2551
|
if (!matchesQuery(entry, query)) return false;
|
|
2240
2552
|
if (filter === "unexplained") return isUnexplained(entry);
|
|
2241
2553
|
if (filter === "pinned") return entry.pinned;
|
|
@@ -2245,13 +2557,14 @@ window.__ModuleLoader__.load({
|
|
|
2245
2557
|
if (filter === "flagged") return isFlagged(entry);
|
|
2246
2558
|
return true;
|
|
2247
2559
|
});
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2560
|
+
// Newest term first, and NOTHING ELSE.
|
|
2561
|
+
//
|
|
2562
|
+
// It used to sort pinned first and unexplained first, and it then sorted by when each term was last
|
|
2563
|
+
// SEEN — every one of which moves a row under the reader's hand: pin a term and it jumped to the top,
|
|
2564
|
+
// answer a prompt and it jumped, and the transcript merely mentioning a term again shuffled the list.
|
|
2565
|
+
// Creation time is the one thing no action changes, so the list is still while the reader reads it.
|
|
2566
|
+
// The pin is shown on the row itself and 「已钉选」 gathers the pinned ones, so ordering has no job left.
|
|
2567
|
+
return matched.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
|
|
2255
2568
|
}
|
|
2256
2569
|
|
|
2257
2570
|
/**
|
|
@@ -2393,6 +2706,9 @@ window.__ModuleLoader__.load({
|
|
|
2393
2706
|
tombstonesOf,
|
|
2394
2707
|
upsertEntry,
|
|
2395
2708
|
editEntry,
|
|
2709
|
+
restoreEntry,
|
|
2710
|
+
renameGroup,
|
|
2711
|
+
deleteGroup,
|
|
2396
2712
|
recordSighting,
|
|
2397
2713
|
removeEntry,
|
|
2398
2714
|
reviveEntry,
|
|
@@ -2402,6 +2718,7 @@ window.__ModuleLoader__.load({
|
|
|
2402
2718
|
mergeState,
|
|
2403
2719
|
summarize,
|
|
2404
2720
|
listEntries,
|
|
2721
|
+
groupsIn,
|
|
2405
2722
|
listDeleted,
|
|
2406
2723
|
createFileStore,
|
|
2407
2724
|
createBrowserStore
|
|
@@ -4295,6 +4612,44 @@ window.__ModuleLoader__.load({
|
|
|
4295
4612
|
return result.entry;
|
|
4296
4613
|
},
|
|
4297
4614
|
|
|
4615
|
+
/**
|
|
4616
|
+
* Move a group, and everything under it, to another path.
|
|
4617
|
+
* @param from - the path to move.
|
|
4618
|
+
* @param to - the path to move it to.
|
|
4619
|
+
* @returns a promise resolving to `{ moved, error }`.
|
|
4620
|
+
*/
|
|
4621
|
+
async renameGroup(from, to) {
|
|
4622
|
+
const result = dictionary.renameGroup(state, from, to, { now: now() });
|
|
4623
|
+
if (result.moved > 0) await commit(result.state);
|
|
4624
|
+
return { moved: result.moved, error: result.error };
|
|
4625
|
+
},
|
|
4626
|
+
|
|
4627
|
+
/**
|
|
4628
|
+
* Delete a group by deleting everything inside it, which is the only way a group can be deleted.
|
|
4629
|
+
* @param from - the path to empty.
|
|
4630
|
+
* @returns a promise resolving to `{ removed, error }`.
|
|
4631
|
+
*/
|
|
4632
|
+
async deleteGroup(from) {
|
|
4633
|
+
const result = dictionary.deleteGroup(state, from, { now: now() });
|
|
4634
|
+
if (result.removed > 0) await commit(result.state);
|
|
4635
|
+
return { removed: result.removed, error: result.error };
|
|
4636
|
+
},
|
|
4637
|
+
|
|
4638
|
+
/**
|
|
4639
|
+
* Bring a deleted term back, as an explicit act.
|
|
4640
|
+
*
|
|
4641
|
+
* See `restoreEntry` in the core: a restoration is a decision with its own evidence and it
|
|
4642
|
+
* withdraws the tombstone, rather than an edit that has to win a comparison on the way to the host.
|
|
4643
|
+
* @param idOrTerm - the entry id, term or alias.
|
|
4644
|
+
* @param patch - the text to restore it with, if any.
|
|
4645
|
+
* @returns a promise resolving to `{ entry, restored }`.
|
|
4646
|
+
*/
|
|
4647
|
+
async restoreEntry(idOrTerm, patch) {
|
|
4648
|
+
const result = dictionary.restoreEntry(state, idOrTerm, patch, { now: now() });
|
|
4649
|
+
if (result.restored) await commit(result.state);
|
|
4650
|
+
return { entry: result.entry, restored: result.restored };
|
|
4651
|
+
},
|
|
4652
|
+
|
|
4298
4653
|
/**
|
|
4299
4654
|
* Record that a term appeared in a message, without overwriting a definition
|
|
4300
4655
|
* the user wrote.
|
|
@@ -4421,17 +4776,29 @@ window.__ModuleLoader__.load({
|
|
|
4421
4776
|
return this.saveEntry(found.term, patch);
|
|
4422
4777
|
},
|
|
4423
4778
|
|
|
4779
|
+
/**
|
|
4780
|
+
* The groups directly inside one path, each with how many entries it holds.
|
|
4781
|
+
* @param prefix - the path to look inside, `""` for the top level.
|
|
4782
|
+
* @returns `[{ group, name, count }]`.
|
|
4783
|
+
*/
|
|
4784
|
+
groups(prefix) {
|
|
4785
|
+
return dictionary.groupsIn(state.entries, prefix);
|
|
4786
|
+
},
|
|
4787
|
+
|
|
4424
4788
|
/**
|
|
4425
4789
|
* The visible list for the panel.
|
|
4426
4790
|
* @param query - search text.
|
|
4427
4791
|
* @param filter - `all`, `unexplained`, `pinned`, `deleted` or `flagged`.
|
|
4792
|
+
* @param group - the group path to look inside, `""` for the top level.
|
|
4428
4793
|
* @returns the ordered entries. For `deleted`, the tombstones.
|
|
4429
4794
|
*/
|
|
4430
|
-
list(query, filter) {
|
|
4795
|
+
list(query, filter, group) {
|
|
4796
|
+
const at = group === undefined || group === null ? "" : String(group);
|
|
4431
4797
|
// The deleted view is a different LIST rather than a different filter: tombstones live in
|
|
4432
|
-
// `backing`, not in `entries`, so no predicate over the live list could ever show one.
|
|
4433
|
-
|
|
4434
|
-
return
|
|
4798
|
+
// `backing`, not in `entries`, so no predicate over the live list could ever show one. The group
|
|
4799
|
+
// still applies — standing in a folder and asking what was deleted there means that folder.
|
|
4800
|
+
if (filter === "deleted") return dictionary.listDeleted(state, query).filter((entry) => (typeof entry.group === "string" ? entry.group : "") === at);
|
|
4801
|
+
return listEntries(state, query, filter, at);
|
|
4435
4802
|
},
|
|
4436
4803
|
|
|
4437
4804
|
/**
|
|
@@ -4491,12 +4858,34 @@ window.__ModuleLoader__.load({
|
|
|
4491
4858
|
/** The most sources the list will hold. A UI bound, not a security one. */
|
|
4492
4859
|
const MAX_SOURCES = 12;
|
|
4493
4860
|
|
|
4861
|
+
/**
|
|
4862
|
+
* The source a fresh install starts with: this plugin's own repository, as a static file.
|
|
4863
|
+
*
|
|
4864
|
+
* Why ship one at all: without it the packs page opens on an empty list, and a first-time reader has
|
|
4865
|
+
* to be TOLD a URL before they can see what a term pack even is. The alternative — fetching from a
|
|
4866
|
+
* server we run — is the thing this design refuses to have, so the default is an ordinary source like
|
|
4867
|
+
* any other: visible in the list, removable in one press, and fetched only when the reader asks (the
|
|
4868
|
+
* page refreshes it on first open, which is a request to the source THEY have configured).
|
|
4869
|
+
*
|
|
4870
|
+
* `@main` rather than a tag because a default should follow the packs the repository actually has:
|
|
4871
|
+
* pinned to a tag, a new pack would need a plugin release before anyone could see it.
|
|
4872
|
+
*/
|
|
4873
|
+
const DEFAULT_PACK_SOURCE = "https://cdn.jsdelivr.net/gh/lakerian/dsh-plugin-term-dictionary@main/packs/index.json";
|
|
4874
|
+
|
|
4494
4875
|
/**
|
|
4495
4876
|
* The URL rule, imported rather than restated: the settings store, the page and the host's transport
|
|
4496
4877
|
* must agree on what a usable source is, and two copies of that rule is how one of them drifts.
|
|
4497
4878
|
*/
|
|
4498
4879
|
const { refuseUrl } = require("./pack.js");
|
|
4499
4880
|
|
|
4881
|
+
const ENTRY_COLUMNS = ["auto", "1", "2", "3"];
|
|
4882
|
+
|
|
4883
|
+
/** The two row-height readings: equal cards, or each card as tall as its own text. */
|
|
4884
|
+
const ENTRY_ROWS = ["uniform", "compact"];
|
|
4885
|
+
|
|
4886
|
+
/** The narrowest a column may be when the layout is choosing. Below this a gloss wraps every other word. */
|
|
4887
|
+
const MIN_COLUMN_PX = 260;
|
|
4888
|
+
|
|
4500
4889
|
/**
|
|
4501
4890
|
* The three switches and their defaults.
|
|
4502
4891
|
*
|
|
@@ -4548,6 +4937,23 @@ window.__ModuleLoader__.load({
|
|
|
4548
4937
|
* the pointer had gone. Anyone who wants a grace period may have one; nobody gets it by accident.
|
|
4549
4938
|
*/
|
|
4550
4939
|
hoverOutMs: 0,
|
|
4940
|
+
/**
|
|
4941
|
+
* How many columns the entry list is laid out in.
|
|
4942
|
+
*
|
|
4943
|
+
* `auto` fits as many as the panel is wide enough for, and is the default: a dictionary row is
|
|
4944
|
+
* short, a single column of them is a very long strip to read, and a narrow panel simply gets one
|
|
4945
|
+
* column back. The fixed values exist for a reader who wants the layout to stop moving under them.
|
|
4946
|
+
*/
|
|
4947
|
+
entryColumns: "auto",
|
|
4948
|
+
/**
|
|
4949
|
+
* How tall a row of the entry list is.
|
|
4950
|
+
*
|
|
4951
|
+
* `uniform` gives every card in a grid row the height of the tallest, so the cards line up and no
|
|
4952
|
+
* empty strip is left under the short ones. `compact` lets each keep its own height, which reads
|
|
4953
|
+
* tighter in a single column and leaves exactly those strips in a grid. Both are defensible, which
|
|
4954
|
+
* is why it is a preference rather than a constant.
|
|
4955
|
+
*/
|
|
4956
|
+
entryRows: "uniform",
|
|
4551
4957
|
/**
|
|
4552
4958
|
* The term-pack sources the user added: https URLs of static `index.json` files.
|
|
4553
4959
|
*
|
|
@@ -4556,8 +4962,13 @@ window.__ModuleLoader__.load({
|
|
|
4556
4962
|
* is not configuration. Validated by the same rule the host applies before it fetches anything
|
|
4557
4963
|
* ({@link module:core/pack.refuseUrl}), so the page and the transport cannot disagree about what a
|
|
4558
4964
|
* usable source is.
|
|
4965
|
+
*
|
|
4966
|
+
* The default is not empty: see {@link DEFAULT_PACK_SOURCE}. An ABSENT key means "never touched, use
|
|
4967
|
+
* the default"; an empty array means "the reader removed them all", which is honoured (§
|
|
4968
|
+
* `normalizeSettings`) — the two are different statements and a store that conflated them would
|
|
4969
|
+
* resurrect the default every time somebody cleared the list.
|
|
4559
4970
|
*/
|
|
4560
|
-
packSources: []
|
|
4971
|
+
packSources: [DEFAULT_PACK_SOURCE]
|
|
4561
4972
|
};
|
|
4562
4973
|
|
|
4563
4974
|
/** The accepted minimum term lengths, for the panel's cycling control. */
|
|
@@ -4663,7 +5074,13 @@ window.__ModuleLoader__.load({
|
|
|
4663
5074
|
collectCjk: readFlag(value.collectCjk, DEFAULT_SETTINGS.collectCjk),
|
|
4664
5075
|
hoverInMs: readDelay(value.hoverInMs, DEFAULT_SETTINGS.hoverInMs),
|
|
4665
5076
|
hoverOutMs: readDelay(value.hoverOutMs, DEFAULT_SETTINGS.hoverOutMs),
|
|
4666
|
-
|
|
5077
|
+
entryColumns: readChoice(value.entryColumns, ENTRY_COLUMNS, DEFAULT_SETTINGS.entryColumns),
|
|
5078
|
+
entryRows: readChoice(value.entryRows, ENTRY_ROWS, DEFAULT_SETTINGS.entryRows),
|
|
5079
|
+
// Absent means "never touched" and gets the shipped default; an empty array means the reader
|
|
5080
|
+
// removed every source, and is kept as it is. A fresh array either way: a snapshot is compared by
|
|
5081
|
+
// identity, so handing back the shared default array would make one page's edit appear in
|
|
5082
|
+
// another page's defaults.
|
|
5083
|
+
packSources: value.packSources === undefined ? [...DEFAULT_SETTINGS.packSources] : readSources(value.packSources)
|
|
4667
5084
|
};
|
|
4668
5085
|
}
|
|
4669
5086
|
|
|
@@ -4771,6 +5188,10 @@ window.__ModuleLoader__.load({
|
|
|
4771
5188
|
DEFAULT_SETTINGS,
|
|
4772
5189
|
normalizeSettings,
|
|
4773
5190
|
MAX_SOURCES,
|
|
5191
|
+
DEFAULT_PACK_SOURCE,
|
|
5192
|
+
ENTRY_COLUMNS,
|
|
5193
|
+
ENTRY_ROWS,
|
|
5194
|
+
MIN_COLUMN_PX,
|
|
4774
5195
|
EXPLAIN_LANGS,
|
|
4775
5196
|
EXPLAIN_DEPTHS,
|
|
4776
5197
|
COLLECT_LENGTHS,
|
|
@@ -4854,6 +5275,9 @@ window.__ModuleLoader__.load({
|
|
|
4854
5275
|
}
|
|
4855
5276
|
};
|
|
4856
5277
|
if (typeof entry?.domain === "string" && entry.domain !== "") record.domain = entry.domain;
|
|
5278
|
+
// A group travels through an export, so a backup restores the structure it was filed under; it does NOT
|
|
5279
|
+
// travel in a pack, where the reader organizes entries their own way (see `pack.js`'s allowlist).
|
|
5280
|
+
if (typeof entry?.group === "string" && entry.group !== "") record.group = entry.group;
|
|
4857
5281
|
if (Array.isArray(entry?.aliases) && entry.aliases.length > 0) record.aliases = [...entry.aliases];
|
|
4858
5282
|
if (entry?.pinned === true) record.pinned = true;
|
|
4859
5283
|
if (typeof entry?.source === "string" && entry.source !== "") record.source = entry.source;
|
|
@@ -4935,6 +5359,9 @@ window.__ModuleLoader__.load({
|
|
|
4935
5359
|
usage: typeof definition.usage === "string" ? definition.usage : ""
|
|
4936
5360
|
},
|
|
4937
5361
|
domain: typeof raw.domain === "string" ? raw.domain.trim() : "",
|
|
5362
|
+
// A group is read back from a file like any other label the user assigned — that is what makes an
|
|
5363
|
+
// export a backup of the STRUCTURE and not only of the words.
|
|
5364
|
+
group: typeof raw.group === "string" ? raw.group.trim() : "",
|
|
4938
5365
|
aliases: Array.isArray(raw.aliases) ? raw.aliases.filter((alias) => typeof alias === "string" && alias.trim() !== "") : [],
|
|
4939
5366
|
pinned: raw.pinned === true
|
|
4940
5367
|
};
|
|
@@ -4975,6 +5402,64 @@ window.__ModuleLoader__.load({
|
|
|
4975
5402
|
return { ok: true, entries, skipped };
|
|
4976
5403
|
}
|
|
4977
5404
|
|
|
5405
|
+
/**
|
|
5406
|
+
* The folder path a file sits in, as a group path.
|
|
5407
|
+
*
|
|
5408
|
+
* The picked folder's own name IS the first group: choosing a folder called `packs` is how you say "file
|
|
5409
|
+
* these under `packs`", and dropping the name would put everything at the top level — which is precisely
|
|
5410
|
+
* the structure the user just picked a folder to express.
|
|
5411
|
+
*
|
|
5412
|
+
* @param path - a `relative/path/file.json`, as `webkitRelativePath` reports it.
|
|
5413
|
+
* @returns the group path.
|
|
5414
|
+
*/
|
|
5415
|
+
function folderPathOf(path) {
|
|
5416
|
+
const parts = String(path)
|
|
5417
|
+
.split("/")
|
|
5418
|
+
.filter((part) => part !== "");
|
|
5419
|
+
// Everything but the file name.
|
|
5420
|
+
return parts.slice(0, -1).join("/");
|
|
5421
|
+
}
|
|
5422
|
+
|
|
5423
|
+
/**
|
|
5424
|
+
* Read a whole directory into one import.
|
|
5425
|
+
*
|
|
5426
|
+
* A folder is a mixed bag: several exported dictionaries, somebody's downloaded pack files, an
|
|
5427
|
+
* `index.json` that lists packs rather than holding entries, and often a README or a screenshot. So this
|
|
5428
|
+
* reports what it could NOT use instead of refusing the folder — the alternative is a user with twenty
|
|
5429
|
+
* files and one bad one, told "no" with no idea which file it was. Files that are not JSON are not
|
|
5430
|
+
* reported at all: they are not mistakes.
|
|
5431
|
+
*
|
|
5432
|
+
* @param files - `[{ path, text }]`, the directory's files as the browser hands them over.
|
|
5433
|
+
* @param options - `useFolderAsGroup` files each entry under the folder it came from, which is the shape
|
|
5434
|
+
* people actually keep: one folder per topic, and the reason the option exists at all.
|
|
5435
|
+
* @returns `{ entries, files, used, skipped }`, where `skipped` is `[{ name, error }]`.
|
|
5436
|
+
*/
|
|
5437
|
+
function collectFolderImport(files, options) {
|
|
5438
|
+
const list = Array.isArray(files) ? files : [];
|
|
5439
|
+
const entries = [];
|
|
5440
|
+
const skipped = [];
|
|
5441
|
+
const useFolder = options?.useFolderAsGroup === true;
|
|
5442
|
+
let used = 0;
|
|
5443
|
+
let jsonFiles = 0;
|
|
5444
|
+
for (const file of list) {
|
|
5445
|
+
const path = typeof file?.path === "string" ? file.path : "";
|
|
5446
|
+
if (!/\.json$/i.test(path)) continue;
|
|
5447
|
+
jsonFiles++;
|
|
5448
|
+
const parsed = parseImport(typeof file?.text === "string" ? file.text : "");
|
|
5449
|
+
if (parsed.ok !== true) {
|
|
5450
|
+
skipped.push({ name: path, error: parsed.error });
|
|
5451
|
+
continue;
|
|
5452
|
+
}
|
|
5453
|
+
used++;
|
|
5454
|
+
const folder = useFolder ? folderPathOf(path) : "";
|
|
5455
|
+
for (const entry of parsed.entries) {
|
|
5456
|
+
const hasGroup = typeof entry.group === "string" && entry.group !== "";
|
|
5457
|
+
entries.push(folder !== "" && !hasGroup ? { ...entry, group: folder } : entry);
|
|
5458
|
+
}
|
|
5459
|
+
}
|
|
5460
|
+
return { entries, files: jsonFiles, used, skipped };
|
|
5461
|
+
}
|
|
5462
|
+
|
|
4978
5463
|
/**
|
|
4979
5464
|
* Decide what an import would do, without doing any of it.
|
|
4980
5465
|
*
|
|
@@ -5020,10 +5505,15 @@ window.__ModuleLoader__.load({
|
|
|
5020
5505
|
// Opting in does not empty the `deleted` bucket by accident: it moves those entries into
|
|
5021
5506
|
// `accepted`, and the bucket is then genuinely empty, so the report says "0 you deleted" and
|
|
5022
5507
|
// means it.
|
|
5508
|
+
//
|
|
5509
|
+
// `revived` names the accepted entries that were previously DELETED, separately from the ones that are
|
|
5510
|
+
// simply new. The caller must not treat the two the same: a new term is a write, while a deleted term is
|
|
5511
|
+
// a RESTORATION — a decision that has to beat the tombstone, not an edit that hopes to. Running both
|
|
5512
|
+
// through the same write is how "a term that was collected and then deleted cannot be restored" happened.
|
|
5023
5513
|
const retract = options?.includeDeleted === true;
|
|
5024
5514
|
return retract
|
|
5025
|
-
? { accepted: [...accepted, ...wasDeleted], existing: alreadyHave, deleted: [], duplicates }
|
|
5026
|
-
: { accepted, existing: alreadyHave, deleted: wasDeleted, duplicates };
|
|
5515
|
+
? { accepted: [...accepted, ...wasDeleted], revived: wasDeleted, existing: alreadyHave, deleted: [], duplicates }
|
|
5516
|
+
: { accepted, revived: [], existing: alreadyHave, deleted: wasDeleted, duplicates };
|
|
5027
5517
|
}
|
|
5028
5518
|
|
|
5029
5519
|
/**
|
|
@@ -5085,6 +5575,7 @@ window.__ModuleLoader__.load({
|
|
|
5085
5575
|
buildExport,
|
|
5086
5576
|
buildFeedbackReport,
|
|
5087
5577
|
parseImport,
|
|
5578
|
+
collectFolderImport,
|
|
5088
5579
|
planImport,
|
|
5089
5580
|
domainsIn,
|
|
5090
5581
|
toRecord,
|
|
@@ -5260,28 +5751,41 @@ window.__ModuleLoader__.load({
|
|
|
5260
5751
|
}
|
|
5261
5752
|
|
|
5262
5753
|
/**
|
|
5263
|
-
* Read a pack from whatever arrived: a fetched file,
|
|
5754
|
+
* Read a pack from whatever arrived: a fetched file, a decoded share code, or the text of either.
|
|
5264
5755
|
*
|
|
5265
5756
|
* Tolerant about unknown keys in the envelope, strict about the two things that make it a pack: its
|
|
5266
5757
|
* marker and at least one usable entry. Every record goes through the same validator an imported file
|
|
5267
5758
|
* does, so a record that cannot be imported is a record this refuses.
|
|
5268
5759
|
*
|
|
5269
|
-
*
|
|
5760
|
+
* Accepts TEXT as well as a parsed object, because text is what actually arrives: the host fetches the
|
|
5761
|
+
* file and passes the body on. Accepting only an object made every source and every pack answer
|
|
5762
|
+
* `not-an-object` — a feature that refuses everything, in the shape of a bad URL.
|
|
5763
|
+
*
|
|
5764
|
+
* @param raw - the fetched body, the decoded text, or the parsed value.
|
|
5270
5765
|
* @returns `{ ok: true, pack }`, or `{ ok: false, error }`.
|
|
5271
5766
|
*/
|
|
5272
5767
|
function parsePack(raw) {
|
|
5273
|
-
|
|
5274
|
-
if (raw
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5768
|
+
let value = raw;
|
|
5769
|
+
if (typeof raw === "string") {
|
|
5770
|
+
try {
|
|
5771
|
+
value = JSON.parse(raw);
|
|
5772
|
+
} catch (error) {
|
|
5773
|
+
return { ok: false, error: "not-json" };
|
|
5774
|
+
}
|
|
5775
|
+
}
|
|
5776
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "not-an-object" };
|
|
5777
|
+
const raw2 = value;
|
|
5778
|
+
if (raw2.kind !== PACK_KIND) return { ok: false, error: "not-a-pack" };
|
|
5779
|
+
if (raw2.version !== PACK_VERSION) return { ok: false, error: "unsupported-version" };
|
|
5780
|
+
if (!Array.isArray(raw2.entries)) return { ok: false, error: "no-entries" };
|
|
5781
|
+
if (raw2.entries.length > MAX_PACK_ENTRIES) return { ok: false, error: "too-many" };
|
|
5278
5782
|
const entries = [];
|
|
5279
|
-
for (const record of
|
|
5783
|
+
for (const record of raw2.entries) {
|
|
5280
5784
|
const entry = transfer.toEntry(record);
|
|
5281
5785
|
if (entry !== null) entries.push(record);
|
|
5282
5786
|
}
|
|
5283
5787
|
if (entries.length === 0) return { ok: false, error: "no-usable-entries" };
|
|
5284
|
-
const scope =
|
|
5788
|
+
const scope = raw2.scope !== null && typeof raw2.scope === "object" && raw2.scope.kind === "domains" ? "domains" : "all";
|
|
5285
5789
|
return {
|
|
5286
5790
|
ok: true,
|
|
5287
5791
|
pack: {
|
|
@@ -5380,17 +5884,31 @@ window.__ModuleLoader__.load({
|
|
|
5380
5884
|
* in somebody's index must not hide the other forty packs. An index with nothing usable left is a
|
|
5381
5885
|
* refusal, because a source that offers nothing is a source that is misconfigured or moved.
|
|
5382
5886
|
*
|
|
5383
|
-
* @
|
|
5887
|
+
* Takes TEXT or a parsed object, like {@link parsePack} — and the text form is the one production uses:
|
|
5888
|
+
* the host fetches the source and hands the body straight over. Requiring an object here while the host
|
|
5889
|
+
* sends text meant every source answered `not-an-object`, which is a whole feature answering "no" in a
|
|
5890
|
+
* way that looks like the user's URL was wrong.
|
|
5891
|
+
*
|
|
5892
|
+
* @param raw - the fetched body, or the parsed value.
|
|
5384
5893
|
* @returns `{ ok: true, index, dropped }`, or `{ ok: false, error }`.
|
|
5385
5894
|
*/
|
|
5386
5895
|
function parseIndex(raw) {
|
|
5387
|
-
|
|
5388
|
-
if (raw
|
|
5389
|
-
|
|
5390
|
-
|
|
5896
|
+
let value = raw;
|
|
5897
|
+
if (typeof raw === "string") {
|
|
5898
|
+
try {
|
|
5899
|
+
value = JSON.parse(raw);
|
|
5900
|
+
} catch (error) {
|
|
5901
|
+
return { ok: false, error: "not-json" };
|
|
5902
|
+
}
|
|
5903
|
+
}
|
|
5904
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "not-an-object" };
|
|
5905
|
+
const raw2 = value;
|
|
5906
|
+
if (raw2.kind !== INDEX_KIND) return { ok: false, error: "not-an-index" };
|
|
5907
|
+
if (raw2.version !== INDEX_VERSION) return { ok: false, error: "unsupported-version" };
|
|
5908
|
+
if (!Array.isArray(raw2.packs)) return { ok: false, error: "no-packs" };
|
|
5391
5909
|
const packs = [];
|
|
5392
5910
|
let dropped = 0;
|
|
5393
|
-
for (const pack of
|
|
5911
|
+
for (const pack of raw2.packs) {
|
|
5394
5912
|
if (pack === null || typeof pack !== "object") {
|
|
5395
5913
|
dropped++;
|
|
5396
5914
|
continue;
|
|
@@ -8022,6 +8540,45 @@ window.__ModuleLoader__.load({
|
|
|
8022
8540
|
return null;
|
|
8023
8541
|
}
|
|
8024
8542
|
|
|
8543
|
+
/**
|
|
8544
|
+
* How far left of the caret a pointer may still count as being on the text.
|
|
8545
|
+
*
|
|
8546
|
+
* The space before a word is enough: landing in it resolves the caret to the word's own start, and a
|
|
8547
|
+
* reader aiming at the first letter often lands there. A paragraph's left margin is tens of pixels, which
|
|
8548
|
+
* is what this has to reject.
|
|
8549
|
+
*/
|
|
8550
|
+
const LEFT_TOLERANCE_PX = 8;
|
|
8551
|
+
|
|
8552
|
+
/**
|
|
8553
|
+
* Whether the caret a point resolved to actually sits at that point.
|
|
8554
|
+
*
|
|
8555
|
+
* `caretRangeFromPoint` CLAMPS horizontally: a point in the left margin of a line resolves to the line's
|
|
8556
|
+
* start, offset 0. When a term begins that line, offset 0 IS that term — so every point from the panel's
|
|
8557
|
+
* left edge to the first character read as "on the term", and the card opened from anywhere in the margin,
|
|
8558
|
+
* with a click to match. That is how this was reported.
|
|
8559
|
+
*
|
|
8560
|
+
* The caret's own rectangle is the live proof of where the text is: it is measured from the document at
|
|
8561
|
+
* this instant, so unlike a block snapshot it cannot go stale. One measurement per event, and only once a
|
|
8562
|
+
* caret has been found at all.
|
|
8563
|
+
*
|
|
8564
|
+
* @param x - viewport x of the pointer.
|
|
8565
|
+
* @param caret - the Range the point resolved to.
|
|
8566
|
+
* @returns true when the caret is at the pointer rather than clamped to the start of a line.
|
|
8567
|
+
*/
|
|
8568
|
+
function caretIsAt(x, caret) {
|
|
8569
|
+
let rect = null;
|
|
8570
|
+
try {
|
|
8571
|
+
rect = typeof caret?.getBoundingClientRect === "function" ? caret.getBoundingClientRect() : null;
|
|
8572
|
+
} catch (error) {
|
|
8573
|
+
rect = null;
|
|
8574
|
+
}
|
|
8575
|
+
// A browser (or a fixture) that cannot answer is trusted: refusing every hover because one optional
|
|
8576
|
+
// measurement is missing would be a worse failure than the one being fixed. An all-zero rectangle says
|
|
8577
|
+
// the same thing in another shape — a detached or not-yet-laid-out range.
|
|
8578
|
+
if (rect === null || (rect.left === 0 && rect.top === 0 && rect.width === 0 && rect.height === 0)) return true;
|
|
8579
|
+
return rect.left <= x + LEFT_TOLERANCE_PX;
|
|
8580
|
+
}
|
|
8581
|
+
|
|
8025
8582
|
/**
|
|
8026
8583
|
* The marked occurrence under a point.
|
|
8027
8584
|
*
|
|
@@ -8036,6 +8593,7 @@ window.__ModuleLoader__.load({
|
|
|
8036
8593
|
function matchAt(x, y) {
|
|
8037
8594
|
const caret = caretAt(x, y);
|
|
8038
8595
|
if (caret === null) return null;
|
|
8596
|
+
if (!caretIsAt(x, caret)) return null;
|
|
8039
8597
|
const at = resolveCaret(caret, doc);
|
|
8040
8598
|
if (at === null) return null;
|
|
8041
8599
|
let best = null;
|
|
@@ -8787,6 +9345,25 @@ window.__ModuleLoader__.load({
|
|
|
8787
9345
|
boxShadow: "0 0 0 1px var(--dsw-alias-brand-primary, #4d6bfe)"
|
|
8788
9346
|
},
|
|
8789
9347
|
rowHead: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" },
|
|
9348
|
+
/** Where you are, and the way back up: a directory you can only enter is a trap. */
|
|
9349
|
+
breadcrumb: { display: "flex", alignItems: "center", gap: "2px", flexWrap: "wrap", padding: "2px 0 6px", fontSize: "12px" },
|
|
9350
|
+
breadcrumbPart: { display: "inline-flex", alignItems: "center", gap: "2px" },
|
|
9351
|
+
breadcrumbSep: { color: "var(--dsw-alias-label-tertiary)", margin: "0 2px" },
|
|
9352
|
+
breadcrumbButton: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-brand-primary, #4d6bfe)", cursor: "pointer" },
|
|
9353
|
+
breadcrumbHere: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-label-secondary)", cursor: "default" },
|
|
9354
|
+
/** The group row's own bits: the mark that says "not an entry", and the chevron that says "opens". */
|
|
9355
|
+
groupIcon: { display: "inline-flex", alignItems: "center", color: "var(--dsw-alias-label-secondary)" },
|
|
9356
|
+
groupEnter: { color: "var(--dsw-alias-label-tertiary)", fontSize: "14px", lineHeight: 1 },
|
|
9357
|
+
/** One line saying what is inside a group, so the card is not a name over a blank space. */
|
|
9358
|
+
groupPreview: { margin: 0, fontSize: "12px", lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
9359
|
+
/**
|
|
9360
|
+
* The row's action toolbar: the second line, always.
|
|
9361
|
+
*
|
|
9362
|
+
* On the title line the controls moved from row to row with the length of the term and the number of
|
|
9363
|
+
* badges; a line of their own puts every row's controls in the same place and keeps them out of the
|
|
9364
|
+
* way of the text they act on.
|
|
9365
|
+
*/
|
|
9366
|
+
rowActions: { display: "flex", alignItems: "center", gap: "6px", marginTop: "2px" },
|
|
8790
9367
|
termButton: {
|
|
8791
9368
|
appearance: "none",
|
|
8792
9369
|
background: "transparent",
|
|
@@ -9293,6 +9870,13 @@ window.__ModuleLoader__.load({
|
|
|
9293
9870
|
{ width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
|
|
9294
9871
|
h("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round" })
|
|
9295
9872
|
),
|
|
9873
|
+
// The group row's icon: the one thing on screen that is not an entry, so it says so without a label.
|
|
9874
|
+
folder: (size) =>
|
|
9875
|
+
h(
|
|
9876
|
+
"svg",
|
|
9877
|
+
{ width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
|
|
9878
|
+
h("path", { d: "M1.5 3.5A1 1 0 0 1 2.5 2.5h3.2l1.3 1.6h6.5a1 1 0 0 1 1 1v6.4a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z", stroke: "currentColor", strokeWidth: "1.2", fill: "none" })
|
|
9879
|
+
),
|
|
9296
9880
|
pin: (size) =>
|
|
9297
9881
|
h(
|
|
9298
9882
|
"svg",
|
|
@@ -9366,11 +9950,11 @@ window.__ModuleLoader__.load({
|
|
|
9366
9950
|
};
|
|
9367
9951
|
|
|
9368
9952
|
/** Settings used when the panel is rendered without a settings store (tests, odd hosts). */
|
|
9369
|
-
const FALLBACK_SETTINGS = { autoCollect: true, autoAnnotate: true, autoExplain: true, explainLang: "auto", explainDepth: "normal", explainParagraph: true, collectMinLength: 4, collectIdentifiers: false, collectCjk: true };
|
|
9953
|
+
const FALLBACK_SETTINGS = { autoCollect: true, autoAnnotate: true, autoExplain: true, explainLang: "auto", explainDepth: "normal", explainParagraph: true, collectMinLength: 4, collectIdentifiers: false, collectCjk: true, entryColumns: "auto", entryRows: "uniform", packSources: [] };
|
|
9370
9954
|
|
|
9371
9955
|
/** The accepted preference values, so the page offers exactly what the store validates. */
|
|
9372
|
-
const { EXPLAIN_LANGS, EXPLAIN_DEPTHS, COLLECT_LENGTHS, HOVER_DELAY_MIN, HOVER_DELAY_MAX, HOVER_DELAY_STEP } = require("./settings.js");
|
|
9373
|
-
const { buildExport, buildFeedbackReport, parseImport, planImport, domainsIn } = require("./transfer.js");
|
|
9956
|
+
const { EXPLAIN_LANGS, EXPLAIN_DEPTHS, COLLECT_LENGTHS, HOVER_DELAY_MIN, HOVER_DELAY_MAX, HOVER_DELAY_STEP, DEFAULT_PACK_SOURCE, ENTRY_COLUMNS, ENTRY_ROWS, MIN_COLUMN_PX } = require("./settings.js");
|
|
9957
|
+
const { buildExport, buildFeedbackReport, parseImport, collectFolderImport, planImport, domainsIn } = require("./transfer.js");
|
|
9374
9958
|
const { FEEDBACK_KINDS, isFlagged, entryAsText } = require("./entries.js");
|
|
9375
9959
|
const { refuseUrl } = require("./pack.js");
|
|
9376
9960
|
const { selectionInside } = require("./selection.js");
|
|
@@ -9544,13 +10128,20 @@ window.__ModuleLoader__.load({
|
|
|
9544
10128
|
const plan = planImport(store.getState().entries ?? [], incoming, store.getState().deletedKeys ?? new Set(), {
|
|
9545
10129
|
includeDeleted: options?.includeDeleted === true
|
|
9546
10130
|
});
|
|
10131
|
+
// Two different acts, deliberately: a term the dictionary never had is a WRITE, while a term the user
|
|
10132
|
+
// deleted is a RESTORATION. `store.restoreEntry` withdraws the tombstone and records the decision; a
|
|
10133
|
+
// write would instead have to BEAT the tombstone in a merge, and it can lose that comparison — which is
|
|
10134
|
+
// exactly "a term that was collected and then deleted could not be restored".
|
|
10135
|
+
const revived = new Set((plan.revived ?? []).map((entry) => entry.key));
|
|
9547
10136
|
for (const entry of plan.accepted) {
|
|
9548
|
-
|
|
10137
|
+
const patch = {
|
|
9549
10138
|
definition: entry.definition,
|
|
9550
10139
|
domain: entry.domain,
|
|
9551
10140
|
aliases: entry.aliases,
|
|
9552
10141
|
pinned: entry.pinned
|
|
9553
|
-
}
|
|
10142
|
+
};
|
|
10143
|
+
if (revived.has(entry.key)) await store.restoreEntry(entry.term, patch);
|
|
10144
|
+
else await store.saveEntry(entry.term, patch);
|
|
9554
10145
|
}
|
|
9555
10146
|
return {
|
|
9556
10147
|
imported: plan.accepted.length,
|
|
@@ -9658,6 +10249,21 @@ window.__ModuleLoader__.load({
|
|
|
9658
10249
|
* choice this page offers.
|
|
9659
10250
|
*/
|
|
9660
10251
|
const [includeGloss, setIncludeGloss] = React.useState(false);
|
|
10252
|
+
/**
|
|
10253
|
+
* A file that has been read but not imported yet: `{ name, entries, skippedCount, plan }`.
|
|
10254
|
+
*
|
|
10255
|
+
* Reading a file and writing it are two decisions, and this state is the gap between them. It exists
|
|
10256
|
+
* because the second decision used to have no moment of its own: picking a file imported it outright,
|
|
10257
|
+
* so the only way to see what was in it was the report afterwards.
|
|
10258
|
+
*/
|
|
10259
|
+
const [pending, setPending] = React.useState(null);
|
|
10260
|
+
/**
|
|
10261
|
+
* Whether a folder import names each entry's category after the folder it came from.
|
|
10262
|
+
*
|
|
10263
|
+
* Off by default: it writes a value the user did not type. On is for the shape people actually keep —
|
|
10264
|
+
* one folder per topic.
|
|
10265
|
+
*/
|
|
10266
|
+
const [useFolderAsGroup, setUseFolderAsGroup] = React.useState(true);
|
|
9661
10267
|
/**
|
|
9662
10268
|
* What came back from the host's own feedback channel, or null when nothing was sent yet.
|
|
9663
10269
|
*
|
|
@@ -9742,7 +10348,7 @@ window.__ModuleLoader__.load({
|
|
|
9742
10348
|
/** Save the export as a file. */
|
|
9743
10349
|
const download = () => downloadText(textOf(), `term-dictionary-${new Date().toISOString().slice(0, 10)}.json`);
|
|
9744
10350
|
|
|
9745
|
-
/** Take a file, and
|
|
10351
|
+
/** Take a file: read it, show what is in it, and write nothing until the user says so. */
|
|
9746
10352
|
const takeFile = async (event) => {
|
|
9747
10353
|
const file = event?.target?.files?.[0];
|
|
9748
10354
|
if (file === undefined || file === null) return;
|
|
@@ -9753,9 +10359,76 @@ window.__ModuleLoader__.load({
|
|
|
9753
10359
|
setReport({ failed: parsed.error });
|
|
9754
10360
|
return;
|
|
9755
10361
|
}
|
|
9756
|
-
//
|
|
9757
|
-
//
|
|
9758
|
-
|
|
10362
|
+
// The previous import's report goes away when a new file is read: it describes something that
|
|
10363
|
+
// has already happened, and leaving it under a fresh confirmation reads as if it described
|
|
10364
|
+
// this one.
|
|
10365
|
+
setReport(null);
|
|
10366
|
+
// The plan is a DRY RUN: `planImport` is pure, so the page can say what would happen without
|
|
10367
|
+
// any of it happening. The write path recomputes it at the moment of the write, so a preview
|
|
10368
|
+
// that went stale (another window deleted something) cannot import the stale answer.
|
|
10369
|
+
setPending({ name: file.name, entries: parsed.entries, skippedCount: parsed.skipped ?? 0, plan: planFor(parsed.entries, includeDeleted) });
|
|
10370
|
+
} catch (error) {
|
|
10371
|
+
setReport({ failed: String(error?.message ?? error) });
|
|
10372
|
+
} finally {
|
|
10373
|
+
setBusy(false);
|
|
10374
|
+
}
|
|
10375
|
+
};
|
|
10376
|
+
|
|
10377
|
+
/** The dry-run plan for a set of incoming records, used by the preview and by the write. */
|
|
10378
|
+
function planFor(incoming, withDeleted) {
|
|
10379
|
+
return planImport(store.getState().entries ?? [], incoming, store.getState().deletedKeys ?? new Set(), { includeDeleted: withDeleted === true });
|
|
10380
|
+
}
|
|
10381
|
+
|
|
10382
|
+
/** The confirmation the user actually presses. */
|
|
10383
|
+
const confirmImport = async () => {
|
|
10384
|
+
if (pending === null || busy) return;
|
|
10385
|
+
setBusy(true);
|
|
10386
|
+
try {
|
|
10387
|
+
const result = await importEntries(store, pending.entries, { includeDeleted, skipped: pending.skippedCount });
|
|
10388
|
+
// The panel owns the view, so IT decides what has to move to show what was just written — and
|
|
10389
|
+
// says whether it had to. An import that lands while the list is filtered to 「已删除」 or
|
|
10390
|
+
// narrowed by a search looks exactly like an import that did nothing, which is how this was
|
|
10391
|
+
// reported once ("the imported entries do not appear").
|
|
10392
|
+
const moved = typeof props.onImported === "function" && props.onImported() === true;
|
|
10393
|
+
setReport(moved ? { ...result, moved: true } : result);
|
|
10394
|
+
setPending(null);
|
|
10395
|
+
} catch (error) {
|
|
10396
|
+
setReport({ failed: String(error?.message ?? error) });
|
|
10397
|
+
} finally {
|
|
10398
|
+
setBusy(false);
|
|
10399
|
+
}
|
|
10400
|
+
};
|
|
10401
|
+
|
|
10402
|
+
/**
|
|
10403
|
+
* Read a whole folder: every `*.json` in it, as one import.
|
|
10404
|
+
*
|
|
10405
|
+
* A directory reaches the page through `webkitdirectory`, which is the OS's own directory browser —
|
|
10406
|
+
* so this needs no new service dependency, and nothing can park the plugin waiting for one. The
|
|
10407
|
+
* browser supplies `webkitRelativePath`, which is what names a category after its folder.
|
|
10408
|
+
*/
|
|
10409
|
+
const takeFolder = async (event) => {
|
|
10410
|
+
const files = [...(event?.target?.files ?? [])];
|
|
10411
|
+
if (files.length === 0) return;
|
|
10412
|
+
setBusy(true);
|
|
10413
|
+
try {
|
|
10414
|
+
const read = [];
|
|
10415
|
+
for (const file of files) {
|
|
10416
|
+
const path = typeof file.webkitRelativePath === "string" && file.webkitRelativePath !== "" ? file.webkitRelativePath : file.name;
|
|
10417
|
+
read.push({ path, text: await file.text() });
|
|
10418
|
+
}
|
|
10419
|
+
const collected = collectFolderImport(read, { useFolderAsGroup });
|
|
10420
|
+
if (collected.entries.length === 0) {
|
|
10421
|
+
setReport({ failed: collected.skipped.length === 0 ? t("transferNoJson") : t("transferFolderAllSkipped", { count: String(collected.skipped.length) }) });
|
|
10422
|
+
return;
|
|
10423
|
+
}
|
|
10424
|
+
setReport(null);
|
|
10425
|
+
setPending({
|
|
10426
|
+
name: t("transferFolderLabel", { count: String(collected.used) }),
|
|
10427
|
+
entries: collected.entries,
|
|
10428
|
+
skippedCount: 0,
|
|
10429
|
+
plan: planFor(collected.entries, includeDeleted),
|
|
10430
|
+
folder: { files: collected.files, used: collected.used, skipped: collected.skipped }
|
|
10431
|
+
});
|
|
9759
10432
|
} catch (error) {
|
|
9760
10433
|
setReport({ failed: String(error?.message ?? error) });
|
|
9761
10434
|
} finally {
|
|
@@ -9866,7 +10539,13 @@ window.__ModuleLoader__.load({
|
|
|
9866
10539
|
type: "checkbox",
|
|
9867
10540
|
style: applyStyles.checkbox,
|
|
9868
10541
|
checked: includeDeleted,
|
|
9869
|
-
|
|
10542
|
+
// Toggling this re-plans the preview rather than leaving it: the number the user is about
|
|
10543
|
+
// to confirm is the number this opt-in produces, or the confirmation is a lie.
|
|
10544
|
+
onChange: () => {
|
|
10545
|
+
const next = !includeDeleted;
|
|
10546
|
+
setIncludeDeleted(next);
|
|
10547
|
+
setPending((current) => (current === null ? null : { ...current, plan: planFor(current.entries, next) }));
|
|
10548
|
+
}
|
|
9870
10549
|
}),
|
|
9871
10550
|
h("span", { style: applyStyles.transferName }, t("transferIncludeDeleted"))
|
|
9872
10551
|
),
|
|
@@ -9879,19 +10558,114 @@ window.__ModuleLoader__.load({
|
|
|
9879
10558
|
onChange: (event) => void takeFile(event),
|
|
9880
10559
|
style: applyStyles.transferFile
|
|
9881
10560
|
}),
|
|
10561
|
+
// The folder half. `webkitdirectory` opens the OS's directory browser and hands over every file
|
|
10562
|
+
// inside it, with the path — which is the only directory browser a page needs, and the one that
|
|
10563
|
+
// arrives without a service the plugin could end up parked on.
|
|
10564
|
+
h("p", { style: applyStyles.settingLabel }, t("transferFolderTitle")),
|
|
10565
|
+
h("p", { style: applyStyles.settingHint }, t("transferFolderHint")),
|
|
10566
|
+
h("input", {
|
|
10567
|
+
type: "file",
|
|
10568
|
+
webkitdirectory: "true",
|
|
10569
|
+
directory: "true",
|
|
10570
|
+
multiple: true,
|
|
10571
|
+
"aria-label": t("transferFolderTitle"),
|
|
10572
|
+
disabled: busy,
|
|
10573
|
+
onChange: (event) => void takeFolder(event),
|
|
10574
|
+
style: applyStyles.transferFile,
|
|
10575
|
+
"data-term-dictionary": "transfer-folder"
|
|
10576
|
+
}),
|
|
10577
|
+
h(
|
|
10578
|
+
"label",
|
|
10579
|
+
{ style: applyStyles.transferItem, "data-term-dictionary": "transfer-folder-group" },
|
|
10580
|
+
h("input", { type: "checkbox", style: applyStyles.checkbox, checked: useFolderAsGroup, onChange: () => setUseFolderAsGroup(!useFolderAsGroup) }),
|
|
10581
|
+
h("span", { style: applyStyles.transferName }, t("transferFolderAsGroup"))
|
|
10582
|
+
),
|
|
10583
|
+
// The confirmation. Picking a file used to import it, which made "what is in this file" a
|
|
10584
|
+
// question you could only answer afterwards; the plan below is a dry run, so the answer comes
|
|
10585
|
+
// first and the write waits for a second press.
|
|
10586
|
+
pending === null
|
|
10587
|
+
? null
|
|
10588
|
+
: h(
|
|
10589
|
+
"div",
|
|
10590
|
+
{ style: applyStyles.notice, "data-term-import-pending": "true" },
|
|
10591
|
+
h("p", { style: applyStyles.settingLabel }, t("transferConfirmTitle", { name: pending.name })),
|
|
10592
|
+
h(
|
|
10593
|
+
"p",
|
|
10594
|
+
{ style: applyStyles.settingHint },
|
|
10595
|
+
[
|
|
10596
|
+
t("transferWillImport", { count: String(pending.plan.accepted.length) }),
|
|
10597
|
+
pending.plan.existing.length > 0 ? t("transferWillSkipExisting", { count: String(pending.plan.existing.length) }) : "",
|
|
10598
|
+
pending.plan.deleted.length > 0 ? t("transferWillSkipDeleted", { count: String(pending.plan.deleted.length) }) : "",
|
|
10599
|
+
pending.plan.duplicates + pending.skippedCount > 0 ? t("transferDuplicates", { count: String(pending.plan.duplicates + pending.skippedCount) }) : ""
|
|
10600
|
+
]
|
|
10601
|
+
.filter((part) => part !== "")
|
|
10602
|
+
.join(" · ")
|
|
10603
|
+
),
|
|
10604
|
+
// A folder says what it found and what it had to pass over — by name, because "one file was
|
|
10605
|
+
// skipped" with twenty files in the folder is not an answer.
|
|
10606
|
+
pending.folder === undefined
|
|
10607
|
+
? null
|
|
10608
|
+
: h(
|
|
10609
|
+
"div",
|
|
10610
|
+
null,
|
|
10611
|
+
h("p", { style: applyStyles.settingHint }, t("transferFolderFound", { files: String(pending.folder.files), used: String(pending.folder.used) })),
|
|
10612
|
+
pending.folder.skipped.length === 0
|
|
10613
|
+
? null
|
|
10614
|
+
: h("p", { style: applyStyles.error }, `${t("transferFolderSkipped", { count: String(pending.folder.skipped.length) })} ${pending.folder.skipped.map((each) => `${each.name}(${each.error})`).join("、")}`)
|
|
10615
|
+
),
|
|
10616
|
+
h(
|
|
10617
|
+
"div",
|
|
10618
|
+
{ style: applyStyles.transferRow },
|
|
10619
|
+
h(Button, { label: t("transferConfirm"), disabled: busy, onClick: () => void confirmImport() }, t("transferConfirm")),
|
|
10620
|
+
h(Button, { label: t("cancel"), onClick: () => setPending(null) }, t("cancel"))
|
|
10621
|
+
)
|
|
10622
|
+
),
|
|
9882
10623
|
report === null
|
|
9883
10624
|
? null
|
|
9884
10625
|
: h(
|
|
9885
10626
|
"p",
|
|
9886
|
-
{ style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error },
|
|
10627
|
+
{ style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error, "data-term-report": "true" },
|
|
9887
10628
|
report.failed === undefined
|
|
9888
|
-
? t("transferReport", { imported: report.imported, existing: report.existing, deleted: report.deleted, duplicates: report.duplicates })
|
|
10629
|
+
? [t("transferReport", { imported: report.imported, existing: report.existing, deleted: report.deleted, duplicates: report.duplicates }), report.moved === true ? t("transferShown") : ""]
|
|
10630
|
+
.filter((part) => part !== "")
|
|
10631
|
+
.join(" ")
|
|
9889
10632
|
: t("transferFailed", { message: report.failed })
|
|
9890
10633
|
)
|
|
9891
10634
|
//#endregion
|
|
9892
10635
|
);
|
|
9893
10636
|
}
|
|
9894
10637
|
|
|
10638
|
+
/**
|
|
10639
|
+
* The entry list's layout, given the column preference.
|
|
10640
|
+
*
|
|
10641
|
+
* A module-level function rather than an inline style, and pure, so the rule can be asserted without a
|
|
10642
|
+
* browser: `auto` asks the grid for as many columns as fit at a readable width, a number pins the
|
|
10643
|
+
* layout, and `1` is the plain column the panel had before any of this existed.
|
|
10644
|
+
*
|
|
10645
|
+
* @param columns - `"auto"`, `"1"`, `"2"` or `"3"` (anything else behaves as `auto`).
|
|
10646
|
+
* @returns the style for the list container.
|
|
10647
|
+
*/
|
|
10648
|
+
function listStyle(columns, rowLayout) {
|
|
10649
|
+
const fixed = Number.parseInt(String(columns), 10);
|
|
10650
|
+
const gridTemplateColumns =
|
|
10651
|
+
Number.isFinite(fixed) && fixed > 1
|
|
10652
|
+
? `repeat(${fixed}, minmax(0, 1fr))`
|
|
10653
|
+
: `repeat(auto-fill, minmax(${MIN_COLUMN_PX}px, 1fr))`;
|
|
10654
|
+
// `stretch` is the default because the complaint was the GAPS: with each cell as tall as its track,
|
|
10655
|
+
// cards line up and no empty strip appears under the short ones. `compact` lets every card keep its
|
|
10656
|
+
// own height instead, which is tidier in a column but leaves those strips — so it is a choice.
|
|
10657
|
+
return {
|
|
10658
|
+
...applyStyles.list,
|
|
10659
|
+
display: "grid",
|
|
10660
|
+
// Rows are as tall as their content, and the leftover height stays at the bottom. Without this a
|
|
10661
|
+
// grid STRETCHES its rows to fill the container, so two cards in a tall panel became two 700px
|
|
10662
|
+
// boxes — the opposite of what "equal cards" was asking for.
|
|
10663
|
+
alignContent: "start",
|
|
10664
|
+
alignItems: rowLayout === "compact" ? "start" : "stretch",
|
|
10665
|
+
gridTemplateColumns
|
|
10666
|
+
};
|
|
10667
|
+
}
|
|
10668
|
+
|
|
9895
10669
|
/**
|
|
9896
10670
|
* Term packs: hand this dictionary to somebody, or take theirs.
|
|
9897
10671
|
*
|
|
@@ -9934,6 +10708,7 @@ window.__ModuleLoader__.load({
|
|
|
9934
10708
|
/** One row per source after a refresh: `{ url, ok, stale, error, index }`. */
|
|
9935
10709
|
const [listing, setListing] = React.useState(null);
|
|
9936
10710
|
const [packNote, setPackNote] = React.useState({});
|
|
10711
|
+
const [autoRefreshed, setAutoRefreshed] = React.useState(false);
|
|
9937
10712
|
/** Say something in the page's one notice line rather than in a dialog. */
|
|
9938
10713
|
const say = (message) => setNotice(message);
|
|
9939
10714
|
|
|
@@ -10010,6 +10785,25 @@ window.__ModuleLoader__.load({
|
|
|
10010
10785
|
}
|
|
10011
10786
|
};
|
|
10012
10787
|
|
|
10788
|
+
/**
|
|
10789
|
+
* Ask the reader's own sources what they hold, once, when the page first opens.
|
|
10790
|
+
*
|
|
10791
|
+
* Without it a fresh install shows a configured source and nothing else, and "press 刷新" is a step
|
|
10792
|
+
* whose purpose nobody can guess. The request goes to the sources THEY have (the shipped default
|
|
10793
|
+
* included), the host does the fetching and caches it for an hour, and the list stays theirs to clear
|
|
10794
|
+
* — a source removed here is not added back, which is why this looks at the length rather than at
|
|
10795
|
+
* whether the list is the default one.
|
|
10796
|
+
*
|
|
10797
|
+
* Declared after `refresh` on purpose: the effect callback runs on the first render in some
|
|
10798
|
+
* harnesses (and in React's own test renderers), and a callback closing over a `const` below it
|
|
10799
|
+
* would read an uninitialised binding.
|
|
10800
|
+
*/
|
|
10801
|
+
React.useEffect(() => {
|
|
10802
|
+
if (autoRefreshed || sources.length === 0) return;
|
|
10803
|
+
setAutoRefreshed(true);
|
|
10804
|
+
void refresh();
|
|
10805
|
+
}, [autoRefreshed, sources.length]);
|
|
10806
|
+
|
|
10013
10807
|
/** Fetch one listed pack and offer it for import. */
|
|
10014
10808
|
const preview = async (row, listed) => {
|
|
10015
10809
|
if (typeof packApi?.fetch !== "function") return say(t("packNoHost"));
|
|
@@ -10181,6 +10975,9 @@ window.__ModuleLoader__.load({
|
|
|
10181
10975
|
h(
|
|
10182
10976
|
"div",
|
|
10183
10977
|
{ key: url, style: applyStyles.transferItem, "data-term-source": url },
|
|
10978
|
+
// The shipped one says so: a source nobody remembers adding is a source nobody dares
|
|
10979
|
+
// remove, and this one is removable like any other.
|
|
10980
|
+
url === DEFAULT_PACK_SOURCE ? h("span", { style: applyStyles.badge }, t("packBuiltInSource")) : null,
|
|
10184
10981
|
h("span", { style: applyStyles.transferName }, url),
|
|
10185
10982
|
h(Button, { label: t("packRemoveSource"), onClick: () => settingsStore?.set?.("packSources", sources.filter((each) => each !== url)) }, t("packRemoveSource"))
|
|
10186
10983
|
)
|
|
@@ -10189,7 +10986,7 @@ window.__ModuleLoader__.load({
|
|
|
10189
10986
|
h(
|
|
10190
10987
|
"div",
|
|
10191
10988
|
{ style: applyStyles.transferRow },
|
|
10192
|
-
h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), onChange: setSourceDraft }),
|
|
10989
|
+
h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), width: "30em", onChange: setSourceDraft }),
|
|
10193
10990
|
h(Button, { label: t("packAddSource"), disabled: busy, onClick: addSource }, t("packAddSource")),
|
|
10194
10991
|
h(Button, { label: t("packRefresh"), disabled: busy || sources.length === 0, onClick: () => void refresh() }, t("packRefresh"))
|
|
10195
10992
|
),
|
|
@@ -10393,6 +11190,28 @@ window.__ModuleLoader__.load({
|
|
|
10393
11190
|
onSelect: (value) => setSwitch("explainDepth", value)
|
|
10394
11191
|
})
|
|
10395
11192
|
),
|
|
11193
|
+
row(
|
|
11194
|
+
"rows",
|
|
11195
|
+
t("entryRowsName"),
|
|
11196
|
+
t("entryRowsHint"),
|
|
11197
|
+
h(OptionBar, {
|
|
11198
|
+
label: t("entryRowsName"),
|
|
11199
|
+
value: ENTRY_ROWS.includes(settings.entryRows) ? settings.entryRows : "uniform",
|
|
11200
|
+
options: ENTRY_ROWS.map((value) => ({ value, label: t(`entryRows_${value}`) })),
|
|
11201
|
+
onSelect: (value) => setSwitch("entryRows", value)
|
|
11202
|
+
})
|
|
11203
|
+
),
|
|
11204
|
+
row(
|
|
11205
|
+
"columns",
|
|
11206
|
+
t("entryColumnsName"),
|
|
11207
|
+
t("entryColumnsHint"),
|
|
11208
|
+
h(OptionBar, {
|
|
11209
|
+
label: t("entryColumnsName"),
|
|
11210
|
+
value: ENTRY_COLUMNS.includes(settings.entryColumns) ? settings.entryColumns : "auto",
|
|
11211
|
+
options: ENTRY_COLUMNS.map((value) => ({ value, label: t(`entryColumns_${value}`) })),
|
|
11212
|
+
onSelect: (value) => setSwitch("entryColumns", value)
|
|
11213
|
+
})
|
|
11214
|
+
),
|
|
10396
11215
|
flag(
|
|
10397
11216
|
"identifiers",
|
|
10398
11217
|
t("collectIdentifiersOn"),
|
|
@@ -10467,6 +11286,15 @@ window.__ModuleLoader__.load({
|
|
|
10467
11286
|
}
|
|
10468
11287
|
|
|
10469
11288
|
/** A labelled text input. */
|
|
11289
|
+
/**
|
|
11290
|
+
* A labelled text field.
|
|
11291
|
+
*
|
|
11292
|
+
* One width for every single-line field, because a form of differently-sized boxes reads as a mistake
|
|
11293
|
+
* rather than as a decision. `width` overrides the cap for the rare field whose content really is longer
|
|
11294
|
+
* (a URL, a path), and a multi-line field is an {@link Area} and takes the full width instead.
|
|
11295
|
+
*/
|
|
11296
|
+
const FIELD_WIDTH = "22em";
|
|
11297
|
+
|
|
10470
11298
|
function Field(props) {
|
|
10471
11299
|
return h(
|
|
10472
11300
|
"label",
|
|
@@ -10477,7 +11305,7 @@ window.__ModuleLoader__.load({
|
|
|
10477
11305
|
value: props.value,
|
|
10478
11306
|
placeholder: props.placeholder,
|
|
10479
11307
|
onChange: (event) => props.onChange(event.target.value),
|
|
10480
|
-
style: applyStyles.input,
|
|
11308
|
+
style: { ...applyStyles.input, maxWidth: props.width ?? FIELD_WIDTH },
|
|
10481
11309
|
"aria-label": props.label
|
|
10482
11310
|
})
|
|
10483
11311
|
);
|
|
@@ -10541,6 +11369,8 @@ window.__ModuleLoader__.load({
|
|
|
10541
11369
|
);
|
|
10542
11370
|
const [query, setQuery] = React.useState("");
|
|
10543
11371
|
const [filter, setFilter] = React.useState("all");
|
|
11372
|
+
/** How the entry list is laid out; the settings store owns the value, this only reads it. */
|
|
11373
|
+
const columns = settings.entryColumns ?? FALLBACK_SETTINGS.entryColumns;
|
|
10544
11374
|
const [editing, setEditing] = React.useState(null);
|
|
10545
11375
|
/** Whether the panel is showing its settings page instead of the entry list. */
|
|
10546
11376
|
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
|
@@ -10587,7 +11417,28 @@ window.__ModuleLoader__.load({
|
|
|
10587
11417
|
};
|
|
10588
11418
|
}, [store]);
|
|
10589
11419
|
|
|
10590
|
-
|
|
11420
|
+
/**
|
|
11421
|
+
* Which group the list is showing: `""` is the top level, otherwise a path like `backend/net`.
|
|
11422
|
+
*
|
|
11423
|
+
* Standing somewhere is a property of the VIEW, not of the dictionary — nothing about an entry changes
|
|
11424
|
+
* because you looked at it — so it is panel state, next to the search box and the tab.
|
|
11425
|
+
*/
|
|
11426
|
+
const [group, setGroup] = React.useState("");
|
|
11427
|
+
/**
|
|
11428
|
+
* The group being renamed or moved, as `{ group, value }`, or null.
|
|
11429
|
+
*
|
|
11430
|
+
* An inline form rather than `prompt()`: Electron does not implement `prompt`, so a rename dialog
|
|
11431
|
+
* built on it would do nothing at all in the app this plugin runs in.
|
|
11432
|
+
*/
|
|
11433
|
+
const [renaming, setRenaming] = React.useState(null);
|
|
11434
|
+
const visible = React.useMemo(() => store.list(query, filter, group), [view, query, filter, group]);
|
|
11435
|
+
/**
|
|
11436
|
+
* The groups directly inside the current one, each with how many entries it holds.
|
|
11437
|
+
*
|
|
11438
|
+
* They are ROWS of the list rather than a sidebar or a header: a group is the same kind of thing as an
|
|
11439
|
+
* entry — something you open — and the point of the shape is that both are the same size on screen.
|
|
11440
|
+
*/
|
|
11441
|
+
const subGroups = React.useMemo(() => store.groups(group), [view, group]);
|
|
10591
11442
|
const current = view.state.entries;
|
|
10592
11443
|
/**
|
|
10593
11444
|
* Whether the panel is showing what was deleted.
|
|
@@ -10606,6 +11457,59 @@ window.__ModuleLoader__.load({
|
|
|
10606
11457
|
*/
|
|
10607
11458
|
const showingFlagged = filter === "flagged";
|
|
10608
11459
|
|
|
11460
|
+
/**
|
|
11461
|
+
* Make what an import just wrote visible.
|
|
11462
|
+
*
|
|
11463
|
+
* The panel owns the view, so this is the panel's job and not the import page's. It returns whether it
|
|
11464
|
+
* had to move anything, because a report that says "12 imported" while the list is still filtered to
|
|
11465
|
+
* 「已删除」 is a report nobody can check.
|
|
11466
|
+
*
|
|
11467
|
+
* @returns true when the search box or the tab had to change.
|
|
11468
|
+
*/
|
|
11469
|
+
const showImported = () => {
|
|
11470
|
+
const moved = query !== "" || filter !== "all";
|
|
11471
|
+
setQuery("");
|
|
11472
|
+
setFilter("all");
|
|
11473
|
+
return moved;
|
|
11474
|
+
};
|
|
11475
|
+
|
|
11476
|
+
/**
|
|
11477
|
+
* Rename or move a group, and follow it if the panel was standing inside it.
|
|
11478
|
+
*
|
|
11479
|
+
* Following is the point: a reader who renames the folder they are in must still be looking at it
|
|
11480
|
+
* afterwards, not dropped back at the top wondering where it went.
|
|
11481
|
+
*/
|
|
11482
|
+
const commitRename = async () => {
|
|
11483
|
+
if (renaming === null) return;
|
|
11484
|
+
const result = await store.renameGroup(renaming.group, renaming.value);
|
|
11485
|
+
if (result.error !== null) {
|
|
11486
|
+
props.setToast?.(t(`groupError_${result.error}`));
|
|
11487
|
+
return;
|
|
11488
|
+
}
|
|
11489
|
+
const target = renaming.value.replace(/^\/+|\/+$/g, "");
|
|
11490
|
+
if (group === renaming.group || group.startsWith(`${renaming.group}/`)) {
|
|
11491
|
+
setGroup(group === renaming.group ? target : `${target}${group.slice(renaming.group.length)}`);
|
|
11492
|
+
}
|
|
11493
|
+
setRenaming(null);
|
|
11494
|
+
props.setToast?.(t("groupRenamed", { count: String(result.moved) }));
|
|
11495
|
+
};
|
|
11496
|
+
|
|
11497
|
+
/**
|
|
11498
|
+
* Delete a group by deleting what is in it, after saying how much that is.
|
|
11499
|
+
*
|
|
11500
|
+
* The entries go to 「已删除」 rather than away, so this is reversible — and the count is in the
|
|
11501
|
+
* question, because "delete this group?" and "delete 12 entries?" are different offers.
|
|
11502
|
+
*/
|
|
11503
|
+
const removeGroup = async (sub) => {
|
|
11504
|
+
if (globalThis.confirm?.(t("groupDeleteConfirm", { name: sub.name, count: String(sub.count) })) !== true) return;
|
|
11505
|
+
const result = await store.deleteGroup(sub.group);
|
|
11506
|
+
if (result.error !== null) {
|
|
11507
|
+
props.setToast?.(t(`groupError_${result.error}`));
|
|
11508
|
+
return;
|
|
11509
|
+
}
|
|
11510
|
+
props.setToast?.(t("groupDeleted", { count: String(result.removed) }));
|
|
11511
|
+
};
|
|
11512
|
+
|
|
10609
11513
|
/** Open the editor for a blank entry, prefilled from an optional term. */
|
|
10610
11514
|
const startCreate = (term = "", context = "") => {
|
|
10611
11515
|
setEditing({
|
|
@@ -10615,6 +11519,9 @@ window.__ModuleLoader__.load({
|
|
|
10615
11519
|
usage: "",
|
|
10616
11520
|
notes: "",
|
|
10617
11521
|
domain: "",
|
|
11522
|
+
// A new entry made while standing in a group belongs to that group: that is what standing there
|
|
11523
|
+
// meant, and asking again in the form would be a question with one sensible answer.
|
|
11524
|
+
group,
|
|
10618
11525
|
aliases: "",
|
|
10619
11526
|
pinned: false,
|
|
10620
11527
|
// Nothing to say yet, and `feedbackTouched` is what tells a save apart from a retraction:
|
|
@@ -10638,6 +11545,7 @@ window.__ModuleLoader__.load({
|
|
|
10638
11545
|
usage: entry.definition.usage,
|
|
10639
11546
|
notes: entry.definition.notes,
|
|
10640
11547
|
domain: entry.domain,
|
|
11548
|
+
group: entry.group,
|
|
10641
11549
|
aliases: entry.aliases.join(", "),
|
|
10642
11550
|
pinned: entry.pinned,
|
|
10643
11551
|
// Seeded from the entry, and `touched` starts true when there is already a remark: saving
|
|
@@ -10699,6 +11607,7 @@ window.__ModuleLoader__.load({
|
|
|
10699
11607
|
await store.saveEntry(editing.term, {
|
|
10700
11608
|
definition: { zh: editing.zh, gloss: editing.gloss, usage: editing.usage, notes: editing.notes },
|
|
10701
11609
|
domain: editing.domain,
|
|
11610
|
+
group: editing.group,
|
|
10702
11611
|
aliases: editing.aliases
|
|
10703
11612
|
.split(",")
|
|
10704
11613
|
.map((alias) => alias.trim())
|
|
@@ -10856,6 +11765,12 @@ window.__ModuleLoader__.load({
|
|
|
10856
11765
|
placeholder: t("zhPlaceholder"),
|
|
10857
11766
|
onChange: (value) => setEditing({ ...editing, zh: value })
|
|
10858
11767
|
}),
|
|
11768
|
+
h(Field, {
|
|
11769
|
+
label: t("groupLabel"),
|
|
11770
|
+
value: editing.group,
|
|
11771
|
+
placeholder: t("groupPlaceholder"),
|
|
11772
|
+
onChange: (value) => setEditing({ ...editing, group: value })
|
|
11773
|
+
}),
|
|
10859
11774
|
h(Area, {
|
|
10860
11775
|
label: t("glossLabel"),
|
|
10861
11776
|
value: editing.gloss,
|
|
@@ -10988,7 +11903,7 @@ window.__ModuleLoader__.load({
|
|
|
10988
11903
|
),
|
|
10989
11904
|
h(Button, { label: t("settingsBack"), onClick: () => setTransferOpen(false) }, t("settingsBack"))
|
|
10990
11905
|
),
|
|
10991
|
-
h(TransferPage, { store, t, setToast: props.setToast })
|
|
11906
|
+
h(TransferPage, { store, t, setToast: props.setToast, onImported: showImported })
|
|
10992
11907
|
);
|
|
10993
11908
|
}
|
|
10994
11909
|
|
|
@@ -11116,14 +12031,118 @@ window.__ModuleLoader__.load({
|
|
|
11116
12031
|
) : null,
|
|
11117
12032
|
h(
|
|
11118
12033
|
"div",
|
|
11119
|
-
|
|
11120
|
-
|
|
11121
|
-
|
|
11122
|
-
|
|
11123
|
-
|
|
11124
|
-
:
|
|
11125
|
-
|
|
11126
|
-
|
|
12034
|
+
// The entry list is the one that gets columns. The deleted and flagged views are lists of
|
|
12035
|
+
// DECISIONS rather than of terms — shorter rows, read one at a time — and the picking mode
|
|
12036
|
+
// pairs every row with a checkbox, so laying those out in a grid would be a change nobody
|
|
12037
|
+
// asked for.
|
|
12038
|
+
{
|
|
12039
|
+
style: showingDeleted || showingFlagged || picking ? applyStyles.list : listStyle(columns, settings.entryRows),
|
|
12040
|
+
"data-term-columns": showingDeleted || showingFlagged || picking ? undefined : columns
|
|
12041
|
+
},
|
|
12042
|
+
// The groups come first, then the entries at this level — one list, one scroll area, one kind of
|
|
12043
|
+
// row. A breadcrumb says where you are and is the way back up, because a directory you can only
|
|
12044
|
+
// enter is a trap.
|
|
12045
|
+
[
|
|
12046
|
+
renaming === null ? null : h(
|
|
12047
|
+
"div",
|
|
12048
|
+
{ style: { ...applyStyles.transferRow, gridColumn: "1 / -1" }, "data-term-group-form": renaming.group },
|
|
12049
|
+
h("span", { style: applyStyles.fieldLabel }, t("groupRenameTo")),
|
|
12050
|
+
h("input", {
|
|
12051
|
+
type: "text",
|
|
12052
|
+
value: renaming.value,
|
|
12053
|
+
"aria-label": t("groupRename"),
|
|
12054
|
+
onChange: (event) => setRenaming({ ...renaming, value: event.target.value }),
|
|
12055
|
+
onKeyDown: (event) => {
|
|
12056
|
+
if (event.key === "Enter") void commitRename();
|
|
12057
|
+
if (event.key === "Escape") setRenaming(null);
|
|
12058
|
+
},
|
|
12059
|
+
style: { ...applyStyles.input, maxWidth: "18em" }
|
|
12060
|
+
}),
|
|
12061
|
+
h(Button, { label: t("groupRenameApply"), onClick: () => void commitRename() }, t("groupRenameApply")),
|
|
12062
|
+
h(Button, { label: t("cancel"), onClick: () => setRenaming(null) }, t("cancel"))
|
|
12063
|
+
),
|
|
12064
|
+
group === "" ? null : h(
|
|
12065
|
+
"div",
|
|
12066
|
+
{ style: { ...applyStyles.breadcrumb, gridColumn: "1 / -1" }, "data-term-breadcrumb": group },
|
|
12067
|
+
[
|
|
12068
|
+
{ path: "", label: t("filterAll") },
|
|
12069
|
+
...group.split("/").map((name, index, all) => ({ path: all.slice(0, index + 1).join("/"), label: name }))
|
|
12070
|
+
].map((crumb, index) =>
|
|
12071
|
+
h(
|
|
12072
|
+
"span",
|
|
12073
|
+
{ key: crumb.path === "" ? "root" : crumb.path, style: applyStyles.breadcrumbPart },
|
|
12074
|
+
index === 0 ? null : h("span", { style: applyStyles.breadcrumbSep }, "›"),
|
|
12075
|
+
h(
|
|
12076
|
+
"button",
|
|
12077
|
+
{
|
|
12078
|
+
type: "button",
|
|
12079
|
+
style: index === group.split("/").length ? applyStyles.breadcrumbHere : applyStyles.breadcrumbButton,
|
|
12080
|
+
onClick: () => setGroup(crumb.path),
|
|
12081
|
+
title: t("groupGo", { name: crumb.label })
|
|
12082
|
+
},
|
|
12083
|
+
crumb.label
|
|
12084
|
+
)
|
|
12085
|
+
)
|
|
12086
|
+
)
|
|
12087
|
+
),
|
|
12088
|
+
...subGroups.map((sub) =>
|
|
12089
|
+
h(
|
|
12090
|
+
"article",
|
|
12091
|
+
{
|
|
12092
|
+
key: `group:${sub.group}`,
|
|
12093
|
+
// It fills its row again, now that the height carries something: with the preview below, the
|
|
12094
|
+
// space that used to be emptiness is the first thing you would see on opening it.
|
|
12095
|
+
style: applyStyles.row,
|
|
12096
|
+
"data-term-group": sub.group,
|
|
12097
|
+
// The WHOLE row opens the group, exactly as the whole row of an entry opens the entry.
|
|
12098
|
+
// Only the label being live is what "the group cannot be clicked into" meant: a reader
|
|
12099
|
+
// clicks the row, the same gesture that works one line below, and nothing happened.
|
|
12100
|
+
// The label stays a button so it is reachable by keyboard and named for a screen reader.
|
|
12101
|
+
onClick: (event) => {
|
|
12102
|
+
// The buttons inside keep their own meaning, exactly as they do on an entry row.
|
|
12103
|
+
if (typeof event?.target?.closest === "function" && event.target.closest("button") !== null) return;
|
|
12104
|
+
setGroup(sub.group);
|
|
12105
|
+
}
|
|
12106
|
+
},
|
|
12107
|
+
h("div", { style: applyStyles.rowHead },
|
|
12108
|
+
h("div", { style: applyStyles.groupIcon, "aria-hidden": "true" }, Icons.folder(14)),
|
|
12109
|
+
h("button", {
|
|
12110
|
+
type: "button",
|
|
12111
|
+
style: applyStyles.termButton,
|
|
12112
|
+
onClick: () => setGroup(sub.group),
|
|
12113
|
+
title: t("groupOpen")
|
|
12114
|
+
}, sub.name),
|
|
12115
|
+
h("span", { style: applyStyles.badge }, t("groupCount", { count: String(sub.count) })),
|
|
12116
|
+
h("span", { style: applyStyles.spacer }),
|
|
12117
|
+
h("span", { style: applyStyles.groupEnter, "aria-hidden": "true" }, "›")
|
|
12118
|
+
),
|
|
12119
|
+
// The same shape an entry row has: a title line, a line of actions, then the body.
|
|
12120
|
+
h(
|
|
12121
|
+
"div",
|
|
12122
|
+
{ style: applyStyles.rowActions, "data-term-group-actions": sub.group },
|
|
12123
|
+
h(IconButton, { title: t("groupRename"), onClick: () => setRenaming({ group: sub.group, value: sub.group }) }, Icons.edit(13)),
|
|
12124
|
+
h(IconButton, { title: t("groupDelete"), onClick: () => void removeGroup(sub) }, Icons.trash(13))
|
|
12125
|
+
),
|
|
12126
|
+
// What is inside, in one line: the row stops being a name over a blank card.
|
|
12127
|
+
h(
|
|
12128
|
+
"p",
|
|
12129
|
+
{ style: applyStyles.groupPreview, "data-term-group-sample": sub.group },
|
|
12130
|
+
[
|
|
12131
|
+
sub.sample.join(t("groupSampleJoin")),
|
|
12132
|
+
sub.count > sub.sample.length ? t("groupSampleMore", { count: String(sub.count - sub.sample.length) }) : ""
|
|
12133
|
+
]
|
|
12134
|
+
.filter((part) => part !== "")
|
|
12135
|
+
.join("")
|
|
12136
|
+
)
|
|
12137
|
+
)
|
|
12138
|
+
),
|
|
12139
|
+
showingDeleted
|
|
12140
|
+
? h(DeletedList, { entries: visible, t, onRevive: (entry) => void revive(entry) })
|
|
12141
|
+
: showingFlagged
|
|
12142
|
+
? h(FlaggedList, { entries: visible, t, onClear: (entry) => void clearFlag(entry) })
|
|
12143
|
+
: visible.length === 0
|
|
12144
|
+
? h("p", { style: { ...applyStyles.blank, gridColumn: "1 / -1" } }, current.length === 0 ? t("empty") : subGroups.length > 0 ? t("groupEmpty") : t("searchEmpty"))
|
|
12145
|
+
: visible.map((entry) =>
|
|
11127
12146
|
picking
|
|
11128
12147
|
? h(
|
|
11129
12148
|
"article",
|
|
@@ -11179,6 +12198,10 @@ window.__ModuleLoader__.load({
|
|
|
11179
12198
|
h(
|
|
11180
12199
|
"div",
|
|
11181
12200
|
{ style: applyStyles.rowHead },
|
|
12201
|
+
// The pin comes FIRST, on the title line: it is a state of the entry rather than an
|
|
12202
|
+
// action on it, and the list no longer re-sorts when it changes, so this is where
|
|
12203
|
+
// the state has to be readable.
|
|
12204
|
+
h(IconButton, { title: entry.pinned ? t("unpin") : t("pin"), pressed: entry.pinned, onClick: () => store.togglePinned(entry.id) }, Icons.pin(13)),
|
|
11182
12205
|
h("button", {
|
|
11183
12206
|
type: "button",
|
|
11184
12207
|
style: applyStyles.termButton,
|
|
@@ -11195,8 +12218,16 @@ window.__ModuleLoader__.load({
|
|
|
11195
12218
|
: null,
|
|
11196
12219
|
entry.untrusted === true ? h("span", { style: applyStyles.badgeWarn, "data-term-badge": "untrusted" }, t("flaggedUntrusted")) : null,
|
|
11197
12220
|
h("span", { style: applyStyles.spacer }),
|
|
11198
|
-
entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null
|
|
11199
|
-
|
|
12221
|
+
entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null
|
|
12222
|
+
),
|
|
12223
|
+
// Every action on its own line, and always in the same place.
|
|
12224
|
+
//
|
|
12225
|
+
// The title line used to carry them, so their position depended on how long the term
|
|
12226
|
+
// was, which badges it had and whether it had a domain — the controls wandered from
|
|
12227
|
+
// row to row. A toolbar that is always the second line does not.
|
|
12228
|
+
h(
|
|
12229
|
+
"div",
|
|
12230
|
+
{ style: applyStyles.rowActions, "data-term-actions": entry.key },
|
|
11200
12231
|
h(IconButton, { title: t("copyEntry"), onClick: () => void copyEntry(entry) }, Icons.copy(13)),
|
|
11201
12232
|
h(IconButton, { title: t("feedbackTitle"), pressed: entry.feedback !== null && entry.feedback !== undefined, onClick: () => startEdit(entry) }, Icons.flag(13)),
|
|
11202
12233
|
h(IconButton, { title: t("edit"), onClick: () => startEdit(entry) }, Icons.edit(13)),
|
|
@@ -11215,6 +12246,7 @@ window.__ModuleLoader__.load({
|
|
|
11215
12246
|
)
|
|
11216
12247
|
)
|
|
11217
12248
|
)
|
|
12249
|
+
]
|
|
11218
12250
|
),
|
|
11219
12251
|
storageNote(view.hostInfo, t),
|
|
11220
12252
|
h(
|