dsh-plugin-term-dictionary 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -23
- package/README.md +8 -4
- package/lib/client.js +827 -59
- package/lib/core/copy.js +62 -0
- package/lib/core/dictionary.js +114 -10
- package/lib/core/entries.js +35 -0
- package/lib/core/pack.js +42 -15
- package/lib/core/settings.js +56 -2
- package/lib/core/store.js +31 -4
- package/lib/core/styles.js +17 -0
- package/lib/core/transfer.js +72 -2
- package/lib/core/views.js +398 -26
- package/package.json +1 -1
package/lib/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,12 @@ 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
|
+
groupPlaceholder: "留空 = 顶层;用 / 分层,例如 backend/net",
|
|
557
588
|
termPlaceholder: "例如 event sourcing",
|
|
558
589
|
glossPlaceholder: "用一两句话说明它的含义",
|
|
559
590
|
usagePlaceholder: "可选:一句例子或典型用法",
|
|
@@ -639,6 +670,16 @@ window.__ModuleLoader__.load({
|
|
|
639
670
|
explainLang_en: "English",
|
|
640
671
|
explainDepthName: "Explanation depth",
|
|
641
672
|
explainDepthHint: "How much the explanation says",
|
|
673
|
+
entryColumnsName: "Entry layout",
|
|
674
|
+
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.",
|
|
675
|
+
entryColumns_auto: "Auto",
|
|
676
|
+
entryColumns_1: "1 column",
|
|
677
|
+
entryColumns_2: "2 columns",
|
|
678
|
+
entryColumns_3: "3 columns",
|
|
679
|
+
entryRowsName: "Row height",
|
|
680
|
+
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.",
|
|
681
|
+
entryRows_uniform: "Uniform",
|
|
682
|
+
entryRows_compact: "Compact",
|
|
642
683
|
explainDepth_brief: "One line",
|
|
643
684
|
explainDepth_normal: "Normal",
|
|
644
685
|
explainDepth_detailed: "Detailed",
|
|
@@ -686,6 +727,7 @@ window.__ModuleLoader__.load({
|
|
|
686
727
|
packSourcesTitle: "Sources",
|
|
687
728
|
packSourcesHint: "A source is somebody's static index.json. Only https is accepted (http, file and URLs carrying credentials are refused).",
|
|
688
729
|
packNoSources: "No sources yet",
|
|
730
|
+
packBuiltInSource: "built in",
|
|
689
731
|
packRemoveSource: "Remove",
|
|
690
732
|
packAddSourceLabel: "Add a source",
|
|
691
733
|
packAddSourcePlaceholder: "https://cdn.jsdelivr.net/gh/…/index.json",
|
|
@@ -741,6 +783,20 @@ window.__ModuleLoader__.load({
|
|
|
741
783
|
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
784
|
transferIncludeDeleted: "Import entries I deleted",
|
|
743
785
|
transferIncludeDeletedHint: "When ticked, entries in the file that you had deleted are imported and retracted from the deleted list. Unticked, they stay there.",
|
|
786
|
+
transferConfirmTitle: "What “{name}” holds",
|
|
787
|
+
transferWillImport: "{count} entries will be imported",
|
|
788
|
+
transferWillSkipExisting: "{count} already here (skipped)",
|
|
789
|
+
transferWillSkipDeleted: "{count} you deleted (skipped by default)",
|
|
790
|
+
transferConfirm: "Import them",
|
|
791
|
+
transferShown: "Moved to All and cleared the search, so what was imported is in front of you.",
|
|
792
|
+
transferFolderTitle: "Import a folder",
|
|
793
|
+
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.",
|
|
794
|
+
transferFolderAsGroup: "Build groups from the folders (the folder name is the group)",
|
|
795
|
+
transferFolderLabel: "{count} files in the folder",
|
|
796
|
+
transferFolderFound: "{files} .json files in the folder, {used} of them usable",
|
|
797
|
+
transferFolderSkipped: "{count} skipped:",
|
|
798
|
+
transferFolderAllSkipped: "none of the {count} .json files in that folder could be read",
|
|
799
|
+
transferNoJson: "that folder holds no .json files",
|
|
744
800
|
transferReport: "Imported {imported}; skipped {existing} already here, {deleted} you deleted, {duplicates} duplicate or unusable.",
|
|
745
801
|
transferFailed: "That did not work: {message}",
|
|
746
802
|
settingsBack: "Back to the entries",
|
|
@@ -825,6 +881,12 @@ window.__ModuleLoader__.load({
|
|
|
825
881
|
usageLabel: "Usage",
|
|
826
882
|
notesLabel: "Notes",
|
|
827
883
|
domainLabel: "Domain",
|
|
884
|
+
groupLabel: "Group",
|
|
885
|
+
groupOpen: "Open this group",
|
|
886
|
+
groupGo: "Go back to {name}",
|
|
887
|
+
groupCount: "{count} entries",
|
|
888
|
+
groupEmpty: "This level holds only groups so far.",
|
|
889
|
+
groupPlaceholder: "empty = top level; use / to nest, e.g. backend/net",
|
|
828
890
|
termPlaceholder: "e.g. event sourcing",
|
|
829
891
|
glossPlaceholder: "Say what it means in one or two sentences",
|
|
830
892
|
usagePlaceholder: "Optional: an example or typical use",
|
|
@@ -934,6 +996,15 @@ window.__ModuleLoader__.load({
|
|
|
934
996
|
/** Upper bound on a feedback note, which is a remark rather than an explanation. */
|
|
935
997
|
const MAX_NOTE_CHARS = 200;
|
|
936
998
|
|
|
999
|
+
/**
|
|
1000
|
+
* Upper bound on an entry's group path.
|
|
1001
|
+
*
|
|
1002
|
+
* A path rather than a name: a group can hold groups, because that is what a directory does and what a
|
|
1003
|
+
* folder import produces. One string covers any depth, which is why nothing else had to be added to the
|
|
1004
|
+
* document to get nesting.
|
|
1005
|
+
*/
|
|
1006
|
+
const MAX_GROUP_CHARS = 120;
|
|
1007
|
+
|
|
937
1008
|
/**
|
|
938
1009
|
* What a user can say is wrong with an entry.
|
|
939
1010
|
*
|
|
@@ -1023,6 +1094,25 @@ window.__ModuleLoader__.load({
|
|
|
1023
1094
|
notes: clampText(definition.notes, MAX_DEFINITION_CHARS)
|
|
1024
1095
|
},
|
|
1025
1096
|
domain: clampText(value.domain, 24),
|
|
1097
|
+
/**
|
|
1098
|
+
* The group (folder) this entry lives in: `""` for the top level, or a `/`-separated path.
|
|
1099
|
+
*
|
|
1100
|
+
* A CONTAINER, not a topic: `domain` is a semantic label the reader assigns, while this is where
|
|
1101
|
+
* the entry was put. It needs its own stamp (§ `groupAt`) because a move is a decision — the first
|
|
1102
|
+
* attempt let it ride on the content merge, and a merge of one entry from two windows then wiped
|
|
1103
|
+
* the group order-dependently.
|
|
1104
|
+
*/
|
|
1105
|
+
group: clampText(value.group, MAX_GROUP_CHARS),
|
|
1106
|
+
// When the group was last decided, epoch milliseconds, 0 for "never moved".
|
|
1107
|
+
//
|
|
1108
|
+
// The third use of this pattern, and for the reason the other two exist: a decision the user can
|
|
1109
|
+
// make and unmake cannot be carried by content-merging rules.
|
|
1110
|
+
// When the user restored this term, epoch milliseconds, 0 for never.
|
|
1111
|
+
//
|
|
1112
|
+
// The named evidence a merge weighs against a deletion, so a restoration does not have to win a
|
|
1113
|
+
// contest over a clock — see `restoreEntry`.
|
|
1114
|
+
restoredAt: typeof value.restoredAt === "number" && Number.isFinite(value.restoredAt) && value.restoredAt > 0 ? Math.floor(value.restoredAt) : 0,
|
|
1115
|
+
groupAt: typeof value.groupAt === "number" && Number.isFinite(value.groupAt) && value.groupAt > 0 ? Math.floor(value.groupAt) : 0,
|
|
1026
1116
|
source: SOURCES.includes(value.source) ? value.source : "auto",
|
|
1027
1117
|
confidence: typeof value.confidence === "number" && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : 0.5,
|
|
1028
1118
|
createdAt: typeof value.createdAt === "number" && value.createdAt > 0 ? value.createdAt : now,
|
|
@@ -1204,6 +1294,12 @@ window.__ModuleLoader__.load({
|
|
|
1204
1294
|
aliases: [...new Set([...current.aliases, ...incoming.aliases])].slice(0, 8),
|
|
1205
1295
|
definition: takeDefinition ? incoming.definition : current.definition,
|
|
1206
1296
|
domain: takeDefinition && incoming.domain !== "" ? incoming.domain : current.domain,
|
|
1297
|
+
// Unlike the domain, an empty incoming group is taken when the content changed: moving an entry back
|
|
1298
|
+
// to the top level is a move, and the old rule ("an empty value never overwrites") would make the
|
|
1299
|
+
// top level unreachable for anything that had ever been filed.
|
|
1300
|
+
group: (incoming.groupAt ?? 0) > (current.groupAt ?? 0) ? incoming.group : current.group,
|
|
1301
|
+
groupAt: Math.max(incoming.groupAt ?? 0, current.groupAt ?? 0),
|
|
1302
|
+
restoredAt: Math.max(incoming.restoredAt ?? 0, current.restoredAt ?? 0),
|
|
1207
1303
|
// The STRONGEST provenance survives, not the arriving one. Taking the arriving
|
|
1208
1304
|
// source let a later automatic copy of an already-curated term downgrade it to
|
|
1209
1305
|
// `auto`, which then denied that term its own revival — and let its source be
|
|
@@ -1307,6 +1403,7 @@ window.__ModuleLoader__.load({
|
|
|
1307
1403
|
MAX_DEFINITION_CHARS,
|
|
1308
1404
|
MAX_CONTEXT_CHARS,
|
|
1309
1405
|
MAX_NOTE_CHARS,
|
|
1406
|
+
MAX_GROUP_CHARS,
|
|
1310
1407
|
normalizeEntry,
|
|
1311
1408
|
normalizeFeedback,
|
|
1312
1409
|
entryAsText,
|
|
@@ -1610,6 +1707,9 @@ window.__ModuleLoader__.load({
|
|
|
1610
1707
|
//
|
|
1611
1708
|
// So the comparison uses the newest time a record the USER asked for carries, and
|
|
1612
1709
|
// being strictly newer is what revives. Time alone is not intent; provenance is.
|
|
1710
|
+
// A NAMED restoration first: `restoreEntry` records that the user asked for this term back, which
|
|
1711
|
+
// is not an inference from a clock. The comparison below stays for the paths that revive by editing.
|
|
1712
|
+
if ((record.restoredAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
|
|
1613
1713
|
if ((options?.craftedAt ?? 0) > notice.deletedAt) return { ...record, deletedAt: 0 };
|
|
1614
1714
|
const alreadyDeleted = record.deletedAt ?? 0;
|
|
1615
1715
|
return { ...record, deletedAt: Math.max(alreadyDeleted, notice.deletedAt) };
|
|
@@ -1892,6 +1992,10 @@ window.__ModuleLoader__.load({
|
|
|
1892
1992
|
const feedbackStamp = mentionsFeedback ? Math.max(stamp, (base.feedbackAt ?? 0) + 1) : base.feedbackAt ?? 0;
|
|
1893
1993
|
const untrustedPatch = typeof patch?.untrusted === "boolean" ? patch.untrusted : null;
|
|
1894
1994
|
const untrustedStamp = untrustedPatch === null ? base.untrustedAt ?? 0 : Math.max(stamp, (base.untrustedAt ?? 0) + 1);
|
|
1995
|
+
// A move is a decision like a pin or a remark, and it is stamped strictly past the last one so that two
|
|
1996
|
+
// moves inside the same millisecond still have an order.
|
|
1997
|
+
const mentionsGroup = patch !== null && patch !== undefined && Object.prototype.hasOwnProperty.call(patch, "group");
|
|
1998
|
+
const groupStamp = mentionsGroup ? Math.max(stamp, (base.groupAt ?? 0) + 1) : base.groupAt ?? 0;
|
|
1895
1999
|
const candidate = normalizeEntry(
|
|
1896
2000
|
{
|
|
1897
2001
|
...base,
|
|
@@ -1900,6 +2004,11 @@ window.__ModuleLoader__.load({
|
|
|
1900
2004
|
deletedAt: 0,
|
|
1901
2005
|
aliases: patch?.aliases ?? base.aliases,
|
|
1902
2006
|
domain: patch?.domain ?? base.domain,
|
|
2007
|
+
// `patch.group` may legitimately be `""` — that is how an entry is moved back to the top level —
|
|
2008
|
+
// so this reads with `hasOwnProperty` rather than with `??`: a patch that says nothing about the
|
|
2009
|
+
// group must leave it, and its stamp, exactly where they were.
|
|
2010
|
+
group: mentionsGroup ? patch.group : base.group,
|
|
2011
|
+
groupAt: groupStamp,
|
|
1903
2012
|
definition: { ...base.definition, ...(patch?.definition ?? {}) },
|
|
1904
2013
|
source: "user",
|
|
1905
2014
|
confidence: 1,
|
|
@@ -1917,6 +2026,60 @@ window.__ModuleLoader__.load({
|
|
|
1917
2026
|
return upsertEntry(state, { ...candidate, source: "user", deletedAt: 0 }, { now: stamp });
|
|
1918
2027
|
}
|
|
1919
2028
|
|
|
2029
|
+
/**
|
|
2030
|
+
* Bring a deleted term back, as an explicit act rather than as a side effect of an edit.
|
|
2031
|
+
*
|
|
2032
|
+
* This exists because "restore this deleted term" is a decision, and until now it was expressed as "write
|
|
2033
|
+
* an edit and hope the merge weighs it right": `editEntry` produced a crafted timestamp, and a merge
|
|
2034
|
+
* revived the record when that timestamp beat the tombstone's. That works — there is a check for it — but
|
|
2035
|
+
* it means the import path, the editor, a sighting and a second window all revive through one inferred
|
|
2036
|
+
* comparison, and a term the user had collected and deleted came back as "nothing happened".
|
|
2037
|
+
*
|
|
2038
|
+
* So a restoration is its own thing:
|
|
2039
|
+
*
|
|
2040
|
+
* - `restoredAt` is the evidence, weighed against `notice.deletedAt` like `pinnedAt` is weighed against
|
|
2041
|
+
* silence. It is what the merge reads, and it exists even when the tombstone's clock ran ahead;
|
|
2042
|
+
* - the tombstone is WITHDRAWN from the document this returns — the key leaves `deletedKeys` and the
|
|
2043
|
+
* record leaves `backing` — so a reader of this document is not told the term is deleted at all. Being
|
|
2044
|
+
* merely outvoted is what let a later merge put the deletion back;
|
|
2045
|
+
* - the text is kept: restoring is not the same as rewriting, so the patch is applied on top of whatever
|
|
2046
|
+
* the tombstone still remembers.
|
|
2047
|
+
*
|
|
2048
|
+
* @param state - the current document.
|
|
2049
|
+
* @param idOrTerm - the entry id, term or alias.
|
|
2050
|
+
* @param patch - `definition`, `domain`, `group`, `aliases`, `pinned`.
|
|
2051
|
+
* @param options - `now` overrides the restoration time.
|
|
2052
|
+
* @returns `{ state, entry, restored }`; `restored` is false when the term was not deleted.
|
|
2053
|
+
*/
|
|
2054
|
+
function restoreEntry(state, idOrTerm, patch, options) {
|
|
2055
|
+
const found = findEntryIncludingDeleted(state, idOrTerm);
|
|
2056
|
+
if (found === undefined) {
|
|
2057
|
+
// Nothing to restore: the caller wanted a new entry, and `editEntry` is the path for that.
|
|
2058
|
+
const created = editEntry(state, typeof idOrTerm === "string" ? idOrTerm : "", patch, options);
|
|
2059
|
+
return { ...created, restored: false };
|
|
2060
|
+
}
|
|
2061
|
+
if ((found.deletedAt ?? 0) === 0) {
|
|
2062
|
+
// Already live: a restoration must not disturb it, and saying "restored" would be a lie the caller
|
|
2063
|
+
// might act on (the import reports it).
|
|
2064
|
+
return { state, entry: found, restored: false };
|
|
2065
|
+
}
|
|
2066
|
+
// The stamp is strictly past the deletion for the same reason `editEntry`'s is: a clock that ran ahead
|
|
2067
|
+
// must not let the tombstone win a comparison it lost the argument for.
|
|
2068
|
+
const now = options?.now ?? Date.now();
|
|
2069
|
+
const stamp = Math.max(now, (found.deletedAt ?? 0) + 1);
|
|
2070
|
+
const edited = editEntry(state, found.term, { ...patch, definition: { ...found.definition, ...(patch?.definition ?? {}) } }, { now: stamp });
|
|
2071
|
+
const key = found.key;
|
|
2072
|
+
// The withdrawal, in the document itself: the record is rewritten live and the whole thing is re-sealed,
|
|
2073
|
+
// so `deletedKeys` and `backing` are derived fresh and no longer announce the deletion. (An earlier
|
|
2074
|
+
// version also filtered them by hand first, which the re-seal made redundant — a mutation proved it by
|
|
2075
|
+
// not biting, so the dead work is gone.)
|
|
2076
|
+
const records = recordsOf(edited.state).map((entry) =>
|
|
2077
|
+
entry.id === found.id || entry.key === key ? { ...entry, deletedAt: 0, restoredAt: stamp, source: "user", updatedAt: stamp, lastSeenAt: stamp } : entry
|
|
2078
|
+
);
|
|
2079
|
+
const next = sealRecords(records, { now: stamp });
|
|
2080
|
+
return { state: next, entry: next.entries.find((entry) => entry.key === key) ?? edited.entry, restored: true };
|
|
2081
|
+
}
|
|
2082
|
+
|
|
1920
2083
|
/**
|
|
1921
2084
|
* Record one sighting of a term without replacing its meaning: the counters move
|
|
1922
2085
|
* and a missing definition is filled from the glossary, but a curated definition
|
|
@@ -2226,16 +2389,51 @@ window.__ModuleLoader__.load({
|
|
|
2226
2389
|
}
|
|
2227
2390
|
|
|
2228
2391
|
/**
|
|
2229
|
-
* The
|
|
2230
|
-
*
|
|
2392
|
+
* The groups immediately inside one path, with how many entries each holds.
|
|
2393
|
+
*
|
|
2394
|
+
* Only the immediate children: the list shows one level at a time, and a count covers everything under
|
|
2395
|
+
* that child, however deep. A group exists as long as something is in it — there is no separate list of
|
|
2396
|
+
* empty groups to keep in step with the entries, which is the trade this makes deliberately.
|
|
2397
|
+
*
|
|
2398
|
+
* @param entries - every live entry.
|
|
2399
|
+
* @param prefix - the path to look inside, `""` for the top level.
|
|
2400
|
+
* @returns `[{ group, name, count }]`, alphabetical.
|
|
2401
|
+
*/
|
|
2402
|
+
function groupsIn(entries, prefix) {
|
|
2403
|
+
const base = prefix === undefined || prefix === null ? "" : String(prefix);
|
|
2404
|
+
const counts = new Map();
|
|
2405
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
2406
|
+
if ((entry?.deletedAt ?? 0) !== 0) continue;
|
|
2407
|
+
const group = typeof entry?.group === "string" ? entry.group : "";
|
|
2408
|
+
if (group === "" || group === base) continue;
|
|
2409
|
+
let inside = null;
|
|
2410
|
+
if (base === "") inside = group;
|
|
2411
|
+
else if (group.startsWith(`${base}/`)) inside = group.slice(base.length + 1);
|
|
2412
|
+
if (inside === null || inside === "") continue;
|
|
2413
|
+
const name = inside.split("/")[0];
|
|
2414
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
2415
|
+
}
|
|
2416
|
+
return [...counts.entries()]
|
|
2417
|
+
.map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count }))
|
|
2418
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
/**
|
|
2422
|
+
* The panel's visible list: one level of the group tree, filtered by query, then ordered by when each
|
|
2423
|
+
* term was last seen.
|
|
2231
2424
|
* @param state - the document.
|
|
2232
2425
|
* @param query - search text.
|
|
2233
2426
|
* @param filter - `all`, `unexplained`, `pinned` or `flagged`.
|
|
2427
|
+
* @param group - the group path to look inside, `""` for the top level.
|
|
2234
2428
|
* @returns the ordered entries.
|
|
2235
2429
|
*/
|
|
2236
|
-
function listEntries(state, query, filter) {
|
|
2430
|
+
function listEntries(state, query, filter, group) {
|
|
2431
|
+
const base = group === undefined || group === null ? "" : String(group);
|
|
2237
2432
|
const matched = state.entries.filter((entry) => {
|
|
2238
2433
|
if (entry.deletedAt !== 0) return false;
|
|
2434
|
+
// The list shows the level you are standing in, not everything below it: a group is a directory,
|
|
2435
|
+
// and what is inside it is what you see after entering it.
|
|
2436
|
+
if ((typeof entry.group === "string" ? entry.group : "") !== base) return false;
|
|
2239
2437
|
if (!matchesQuery(entry, query)) return false;
|
|
2240
2438
|
if (filter === "unexplained") return isUnexplained(entry);
|
|
2241
2439
|
if (filter === "pinned") return entry.pinned;
|
|
@@ -2245,13 +2443,14 @@ window.__ModuleLoader__.load({
|
|
|
2245
2443
|
if (filter === "flagged") return isFlagged(entry);
|
|
2246
2444
|
return true;
|
|
2247
2445
|
});
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2446
|
+
// Newest term first, and NOTHING ELSE.
|
|
2447
|
+
//
|
|
2448
|
+
// It used to sort pinned first and unexplained first, and it then sorted by when each term was last
|
|
2449
|
+
// SEEN — every one of which moves a row under the reader's hand: pin a term and it jumped to the top,
|
|
2450
|
+
// answer a prompt and it jumped, and the transcript merely mentioning a term again shuffled the list.
|
|
2451
|
+
// Creation time is the one thing no action changes, so the list is still while the reader reads it.
|
|
2452
|
+
// The pin is shown on the row itself and 「已钉选」 gathers the pinned ones, so ordering has no job left.
|
|
2453
|
+
return matched.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
|
|
2255
2454
|
}
|
|
2256
2455
|
|
|
2257
2456
|
/**
|
|
@@ -2393,6 +2592,7 @@ window.__ModuleLoader__.load({
|
|
|
2393
2592
|
tombstonesOf,
|
|
2394
2593
|
upsertEntry,
|
|
2395
2594
|
editEntry,
|
|
2595
|
+
restoreEntry,
|
|
2396
2596
|
recordSighting,
|
|
2397
2597
|
removeEntry,
|
|
2398
2598
|
reviveEntry,
|
|
@@ -2402,6 +2602,7 @@ window.__ModuleLoader__.load({
|
|
|
2402
2602
|
mergeState,
|
|
2403
2603
|
summarize,
|
|
2404
2604
|
listEntries,
|
|
2605
|
+
groupsIn,
|
|
2405
2606
|
listDeleted,
|
|
2406
2607
|
createFileStore,
|
|
2407
2608
|
createBrowserStore
|
|
@@ -4295,6 +4496,21 @@ window.__ModuleLoader__.load({
|
|
|
4295
4496
|
return result.entry;
|
|
4296
4497
|
},
|
|
4297
4498
|
|
|
4499
|
+
/**
|
|
4500
|
+
* Bring a deleted term back, as an explicit act.
|
|
4501
|
+
*
|
|
4502
|
+
* See `restoreEntry` in the core: a restoration is a decision with its own evidence and it
|
|
4503
|
+
* withdraws the tombstone, rather than an edit that has to win a comparison on the way to the host.
|
|
4504
|
+
* @param idOrTerm - the entry id, term or alias.
|
|
4505
|
+
* @param patch - the text to restore it with, if any.
|
|
4506
|
+
* @returns a promise resolving to `{ entry, restored }`.
|
|
4507
|
+
*/
|
|
4508
|
+
async restoreEntry(idOrTerm, patch) {
|
|
4509
|
+
const result = dictionary.restoreEntry(state, idOrTerm, patch, { now: now() });
|
|
4510
|
+
if (result.restored) await commit(result.state);
|
|
4511
|
+
return { entry: result.entry, restored: result.restored };
|
|
4512
|
+
},
|
|
4513
|
+
|
|
4298
4514
|
/**
|
|
4299
4515
|
* Record that a term appeared in a message, without overwriting a definition
|
|
4300
4516
|
* the user wrote.
|
|
@@ -4421,17 +4637,29 @@ window.__ModuleLoader__.load({
|
|
|
4421
4637
|
return this.saveEntry(found.term, patch);
|
|
4422
4638
|
},
|
|
4423
4639
|
|
|
4640
|
+
/**
|
|
4641
|
+
* The groups directly inside one path, each with how many entries it holds.
|
|
4642
|
+
* @param prefix - the path to look inside, `""` for the top level.
|
|
4643
|
+
* @returns `[{ group, name, count }]`.
|
|
4644
|
+
*/
|
|
4645
|
+
groups(prefix) {
|
|
4646
|
+
return dictionary.groupsIn(state.entries, prefix);
|
|
4647
|
+
},
|
|
4648
|
+
|
|
4424
4649
|
/**
|
|
4425
4650
|
* The visible list for the panel.
|
|
4426
4651
|
* @param query - search text.
|
|
4427
4652
|
* @param filter - `all`, `unexplained`, `pinned`, `deleted` or `flagged`.
|
|
4653
|
+
* @param group - the group path to look inside, `""` for the top level.
|
|
4428
4654
|
* @returns the ordered entries. For `deleted`, the tombstones.
|
|
4429
4655
|
*/
|
|
4430
|
-
list(query, filter) {
|
|
4656
|
+
list(query, filter, group) {
|
|
4657
|
+
const at = group === undefined || group === null ? "" : String(group);
|
|
4431
4658
|
// 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
|
|
4659
|
+
// `backing`, not in `entries`, so no predicate over the live list could ever show one. The group
|
|
4660
|
+
// still applies — standing in a folder and asking what was deleted there means that folder.
|
|
4661
|
+
if (filter === "deleted") return dictionary.listDeleted(state, query).filter((entry) => (typeof entry.group === "string" ? entry.group : "") === at);
|
|
4662
|
+
return listEntries(state, query, filter, at);
|
|
4435
4663
|
},
|
|
4436
4664
|
|
|
4437
4665
|
/**
|
|
@@ -4491,12 +4719,34 @@ window.__ModuleLoader__.load({
|
|
|
4491
4719
|
/** The most sources the list will hold. A UI bound, not a security one. */
|
|
4492
4720
|
const MAX_SOURCES = 12;
|
|
4493
4721
|
|
|
4722
|
+
/**
|
|
4723
|
+
* The source a fresh install starts with: this plugin's own repository, as a static file.
|
|
4724
|
+
*
|
|
4725
|
+
* Why ship one at all: without it the packs page opens on an empty list, and a first-time reader has
|
|
4726
|
+
* to be TOLD a URL before they can see what a term pack even is. The alternative — fetching from a
|
|
4727
|
+
* server we run — is the thing this design refuses to have, so the default is an ordinary source like
|
|
4728
|
+
* any other: visible in the list, removable in one press, and fetched only when the reader asks (the
|
|
4729
|
+
* page refreshes it on first open, which is a request to the source THEY have configured).
|
|
4730
|
+
*
|
|
4731
|
+
* `@main` rather than a tag because a default should follow the packs the repository actually has:
|
|
4732
|
+
* pinned to a tag, a new pack would need a plugin release before anyone could see it.
|
|
4733
|
+
*/
|
|
4734
|
+
const DEFAULT_PACK_SOURCE = "https://cdn.jsdelivr.net/gh/lakerian/dsh-plugin-term-dictionary@main/packs/index.json";
|
|
4735
|
+
|
|
4494
4736
|
/**
|
|
4495
4737
|
* The URL rule, imported rather than restated: the settings store, the page and the host's transport
|
|
4496
4738
|
* must agree on what a usable source is, and two copies of that rule is how one of them drifts.
|
|
4497
4739
|
*/
|
|
4498
4740
|
const { refuseUrl } = require("./pack.js");
|
|
4499
4741
|
|
|
4742
|
+
const ENTRY_COLUMNS = ["auto", "1", "2", "3"];
|
|
4743
|
+
|
|
4744
|
+
/** The two row-height readings: equal cards, or each card as tall as its own text. */
|
|
4745
|
+
const ENTRY_ROWS = ["uniform", "compact"];
|
|
4746
|
+
|
|
4747
|
+
/** The narrowest a column may be when the layout is choosing. Below this a gloss wraps every other word. */
|
|
4748
|
+
const MIN_COLUMN_PX = 260;
|
|
4749
|
+
|
|
4500
4750
|
/**
|
|
4501
4751
|
* The three switches and their defaults.
|
|
4502
4752
|
*
|
|
@@ -4548,6 +4798,23 @@ window.__ModuleLoader__.load({
|
|
|
4548
4798
|
* the pointer had gone. Anyone who wants a grace period may have one; nobody gets it by accident.
|
|
4549
4799
|
*/
|
|
4550
4800
|
hoverOutMs: 0,
|
|
4801
|
+
/**
|
|
4802
|
+
* How many columns the entry list is laid out in.
|
|
4803
|
+
*
|
|
4804
|
+
* `auto` fits as many as the panel is wide enough for, and is the default: a dictionary row is
|
|
4805
|
+
* short, a single column of them is a very long strip to read, and a narrow panel simply gets one
|
|
4806
|
+
* column back. The fixed values exist for a reader who wants the layout to stop moving under them.
|
|
4807
|
+
*/
|
|
4808
|
+
entryColumns: "auto",
|
|
4809
|
+
/**
|
|
4810
|
+
* How tall a row of the entry list is.
|
|
4811
|
+
*
|
|
4812
|
+
* `uniform` gives every card in a grid row the height of the tallest, so the cards line up and no
|
|
4813
|
+
* empty strip is left under the short ones. `compact` lets each keep its own height, which reads
|
|
4814
|
+
* tighter in a single column and leaves exactly those strips in a grid. Both are defensible, which
|
|
4815
|
+
* is why it is a preference rather than a constant.
|
|
4816
|
+
*/
|
|
4817
|
+
entryRows: "uniform",
|
|
4551
4818
|
/**
|
|
4552
4819
|
* The term-pack sources the user added: https URLs of static `index.json` files.
|
|
4553
4820
|
*
|
|
@@ -4556,8 +4823,13 @@ window.__ModuleLoader__.load({
|
|
|
4556
4823
|
* is not configuration. Validated by the same rule the host applies before it fetches anything
|
|
4557
4824
|
* ({@link module:core/pack.refuseUrl}), so the page and the transport cannot disagree about what a
|
|
4558
4825
|
* usable source is.
|
|
4826
|
+
*
|
|
4827
|
+
* The default is not empty: see {@link DEFAULT_PACK_SOURCE}. An ABSENT key means "never touched, use
|
|
4828
|
+
* the default"; an empty array means "the reader removed them all", which is honoured (§
|
|
4829
|
+
* `normalizeSettings`) — the two are different statements and a store that conflated them would
|
|
4830
|
+
* resurrect the default every time somebody cleared the list.
|
|
4559
4831
|
*/
|
|
4560
|
-
packSources: []
|
|
4832
|
+
packSources: [DEFAULT_PACK_SOURCE]
|
|
4561
4833
|
};
|
|
4562
4834
|
|
|
4563
4835
|
/** The accepted minimum term lengths, for the panel's cycling control. */
|
|
@@ -4663,7 +4935,13 @@ window.__ModuleLoader__.load({
|
|
|
4663
4935
|
collectCjk: readFlag(value.collectCjk, DEFAULT_SETTINGS.collectCjk),
|
|
4664
4936
|
hoverInMs: readDelay(value.hoverInMs, DEFAULT_SETTINGS.hoverInMs),
|
|
4665
4937
|
hoverOutMs: readDelay(value.hoverOutMs, DEFAULT_SETTINGS.hoverOutMs),
|
|
4666
|
-
|
|
4938
|
+
entryColumns: readChoice(value.entryColumns, ENTRY_COLUMNS, DEFAULT_SETTINGS.entryColumns),
|
|
4939
|
+
entryRows: readChoice(value.entryRows, ENTRY_ROWS, DEFAULT_SETTINGS.entryRows),
|
|
4940
|
+
// Absent means "never touched" and gets the shipped default; an empty array means the reader
|
|
4941
|
+
// removed every source, and is kept as it is. A fresh array either way: a snapshot is compared by
|
|
4942
|
+
// identity, so handing back the shared default array would make one page's edit appear in
|
|
4943
|
+
// another page's defaults.
|
|
4944
|
+
packSources: value.packSources === undefined ? [...DEFAULT_SETTINGS.packSources] : readSources(value.packSources)
|
|
4667
4945
|
};
|
|
4668
4946
|
}
|
|
4669
4947
|
|
|
@@ -4771,6 +5049,10 @@ window.__ModuleLoader__.load({
|
|
|
4771
5049
|
DEFAULT_SETTINGS,
|
|
4772
5050
|
normalizeSettings,
|
|
4773
5051
|
MAX_SOURCES,
|
|
5052
|
+
DEFAULT_PACK_SOURCE,
|
|
5053
|
+
ENTRY_COLUMNS,
|
|
5054
|
+
ENTRY_ROWS,
|
|
5055
|
+
MIN_COLUMN_PX,
|
|
4774
5056
|
EXPLAIN_LANGS,
|
|
4775
5057
|
EXPLAIN_DEPTHS,
|
|
4776
5058
|
COLLECT_LENGTHS,
|
|
@@ -4854,6 +5136,9 @@ window.__ModuleLoader__.load({
|
|
|
4854
5136
|
}
|
|
4855
5137
|
};
|
|
4856
5138
|
if (typeof entry?.domain === "string" && entry.domain !== "") record.domain = entry.domain;
|
|
5139
|
+
// A group travels through an export, so a backup restores the structure it was filed under; it does NOT
|
|
5140
|
+
// travel in a pack, where the reader organizes entries their own way (see `pack.js`'s allowlist).
|
|
5141
|
+
if (typeof entry?.group === "string" && entry.group !== "") record.group = entry.group;
|
|
4857
5142
|
if (Array.isArray(entry?.aliases) && entry.aliases.length > 0) record.aliases = [...entry.aliases];
|
|
4858
5143
|
if (entry?.pinned === true) record.pinned = true;
|
|
4859
5144
|
if (typeof entry?.source === "string" && entry.source !== "") record.source = entry.source;
|
|
@@ -4935,6 +5220,9 @@ window.__ModuleLoader__.load({
|
|
|
4935
5220
|
usage: typeof definition.usage === "string" ? definition.usage : ""
|
|
4936
5221
|
},
|
|
4937
5222
|
domain: typeof raw.domain === "string" ? raw.domain.trim() : "",
|
|
5223
|
+
// A group is read back from a file like any other label the user assigned — that is what makes an
|
|
5224
|
+
// export a backup of the STRUCTURE and not only of the words.
|
|
5225
|
+
group: typeof raw.group === "string" ? raw.group.trim() : "",
|
|
4938
5226
|
aliases: Array.isArray(raw.aliases) ? raw.aliases.filter((alias) => typeof alias === "string" && alias.trim() !== "") : [],
|
|
4939
5227
|
pinned: raw.pinned === true
|
|
4940
5228
|
};
|
|
@@ -4975,6 +5263,64 @@ window.__ModuleLoader__.load({
|
|
|
4975
5263
|
return { ok: true, entries, skipped };
|
|
4976
5264
|
}
|
|
4977
5265
|
|
|
5266
|
+
/**
|
|
5267
|
+
* The folder path a file sits in, as a group path.
|
|
5268
|
+
*
|
|
5269
|
+
* The picked folder's own name IS the first group: choosing a folder called `packs` is how you say "file
|
|
5270
|
+
* these under `packs`", and dropping the name would put everything at the top level — which is precisely
|
|
5271
|
+
* the structure the user just picked a folder to express.
|
|
5272
|
+
*
|
|
5273
|
+
* @param path - a `relative/path/file.json`, as `webkitRelativePath` reports it.
|
|
5274
|
+
* @returns the group path.
|
|
5275
|
+
*/
|
|
5276
|
+
function folderPathOf(path) {
|
|
5277
|
+
const parts = String(path)
|
|
5278
|
+
.split("/")
|
|
5279
|
+
.filter((part) => part !== "");
|
|
5280
|
+
// Everything but the file name.
|
|
5281
|
+
return parts.slice(0, -1).join("/");
|
|
5282
|
+
}
|
|
5283
|
+
|
|
5284
|
+
/**
|
|
5285
|
+
* Read a whole directory into one import.
|
|
5286
|
+
*
|
|
5287
|
+
* A folder is a mixed bag: several exported dictionaries, somebody's downloaded pack files, an
|
|
5288
|
+
* `index.json` that lists packs rather than holding entries, and often a README or a screenshot. So this
|
|
5289
|
+
* reports what it could NOT use instead of refusing the folder — the alternative is a user with twenty
|
|
5290
|
+
* files and one bad one, told "no" with no idea which file it was. Files that are not JSON are not
|
|
5291
|
+
* reported at all: they are not mistakes.
|
|
5292
|
+
*
|
|
5293
|
+
* @param files - `[{ path, text }]`, the directory's files as the browser hands them over.
|
|
5294
|
+
* @param options - `useFolderAsGroup` files each entry under the folder it came from, which is the shape
|
|
5295
|
+
* people actually keep: one folder per topic, and the reason the option exists at all.
|
|
5296
|
+
* @returns `{ entries, files, used, skipped }`, where `skipped` is `[{ name, error }]`.
|
|
5297
|
+
*/
|
|
5298
|
+
function collectFolderImport(files, options) {
|
|
5299
|
+
const list = Array.isArray(files) ? files : [];
|
|
5300
|
+
const entries = [];
|
|
5301
|
+
const skipped = [];
|
|
5302
|
+
const useFolder = options?.useFolderAsGroup === true;
|
|
5303
|
+
let used = 0;
|
|
5304
|
+
let jsonFiles = 0;
|
|
5305
|
+
for (const file of list) {
|
|
5306
|
+
const path = typeof file?.path === "string" ? file.path : "";
|
|
5307
|
+
if (!/\.json$/i.test(path)) continue;
|
|
5308
|
+
jsonFiles++;
|
|
5309
|
+
const parsed = parseImport(typeof file?.text === "string" ? file.text : "");
|
|
5310
|
+
if (parsed.ok !== true) {
|
|
5311
|
+
skipped.push({ name: path, error: parsed.error });
|
|
5312
|
+
continue;
|
|
5313
|
+
}
|
|
5314
|
+
used++;
|
|
5315
|
+
const folder = useFolder ? folderPathOf(path) : "";
|
|
5316
|
+
for (const entry of parsed.entries) {
|
|
5317
|
+
const hasGroup = typeof entry.group === "string" && entry.group !== "";
|
|
5318
|
+
entries.push(folder !== "" && !hasGroup ? { ...entry, group: folder } : entry);
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5321
|
+
return { entries, files: jsonFiles, used, skipped };
|
|
5322
|
+
}
|
|
5323
|
+
|
|
4978
5324
|
/**
|
|
4979
5325
|
* Decide what an import would do, without doing any of it.
|
|
4980
5326
|
*
|
|
@@ -5020,10 +5366,15 @@ window.__ModuleLoader__.load({
|
|
|
5020
5366
|
// Opting in does not empty the `deleted` bucket by accident: it moves those entries into
|
|
5021
5367
|
// `accepted`, and the bucket is then genuinely empty, so the report says "0 you deleted" and
|
|
5022
5368
|
// means it.
|
|
5369
|
+
//
|
|
5370
|
+
// `revived` names the accepted entries that were previously DELETED, separately from the ones that are
|
|
5371
|
+
// simply new. The caller must not treat the two the same: a new term is a write, while a deleted term is
|
|
5372
|
+
// a RESTORATION — a decision that has to beat the tombstone, not an edit that hopes to. Running both
|
|
5373
|
+
// through the same write is how "a term that was collected and then deleted cannot be restored" happened.
|
|
5023
5374
|
const retract = options?.includeDeleted === true;
|
|
5024
5375
|
return retract
|
|
5025
|
-
? { accepted: [...accepted, ...wasDeleted], existing: alreadyHave, deleted: [], duplicates }
|
|
5026
|
-
: { accepted, existing: alreadyHave, deleted: wasDeleted, duplicates };
|
|
5376
|
+
? { accepted: [...accepted, ...wasDeleted], revived: wasDeleted, existing: alreadyHave, deleted: [], duplicates }
|
|
5377
|
+
: { accepted, revived: [], existing: alreadyHave, deleted: wasDeleted, duplicates };
|
|
5027
5378
|
}
|
|
5028
5379
|
|
|
5029
5380
|
/**
|
|
@@ -5085,6 +5436,7 @@ window.__ModuleLoader__.load({
|
|
|
5085
5436
|
buildExport,
|
|
5086
5437
|
buildFeedbackReport,
|
|
5087
5438
|
parseImport,
|
|
5439
|
+
collectFolderImport,
|
|
5088
5440
|
planImport,
|
|
5089
5441
|
domainsIn,
|
|
5090
5442
|
toRecord,
|
|
@@ -5260,28 +5612,41 @@ window.__ModuleLoader__.load({
|
|
|
5260
5612
|
}
|
|
5261
5613
|
|
|
5262
5614
|
/**
|
|
5263
|
-
* Read a pack from whatever arrived: a fetched file,
|
|
5615
|
+
* Read a pack from whatever arrived: a fetched file, a decoded share code, or the text of either.
|
|
5264
5616
|
*
|
|
5265
5617
|
* Tolerant about unknown keys in the envelope, strict about the two things that make it a pack: its
|
|
5266
5618
|
* marker and at least one usable entry. Every record goes through the same validator an imported file
|
|
5267
5619
|
* does, so a record that cannot be imported is a record this refuses.
|
|
5268
5620
|
*
|
|
5269
|
-
*
|
|
5621
|
+
* Accepts TEXT as well as a parsed object, because text is what actually arrives: the host fetches the
|
|
5622
|
+
* file and passes the body on. Accepting only an object made every source and every pack answer
|
|
5623
|
+
* `not-an-object` — a feature that refuses everything, in the shape of a bad URL.
|
|
5624
|
+
*
|
|
5625
|
+
* @param raw - the fetched body, the decoded text, or the parsed value.
|
|
5270
5626
|
* @returns `{ ok: true, pack }`, or `{ ok: false, error }`.
|
|
5271
5627
|
*/
|
|
5272
5628
|
function parsePack(raw) {
|
|
5273
|
-
|
|
5274
|
-
if (raw
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5629
|
+
let value = raw;
|
|
5630
|
+
if (typeof raw === "string") {
|
|
5631
|
+
try {
|
|
5632
|
+
value = JSON.parse(raw);
|
|
5633
|
+
} catch (error) {
|
|
5634
|
+
return { ok: false, error: "not-json" };
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "not-an-object" };
|
|
5638
|
+
const raw2 = value;
|
|
5639
|
+
if (raw2.kind !== PACK_KIND) return { ok: false, error: "not-a-pack" };
|
|
5640
|
+
if (raw2.version !== PACK_VERSION) return { ok: false, error: "unsupported-version" };
|
|
5641
|
+
if (!Array.isArray(raw2.entries)) return { ok: false, error: "no-entries" };
|
|
5642
|
+
if (raw2.entries.length > MAX_PACK_ENTRIES) return { ok: false, error: "too-many" };
|
|
5278
5643
|
const entries = [];
|
|
5279
|
-
for (const record of
|
|
5644
|
+
for (const record of raw2.entries) {
|
|
5280
5645
|
const entry = transfer.toEntry(record);
|
|
5281
5646
|
if (entry !== null) entries.push(record);
|
|
5282
5647
|
}
|
|
5283
5648
|
if (entries.length === 0) return { ok: false, error: "no-usable-entries" };
|
|
5284
|
-
const scope =
|
|
5649
|
+
const scope = raw2.scope !== null && typeof raw2.scope === "object" && raw2.scope.kind === "domains" ? "domains" : "all";
|
|
5285
5650
|
return {
|
|
5286
5651
|
ok: true,
|
|
5287
5652
|
pack: {
|
|
@@ -5380,17 +5745,31 @@ window.__ModuleLoader__.load({
|
|
|
5380
5745
|
* in somebody's index must not hide the other forty packs. An index with nothing usable left is a
|
|
5381
5746
|
* refusal, because a source that offers nothing is a source that is misconfigured or moved.
|
|
5382
5747
|
*
|
|
5383
|
-
* @
|
|
5748
|
+
* Takes TEXT or a parsed object, like {@link parsePack} — and the text form is the one production uses:
|
|
5749
|
+
* the host fetches the source and hands the body straight over. Requiring an object here while the host
|
|
5750
|
+
* sends text meant every source answered `not-an-object`, which is a whole feature answering "no" in a
|
|
5751
|
+
* way that looks like the user's URL was wrong.
|
|
5752
|
+
*
|
|
5753
|
+
* @param raw - the fetched body, or the parsed value.
|
|
5384
5754
|
* @returns `{ ok: true, index, dropped }`, or `{ ok: false, error }`.
|
|
5385
5755
|
*/
|
|
5386
5756
|
function parseIndex(raw) {
|
|
5387
|
-
|
|
5388
|
-
if (raw
|
|
5389
|
-
|
|
5390
|
-
|
|
5757
|
+
let value = raw;
|
|
5758
|
+
if (typeof raw === "string") {
|
|
5759
|
+
try {
|
|
5760
|
+
value = JSON.parse(raw);
|
|
5761
|
+
} catch (error) {
|
|
5762
|
+
return { ok: false, error: "not-json" };
|
|
5763
|
+
}
|
|
5764
|
+
}
|
|
5765
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "not-an-object" };
|
|
5766
|
+
const raw2 = value;
|
|
5767
|
+
if (raw2.kind !== INDEX_KIND) return { ok: false, error: "not-an-index" };
|
|
5768
|
+
if (raw2.version !== INDEX_VERSION) return { ok: false, error: "unsupported-version" };
|
|
5769
|
+
if (!Array.isArray(raw2.packs)) return { ok: false, error: "no-packs" };
|
|
5391
5770
|
const packs = [];
|
|
5392
5771
|
let dropped = 0;
|
|
5393
|
-
for (const pack of
|
|
5772
|
+
for (const pack of raw2.packs) {
|
|
5394
5773
|
if (pack === null || typeof pack !== "object") {
|
|
5395
5774
|
dropped++;
|
|
5396
5775
|
continue;
|
|
@@ -8787,6 +9166,23 @@ window.__ModuleLoader__.load({
|
|
|
8787
9166
|
boxShadow: "0 0 0 1px var(--dsw-alias-brand-primary, #4d6bfe)"
|
|
8788
9167
|
},
|
|
8789
9168
|
rowHead: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" },
|
|
9169
|
+
/** Where you are, and the way back up: a directory you can only enter is a trap. */
|
|
9170
|
+
breadcrumb: { display: "flex", alignItems: "center", gap: "2px", flexWrap: "wrap", padding: "2px 0 6px", fontSize: "12px" },
|
|
9171
|
+
breadcrumbPart: { display: "inline-flex", alignItems: "center", gap: "2px" },
|
|
9172
|
+
breadcrumbSep: { color: "var(--dsw-alias-label-tertiary)", margin: "0 2px" },
|
|
9173
|
+
breadcrumbButton: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-brand-primary, #4d6bfe)", cursor: "pointer" },
|
|
9174
|
+
breadcrumbHere: { appearance: "none", border: "none", background: "none", padding: "2px 4px", font: "inherit", fontSize: "12px", color: "var(--dsw-alias-label-secondary)", cursor: "default" },
|
|
9175
|
+
/** The group row's own bits: the mark that says "not an entry", and the chevron that says "opens". */
|
|
9176
|
+
groupIcon: { display: "inline-flex", alignItems: "center", color: "var(--dsw-alias-label-secondary)" },
|
|
9177
|
+
groupEnter: { color: "var(--dsw-alias-label-tertiary)", fontSize: "14px", lineHeight: 1 },
|
|
9178
|
+
/**
|
|
9179
|
+
* The row's action toolbar: the second line, always.
|
|
9180
|
+
*
|
|
9181
|
+
* On the title line the controls moved from row to row with the length of the term and the number of
|
|
9182
|
+
* badges; a line of their own puts every row's controls in the same place and keeps them out of the
|
|
9183
|
+
* way of the text they act on.
|
|
9184
|
+
*/
|
|
9185
|
+
rowActions: { display: "flex", alignItems: "center", gap: "6px", marginTop: "2px" },
|
|
8790
9186
|
termButton: {
|
|
8791
9187
|
appearance: "none",
|
|
8792
9188
|
background: "transparent",
|
|
@@ -9293,6 +9689,13 @@ window.__ModuleLoader__.load({
|
|
|
9293
9689
|
{ width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
|
|
9294
9690
|
h("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round" })
|
|
9295
9691
|
),
|
|
9692
|
+
// The group row's icon: the one thing on screen that is not an entry, so it says so without a label.
|
|
9693
|
+
folder: (size) =>
|
|
9694
|
+
h(
|
|
9695
|
+
"svg",
|
|
9696
|
+
{ width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
|
|
9697
|
+
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" })
|
|
9698
|
+
),
|
|
9296
9699
|
pin: (size) =>
|
|
9297
9700
|
h(
|
|
9298
9701
|
"svg",
|
|
@@ -9366,11 +9769,11 @@ window.__ModuleLoader__.load({
|
|
|
9366
9769
|
};
|
|
9367
9770
|
|
|
9368
9771
|
/** 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 };
|
|
9772
|
+
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
9773
|
|
|
9371
9774
|
/** 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");
|
|
9775
|
+
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");
|
|
9776
|
+
const { buildExport, buildFeedbackReport, parseImport, collectFolderImport, planImport, domainsIn } = require("./transfer.js");
|
|
9374
9777
|
const { FEEDBACK_KINDS, isFlagged, entryAsText } = require("./entries.js");
|
|
9375
9778
|
const { refuseUrl } = require("./pack.js");
|
|
9376
9779
|
const { selectionInside } = require("./selection.js");
|
|
@@ -9544,13 +9947,20 @@ window.__ModuleLoader__.load({
|
|
|
9544
9947
|
const plan = planImport(store.getState().entries ?? [], incoming, store.getState().deletedKeys ?? new Set(), {
|
|
9545
9948
|
includeDeleted: options?.includeDeleted === true
|
|
9546
9949
|
});
|
|
9950
|
+
// Two different acts, deliberately: a term the dictionary never had is a WRITE, while a term the user
|
|
9951
|
+
// deleted is a RESTORATION. `store.restoreEntry` withdraws the tombstone and records the decision; a
|
|
9952
|
+
// write would instead have to BEAT the tombstone in a merge, and it can lose that comparison — which is
|
|
9953
|
+
// exactly "a term that was collected and then deleted could not be restored".
|
|
9954
|
+
const revived = new Set((plan.revived ?? []).map((entry) => entry.key));
|
|
9547
9955
|
for (const entry of plan.accepted) {
|
|
9548
|
-
|
|
9956
|
+
const patch = {
|
|
9549
9957
|
definition: entry.definition,
|
|
9550
9958
|
domain: entry.domain,
|
|
9551
9959
|
aliases: entry.aliases,
|
|
9552
9960
|
pinned: entry.pinned
|
|
9553
|
-
}
|
|
9961
|
+
};
|
|
9962
|
+
if (revived.has(entry.key)) await store.restoreEntry(entry.term, patch);
|
|
9963
|
+
else await store.saveEntry(entry.term, patch);
|
|
9554
9964
|
}
|
|
9555
9965
|
return {
|
|
9556
9966
|
imported: plan.accepted.length,
|
|
@@ -9658,6 +10068,21 @@ window.__ModuleLoader__.load({
|
|
|
9658
10068
|
* choice this page offers.
|
|
9659
10069
|
*/
|
|
9660
10070
|
const [includeGloss, setIncludeGloss] = React.useState(false);
|
|
10071
|
+
/**
|
|
10072
|
+
* A file that has been read but not imported yet: `{ name, entries, skippedCount, plan }`.
|
|
10073
|
+
*
|
|
10074
|
+
* Reading a file and writing it are two decisions, and this state is the gap between them. It exists
|
|
10075
|
+
* because the second decision used to have no moment of its own: picking a file imported it outright,
|
|
10076
|
+
* so the only way to see what was in it was the report afterwards.
|
|
10077
|
+
*/
|
|
10078
|
+
const [pending, setPending] = React.useState(null);
|
|
10079
|
+
/**
|
|
10080
|
+
* Whether a folder import names each entry's category after the folder it came from.
|
|
10081
|
+
*
|
|
10082
|
+
* Off by default: it writes a value the user did not type. On is for the shape people actually keep —
|
|
10083
|
+
* one folder per topic.
|
|
10084
|
+
*/
|
|
10085
|
+
const [useFolderAsGroup, setUseFolderAsGroup] = React.useState(true);
|
|
9661
10086
|
/**
|
|
9662
10087
|
* What came back from the host's own feedback channel, or null when nothing was sent yet.
|
|
9663
10088
|
*
|
|
@@ -9742,7 +10167,7 @@ window.__ModuleLoader__.load({
|
|
|
9742
10167
|
/** Save the export as a file. */
|
|
9743
10168
|
const download = () => downloadText(textOf(), `term-dictionary-${new Date().toISOString().slice(0, 10)}.json`);
|
|
9744
10169
|
|
|
9745
|
-
/** Take a file, and
|
|
10170
|
+
/** Take a file: read it, show what is in it, and write nothing until the user says so. */
|
|
9746
10171
|
const takeFile = async (event) => {
|
|
9747
10172
|
const file = event?.target?.files?.[0];
|
|
9748
10173
|
if (file === undefined || file === null) return;
|
|
@@ -9753,9 +10178,76 @@ window.__ModuleLoader__.load({
|
|
|
9753
10178
|
setReport({ failed: parsed.error });
|
|
9754
10179
|
return;
|
|
9755
10180
|
}
|
|
9756
|
-
//
|
|
9757
|
-
//
|
|
9758
|
-
|
|
10181
|
+
// The previous import's report goes away when a new file is read: it describes something that
|
|
10182
|
+
// has already happened, and leaving it under a fresh confirmation reads as if it described
|
|
10183
|
+
// this one.
|
|
10184
|
+
setReport(null);
|
|
10185
|
+
// The plan is a DRY RUN: `planImport` is pure, so the page can say what would happen without
|
|
10186
|
+
// any of it happening. The write path recomputes it at the moment of the write, so a preview
|
|
10187
|
+
// that went stale (another window deleted something) cannot import the stale answer.
|
|
10188
|
+
setPending({ name: file.name, entries: parsed.entries, skippedCount: parsed.skipped ?? 0, plan: planFor(parsed.entries, includeDeleted) });
|
|
10189
|
+
} catch (error) {
|
|
10190
|
+
setReport({ failed: String(error?.message ?? error) });
|
|
10191
|
+
} finally {
|
|
10192
|
+
setBusy(false);
|
|
10193
|
+
}
|
|
10194
|
+
};
|
|
10195
|
+
|
|
10196
|
+
/** The dry-run plan for a set of incoming records, used by the preview and by the write. */
|
|
10197
|
+
function planFor(incoming, withDeleted) {
|
|
10198
|
+
return planImport(store.getState().entries ?? [], incoming, store.getState().deletedKeys ?? new Set(), { includeDeleted: withDeleted === true });
|
|
10199
|
+
}
|
|
10200
|
+
|
|
10201
|
+
/** The confirmation the user actually presses. */
|
|
10202
|
+
const confirmImport = async () => {
|
|
10203
|
+
if (pending === null || busy) return;
|
|
10204
|
+
setBusy(true);
|
|
10205
|
+
try {
|
|
10206
|
+
const result = await importEntries(store, pending.entries, { includeDeleted, skipped: pending.skippedCount });
|
|
10207
|
+
// The panel owns the view, so IT decides what has to move to show what was just written — and
|
|
10208
|
+
// says whether it had to. An import that lands while the list is filtered to 「已删除」 or
|
|
10209
|
+
// narrowed by a search looks exactly like an import that did nothing, which is how this was
|
|
10210
|
+
// reported once ("the imported entries do not appear").
|
|
10211
|
+
const moved = typeof props.onImported === "function" && props.onImported() === true;
|
|
10212
|
+
setReport(moved ? { ...result, moved: true } : result);
|
|
10213
|
+
setPending(null);
|
|
10214
|
+
} catch (error) {
|
|
10215
|
+
setReport({ failed: String(error?.message ?? error) });
|
|
10216
|
+
} finally {
|
|
10217
|
+
setBusy(false);
|
|
10218
|
+
}
|
|
10219
|
+
};
|
|
10220
|
+
|
|
10221
|
+
/**
|
|
10222
|
+
* Read a whole folder: every `*.json` in it, as one import.
|
|
10223
|
+
*
|
|
10224
|
+
* A directory reaches the page through `webkitdirectory`, which is the OS's own directory browser —
|
|
10225
|
+
* so this needs no new service dependency, and nothing can park the plugin waiting for one. The
|
|
10226
|
+
* browser supplies `webkitRelativePath`, which is what names a category after its folder.
|
|
10227
|
+
*/
|
|
10228
|
+
const takeFolder = async (event) => {
|
|
10229
|
+
const files = [...(event?.target?.files ?? [])];
|
|
10230
|
+
if (files.length === 0) return;
|
|
10231
|
+
setBusy(true);
|
|
10232
|
+
try {
|
|
10233
|
+
const read = [];
|
|
10234
|
+
for (const file of files) {
|
|
10235
|
+
const path = typeof file.webkitRelativePath === "string" && file.webkitRelativePath !== "" ? file.webkitRelativePath : file.name;
|
|
10236
|
+
read.push({ path, text: await file.text() });
|
|
10237
|
+
}
|
|
10238
|
+
const collected = collectFolderImport(read, { useFolderAsGroup });
|
|
10239
|
+
if (collected.entries.length === 0) {
|
|
10240
|
+
setReport({ failed: collected.skipped.length === 0 ? t("transferNoJson") : t("transferFolderAllSkipped", { count: String(collected.skipped.length) }) });
|
|
10241
|
+
return;
|
|
10242
|
+
}
|
|
10243
|
+
setReport(null);
|
|
10244
|
+
setPending({
|
|
10245
|
+
name: t("transferFolderLabel", { count: String(collected.used) }),
|
|
10246
|
+
entries: collected.entries,
|
|
10247
|
+
skippedCount: 0,
|
|
10248
|
+
plan: planFor(collected.entries, includeDeleted),
|
|
10249
|
+
folder: { files: collected.files, used: collected.used, skipped: collected.skipped }
|
|
10250
|
+
});
|
|
9759
10251
|
} catch (error) {
|
|
9760
10252
|
setReport({ failed: String(error?.message ?? error) });
|
|
9761
10253
|
} finally {
|
|
@@ -9866,7 +10358,13 @@ window.__ModuleLoader__.load({
|
|
|
9866
10358
|
type: "checkbox",
|
|
9867
10359
|
style: applyStyles.checkbox,
|
|
9868
10360
|
checked: includeDeleted,
|
|
9869
|
-
|
|
10361
|
+
// Toggling this re-plans the preview rather than leaving it: the number the user is about
|
|
10362
|
+
// to confirm is the number this opt-in produces, or the confirmation is a lie.
|
|
10363
|
+
onChange: () => {
|
|
10364
|
+
const next = !includeDeleted;
|
|
10365
|
+
setIncludeDeleted(next);
|
|
10366
|
+
setPending((current) => (current === null ? null : { ...current, plan: planFor(current.entries, next) }));
|
|
10367
|
+
}
|
|
9870
10368
|
}),
|
|
9871
10369
|
h("span", { style: applyStyles.transferName }, t("transferIncludeDeleted"))
|
|
9872
10370
|
),
|
|
@@ -9879,19 +10377,114 @@ window.__ModuleLoader__.load({
|
|
|
9879
10377
|
onChange: (event) => void takeFile(event),
|
|
9880
10378
|
style: applyStyles.transferFile
|
|
9881
10379
|
}),
|
|
10380
|
+
// The folder half. `webkitdirectory` opens the OS's directory browser and hands over every file
|
|
10381
|
+
// inside it, with the path — which is the only directory browser a page needs, and the one that
|
|
10382
|
+
// arrives without a service the plugin could end up parked on.
|
|
10383
|
+
h("p", { style: applyStyles.settingLabel }, t("transferFolderTitle")),
|
|
10384
|
+
h("p", { style: applyStyles.settingHint }, t("transferFolderHint")),
|
|
10385
|
+
h("input", {
|
|
10386
|
+
type: "file",
|
|
10387
|
+
webkitdirectory: "true",
|
|
10388
|
+
directory: "true",
|
|
10389
|
+
multiple: true,
|
|
10390
|
+
"aria-label": t("transferFolderTitle"),
|
|
10391
|
+
disabled: busy,
|
|
10392
|
+
onChange: (event) => void takeFolder(event),
|
|
10393
|
+
style: applyStyles.transferFile,
|
|
10394
|
+
"data-term-dictionary": "transfer-folder"
|
|
10395
|
+
}),
|
|
10396
|
+
h(
|
|
10397
|
+
"label",
|
|
10398
|
+
{ style: applyStyles.transferItem, "data-term-dictionary": "transfer-folder-group" },
|
|
10399
|
+
h("input", { type: "checkbox", style: applyStyles.checkbox, checked: useFolderAsGroup, onChange: () => setUseFolderAsGroup(!useFolderAsGroup) }),
|
|
10400
|
+
h("span", { style: applyStyles.transferName }, t("transferFolderAsGroup"))
|
|
10401
|
+
),
|
|
10402
|
+
// The confirmation. Picking a file used to import it, which made "what is in this file" a
|
|
10403
|
+
// question you could only answer afterwards; the plan below is a dry run, so the answer comes
|
|
10404
|
+
// first and the write waits for a second press.
|
|
10405
|
+
pending === null
|
|
10406
|
+
? null
|
|
10407
|
+
: h(
|
|
10408
|
+
"div",
|
|
10409
|
+
{ style: applyStyles.notice, "data-term-import-pending": "true" },
|
|
10410
|
+
h("p", { style: applyStyles.settingLabel }, t("transferConfirmTitle", { name: pending.name })),
|
|
10411
|
+
h(
|
|
10412
|
+
"p",
|
|
10413
|
+
{ style: applyStyles.settingHint },
|
|
10414
|
+
[
|
|
10415
|
+
t("transferWillImport", { count: String(pending.plan.accepted.length) }),
|
|
10416
|
+
pending.plan.existing.length > 0 ? t("transferWillSkipExisting", { count: String(pending.plan.existing.length) }) : "",
|
|
10417
|
+
pending.plan.deleted.length > 0 ? t("transferWillSkipDeleted", { count: String(pending.plan.deleted.length) }) : "",
|
|
10418
|
+
pending.plan.duplicates + pending.skippedCount > 0 ? t("transferDuplicates", { count: String(pending.plan.duplicates + pending.skippedCount) }) : ""
|
|
10419
|
+
]
|
|
10420
|
+
.filter((part) => part !== "")
|
|
10421
|
+
.join(" · ")
|
|
10422
|
+
),
|
|
10423
|
+
// A folder says what it found and what it had to pass over — by name, because "one file was
|
|
10424
|
+
// skipped" with twenty files in the folder is not an answer.
|
|
10425
|
+
pending.folder === undefined
|
|
10426
|
+
? null
|
|
10427
|
+
: h(
|
|
10428
|
+
"div",
|
|
10429
|
+
null,
|
|
10430
|
+
h("p", { style: applyStyles.settingHint }, t("transferFolderFound", { files: String(pending.folder.files), used: String(pending.folder.used) })),
|
|
10431
|
+
pending.folder.skipped.length === 0
|
|
10432
|
+
? null
|
|
10433
|
+
: h("p", { style: applyStyles.error }, `${t("transferFolderSkipped", { count: String(pending.folder.skipped.length) })} ${pending.folder.skipped.map((each) => `${each.name}(${each.error})`).join("、")}`)
|
|
10434
|
+
),
|
|
10435
|
+
h(
|
|
10436
|
+
"div",
|
|
10437
|
+
{ style: applyStyles.transferRow },
|
|
10438
|
+
h(Button, { label: t("transferConfirm"), disabled: busy, onClick: () => void confirmImport() }, t("transferConfirm")),
|
|
10439
|
+
h(Button, { label: t("cancel"), onClick: () => setPending(null) }, t("cancel"))
|
|
10440
|
+
)
|
|
10441
|
+
),
|
|
9882
10442
|
report === null
|
|
9883
10443
|
? null
|
|
9884
10444
|
: h(
|
|
9885
10445
|
"p",
|
|
9886
|
-
{ style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error },
|
|
10446
|
+
{ style: report.failed === undefined ? applyStyles.settingHint : applyStyles.error, "data-term-report": "true" },
|
|
9887
10447
|
report.failed === undefined
|
|
9888
|
-
? t("transferReport", { imported: report.imported, existing: report.existing, deleted: report.deleted, duplicates: report.duplicates })
|
|
10448
|
+
? [t("transferReport", { imported: report.imported, existing: report.existing, deleted: report.deleted, duplicates: report.duplicates }), report.moved === true ? t("transferShown") : ""]
|
|
10449
|
+
.filter((part) => part !== "")
|
|
10450
|
+
.join(" ")
|
|
9889
10451
|
: t("transferFailed", { message: report.failed })
|
|
9890
10452
|
)
|
|
9891
10453
|
//#endregion
|
|
9892
10454
|
);
|
|
9893
10455
|
}
|
|
9894
10456
|
|
|
10457
|
+
/**
|
|
10458
|
+
* The entry list's layout, given the column preference.
|
|
10459
|
+
*
|
|
10460
|
+
* A module-level function rather than an inline style, and pure, so the rule can be asserted without a
|
|
10461
|
+
* browser: `auto` asks the grid for as many columns as fit at a readable width, a number pins the
|
|
10462
|
+
* layout, and `1` is the plain column the panel had before any of this existed.
|
|
10463
|
+
*
|
|
10464
|
+
* @param columns - `"auto"`, `"1"`, `"2"` or `"3"` (anything else behaves as `auto`).
|
|
10465
|
+
* @returns the style for the list container.
|
|
10466
|
+
*/
|
|
10467
|
+
function listStyle(columns, rowLayout) {
|
|
10468
|
+
const fixed = Number.parseInt(String(columns), 10);
|
|
10469
|
+
const gridTemplateColumns =
|
|
10470
|
+
Number.isFinite(fixed) && fixed > 1
|
|
10471
|
+
? `repeat(${fixed}, minmax(0, 1fr))`
|
|
10472
|
+
: `repeat(auto-fill, minmax(${MIN_COLUMN_PX}px, 1fr))`;
|
|
10473
|
+
// `stretch` is the default because the complaint was the GAPS: with each cell as tall as its track,
|
|
10474
|
+
// cards line up and no empty strip appears under the short ones. `compact` lets every card keep its
|
|
10475
|
+
// own height instead, which is tidier in a column but leaves those strips — so it is a choice.
|
|
10476
|
+
return {
|
|
10477
|
+
...applyStyles.list,
|
|
10478
|
+
display: "grid",
|
|
10479
|
+
// Rows are as tall as their content, and the leftover height stays at the bottom. Without this a
|
|
10480
|
+
// grid STRETCHES its rows to fill the container, so two cards in a tall panel became two 700px
|
|
10481
|
+
// boxes — the opposite of what "equal cards" was asking for.
|
|
10482
|
+
alignContent: "start",
|
|
10483
|
+
alignItems: rowLayout === "compact" ? "start" : "stretch",
|
|
10484
|
+
gridTemplateColumns
|
|
10485
|
+
};
|
|
10486
|
+
}
|
|
10487
|
+
|
|
9895
10488
|
/**
|
|
9896
10489
|
* Term packs: hand this dictionary to somebody, or take theirs.
|
|
9897
10490
|
*
|
|
@@ -9934,6 +10527,7 @@ window.__ModuleLoader__.load({
|
|
|
9934
10527
|
/** One row per source after a refresh: `{ url, ok, stale, error, index }`. */
|
|
9935
10528
|
const [listing, setListing] = React.useState(null);
|
|
9936
10529
|
const [packNote, setPackNote] = React.useState({});
|
|
10530
|
+
const [autoRefreshed, setAutoRefreshed] = React.useState(false);
|
|
9937
10531
|
/** Say something in the page's one notice line rather than in a dialog. */
|
|
9938
10532
|
const say = (message) => setNotice(message);
|
|
9939
10533
|
|
|
@@ -10010,6 +10604,25 @@ window.__ModuleLoader__.load({
|
|
|
10010
10604
|
}
|
|
10011
10605
|
};
|
|
10012
10606
|
|
|
10607
|
+
/**
|
|
10608
|
+
* Ask the reader's own sources what they hold, once, when the page first opens.
|
|
10609
|
+
*
|
|
10610
|
+
* Without it a fresh install shows a configured source and nothing else, and "press 刷新" is a step
|
|
10611
|
+
* whose purpose nobody can guess. The request goes to the sources THEY have (the shipped default
|
|
10612
|
+
* included), the host does the fetching and caches it for an hour, and the list stays theirs to clear
|
|
10613
|
+
* — a source removed here is not added back, which is why this looks at the length rather than at
|
|
10614
|
+
* whether the list is the default one.
|
|
10615
|
+
*
|
|
10616
|
+
* Declared after `refresh` on purpose: the effect callback runs on the first render in some
|
|
10617
|
+
* harnesses (and in React's own test renderers), and a callback closing over a `const` below it
|
|
10618
|
+
* would read an uninitialised binding.
|
|
10619
|
+
*/
|
|
10620
|
+
React.useEffect(() => {
|
|
10621
|
+
if (autoRefreshed || sources.length === 0) return;
|
|
10622
|
+
setAutoRefreshed(true);
|
|
10623
|
+
void refresh();
|
|
10624
|
+
}, [autoRefreshed, sources.length]);
|
|
10625
|
+
|
|
10013
10626
|
/** Fetch one listed pack and offer it for import. */
|
|
10014
10627
|
const preview = async (row, listed) => {
|
|
10015
10628
|
if (typeof packApi?.fetch !== "function") return say(t("packNoHost"));
|
|
@@ -10181,6 +10794,9 @@ window.__ModuleLoader__.load({
|
|
|
10181
10794
|
h(
|
|
10182
10795
|
"div",
|
|
10183
10796
|
{ key: url, style: applyStyles.transferItem, "data-term-source": url },
|
|
10797
|
+
// The shipped one says so: a source nobody remembers adding is a source nobody dares
|
|
10798
|
+
// remove, and this one is removable like any other.
|
|
10799
|
+
url === DEFAULT_PACK_SOURCE ? h("span", { style: applyStyles.badge }, t("packBuiltInSource")) : null,
|
|
10184
10800
|
h("span", { style: applyStyles.transferName }, url),
|
|
10185
10801
|
h(Button, { label: t("packRemoveSource"), onClick: () => settingsStore?.set?.("packSources", sources.filter((each) => each !== url)) }, t("packRemoveSource"))
|
|
10186
10802
|
)
|
|
@@ -10189,7 +10805,7 @@ window.__ModuleLoader__.load({
|
|
|
10189
10805
|
h(
|
|
10190
10806
|
"div",
|
|
10191
10807
|
{ style: applyStyles.transferRow },
|
|
10192
|
-
h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), onChange: setSourceDraft }),
|
|
10808
|
+
h(Field, { label: t("packAddSourceLabel"), value: sourceDraft, placeholder: t("packAddSourcePlaceholder"), width: "30em", onChange: setSourceDraft }),
|
|
10193
10809
|
h(Button, { label: t("packAddSource"), disabled: busy, onClick: addSource }, t("packAddSource")),
|
|
10194
10810
|
h(Button, { label: t("packRefresh"), disabled: busy || sources.length === 0, onClick: () => void refresh() }, t("packRefresh"))
|
|
10195
10811
|
),
|
|
@@ -10393,6 +11009,28 @@ window.__ModuleLoader__.load({
|
|
|
10393
11009
|
onSelect: (value) => setSwitch("explainDepth", value)
|
|
10394
11010
|
})
|
|
10395
11011
|
),
|
|
11012
|
+
row(
|
|
11013
|
+
"rows",
|
|
11014
|
+
t("entryRowsName"),
|
|
11015
|
+
t("entryRowsHint"),
|
|
11016
|
+
h(OptionBar, {
|
|
11017
|
+
label: t("entryRowsName"),
|
|
11018
|
+
value: ENTRY_ROWS.includes(settings.entryRows) ? settings.entryRows : "uniform",
|
|
11019
|
+
options: ENTRY_ROWS.map((value) => ({ value, label: t(`entryRows_${value}`) })),
|
|
11020
|
+
onSelect: (value) => setSwitch("entryRows", value)
|
|
11021
|
+
})
|
|
11022
|
+
),
|
|
11023
|
+
row(
|
|
11024
|
+
"columns",
|
|
11025
|
+
t("entryColumnsName"),
|
|
11026
|
+
t("entryColumnsHint"),
|
|
11027
|
+
h(OptionBar, {
|
|
11028
|
+
label: t("entryColumnsName"),
|
|
11029
|
+
value: ENTRY_COLUMNS.includes(settings.entryColumns) ? settings.entryColumns : "auto",
|
|
11030
|
+
options: ENTRY_COLUMNS.map((value) => ({ value, label: t(`entryColumns_${value}`) })),
|
|
11031
|
+
onSelect: (value) => setSwitch("entryColumns", value)
|
|
11032
|
+
})
|
|
11033
|
+
),
|
|
10396
11034
|
flag(
|
|
10397
11035
|
"identifiers",
|
|
10398
11036
|
t("collectIdentifiersOn"),
|
|
@@ -10467,6 +11105,15 @@ window.__ModuleLoader__.load({
|
|
|
10467
11105
|
}
|
|
10468
11106
|
|
|
10469
11107
|
/** A labelled text input. */
|
|
11108
|
+
/**
|
|
11109
|
+
* A labelled text field.
|
|
11110
|
+
*
|
|
11111
|
+
* One width for every single-line field, because a form of differently-sized boxes reads as a mistake
|
|
11112
|
+
* rather than as a decision. `width` overrides the cap for the rare field whose content really is longer
|
|
11113
|
+
* (a URL, a path), and a multi-line field is an {@link Area} and takes the full width instead.
|
|
11114
|
+
*/
|
|
11115
|
+
const FIELD_WIDTH = "22em";
|
|
11116
|
+
|
|
10470
11117
|
function Field(props) {
|
|
10471
11118
|
return h(
|
|
10472
11119
|
"label",
|
|
@@ -10477,7 +11124,7 @@ window.__ModuleLoader__.load({
|
|
|
10477
11124
|
value: props.value,
|
|
10478
11125
|
placeholder: props.placeholder,
|
|
10479
11126
|
onChange: (event) => props.onChange(event.target.value),
|
|
10480
|
-
style: applyStyles.input,
|
|
11127
|
+
style: { ...applyStyles.input, maxWidth: props.width ?? FIELD_WIDTH },
|
|
10481
11128
|
"aria-label": props.label
|
|
10482
11129
|
})
|
|
10483
11130
|
);
|
|
@@ -10541,6 +11188,8 @@ window.__ModuleLoader__.load({
|
|
|
10541
11188
|
);
|
|
10542
11189
|
const [query, setQuery] = React.useState("");
|
|
10543
11190
|
const [filter, setFilter] = React.useState("all");
|
|
11191
|
+
/** How the entry list is laid out; the settings store owns the value, this only reads it. */
|
|
11192
|
+
const columns = settings.entryColumns ?? FALLBACK_SETTINGS.entryColumns;
|
|
10544
11193
|
const [editing, setEditing] = React.useState(null);
|
|
10545
11194
|
/** Whether the panel is showing its settings page instead of the entry list. */
|
|
10546
11195
|
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
|
@@ -10587,7 +11236,21 @@ window.__ModuleLoader__.load({
|
|
|
10587
11236
|
};
|
|
10588
11237
|
}, [store]);
|
|
10589
11238
|
|
|
10590
|
-
|
|
11239
|
+
/**
|
|
11240
|
+
* Which group the list is showing: `""` is the top level, otherwise a path like `backend/net`.
|
|
11241
|
+
*
|
|
11242
|
+
* Standing somewhere is a property of the VIEW, not of the dictionary — nothing about an entry changes
|
|
11243
|
+
* because you looked at it — so it is panel state, next to the search box and the tab.
|
|
11244
|
+
*/
|
|
11245
|
+
const [group, setGroup] = React.useState("");
|
|
11246
|
+
const visible = React.useMemo(() => store.list(query, filter, group), [view, query, filter, group]);
|
|
11247
|
+
/**
|
|
11248
|
+
* The groups directly inside the current one, each with how many entries it holds.
|
|
11249
|
+
*
|
|
11250
|
+
* They are ROWS of the list rather than a sidebar or a header: a group is the same kind of thing as an
|
|
11251
|
+
* entry — something you open — and the point of the shape is that both are the same size on screen.
|
|
11252
|
+
*/
|
|
11253
|
+
const subGroups = React.useMemo(() => store.groups(group), [view, group]);
|
|
10591
11254
|
const current = view.state.entries;
|
|
10592
11255
|
/**
|
|
10593
11256
|
* Whether the panel is showing what was deleted.
|
|
@@ -10606,6 +11269,22 @@ window.__ModuleLoader__.load({
|
|
|
10606
11269
|
*/
|
|
10607
11270
|
const showingFlagged = filter === "flagged";
|
|
10608
11271
|
|
|
11272
|
+
/**
|
|
11273
|
+
* Make what an import just wrote visible.
|
|
11274
|
+
*
|
|
11275
|
+
* The panel owns the view, so this is the panel's job and not the import page's. It returns whether it
|
|
11276
|
+
* had to move anything, because a report that says "12 imported" while the list is still filtered to
|
|
11277
|
+
* 「已删除」 is a report nobody can check.
|
|
11278
|
+
*
|
|
11279
|
+
* @returns true when the search box or the tab had to change.
|
|
11280
|
+
*/
|
|
11281
|
+
const showImported = () => {
|
|
11282
|
+
const moved = query !== "" || filter !== "all";
|
|
11283
|
+
setQuery("");
|
|
11284
|
+
setFilter("all");
|
|
11285
|
+
return moved;
|
|
11286
|
+
};
|
|
11287
|
+
|
|
10609
11288
|
/** Open the editor for a blank entry, prefilled from an optional term. */
|
|
10610
11289
|
const startCreate = (term = "", context = "") => {
|
|
10611
11290
|
setEditing({
|
|
@@ -10615,6 +11294,9 @@ window.__ModuleLoader__.load({
|
|
|
10615
11294
|
usage: "",
|
|
10616
11295
|
notes: "",
|
|
10617
11296
|
domain: "",
|
|
11297
|
+
// A new entry made while standing in a group belongs to that group: that is what standing there
|
|
11298
|
+
// meant, and asking again in the form would be a question with one sensible answer.
|
|
11299
|
+
group,
|
|
10618
11300
|
aliases: "",
|
|
10619
11301
|
pinned: false,
|
|
10620
11302
|
// Nothing to say yet, and `feedbackTouched` is what tells a save apart from a retraction:
|
|
@@ -10638,6 +11320,7 @@ window.__ModuleLoader__.load({
|
|
|
10638
11320
|
usage: entry.definition.usage,
|
|
10639
11321
|
notes: entry.definition.notes,
|
|
10640
11322
|
domain: entry.domain,
|
|
11323
|
+
group: entry.group,
|
|
10641
11324
|
aliases: entry.aliases.join(", "),
|
|
10642
11325
|
pinned: entry.pinned,
|
|
10643
11326
|
// Seeded from the entry, and `touched` starts true when there is already a remark: saving
|
|
@@ -10699,6 +11382,7 @@ window.__ModuleLoader__.load({
|
|
|
10699
11382
|
await store.saveEntry(editing.term, {
|
|
10700
11383
|
definition: { zh: editing.zh, gloss: editing.gloss, usage: editing.usage, notes: editing.notes },
|
|
10701
11384
|
domain: editing.domain,
|
|
11385
|
+
group: editing.group,
|
|
10702
11386
|
aliases: editing.aliases
|
|
10703
11387
|
.split(",")
|
|
10704
11388
|
.map((alias) => alias.trim())
|
|
@@ -10856,6 +11540,12 @@ window.__ModuleLoader__.load({
|
|
|
10856
11540
|
placeholder: t("zhPlaceholder"),
|
|
10857
11541
|
onChange: (value) => setEditing({ ...editing, zh: value })
|
|
10858
11542
|
}),
|
|
11543
|
+
h(Field, {
|
|
11544
|
+
label: t("groupLabel"),
|
|
11545
|
+
value: editing.group,
|
|
11546
|
+
placeholder: t("groupPlaceholder"),
|
|
11547
|
+
onChange: (value) => setEditing({ ...editing, group: value })
|
|
11548
|
+
}),
|
|
10859
11549
|
h(Area, {
|
|
10860
11550
|
label: t("glossLabel"),
|
|
10861
11551
|
value: editing.gloss,
|
|
@@ -10988,7 +11678,7 @@ window.__ModuleLoader__.load({
|
|
|
10988
11678
|
),
|
|
10989
11679
|
h(Button, { label: t("settingsBack"), onClick: () => setTransferOpen(false) }, t("settingsBack"))
|
|
10990
11680
|
),
|
|
10991
|
-
h(TransferPage, { store, t, setToast: props.setToast })
|
|
11681
|
+
h(TransferPage, { store, t, setToast: props.setToast, onImported: showImported })
|
|
10992
11682
|
);
|
|
10993
11683
|
}
|
|
10994
11684
|
|
|
@@ -11116,14 +11806,79 @@ window.__ModuleLoader__.load({
|
|
|
11116
11806
|
) : null,
|
|
11117
11807
|
h(
|
|
11118
11808
|
"div",
|
|
11119
|
-
|
|
11120
|
-
|
|
11121
|
-
|
|
11122
|
-
|
|
11123
|
-
|
|
11124
|
-
:
|
|
11125
|
-
|
|
11126
|
-
|
|
11809
|
+
// The entry list is the one that gets columns. The deleted and flagged views are lists of
|
|
11810
|
+
// DECISIONS rather than of terms — shorter rows, read one at a time — and the picking mode
|
|
11811
|
+
// pairs every row with a checkbox, so laying those out in a grid would be a change nobody
|
|
11812
|
+
// asked for.
|
|
11813
|
+
{
|
|
11814
|
+
style: showingDeleted || showingFlagged || picking ? applyStyles.list : listStyle(columns, settings.entryRows),
|
|
11815
|
+
"data-term-columns": showingDeleted || showingFlagged || picking ? undefined : columns
|
|
11816
|
+
},
|
|
11817
|
+
// The groups come first, then the entries at this level — one list, one scroll area, one kind of
|
|
11818
|
+
// row. A breadcrumb says where you are and is the way back up, because a directory you can only
|
|
11819
|
+
// enter is a trap.
|
|
11820
|
+
[
|
|
11821
|
+
group === "" ? null : h(
|
|
11822
|
+
"div",
|
|
11823
|
+
{ style: { ...applyStyles.breadcrumb, gridColumn: "1 / -1" }, "data-term-breadcrumb": group },
|
|
11824
|
+
[
|
|
11825
|
+
{ path: "", label: t("filterAll") },
|
|
11826
|
+
...group.split("/").map((name, index, all) => ({ path: all.slice(0, index + 1).join("/"), label: name }))
|
|
11827
|
+
].map((crumb, index) =>
|
|
11828
|
+
h(
|
|
11829
|
+
"span",
|
|
11830
|
+
{ key: crumb.path === "" ? "root" : crumb.path, style: applyStyles.breadcrumbPart },
|
|
11831
|
+
index === 0 ? null : h("span", { style: applyStyles.breadcrumbSep }, "›"),
|
|
11832
|
+
h(
|
|
11833
|
+
"button",
|
|
11834
|
+
{
|
|
11835
|
+
type: "button",
|
|
11836
|
+
style: index === group.split("/").length ? applyStyles.breadcrumbHere : applyStyles.breadcrumbButton,
|
|
11837
|
+
onClick: () => setGroup(crumb.path),
|
|
11838
|
+
title: t("groupGo", { name: crumb.label })
|
|
11839
|
+
},
|
|
11840
|
+
crumb.label
|
|
11841
|
+
)
|
|
11842
|
+
)
|
|
11843
|
+
)
|
|
11844
|
+
),
|
|
11845
|
+
...subGroups.map((sub) =>
|
|
11846
|
+
h(
|
|
11847
|
+
"article",
|
|
11848
|
+
{
|
|
11849
|
+
key: `group:${sub.group}`,
|
|
11850
|
+
// Its own height, not the track's: a group has no body text, so stretching it to match a
|
|
11851
|
+
// three-line entry would leave an empty box the size of the entry — which is what it looked
|
|
11852
|
+
// like. Equal KIND of row is the point; equal height only reads as equal among rows with text.
|
|
11853
|
+
style: { ...applyStyles.row, alignSelf: "start" },
|
|
11854
|
+
"data-term-group": sub.group,
|
|
11855
|
+
// The WHOLE row opens the group, exactly as the whole row of an entry opens the entry.
|
|
11856
|
+
// Only the label being live is what "the group cannot be clicked into" meant: a reader
|
|
11857
|
+
// clicks the row, the same gesture that works one line below, and nothing happened.
|
|
11858
|
+
// The label stays a button so it is reachable by keyboard and named for a screen reader.
|
|
11859
|
+
onClick: () => setGroup(sub.group)
|
|
11860
|
+
},
|
|
11861
|
+
h("div", { style: applyStyles.rowHead },
|
|
11862
|
+
h("div", { style: applyStyles.groupIcon, "aria-hidden": "true" }, Icons.folder(14)),
|
|
11863
|
+
h("button", {
|
|
11864
|
+
type: "button",
|
|
11865
|
+
style: applyStyles.termButton,
|
|
11866
|
+
onClick: () => setGroup(sub.group),
|
|
11867
|
+
title: t("groupOpen")
|
|
11868
|
+
}, sub.name),
|
|
11869
|
+
h("span", { style: applyStyles.badge }, t("groupCount", { count: String(sub.count) })),
|
|
11870
|
+
h("span", { style: applyStyles.spacer }),
|
|
11871
|
+
h("span", { style: applyStyles.groupEnter, "aria-hidden": "true" }, "›")
|
|
11872
|
+
)
|
|
11873
|
+
)
|
|
11874
|
+
),
|
|
11875
|
+
showingDeleted
|
|
11876
|
+
? h(DeletedList, { entries: visible, t, onRevive: (entry) => void revive(entry) })
|
|
11877
|
+
: showingFlagged
|
|
11878
|
+
? h(FlaggedList, { entries: visible, t, onClear: (entry) => void clearFlag(entry) })
|
|
11879
|
+
: visible.length === 0
|
|
11880
|
+
? h("p", { style: { ...applyStyles.blank, gridColumn: "1 / -1" } }, current.length === 0 ? t("empty") : subGroups.length > 0 ? t("groupEmpty") : t("searchEmpty"))
|
|
11881
|
+
: visible.map((entry) =>
|
|
11127
11882
|
picking
|
|
11128
11883
|
? h(
|
|
11129
11884
|
"article",
|
|
@@ -11179,6 +11934,10 @@ window.__ModuleLoader__.load({
|
|
|
11179
11934
|
h(
|
|
11180
11935
|
"div",
|
|
11181
11936
|
{ style: applyStyles.rowHead },
|
|
11937
|
+
// The pin comes FIRST, on the title line: it is a state of the entry rather than an
|
|
11938
|
+
// action on it, and the list no longer re-sorts when it changes, so this is where
|
|
11939
|
+
// the state has to be readable.
|
|
11940
|
+
h(IconButton, { title: entry.pinned ? t("unpin") : t("pin"), pressed: entry.pinned, onClick: () => store.togglePinned(entry.id) }, Icons.pin(13)),
|
|
11182
11941
|
h("button", {
|
|
11183
11942
|
type: "button",
|
|
11184
11943
|
style: applyStyles.termButton,
|
|
@@ -11195,8 +11954,16 @@ window.__ModuleLoader__.load({
|
|
|
11195
11954
|
: null,
|
|
11196
11955
|
entry.untrusted === true ? h("span", { style: applyStyles.badgeWarn, "data-term-badge": "untrusted" }, t("flaggedUntrusted")) : null,
|
|
11197
11956
|
h("span", { style: applyStyles.spacer }),
|
|
11198
|
-
entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null
|
|
11199
|
-
|
|
11957
|
+
entry.domain !== "" ? h("span", { style: applyStyles.domain }, entry.domain) : null
|
|
11958
|
+
),
|
|
11959
|
+
// Every action on its own line, and always in the same place.
|
|
11960
|
+
//
|
|
11961
|
+
// The title line used to carry them, so their position depended on how long the term
|
|
11962
|
+
// was, which badges it had and whether it had a domain — the controls wandered from
|
|
11963
|
+
// row to row. A toolbar that is always the second line does not.
|
|
11964
|
+
h(
|
|
11965
|
+
"div",
|
|
11966
|
+
{ style: applyStyles.rowActions, "data-term-actions": entry.key },
|
|
11200
11967
|
h(IconButton, { title: t("copyEntry"), onClick: () => void copyEntry(entry) }, Icons.copy(13)),
|
|
11201
11968
|
h(IconButton, { title: t("feedbackTitle"), pressed: entry.feedback !== null && entry.feedback !== undefined, onClick: () => startEdit(entry) }, Icons.flag(13)),
|
|
11202
11969
|
h(IconButton, { title: t("edit"), onClick: () => startEdit(entry) }, Icons.edit(13)),
|
|
@@ -11215,6 +11982,7 @@ window.__ModuleLoader__.load({
|
|
|
11215
11982
|
)
|
|
11216
11983
|
)
|
|
11217
11984
|
)
|
|
11985
|
+
]
|
|
11218
11986
|
),
|
|
11219
11987
|
storageNote(view.hostInfo, t),
|
|
11220
11988
|
h(
|