dsh-instruction-memory 1.4.0 → 1.4.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 -0
- package/README.md +21 -6
- package/docs/screenshot.png +0 -0
- package/lib/client.js +391 -81
- package/lib/index.js +217 -72
- package/lib/mailer.js +141 -19
- package/package.json +3 -2
package/lib/client.js
CHANGED
|
@@ -22,6 +22,12 @@ window.__ModuleLoader__.load({
|
|
|
22
22
|
|
|
23
23
|
const API = '/instruction-memory/api'
|
|
24
24
|
|
|
25
|
+
// The Host bounds one delivery at 60 s (lib/mailer.js SMTP_TOTAL_TIMEOUT_MS).
|
|
26
|
+
// Waiting longer than the generic 15 s keeps the panel honest: aborting while
|
|
27
|
+
// the Host is still delivering would tell the user "timed out" for a mail
|
|
28
|
+
// that then arrives, and the obvious retry would send a second copy.
|
|
29
|
+
const FEEDBACK_SEND_TIMEOUT_MS = 90000
|
|
30
|
+
|
|
25
31
|
// Feedback form: the kinds are a closed set shared with the Host (see
|
|
26
32
|
// lib/mailer.js); the ceilings repeat the Host defaults so an older Host
|
|
27
33
|
// that does not publish limits.maxFeedback* still renders a safe form.
|
|
@@ -38,6 +44,14 @@ window.__ModuleLoader__.load({
|
|
|
38
44
|
.im-row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
|
39
45
|
.im-row-wrap { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
40
46
|
.im-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
|
47
|
+
/* The desktop shell is routinely dragged narrow: below this width the two
|
|
48
|
+
columns squeeze labels and placeholders into three-word lines. One column
|
|
49
|
+
keeps the form readable; the batch tag field already wraps, it just needs
|
|
50
|
+
its 160px floor lifted so it does not overflow. */
|
|
51
|
+
@media (max-width:560px) {
|
|
52
|
+
.im-grid2 { grid-template-columns:1fr; }
|
|
53
|
+
.im-batch-tags .im-input { flex:1 1 100%; min-width:0; }
|
|
54
|
+
}
|
|
41
55
|
.im-spacer { flex:1; }
|
|
42
56
|
.im-btn { border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-layer-1,#fff);
|
|
43
57
|
color:inherit; border-radius:8px; padding:5px 12px; font-size:12px; font-family:inherit; cursor:pointer; }
|
|
@@ -89,6 +103,9 @@ window.__ModuleLoader__.load({
|
|
|
89
103
|
.im-batch-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 12px; border-radius:10px;
|
|
90
104
|
border:1px solid var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.08); }
|
|
91
105
|
.im-batch-check { width:15px; height:15px; margin:0; cursor:pointer; accent-color:var(--dsw-alias-brand-primary,#3b6cff); }
|
|
106
|
+
.im-batch-tags { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:8px 12px; border-radius:10px;
|
|
107
|
+
border:1px dashed var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.05); }
|
|
108
|
+
.im-batch-tags .im-input { flex:1; min-width:160px; width:auto; }
|
|
92
109
|
.im-profiles { border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); border-radius:10px; padding:10px 12px;
|
|
93
110
|
background:var(--dsw-alias-bg-layer-1,#fff); }
|
|
94
111
|
.im-profiles-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
@@ -117,7 +134,9 @@ window.__ModuleLoader__.load({
|
|
|
117
134
|
.im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
|
|
118
135
|
.im-dot-err { background:var(--dsw-alias-state-error-primary,#d94838); }
|
|
119
136
|
.im-body-open { max-height:none; }
|
|
120
|
-
.im-more { margin-top:4px;
|
|
137
|
+
.im-more { display:block; margin-top:4px; padding:0; border:0; background:transparent; font-family:inherit;
|
|
138
|
+
font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; text-align:left; }
|
|
139
|
+
.im-more:focus-visible { outline:2px solid var(--dsw-alias-brand-primary,#3b6cff); outline-offset:2px; border-radius:4px; }
|
|
121
140
|
.im-search-meta { margin-top:5px; }
|
|
122
141
|
.im-meter-line { margin-top:5px; }
|
|
123
142
|
.im-undo { display:flex; align-items:center; gap:10px; padding:9px 13px; border-radius:8px; font-size:12px;
|
|
@@ -240,6 +259,7 @@ window.__ModuleLoader__.load({
|
|
|
240
259
|
'editor.hint': '保存后对下一轮对话立即生效',
|
|
241
260
|
'editor.needTitleOrContent': '请至少填写标题或指令内容',
|
|
242
261
|
'editor.confirmDiscard': '当前编辑内容尚未保存,确定放弃这些修改吗?',
|
|
262
|
+
'confirm.unavailable': '当前环境无法弹出确认对话框,操作已取消,未做任何改动。',
|
|
243
263
|
'body.expand': '展开全部',
|
|
244
264
|
'body.collapse': '收起',
|
|
245
265
|
'budget.label': '注入字数上限(字符)',
|
|
@@ -259,6 +279,7 @@ window.__ModuleLoader__.load({
|
|
|
259
279
|
'tags.label': '标签(可选,仅用于在本页分组与筛选,不会注入提示词)',
|
|
260
280
|
'tags.placeholder': '输入标签后回车,逗号分隔多个,例如:代码规范',
|
|
261
281
|
'tags.hint': '每条最多 {max} 个标签,单个不超过 {len} 字符',
|
|
282
|
+
'tags.remove': '移除标签 {tag}',
|
|
262
283
|
'tags.duplicate': '已有同名标签',
|
|
263
284
|
'tags.full': '每条最多 {max} 个标签',
|
|
264
285
|
'filter.all': '全部',
|
|
@@ -286,7 +307,9 @@ window.__ModuleLoader__.load({
|
|
|
286
307
|
'batch.tagPrompt': '输入要添加到 {count} 条指令的标签(多个用逗号分隔)',
|
|
287
308
|
'batch.tagEmpty': '未输入有效的标签',
|
|
288
309
|
'batch.noneSelected': '请先勾选要操作的指令',
|
|
289
|
-
'batch.
|
|
310
|
+
'batch.tagInputPlaceholder': '输入要添加的标签(多个用逗号分隔)',
|
|
311
|
+
'batch.tagConfirm': '添加',
|
|
312
|
+
'batch.tagCancel': '取消',
|
|
290
313
|
'profiles.section': '用户形象',
|
|
291
314
|
'profiles.subtitle': '关于你本人的身份背景、喜好、性格与追求。勾选「使用该形象」后,AI 会在保证回答正确且扣题的前提下据此调整详略、举例与建议侧重;不会为了迎合而改变事实或偏离问题。',
|
|
292
315
|
'profiles.new': '+ 新增形象',
|
|
@@ -407,6 +430,7 @@ window.__ModuleLoader__.load({
|
|
|
407
430
|
'feedback.sent': '反馈已发送,感谢你的反馈!',
|
|
408
431
|
'feedback.failed': '反馈发送失败,请稍后重试。',
|
|
409
432
|
'feedback.failedDetail': '反馈发送失败:{detail}',
|
|
433
|
+
'feedback.timeout': '等待宿主响应超时({seconds} 秒)。邮件可能已经发出,请先确认再重试。',
|
|
410
434
|
'feedback.errType': '请选择有效的问题类型',
|
|
411
435
|
'feedback.errTitle': '请填写标题',
|
|
412
436
|
'feedback.errTitleLong': '标题不能超过 {max} 个字符',
|
|
@@ -519,6 +543,7 @@ window.__ModuleLoader__.load({
|
|
|
519
543
|
'editor.hint': 'Takes effect for the next conversation turn once saved',
|
|
520
544
|
'editor.needTitleOrContent': 'Fill in at least a title or the instruction content',
|
|
521
545
|
'editor.confirmDiscard': 'The draft has unsaved changes. Discard them?',
|
|
546
|
+
'confirm.unavailable': 'This shell cannot show a confirmation dialog, so the action was cancelled and nothing changed.',
|
|
522
547
|
'body.expand': 'Show all',
|
|
523
548
|
'body.collapse': 'Show less',
|
|
524
549
|
'budget.label': 'Injection size limit (characters)',
|
|
@@ -538,6 +563,7 @@ window.__ModuleLoader__.load({
|
|
|
538
563
|
'tags.label': 'Tags (optional; used only for grouping/filtering on this page, never injected)',
|
|
539
564
|
'tags.placeholder': 'Type a tag and press Enter; separate several with commas, e.g. code-style',
|
|
540
565
|
'tags.hint': 'Up to {max} tags per entry, each at most {len} characters',
|
|
566
|
+
'tags.remove': 'Remove tag {tag}',
|
|
541
567
|
'tags.duplicate': 'This tag already exists',
|
|
542
568
|
'tags.full': 'Up to {max} tags per entry',
|
|
543
569
|
'filter.all': 'All',
|
|
@@ -565,7 +591,9 @@ window.__ModuleLoader__.load({
|
|
|
565
591
|
'batch.tagPrompt': 'Enter tags to add to {count} entries (separate several with commas)',
|
|
566
592
|
'batch.tagEmpty': 'No valid tag entered',
|
|
567
593
|
'batch.noneSelected': 'Tick the entries to act on first',
|
|
568
|
-
'batch.
|
|
594
|
+
'batch.tagInputPlaceholder': 'Tags to add (separate several with commas)',
|
|
595
|
+
'batch.tagConfirm': 'Add',
|
|
596
|
+
'batch.tagCancel': 'Cancel',
|
|
569
597
|
'profiles.section': 'User persona',
|
|
570
598
|
'profiles.subtitle': 'Facts about you: background, tastes, personality and goals. When “Use this persona” is ticked, the AI adjusts depth, examples and advice to tilt toward you — while staying correct and on-topic; it must never change facts or wander off the question to please you.',
|
|
571
599
|
'profiles.new': '+ New persona',
|
|
@@ -686,6 +714,7 @@ window.__ModuleLoader__.load({
|
|
|
686
714
|
'feedback.sent': 'Feedback sent — thank you!',
|
|
687
715
|
'feedback.failed': 'Could not send the feedback. Please try again later.',
|
|
688
716
|
'feedback.failedDetail': 'Could not send the feedback: {detail}',
|
|
717
|
+
'feedback.timeout': 'Timed out waiting for the host ({seconds} s). The mail may already have been sent — check before retrying.',
|
|
689
718
|
'feedback.errType': 'Choose a valid category',
|
|
690
719
|
'feedback.errTitle': 'Enter a title',
|
|
691
720
|
'feedback.errTitleLong': 'The title must be at most {max} characters',
|
|
@@ -887,32 +916,95 @@ window.__ModuleLoader__.load({
|
|
|
887
916
|
|
|
888
917
|
/* ---------------- data helpers ---------------- */
|
|
889
918
|
|
|
890
|
-
// Mirror the Host's tag limits (lib/index.js)
|
|
891
|
-
//
|
|
919
|
+
// Mirror the Host's tag limits (lib/index.js) as FALLBACKS ONLY. The Host
|
|
920
|
+
// publishes the authoritative values in snapshot.limits; these constants
|
|
921
|
+
// keep the editor usable against an older Host that predates them.
|
|
892
922
|
const MAX_TAGS = 8
|
|
893
923
|
const MAX_TAG_LEN = 24
|
|
924
|
+
const DEFAULT_MAX_TITLE = 120
|
|
925
|
+
const DEFAULT_MAX_CONTENT = 6000
|
|
926
|
+
const DEFAULT_MAX_WHEN = 200
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Per-field ceilings for the entry editor.
|
|
930
|
+
*
|
|
931
|
+
* These used to be hard-coded at the call sites (120/6000/200, tags 8/24),
|
|
932
|
+
* which meant a Host-side limit change left the panel silently disagreeing
|
|
933
|
+
* with the loader: the editor accepted text the Host then truncated, with a
|
|
934
|
+
* notice the user could not have predicted. The Host now publishes every
|
|
935
|
+
* one of them; the historical defaults remain only for older Hosts.
|
|
936
|
+
*/
|
|
937
|
+
function fieldLimitsOf(limits) {
|
|
938
|
+
const source = limits !== null && typeof limits === 'object' ? limits : {}
|
|
939
|
+
const pick = (key, fallback) => (
|
|
940
|
+
typeof source[key] === 'number' && Number.isFinite(source[key]) && source[key] > 0 ? source[key] : fallback
|
|
941
|
+
)
|
|
942
|
+
return {
|
|
943
|
+
maxTitle: pick('maxTitle', DEFAULT_MAX_TITLE),
|
|
944
|
+
maxContent: pick('maxContent', DEFAULT_MAX_CONTENT),
|
|
945
|
+
maxWhen: pick('maxWhen', DEFAULT_MAX_WHEN),
|
|
946
|
+
maxTags: pick('maxTagsPerEntry', MAX_TAGS),
|
|
947
|
+
maxTagLength: pick('maxTagLength', MAX_TAG_LEN),
|
|
948
|
+
}
|
|
949
|
+
}
|
|
894
950
|
|
|
895
951
|
/**
|
|
896
952
|
* Parse free text typed into the tag box into normalised tag candidates.
|
|
897
953
|
* Tags are split on commas (ASCII/full-width), semicolons and newlines —
|
|
898
954
|
* never spaces, so a tag may contain spaces. Each piece is trimmed,
|
|
899
|
-
* collapsed to one line, cut to
|
|
900
|
-
*
|
|
955
|
+
* collapsed to one line, cut to the length cap, de-duplicated and capped at
|
|
956
|
+
* the count cap. Both caps come from the snapshot when supplied. Pinned as
|
|
957
|
+
* a pure export.
|
|
901
958
|
*/
|
|
902
|
-
function parseTagInput(text) {
|
|
959
|
+
function parseTagInput(text, limits) {
|
|
903
960
|
if (typeof text !== 'string') return []
|
|
961
|
+
const caps = fieldLimitsOf(limits)
|
|
904
962
|
const out = []
|
|
905
963
|
const seen = new Set()
|
|
906
964
|
for (const piece of text.split(/[,,;;\n\r]+/)) {
|
|
907
|
-
const tag = piece.replace(/\s+/g, ' ').trim().slice(0,
|
|
965
|
+
const tag = piece.replace(/\s+/g, ' ').trim().slice(0, caps.maxTagLength)
|
|
908
966
|
if (tag === '' || seen.has(tag)) continue
|
|
909
967
|
seen.add(tag)
|
|
910
|
-
if (out.length >=
|
|
968
|
+
if (out.length >= caps.maxTags) break
|
|
911
969
|
out.push(tag)
|
|
912
970
|
}
|
|
913
971
|
return out
|
|
914
972
|
}
|
|
915
973
|
|
|
974
|
+
/**
|
|
975
|
+
* Decide what a `window.prompt` call site should do, without assuming the
|
|
976
|
+
* API works. Pinned as a pure export so the Electron path is testable in
|
|
977
|
+
* Node.
|
|
978
|
+
*
|
|
979
|
+
* Three outcomes, and the difference between the last two is the whole
|
|
980
|
+
* point: Electron 44 installs
|
|
981
|
+
* `window.prompt = function () { throw new Error("prompt() is not
|
|
982
|
+
* supported.") }` (and propagates it through contextBridge), so `typeof`
|
|
983
|
+
* still reports 'function' and a type guard cannot tell it apart from a
|
|
984
|
+
* real prompt. Cancel returns `null` from a WORKING prompt and must stay a
|
|
985
|
+
* deliberate no; only a throw (or a missing API) may open the fallback form.
|
|
986
|
+
*
|
|
987
|
+
* @param {((message: string, initial: string) => string | null) | null} ask
|
|
988
|
+
* @param {string} message
|
|
989
|
+
* @returns {{ kind: 'unavailable' } | { kind: 'cancel' } | { kind: 'answer', text: string }}
|
|
990
|
+
*/
|
|
991
|
+
function batchTagOutcome(ask, message) {
|
|
992
|
+
if (typeof ask !== 'function') return { kind: 'unavailable' }
|
|
993
|
+
let answer
|
|
994
|
+
try {
|
|
995
|
+
answer = ask(message, '')
|
|
996
|
+
} catch (err) {
|
|
997
|
+
return { kind: 'unavailable' }
|
|
998
|
+
}
|
|
999
|
+
// Only a real `null` means the user pressed Cancel. `undefined` and any
|
|
1000
|
+
// non-string (a broken polyfill, a Promise) mean the runtime cannot
|
|
1001
|
+
// deliver an answer, so the caller must show the fallback form instead of
|
|
1002
|
+
// swallowing the click.
|
|
1003
|
+
if (answer === null) return { kind: 'cancel' }
|
|
1004
|
+
if (typeof answer !== 'string') return { kind: 'unavailable' }
|
|
1005
|
+
return { kind: 'answer', text: answer }
|
|
1006
|
+
}
|
|
1007
|
+
|
|
916
1008
|
function cloneEntry(entry) {
|
|
917
1009
|
return {
|
|
918
1010
|
id: entry.id,
|
|
@@ -977,15 +1069,58 @@ window.__ModuleLoader__.load({
|
|
|
977
1069
|
return tagsKey(draft) !== tagsKey(base)
|
|
978
1070
|
}
|
|
979
1071
|
|
|
1072
|
+
const CONFIRM_YES = 'yes'
|
|
1073
|
+
const CONFIRM_NO = 'no'
|
|
1074
|
+
const CONFIRM_UNAVAILABLE = 'unavailable'
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* The single gate every confirmation goes through.
|
|
1078
|
+
*
|
|
1079
|
+
* Two different fallbacks used to be hard-coded at the call sites, which
|
|
1080
|
+
* meant the same class of irreversible action behaved differently
|
|
1081
|
+
* depending on the shell: bulk delete, set delete, persona delete and
|
|
1082
|
+
* single-entry delete all PROCEEDED when `window.confirm` was missing
|
|
1083
|
+
* (`typeof confirm === 'function' && !confirm(…)` short-circuits to false),
|
|
1084
|
+
* while version rollback and whole-store restore REFUSED. On top of that,
|
|
1085
|
+
* Electron 44 ships a `prompt()` that exists but throws — `typeof` still
|
|
1086
|
+
* says 'function' — so a `typeof`-only guard proves nothing.
|
|
1087
|
+
*
|
|
1088
|
+
* Three outcomes, not two, because "the user said no" and "this shell has
|
|
1089
|
+
* no dialog" need different UI: the first is silent, the second must tell
|
|
1090
|
+
* the user why nothing happened instead of swallowing the click.
|
|
1091
|
+
*
|
|
1092
|
+
* `unavailable` makes the fallback explicit per call site, chosen by what
|
|
1093
|
+
* is actually at stake:
|
|
1094
|
+
* - 'deny' (default) — durable data (entries, sets, personas, history,
|
|
1095
|
+
* a whole-store restore). If no dialog exists, do NOT destroy
|
|
1096
|
+
* anything.
|
|
1097
|
+
* - 'allow' — unsaved drafts only (leaving the editor, reloading the
|
|
1098
|
+
* source view). Nothing on disk is lost, and refusing here
|
|
1099
|
+
* would trap the user with a dead button, which is why
|
|
1100
|
+
* verify.mjs pins this fallback.
|
|
1101
|
+
*/
|
|
1102
|
+
function askConfirm(message, unavailable = 'deny') {
|
|
1103
|
+
const fallback = unavailable === 'allow'
|
|
1104
|
+
if (typeof window === 'undefined' || typeof window.confirm !== 'function') {
|
|
1105
|
+
return fallback ? CONFIRM_YES : CONFIRM_UNAVAILABLE
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
return window.confirm(message) === true ? CONFIRM_YES : CONFIRM_NO
|
|
1109
|
+
} catch {
|
|
1110
|
+
// A dialog stub that exists but throws (the Electron 44 `prompt()`
|
|
1111
|
+
// pattern, seen in the desktop shell).
|
|
1112
|
+
return fallback ? CONFIRM_YES : CONFIRM_UNAVAILABLE
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
980
1116
|
/**
|
|
981
1117
|
* Ask before throwing the draft away. Falls back to "yes" when no dialog
|
|
982
1118
|
* is available (a bare webview without window.confirm): blocking the
|
|
983
|
-
* cancel button entirely would be worse than the old instant discard
|
|
1119
|
+
* cancel button entirely would be worse than the old instant discard, and
|
|
1120
|
+
* no saved data is lost either way.
|
|
984
1121
|
*/
|
|
985
1122
|
function confirmDiscard() {
|
|
986
|
-
return
|
|
987
|
-
? window.confirm(t('editor.confirmDiscard'))
|
|
988
|
-
: true
|
|
1123
|
+
return askConfirm(t('editor.confirmDiscard'), 'allow') === CONFIRM_YES
|
|
989
1124
|
}
|
|
990
1125
|
|
|
991
1126
|
/**
|
|
@@ -1316,11 +1451,14 @@ window.__ModuleLoader__.load({
|
|
|
1316
1451
|
}
|
|
1317
1452
|
}
|
|
1318
1453
|
|
|
1319
|
-
async function callHost(method, args) {
|
|
1454
|
+
async function callHost(method, args, options) {
|
|
1320
1455
|
// A hung host used to pin the panel on 保存中… forever; fail visibly
|
|
1321
|
-
// after a generous timeout instead.
|
|
1456
|
+
// after a generous timeout instead. A caller that knows its request is
|
|
1457
|
+
// slow (a feedback send) passes its own budget and its own wording.
|
|
1458
|
+
const settings = options !== null && typeof options === 'object' ? options : {}
|
|
1459
|
+
const timeoutMs = typeof settings.timeoutMs === 'number' && settings.timeoutMs > 0 ? settings.timeoutMs : 15000
|
|
1322
1460
|
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
1323
|
-
const timer = controller !== null ? setTimeout(() => controller.abort(),
|
|
1461
|
+
const timer = controller !== null ? setTimeout(() => controller.abort(), timeoutMs) : null
|
|
1324
1462
|
try {
|
|
1325
1463
|
const response = await fetch(API, {
|
|
1326
1464
|
method: 'POST',
|
|
@@ -1345,7 +1483,11 @@ window.__ModuleLoader__.load({
|
|
|
1345
1483
|
}
|
|
1346
1484
|
return await response.json()
|
|
1347
1485
|
} catch (err) {
|
|
1348
|
-
if (controller !== null && controller.signal.aborted)
|
|
1486
|
+
if (controller !== null && controller.signal.aborted) {
|
|
1487
|
+
throw new Error(typeof settings.timeoutMessage === 'string' && settings.timeoutMessage !== ''
|
|
1488
|
+
? settings.timeoutMessage
|
|
1489
|
+
: t('err.timeout'))
|
|
1490
|
+
}
|
|
1349
1491
|
throw err
|
|
1350
1492
|
} finally {
|
|
1351
1493
|
if (timer !== null) clearTimeout(timer)
|
|
@@ -1384,31 +1526,35 @@ window.__ModuleLoader__.load({
|
|
|
1384
1526
|
|
|
1385
1527
|
function EntryEditor(props) {
|
|
1386
1528
|
const draft = props.draft
|
|
1529
|
+
const caps = fieldLimitsOf(props.limits)
|
|
1387
1530
|
const set = (patch) => props.onChange(Object.assign(cloneEntry(draft), patch))
|
|
1388
1531
|
const children = [
|
|
1389
1532
|
h('h3', { className: 'im-h', key: 'title' }, draft.id === '' ? t('editor.new') : t('editor.edit')),
|
|
1390
1533
|
h('div', { key: 't' },
|
|
1391
|
-
h('label', { className: 'im-label' }, t('editor.title')),
|
|
1534
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-title' }, t('editor.title')),
|
|
1392
1535
|
h('input', {
|
|
1536
|
+
id: 'im-entry-title',
|
|
1393
1537
|
className: 'im-input',
|
|
1394
1538
|
value: draft.title,
|
|
1395
|
-
maxLength:
|
|
1539
|
+
maxLength: caps.maxTitle,
|
|
1396
1540
|
placeholder: t('editor.titlePlaceholder'),
|
|
1397
1541
|
onChange: (ev) => set({ title: ev.target.value }),
|
|
1398
1542
|
})),
|
|
1399
1543
|
h('div', { key: 'c' },
|
|
1400
|
-
h('label', { className: 'im-label' }, t('editor.content')),
|
|
1544
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-content' }, t('editor.content')),
|
|
1401
1545
|
h('textarea', {
|
|
1546
|
+
id: 'im-entry-content',
|
|
1402
1547
|
className: 'im-textarea',
|
|
1403
1548
|
value: draft.content,
|
|
1404
|
-
maxLength:
|
|
1549
|
+
maxLength: caps.maxContent,
|
|
1405
1550
|
placeholder: t('editor.contentPlaceholder'),
|
|
1406
1551
|
onChange: (ev) => set({ content: ev.target.value }),
|
|
1407
1552
|
})),
|
|
1408
1553
|
h('div', { className: 'im-grid2', key: 'g' },
|
|
1409
1554
|
h('div', null,
|
|
1410
|
-
h('label', { className: 'im-label' }, t('editor.mode')),
|
|
1555
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-mode' }, t('editor.mode')),
|
|
1411
1556
|
h('select', {
|
|
1557
|
+
id: 'im-entry-mode',
|
|
1412
1558
|
className: 'im-select',
|
|
1413
1559
|
value: draft.mode,
|
|
1414
1560
|
onChange: (ev) => set({ mode: ev.target.value }),
|
|
@@ -1416,8 +1562,9 @@ window.__ModuleLoader__.load({
|
|
|
1416
1562
|
h('option', { value: 'always' }, t('editor.modeAlways')),
|
|
1417
1563
|
h('option', { value: 'auto' }, t('editor.modeAuto')))),
|
|
1418
1564
|
h('div', null,
|
|
1419
|
-
h('label', { className: 'im-label' }, t('editor.priority')),
|
|
1565
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-priority' }, t('editor.priority')),
|
|
1420
1566
|
h('select', {
|
|
1567
|
+
id: 'im-entry-priority',
|
|
1421
1568
|
className: 'im-select',
|
|
1422
1569
|
value: String(draft.priority),
|
|
1423
1570
|
onChange: (ev) => set({ priority: Number(ev.target.value) }),
|
|
@@ -1428,19 +1575,22 @@ window.__ModuleLoader__.load({
|
|
|
1428
1575
|
]
|
|
1429
1576
|
if (draft.mode === 'auto') {
|
|
1430
1577
|
children.push(h('div', { key: 'w' },
|
|
1431
|
-
h('label', { className: 'im-label' }, t('editor.when')),
|
|
1578
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-when' }, t('editor.when')),
|
|
1432
1579
|
h('input', {
|
|
1580
|
+
id: 'im-entry-when',
|
|
1433
1581
|
className: 'im-input',
|
|
1434
1582
|
value: draft.when,
|
|
1435
|
-
maxLength:
|
|
1583
|
+
maxLength: caps.maxWhen,
|
|
1436
1584
|
placeholder: t('editor.whenPlaceholder'),
|
|
1437
1585
|
onChange: (ev) => set({ when: ev.target.value }),
|
|
1438
1586
|
})))
|
|
1439
1587
|
}
|
|
1440
1588
|
children.push(h('div', { key: 'tags' },
|
|
1441
|
-
h('label', { className: 'im-label' }, t('tags.label')),
|
|
1589
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-tags' }, t('tags.label')),
|
|
1442
1590
|
h(TagInput, {
|
|
1591
|
+
inputId: 'im-entry-tags',
|
|
1443
1592
|
tags: draft.tags,
|
|
1593
|
+
limits: props.limits,
|
|
1444
1594
|
disabled: props.busy,
|
|
1445
1595
|
onChange: (next) => set({ tags: next }),
|
|
1446
1596
|
})))
|
|
@@ -1462,15 +1612,16 @@ window.__ModuleLoader__.load({
|
|
|
1462
1612
|
function TagInput(props) {
|
|
1463
1613
|
const [text, setText] = React.useState('')
|
|
1464
1614
|
const tags = Array.isArray(props.tags) ? props.tags : []
|
|
1615
|
+
const caps = fieldLimitsOf(props.limits)
|
|
1465
1616
|
const merge = (pieces) => {
|
|
1466
1617
|
const merged = []
|
|
1467
1618
|
for (const tag of [...tags, ...pieces]) {
|
|
1468
1619
|
if (!merged.includes(tag)) merged.push(tag)
|
|
1469
1620
|
}
|
|
1470
|
-
props.onChange(merged.slice(0,
|
|
1621
|
+
props.onChange(merged.slice(0, caps.maxTags))
|
|
1471
1622
|
}
|
|
1472
1623
|
const commit = () => {
|
|
1473
|
-
const parsed = parseTagInput(text)
|
|
1624
|
+
const parsed = parseTagInput(text, props.limits)
|
|
1474
1625
|
if (parsed.length > 0) merge(parsed)
|
|
1475
1626
|
setText('')
|
|
1476
1627
|
}
|
|
@@ -1484,14 +1635,19 @@ window.__ModuleLoader__.load({
|
|
|
1484
1635
|
tag,
|
|
1485
1636
|
h('button', {
|
|
1486
1637
|
type: 'button', className: 'im-chip-x', disabled: props.disabled,
|
|
1638
|
+
// A lone "×" is not an accessible name: a screen reader
|
|
1639
|
+
// announces it as "times" with no idea which tag it removes.
|
|
1640
|
+
'aria-label': t('tags.remove', { tag }),
|
|
1641
|
+
title: t('tags.remove', { tag }),
|
|
1487
1642
|
onClick: () => remove(tag),
|
|
1488
1643
|
}, '×'))))
|
|
1489
1644
|
: null,
|
|
1490
1645
|
h('input', {
|
|
1646
|
+
id: props.inputId,
|
|
1491
1647
|
className: 'im-input',
|
|
1492
1648
|
value: text,
|
|
1493
1649
|
disabled: props.disabled,
|
|
1494
|
-
maxLength:
|
|
1650
|
+
maxLength: caps.maxTagLength,
|
|
1495
1651
|
placeholder: t('tags.placeholder'),
|
|
1496
1652
|
onChange: (ev) => {
|
|
1497
1653
|
const v = ev.target.value
|
|
@@ -1500,11 +1656,11 @@ window.__ModuleLoader__.load({
|
|
|
1500
1656
|
// v is the input's full new value (it already contains what was
|
|
1501
1657
|
// typed before the separator) — do NOT prepend `text`, that
|
|
1502
1658
|
// duplicated it ("b" + "b," -> "bb").
|
|
1503
|
-
const parsed = parseTagInput(v)
|
|
1659
|
+
const parsed = parseTagInput(v, props.limits)
|
|
1504
1660
|
if (parsed.length > 0) merge(parsed)
|
|
1505
1661
|
setText('')
|
|
1506
1662
|
} else {
|
|
1507
|
-
setText(v.slice(0,
|
|
1663
|
+
setText(v.slice(0, caps.maxTagLength))
|
|
1508
1664
|
}
|
|
1509
1665
|
},
|
|
1510
1666
|
onKeyDown: (ev) => {
|
|
@@ -1517,7 +1673,7 @@ window.__ModuleLoader__.load({
|
|
|
1517
1673
|
},
|
|
1518
1674
|
onBlur: commit,
|
|
1519
1675
|
}),
|
|
1520
|
-
h('div', { className: 'im-meta' }, t('tags.hint', { max:
|
|
1676
|
+
h('div', { className: 'im-meta' }, t('tags.hint', { max: caps.maxTags, len: caps.maxTagLength })))
|
|
1521
1677
|
}
|
|
1522
1678
|
|
|
1523
1679
|
/**
|
|
@@ -1535,7 +1691,12 @@ window.__ModuleLoader__.load({
|
|
|
1535
1691
|
}, [props.text])
|
|
1536
1692
|
return h('div', null,
|
|
1537
1693
|
h('div', { className: 'im-body' + (open ? ' im-body-open' : ''), ref: bodyRef }, props.text),
|
|
1538
|
-
overflowing ? h('
|
|
1694
|
+
overflowing ? h('button', {
|
|
1695
|
+
type: 'button',
|
|
1696
|
+
className: 'im-more',
|
|
1697
|
+
'aria-expanded': open,
|
|
1698
|
+
onClick: () => setOpen(!open),
|
|
1699
|
+
}, open ? t('body.collapse') : t('body.expand')) : null)
|
|
1539
1700
|
}
|
|
1540
1701
|
|
|
1541
1702
|
/** How long the undo bar keeps its offer alive after a confirmed delete. */
|
|
@@ -1556,8 +1717,9 @@ window.__ModuleLoader__.load({
|
|
|
1556
1717
|
const canSave = (props.draft.name.trim() !== '' || props.draft.content.trim() !== '')
|
|
1557
1718
|
return h('div', { className: 'im-editor im-profiles' },
|
|
1558
1719
|
h('div', null,
|
|
1559
|
-
h('label', { className: 'im-label' }, t('profiles.name')),
|
|
1720
|
+
h('label', { className: 'im-label', htmlFor: 'im-profile-name' }, t('profiles.name')),
|
|
1560
1721
|
h('input', {
|
|
1722
|
+
id: 'im-profile-name',
|
|
1561
1723
|
className: 'im-input',
|
|
1562
1724
|
value: props.draft.name,
|
|
1563
1725
|
maxLength: maxName,
|
|
@@ -1566,8 +1728,9 @@ window.__ModuleLoader__.load({
|
|
|
1566
1728
|
onChange: (ev) => set({ name: ev.target.value }),
|
|
1567
1729
|
})),
|
|
1568
1730
|
h('div', null,
|
|
1569
|
-
h('label', { className: 'im-label' }, t('profiles.content')),
|
|
1731
|
+
h('label', { className: 'im-label', htmlFor: 'im-profile-content' }, t('profiles.content')),
|
|
1570
1732
|
h('textarea', {
|
|
1733
|
+
id: 'im-profile-content',
|
|
1571
1734
|
className: 'im-textarea',
|
|
1572
1735
|
rows: 6,
|
|
1573
1736
|
value: props.draft.content,
|
|
@@ -1621,6 +1784,13 @@ window.__ModuleLoader__.load({
|
|
|
1621
1784
|
// F3: bulk-select mode and the chosen entry ids in it.
|
|
1622
1785
|
const [batchMode, setBatchMode] = React.useState(false)
|
|
1623
1786
|
const [selected, setSelected] = React.useState([])
|
|
1787
|
+
// The bulk "add tags" input. `window.prompt` is the fast path on a plain
|
|
1788
|
+
// browser, but Electron REPLACES it with a function that throws
|
|
1789
|
+
// ("prompt() is not supported.") — typeof still says 'function', so a
|
|
1790
|
+
// type guard cannot catch it. `batchTagOpen` is the fallback form, shown
|
|
1791
|
+
// whenever prompt is missing OR throws.
|
|
1792
|
+
const [batchTagOpen, setBatchTagOpen] = React.useState(false)
|
|
1793
|
+
const [batchTagText, setBatchTagText] = React.useState('')
|
|
1624
1794
|
// F13: the set form (null = closed). One small inline form serves create,
|
|
1625
1795
|
// rename and duplicate, because all three are "type a name and confirm".
|
|
1626
1796
|
const [setForm, setSetForm] = React.useState(null)
|
|
@@ -1819,6 +1989,12 @@ window.__ModuleLoader__.load({
|
|
|
1819
1989
|
function exitBatch() {
|
|
1820
1990
|
setBatchMode(false)
|
|
1821
1991
|
setSelected([])
|
|
1992
|
+
closeBatchTags()
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function closeBatchTags() {
|
|
1996
|
+
setBatchTagOpen(false)
|
|
1997
|
+
setBatchTagText('')
|
|
1822
1998
|
}
|
|
1823
1999
|
|
|
1824
2000
|
async function doBatch(op, value) {
|
|
@@ -1827,10 +2003,12 @@ window.__ModuleLoader__.load({
|
|
|
1827
2003
|
setError(t('batch.noneSelected'))
|
|
1828
2004
|
return
|
|
1829
2005
|
}
|
|
1830
|
-
if (op === 'delete'
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
2006
|
+
if (op === 'delete') {
|
|
2007
|
+
const ask = askConfirm(t('batch.confirmDelete', { count: ids.length }))
|
|
2008
|
+
if (ask !== CONFIRM_YES) {
|
|
2009
|
+
if (ask === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
|
|
2010
|
+
return
|
|
2011
|
+
}
|
|
1834
2012
|
}
|
|
1835
2013
|
const result = await send('batch', { op, ids, value, baseRev: revOf() })
|
|
1836
2014
|
if (result !== null && result.ok === true && op === 'delete') {
|
|
@@ -1840,19 +2018,41 @@ window.__ModuleLoader__.load({
|
|
|
1840
2018
|
}
|
|
1841
2019
|
}
|
|
1842
2020
|
|
|
2021
|
+
/** Parse a raw tag string and fire the bulk op. Returns false when empty. */
|
|
2022
|
+
function commitBatchTags(raw) {
|
|
2023
|
+
const tags = parseTagInput(raw === undefined || raw === null ? '' : String(raw), view.limits)
|
|
2024
|
+
if (tags.length === 0) {
|
|
2025
|
+
setError(t('batch.tagEmpty'))
|
|
2026
|
+
return false
|
|
2027
|
+
}
|
|
2028
|
+
setError('')
|
|
2029
|
+
void doBatch('add-tags', tags)
|
|
2030
|
+
return true
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* Bulk "add tags": `window.prompt` when the runtime really implements it,
|
|
2035
|
+
* the inline form otherwise. See batchTagOutcome() for why `typeof` alone
|
|
2036
|
+
* cannot make that call. The wrapper (rather than passing `window.prompt`
|
|
2037
|
+
* by value) keeps `this === window`, which WebIDL operations require.
|
|
2038
|
+
*/
|
|
1843
2039
|
function batchAddTags() {
|
|
1844
|
-
|
|
1845
|
-
|
|
2040
|
+
const ids = liveSelected()
|
|
2041
|
+
if (ids.length === 0) {
|
|
2042
|
+
setError(t('batch.noneSelected'))
|
|
1846
2043
|
return
|
|
1847
2044
|
}
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
2045
|
+
const ask = typeof window.prompt === 'function'
|
|
2046
|
+
? (message, initial) => window.prompt(message, initial)
|
|
2047
|
+
: null
|
|
2048
|
+
const outcome = batchTagOutcome(ask, t('batch.tagPrompt', { count: ids.length }))
|
|
2049
|
+
if (outcome.kind === 'cancel') return
|
|
2050
|
+
if (outcome.kind === 'answer') {
|
|
2051
|
+
if (commitBatchTags(outcome.text)) closeBatchTags()
|
|
1853
2052
|
return
|
|
1854
2053
|
}
|
|
1855
|
-
|
|
2054
|
+
setError('')
|
|
2055
|
+
setBatchTagOpen(true)
|
|
1856
2056
|
}
|
|
1857
2057
|
|
|
1858
2058
|
// ---- User persona ("用户形象") handlers ----
|
|
@@ -1881,10 +2081,13 @@ window.__ModuleLoader__.load({
|
|
|
1881
2081
|
|
|
1882
2082
|
function removeProfile(profile) {
|
|
1883
2083
|
if (busy) return
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
2084
|
+
const ask = askConfirm(t('profiles.confirmDelete', {
|
|
2085
|
+
name: profile.name !== '' ? profile.name : t('profiles.untitled'),
|
|
2086
|
+
}))
|
|
2087
|
+
if (ask !== CONFIRM_YES) {
|
|
2088
|
+
if (ask === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
|
|
2089
|
+
return
|
|
2090
|
+
}
|
|
1888
2091
|
void send('delete-profile', { id: profile.id, baseRev: revOf() })
|
|
1889
2092
|
}
|
|
1890
2093
|
|
|
@@ -1942,8 +2145,11 @@ window.__ModuleLoader__.load({
|
|
|
1942
2145
|
setError(t('sets.deleteActive'))
|
|
1943
2146
|
return
|
|
1944
2147
|
}
|
|
1945
|
-
|
|
1946
|
-
|
|
2148
|
+
const askDeleteSet = askConfirm(t('sets.deleteConfirm', { name: set.name, entries: set.entries, profiles: set.profiles }))
|
|
2149
|
+
if (askDeleteSet !== CONFIRM_YES) {
|
|
2150
|
+
if (askDeleteSet === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
|
|
2151
|
+
return
|
|
2152
|
+
}
|
|
1947
2153
|
void send('delete-set', { id: set.id, baseRev: revOf() })
|
|
1948
2154
|
}
|
|
1949
2155
|
|
|
@@ -1975,9 +2181,13 @@ window.__ModuleLoader__.load({
|
|
|
1975
2181
|
}
|
|
1976
2182
|
|
|
1977
2183
|
function rollbackTo(item) {
|
|
1978
|
-
if (busy
|
|
2184
|
+
if (busy) return
|
|
1979
2185
|
const when = new Date(item.epoch).toLocaleString()
|
|
1980
|
-
|
|
2186
|
+
const ask = askConfirm(t('history.restoreConfirm', { time: when, rev: item.rev }))
|
|
2187
|
+
if (ask !== CONFIRM_YES) {
|
|
2188
|
+
if (ask === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
|
|
2189
|
+
return
|
|
2190
|
+
}
|
|
1981
2191
|
void send('history-restore', { name: item.name, baseRev: revOf() }).then((result) => {
|
|
1982
2192
|
if (result !== null && result.ok === true) void loadHistory()
|
|
1983
2193
|
})
|
|
@@ -1985,7 +2195,11 @@ window.__ModuleLoader__.load({
|
|
|
1985
2195
|
|
|
1986
2196
|
function removeSnapshot(item) {
|
|
1987
2197
|
if (busy) return
|
|
1988
|
-
|
|
2198
|
+
const askDeleteHistory = askConfirm(t('history.deleteConfirm'))
|
|
2199
|
+
if (askDeleteHistory !== CONFIRM_YES) {
|
|
2200
|
+
if (askDeleteHistory === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
|
|
2201
|
+
return
|
|
2202
|
+
}
|
|
1989
2203
|
void send('history-delete', { name: item.name }).then((result) => {
|
|
1990
2204
|
if (result !== null && result.ok === true) void loadHistory()
|
|
1991
2205
|
})
|
|
@@ -2046,6 +2260,9 @@ window.__ModuleLoader__.load({
|
|
|
2046
2260
|
locale: typeof nav.language === 'string' ? nav.language : '',
|
|
2047
2261
|
userAgent: typeof nav.userAgent === 'string' ? nav.userAgent : '',
|
|
2048
2262
|
timezone,
|
|
2263
|
+
}, {
|
|
2264
|
+
timeoutMs: FEEDBACK_SEND_TIMEOUT_MS,
|
|
2265
|
+
timeoutMessage: t('feedback.timeout', { seconds: Math.round(FEEDBACK_SEND_TIMEOUT_MS / 1000) }),
|
|
2049
2266
|
})
|
|
2050
2267
|
if (result === null || typeof result !== 'object' || result.ok !== true) {
|
|
2051
2268
|
const message = result !== null && typeof result === 'object' && typeof result.message === 'string' && result.message !== ''
|
|
@@ -2109,7 +2326,7 @@ window.__ModuleLoader__.load({
|
|
|
2109
2326
|
}
|
|
2110
2327
|
|
|
2111
2328
|
function reloadSource() {
|
|
2112
|
-
if (
|
|
2329
|
+
if (askConfirm(t('source.reloadConfirm'), 'allow') !== CONFIRM_YES) return
|
|
2113
2330
|
void openSource()
|
|
2114
2331
|
}
|
|
2115
2332
|
|
|
@@ -2142,7 +2359,11 @@ window.__ModuleLoader__.load({
|
|
|
2142
2359
|
|
|
2143
2360
|
async function handleRestoreFile(file) {
|
|
2144
2361
|
try {
|
|
2145
|
-
|
|
2362
|
+
const askRestore = askConfirm(t('restore.confirm'))
|
|
2363
|
+
if (askRestore !== CONFIRM_YES) {
|
|
2364
|
+
if (askRestore === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
|
|
2365
|
+
return
|
|
2366
|
+
}
|
|
2146
2367
|
const text = typeof file.text === 'function'
|
|
2147
2368
|
? await file.text()
|
|
2148
2369
|
: await new Promise((resolve, reject) => {
|
|
@@ -2286,17 +2507,51 @@ window.__ModuleLoader__.load({
|
|
|
2286
2507
|
*/
|
|
2287
2508
|
React.useEffect(() => {
|
|
2288
2509
|
if (typeof EventSource !== 'function') return
|
|
2289
|
-
|
|
2510
|
+
// `?lite=1`: the Host then answers with `{ rev, lite: true }` instead
|
|
2511
|
+
// of a whole snapshot (up to ~3.4 MB at the 200 × 6,000-character cap,
|
|
2512
|
+
// pushed to every open panel on every save). A Host that predates the
|
|
2513
|
+
// flag ignores the query string and keeps sending full frames — the
|
|
2514
|
+
// branch below still accepts those, so the negotiation is additive and
|
|
2515
|
+
// one-way: neither side has to know the other's version.
|
|
2516
|
+
const source = new EventSource(API + '?lite=1')
|
|
2517
|
+
let alive = true
|
|
2518
|
+
let refreshing = false
|
|
2519
|
+
let missed = false
|
|
2520
|
+
const refresh = () => {
|
|
2521
|
+
// One refetch at a time. A frame that arrives mid-flight means the
|
|
2522
|
+
// store moved again, and is re-checked after this response lands —
|
|
2523
|
+
// dropping it outright could leave the panel one revision behind.
|
|
2524
|
+
if (refreshing) { missed = true; return }
|
|
2525
|
+
refreshing = true
|
|
2526
|
+
callHost('state', null).then((result) => {
|
|
2527
|
+
refreshing = false
|
|
2528
|
+
if (!alive) return
|
|
2529
|
+
absorb(result)
|
|
2530
|
+
if (missed) { missed = false; refresh() }
|
|
2531
|
+
}, (err) => {
|
|
2532
|
+
refreshing = false
|
|
2533
|
+
if (!alive) return
|
|
2534
|
+
if (missed) { missed = false; refresh() }
|
|
2535
|
+
else if (err) setError(String(err && err.message ? err.message : err))
|
|
2536
|
+
})
|
|
2537
|
+
}
|
|
2290
2538
|
source.onmessage = (ev) => {
|
|
2291
2539
|
try {
|
|
2292
2540
|
const pushed = JSON.parse(ev.data)
|
|
2541
|
+
if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
|
|
2542
|
+
const current = viewRef.current
|
|
2543
|
+
// A null view means the initial POST `state` is still in flight;
|
|
2544
|
+
// its response already carries the current rev.
|
|
2545
|
+
if (current !== null && current.data !== null && current.data.rev !== pushed.rev) refresh()
|
|
2546
|
+
return
|
|
2547
|
+
}
|
|
2293
2548
|
if (sameSnapshot(pushed, viewRef.current)) return
|
|
2294
2549
|
absorb(pushed)
|
|
2295
2550
|
} catch {
|
|
2296
2551
|
// A malformed frame is dropped; the next frame, or 重新载入, catches up.
|
|
2297
2552
|
}
|
|
2298
2553
|
}
|
|
2299
|
-
return () => { source.close() }
|
|
2554
|
+
return () => { alive = false; source.close() }
|
|
2300
2555
|
}, [])
|
|
2301
2556
|
|
|
2302
2557
|
if (view === null) {
|
|
@@ -2309,6 +2564,10 @@ window.__ModuleLoader__.load({
|
|
|
2309
2564
|
h(EntryEditor, {
|
|
2310
2565
|
draft,
|
|
2311
2566
|
busy,
|
|
2567
|
+
// `limits` is computed below for the main branch; the editor is an
|
|
2568
|
+
// early return, so read the snapshot directly (fieldLimitsOf falls
|
|
2569
|
+
// back per field for an older Host).
|
|
2570
|
+
limits: view.limits || null,
|
|
2312
2571
|
onChange: setDraft,
|
|
2313
2572
|
onCancel: () => {
|
|
2314
2573
|
if (draftIsDirty(draft, view.data.entries) && !confirmDiscard()) return
|
|
@@ -2323,7 +2582,11 @@ window.__ModuleLoader__.load({
|
|
|
2323
2582
|
if (result && result.ok === true) { setDraft(null); setError('') }
|
|
2324
2583
|
})
|
|
2325
2584
|
},
|
|
2326
|
-
})
|
|
2585
|
+
}),
|
|
2586
|
+
// The editor is an early return, so it must render its OWN error
|
|
2587
|
+
// line: without this, a blank save (or a conflict / failed write)
|
|
2588
|
+
// set the message and showed the user nothing at all.
|
|
2589
|
+
error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null)
|
|
2327
2590
|
}
|
|
2328
2591
|
|
|
2329
2592
|
const data = view.data
|
|
@@ -2534,8 +2797,9 @@ window.__ModuleLoader__.load({
|
|
|
2534
2797
|
h('div', { className: 'im-meta' }, t('feedback.intro')),
|
|
2535
2798
|
h('div', { className: 'im-grid2' },
|
|
2536
2799
|
h('div', null,
|
|
2537
|
-
h('label', { className: 'im-label' }, t('feedback.typeLabel')),
|
|
2800
|
+
h('label', { className: 'im-label', htmlFor: 'im-feedback-type' }, t('feedback.typeLabel')),
|
|
2538
2801
|
h('select', {
|
|
2802
|
+
id: 'im-feedback-type',
|
|
2539
2803
|
className: 'im-select',
|
|
2540
2804
|
value: feedbackType,
|
|
2541
2805
|
disabled: feedbackBusy,
|
|
@@ -2545,8 +2809,9 @@ window.__ModuleLoader__.load({
|
|
|
2545
2809
|
h('option', { value: 'suggestion' }, t('feedback.typeSuggestion')),
|
|
2546
2810
|
h('option', { value: 'other' }, t('feedback.typeOther')))),
|
|
2547
2811
|
h('div', null,
|
|
2548
|
-
h('label', { className: 'im-label' }, t('feedback.contactLabel')),
|
|
2812
|
+
h('label', { className: 'im-label', htmlFor: 'im-feedback-contact' }, t('feedback.contactLabel')),
|
|
2549
2813
|
h('input', {
|
|
2814
|
+
id: 'im-feedback-contact',
|
|
2550
2815
|
className: 'im-input',
|
|
2551
2816
|
type: 'text',
|
|
2552
2817
|
value: feedbackContact,
|
|
@@ -2556,8 +2821,9 @@ window.__ModuleLoader__.load({
|
|
|
2556
2821
|
onChange: (ev) => setFeedbackContact(String(ev.target.value)),
|
|
2557
2822
|
}))),
|
|
2558
2823
|
h('div', null,
|
|
2559
|
-
h('label', { className: 'im-label' }, t('feedback.titleLabel')),
|
|
2824
|
+
h('label', { className: 'im-label', htmlFor: 'im-feedback-title' }, t('feedback.titleLabel')),
|
|
2560
2825
|
h('input', {
|
|
2826
|
+
id: 'im-feedback-title',
|
|
2561
2827
|
className: 'im-input',
|
|
2562
2828
|
type: 'text',
|
|
2563
2829
|
value: feedbackTitle,
|
|
@@ -2567,8 +2833,9 @@ window.__ModuleLoader__.load({
|
|
|
2567
2833
|
onChange: (ev) => setFeedbackTitle(String(ev.target.value)),
|
|
2568
2834
|
})),
|
|
2569
2835
|
h('div', null,
|
|
2570
|
-
h('label', { className: 'im-label' }, t('feedback.bodyLabel')),
|
|
2836
|
+
h('label', { className: 'im-label', htmlFor: 'im-feedback-body' }, t('feedback.bodyLabel')),
|
|
2571
2837
|
h('textarea', {
|
|
2838
|
+
id: 'im-feedback-body',
|
|
2572
2839
|
className: 'im-textarea',
|
|
2573
2840
|
value: feedbackBody,
|
|
2574
2841
|
maxLength: feedbackMax.body,
|
|
@@ -2640,8 +2907,9 @@ window.__ModuleLoader__.load({
|
|
|
2640
2907
|
h('span', { className: injection.error ? 'im-err' : 'im-meta' }, dotText)),
|
|
2641
2908
|
h('div', { className: 'im-grid2' },
|
|
2642
2909
|
h('div', null,
|
|
2643
|
-
h('label', { className: 'im-label' }, t('budget.label')),
|
|
2910
|
+
h('label', { className: 'im-label', htmlFor: 'im-budget' }, t('budget.label')),
|
|
2644
2911
|
h('input', {
|
|
2912
|
+
id: 'im-budget',
|
|
2645
2913
|
className: 'im-input',
|
|
2646
2914
|
type: 'number',
|
|
2647
2915
|
min: limits.minBudget,
|
|
@@ -2733,8 +3001,9 @@ window.__ModuleLoader__.load({
|
|
|
2733
3001
|
}, t('sets.delete')))),
|
|
2734
3002
|
setForm !== null
|
|
2735
3003
|
? h('div', { className: 'im-inline-form' },
|
|
2736
|
-
h('
|
|
3004
|
+
h('label', { className: 'im-meta', htmlFor: 'im-set-name' }, t('sets.nameLabel')),
|
|
2737
3005
|
h('input', {
|
|
3006
|
+
id: 'im-set-name',
|
|
2738
3007
|
className: 'im-input',
|
|
2739
3008
|
value: setForm.name,
|
|
2740
3009
|
maxLength: limits.maxSetName,
|
|
@@ -2850,6 +3119,39 @@ window.__ModuleLoader__.load({
|
|
|
2850
3119
|
}, t('batch.delete')),
|
|
2851
3120
|
h('button', { className: 'im-btn', disabled: busy, onClick: exitBatch }, t('batch.exit')))
|
|
2852
3121
|
: null,
|
|
3122
|
+
// Browser runtimes get window.prompt for the bulk tag text; Electron's
|
|
3123
|
+
// prompt throws instead, so the same action falls back to this field.
|
|
3124
|
+
(batchMode && batchTagOpen)
|
|
3125
|
+
? h('div', { key: 'batchtags', className: 'im-batch-tags' },
|
|
3126
|
+
h('input', {
|
|
3127
|
+
className: 'im-input',
|
|
3128
|
+
type: 'text',
|
|
3129
|
+
autoFocus: true,
|
|
3130
|
+
value: batchTagText,
|
|
3131
|
+
disabled: busy,
|
|
3132
|
+
placeholder: t('batch.tagInputPlaceholder'),
|
|
3133
|
+
onChange: (ev) => setBatchTagText(ev.target.value),
|
|
3134
|
+
onKeyDown: (ev) => {
|
|
3135
|
+
if (ev.key === 'Enter') {
|
|
3136
|
+
ev.preventDefault()
|
|
3137
|
+
if (commitBatchTags(batchTagText)) closeBatchTags()
|
|
3138
|
+
} else if (ev.key === 'Escape') {
|
|
3139
|
+
ev.preventDefault()
|
|
3140
|
+
closeBatchTags()
|
|
3141
|
+
}
|
|
3142
|
+
},
|
|
3143
|
+
}),
|
|
3144
|
+
h('button', {
|
|
3145
|
+
className: 'im-btn im-btn-primary',
|
|
3146
|
+
disabled: busy,
|
|
3147
|
+
onClick: () => { if (commitBatchTags(batchTagText)) closeBatchTags() },
|
|
3148
|
+
}, t('batch.tagConfirm')),
|
|
3149
|
+
h('button', {
|
|
3150
|
+
className: 'im-btn',
|
|
3151
|
+
disabled: busy,
|
|
3152
|
+
onClick: () => { closeBatchTags() },
|
|
3153
|
+
}, t('batch.tagCancel')))
|
|
3154
|
+
: null,
|
|
2853
3155
|
// ---- F17 本地预演:字面关键词匹配「按需」条目(不是模型真实判断) ----
|
|
2854
3156
|
(data.enabled === true && autoEntries.length > 0)
|
|
2855
3157
|
? h('div', { key: 'sim', className: 'im-profiles' },
|
|
@@ -2988,7 +3290,7 @@ window.__ModuleLoader__.load({
|
|
|
2988
3290
|
h('button', {
|
|
2989
3291
|
className: 'im-btn',
|
|
2990
3292
|
disabled: busy || entries.length === 0,
|
|
2991
|
-
onClick: () => { setBatchMode(!batchMode); setSelected([]) },
|
|
3293
|
+
onClick: () => { setBatchMode(!batchMode); setSelected([]); closeBatchTags() },
|
|
2992
3294
|
}, batchMode ? t('batch.exit') : t('action.batch')),
|
|
2993
3295
|
h('span', { className: 'im-spacer' }),
|
|
2994
3296
|
h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
|
|
@@ -3004,9 +3306,10 @@ window.__ModuleLoader__.load({
|
|
|
3004
3306
|
// ---- F18 备份位置 ----
|
|
3005
3307
|
h('div', { key: 'backup', className: 'im-backup' },
|
|
3006
3308
|
h('div', { className: 'im-section-title' }, t('backup.section')),
|
|
3007
|
-
h('label', { className: 'im-label' }, t('backup.label')),
|
|
3309
|
+
h('label', { className: 'im-label', htmlFor: 'im-backup-dir' }, t('backup.label')),
|
|
3008
3310
|
h('div', { className: 'im-backup-row' },
|
|
3009
3311
|
h('input', {
|
|
3312
|
+
id: 'im-backup-dir',
|
|
3010
3313
|
className: 'im-input',
|
|
3011
3314
|
value: backupText,
|
|
3012
3315
|
disabled: busy,
|
|
@@ -3111,9 +3414,9 @@ window.__ModuleLoader__.load({
|
|
|
3111
3414
|
}))
|
|
3112
3415
|
: null)
|
|
3113
3416
|
: null),
|
|
3114
|
-
error !== '' ? h('div', { className: 'im-err' }, error) : null,
|
|
3115
|
-
error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
|
|
3116
|
-
error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
|
|
3417
|
+
error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null,
|
|
3418
|
+
error === '' && notice !== '' ? h('div', { className: 'im-ok', role: 'status', 'aria-live': 'polite' }, notice) : null,
|
|
3419
|
+
error === '' && notice === '' && storage.error ? h('div', { className: 'im-err', role: 'alert' }, storage.error) : null,
|
|
3117
3420
|
undo !== null ? h('div', { className: 'im-undo', key: 'undo' },
|
|
3118
3421
|
h('span', null, t('undo.deleted', { title: undo.entry.title !== '' ? undo.entry.title : t('entry.untitled') })),
|
|
3119
3422
|
h('button', { disabled: busy, onClick: () => {
|
|
@@ -3191,12 +3494,12 @@ window.__ModuleLoader__.load({
|
|
|
3191
3494
|
sourceVerdict !== null
|
|
3192
3495
|
? h('div', { className: sourceVerdict.ok ? 'im-source-ok' : 'im-warn' }, sourceVerdict.message)
|
|
3193
3496
|
: null,
|
|
3194
|
-
error !== '' ? h('div', { className: 'im-err' }, error) : null,
|
|
3497
|
+
error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null,
|
|
3195
3498
|
// Saving can be refused (a conflict, a future version, a shape
|
|
3196
3499
|
// problem). The draft is never thrown away by a refusal, and the
|
|
3197
3500
|
// user needs to know that before deciding what to do next.
|
|
3198
3501
|
error !== '' ? h('div', { className: 'im-meta' }, t('source.keepDraft')) : null,
|
|
3199
|
-
error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
|
|
3502
|
+
error === '' && notice !== '' ? h('div', { className: 'im-ok', role: 'status', 'aria-live': 'polite' }, notice) : null,
|
|
3200
3503
|
h('div', { className: 'im-row-wrap' },
|
|
3201
3504
|
h('button', {
|
|
3202
3505
|
className: 'im-btn im-btn-primary',
|
|
@@ -3286,12 +3589,17 @@ window.__ModuleLoader__.load({
|
|
|
3286
3589
|
className: 'im-btn im-btn-danger',
|
|
3287
3590
|
disabled: busy,
|
|
3288
3591
|
onClick: () => {
|
|
3289
|
-
//
|
|
3290
|
-
//
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3592
|
+
// Durable-data delete: if this shell has no working dialog,
|
|
3593
|
+
// the delete is refused and the reason is shown — silently
|
|
3594
|
+
// dropping an instruction because a button was clickable
|
|
3595
|
+
// and the shell lacked confirm() is the worse failure.
|
|
3596
|
+
const askEntry = askConfirm(t('entry.confirmDelete', {
|
|
3597
|
+
title: entry.title !== '' ? entry.title : t('entry.untitled'),
|
|
3598
|
+
}))
|
|
3599
|
+
if (askEntry !== CONFIRM_YES) {
|
|
3600
|
+
if (askEntry === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
|
|
3601
|
+
return
|
|
3602
|
+
}
|
|
3295
3603
|
// Keep the full entry for the undo bar; the POST response
|
|
3296
3604
|
// (not this closure) is what confirms the deletion happened.
|
|
3297
3605
|
setUndo(null)
|
|
@@ -3411,10 +3719,12 @@ window.__ModuleLoader__.load({
|
|
|
3411
3719
|
exports.__draftIsDirty = draftIsDirty
|
|
3412
3720
|
exports.__sameSnapshot = sameSnapshot
|
|
3413
3721
|
exports.__confirmDiscard = confirmDiscard
|
|
3722
|
+
exports.__askConfirm = askConfirm
|
|
3414
3723
|
exports.__importSizeVerdict = importSizeVerdict
|
|
3415
3724
|
exports.__nextBudgetText = nextBudgetText
|
|
3416
3725
|
exports.__budgetMeter = budgetMeter
|
|
3417
3726
|
exports.__parseTagInput = parseTagInput
|
|
3727
|
+
exports.__batchTagOutcome = batchTagOutcome
|
|
3418
3728
|
exports.__relevanceTerms = relevanceTerms
|
|
3419
3729
|
exports.__relevancePreview = relevancePreview
|
|
3420
3730
|
exports.__sourceParseVerdict = sourceParseVerdict
|