dsh-instruction-memory 1.3.3 → 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 +149 -0
- package/README.md +140 -7
- package/docs/screenshot.png +0 -0
- package/lib/client.js +668 -79
- package/lib/index.js +398 -71
- package/lib/mailer.js +932 -0
- package/package.json +4 -3
package/lib/client.js
CHANGED
|
@@ -22,6 +22,18 @@ 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
|
+
|
|
31
|
+
// Feedback form: the kinds are a closed set shared with the Host (see
|
|
32
|
+
// lib/mailer.js); the ceilings repeat the Host defaults so an older Host
|
|
33
|
+
// that does not publish limits.maxFeedback* still renders a safe form.
|
|
34
|
+
const FEEDBACK_TYPES = ['bug', 'suggestion', 'other']
|
|
35
|
+
const FEEDBACK_DEFAULTS = Object.freeze({ maxTitle: 120, maxBody: 4000, maxContact: 200 })
|
|
36
|
+
|
|
25
37
|
const CSS = `
|
|
26
38
|
.im-root { display:flex; flex-direction:column; gap:16px; padding:6px 2px 56px; max-width:860px;
|
|
27
39
|
color:var(--dsw-alias-label-primary,#1f2329); font-size:13px; line-height:1.6; }
|
|
@@ -32,6 +44,14 @@ window.__ModuleLoader__.load({
|
|
|
32
44
|
.im-row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
|
33
45
|
.im-row-wrap { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
34
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
|
+
}
|
|
35
55
|
.im-spacer { flex:1; }
|
|
36
56
|
.im-btn { border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-layer-1,#fff);
|
|
37
57
|
color:inherit; border-radius:8px; padding:5px 12px; font-size:12px; font-family:inherit; cursor:pointer; }
|
|
@@ -83,6 +103,9 @@ window.__ModuleLoader__.load({
|
|
|
83
103
|
.im-batch-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 12px; border-radius:10px;
|
|
84
104
|
border:1px solid var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.08); }
|
|
85
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; }
|
|
86
109
|
.im-profiles { border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); border-radius:10px; padding:10px 12px;
|
|
87
110
|
background:var(--dsw-alias-bg-layer-1,#fff); }
|
|
88
111
|
.im-profiles-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
@@ -111,7 +134,9 @@ window.__ModuleLoader__.load({
|
|
|
111
134
|
.im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
|
|
112
135
|
.im-dot-err { background:var(--dsw-alias-state-error-primary,#d94838); }
|
|
113
136
|
.im-body-open { max-height:none; }
|
|
114
|
-
.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; }
|
|
115
140
|
.im-search-meta { margin-top:5px; }
|
|
116
141
|
.im-meter-line { margin-top:5px; }
|
|
117
142
|
.im-undo { display:flex; align-items:center; gap:10px; padding:9px 13px; border-radius:8px; font-size:12px;
|
|
@@ -173,6 +198,16 @@ window.__ModuleLoader__.load({
|
|
|
173
198
|
padding:6px 10px; border-radius:8px; font-size:12px;
|
|
174
199
|
border:1px solid var(--dsw-alias-state-warn-primary,#d97706);
|
|
175
200
|
background:var(--dsw-alias-state-warn-bg,rgba(217,119,6,.12)); color:var(--dsw-alias-state-warn-primary,#d97706); }
|
|
201
|
+
/* ---- 反馈邮件 ---- */
|
|
202
|
+
.im-feedback { display:flex; flex-direction:column; gap:8px; }
|
|
203
|
+
.im-feedback-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
204
|
+
.im-feedback-head .im-section-title { flex:1; }
|
|
205
|
+
.im-feedback-form { display:flex; flex-direction:column; gap:8px; padding:10px 12px; border-radius:8px;
|
|
206
|
+
border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-base,#fff); }
|
|
207
|
+
.im-feedback-form .im-textarea { min-height:110px; }
|
|
208
|
+
.im-feedback-hint { font-size:11.5px; color:var(--dsw-alias-label-secondary,#8a9099); }
|
|
209
|
+
.im-feedback-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
210
|
+
.im-feedback-actions .im-meta { flex:1; }
|
|
176
211
|
@media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
|
|
177
212
|
`
|
|
178
213
|
|
|
@@ -224,6 +259,7 @@ window.__ModuleLoader__.load({
|
|
|
224
259
|
'editor.hint': '保存后对下一轮对话立即生效',
|
|
225
260
|
'editor.needTitleOrContent': '请至少填写标题或指令内容',
|
|
226
261
|
'editor.confirmDiscard': '当前编辑内容尚未保存,确定放弃这些修改吗?',
|
|
262
|
+
'confirm.unavailable': '当前环境无法弹出确认对话框,操作已取消,未做任何改动。',
|
|
227
263
|
'body.expand': '展开全部',
|
|
228
264
|
'body.collapse': '收起',
|
|
229
265
|
'budget.label': '注入字数上限(字符)',
|
|
@@ -243,6 +279,7 @@ window.__ModuleLoader__.load({
|
|
|
243
279
|
'tags.label': '标签(可选,仅用于在本页分组与筛选,不会注入提示词)',
|
|
244
280
|
'tags.placeholder': '输入标签后回车,逗号分隔多个,例如:代码规范',
|
|
245
281
|
'tags.hint': '每条最多 {max} 个标签,单个不超过 {len} 字符',
|
|
282
|
+
'tags.remove': '移除标签 {tag}',
|
|
246
283
|
'tags.duplicate': '已有同名标签',
|
|
247
284
|
'tags.full': '每条最多 {max} 个标签',
|
|
248
285
|
'filter.all': '全部',
|
|
@@ -270,7 +307,9 @@ window.__ModuleLoader__.load({
|
|
|
270
307
|
'batch.tagPrompt': '输入要添加到 {count} 条指令的标签(多个用逗号分隔)',
|
|
271
308
|
'batch.tagEmpty': '未输入有效的标签',
|
|
272
309
|
'batch.noneSelected': '请先勾选要操作的指令',
|
|
273
|
-
'batch.
|
|
310
|
+
'batch.tagInputPlaceholder': '输入要添加的标签(多个用逗号分隔)',
|
|
311
|
+
'batch.tagConfirm': '添加',
|
|
312
|
+
'batch.tagCancel': '取消',
|
|
274
313
|
'profiles.section': '用户形象',
|
|
275
314
|
'profiles.subtitle': '关于你本人的身份背景、喜好、性格与追求。勾选「使用该形象」后,AI 会在保证回答正确且扣题的前提下据此调整详略、举例与建议侧重;不会为了迎合而改变事实或偏离问题。',
|
|
276
315
|
'profiles.new': '+ 新增形象',
|
|
@@ -369,6 +408,35 @@ window.__ModuleLoader__.load({
|
|
|
369
408
|
'diag.issueWarning': '存储警告',
|
|
370
409
|
'diag.issueHistory': '历史错误',
|
|
371
410
|
'diag.issueSection': '注入错误',
|
|
411
|
+
// ---- 反馈邮件 ----
|
|
412
|
+
'feedback.section': '反馈',
|
|
413
|
+
'feedback.show': '反馈',
|
|
414
|
+
'feedback.hide': '收起反馈',
|
|
415
|
+
'feedback.intro': '遇到问题或有功能建议?填写下面的表单,通过邮件发送给插件维护者。',
|
|
416
|
+
'feedback.typeLabel': '问题类型',
|
|
417
|
+
'feedback.typeBug': '问题反馈(Bug)',
|
|
418
|
+
'feedback.typeSuggestion': '功能建议',
|
|
419
|
+
'feedback.typeOther': '其他',
|
|
420
|
+
'feedback.titleLabel': '标题',
|
|
421
|
+
'feedback.titlePlaceholder': '一句话概括问题或建议',
|
|
422
|
+
'feedback.bodyLabel': '详细描述',
|
|
423
|
+
'feedback.bodyPlaceholder': '复现步骤、期望结果、实际表现;建议请说明使用场景',
|
|
424
|
+
'feedback.contactLabel': '联系方式(可选)',
|
|
425
|
+
'feedback.contactPlaceholder': '邮箱 / 微信 / GitHub 用户名,便于回复你',
|
|
426
|
+
'feedback.hint': '发送时会自动附带插件版本、DSH / Node 版本与运行平台,不包含你的指令内容。',
|
|
427
|
+
'feedback.send': '发送反馈',
|
|
428
|
+
'feedback.sending': '正在发送…',
|
|
429
|
+
'feedback.clear': '清空',
|
|
430
|
+
'feedback.sent': '反馈已发送,感谢你的反馈!',
|
|
431
|
+
'feedback.failed': '反馈发送失败,请稍后重试。',
|
|
432
|
+
'feedback.failedDetail': '反馈发送失败:{detail}',
|
|
433
|
+
'feedback.timeout': '等待宿主响应超时({seconds} 秒)。邮件可能已经发出,请先确认再重试。',
|
|
434
|
+
'feedback.errType': '请选择有效的问题类型',
|
|
435
|
+
'feedback.errTitle': '请填写标题',
|
|
436
|
+
'feedback.errTitleLong': '标题不能超过 {max} 个字符',
|
|
437
|
+
'feedback.errBody': '请补充详细描述(至少 5 个字符)',
|
|
438
|
+
'feedback.errBodyLong': '详细描述不能超过 {max} 个字符',
|
|
439
|
+
'feedback.errContact': '联系方式不能超过 {max} 个字符',
|
|
372
440
|
// ---- F7 临时静音 ----
|
|
373
441
|
'mute.menu': '暂停注入…',
|
|
374
442
|
'mute.30m': '30 分钟',
|
|
@@ -475,6 +543,7 @@ window.__ModuleLoader__.load({
|
|
|
475
543
|
'editor.hint': 'Takes effect for the next conversation turn once saved',
|
|
476
544
|
'editor.needTitleOrContent': 'Fill in at least a title or the instruction content',
|
|
477
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.',
|
|
478
547
|
'body.expand': 'Show all',
|
|
479
548
|
'body.collapse': 'Show less',
|
|
480
549
|
'budget.label': 'Injection size limit (characters)',
|
|
@@ -494,6 +563,7 @@ window.__ModuleLoader__.load({
|
|
|
494
563
|
'tags.label': 'Tags (optional; used only for grouping/filtering on this page, never injected)',
|
|
495
564
|
'tags.placeholder': 'Type a tag and press Enter; separate several with commas, e.g. code-style',
|
|
496
565
|
'tags.hint': 'Up to {max} tags per entry, each at most {len} characters',
|
|
566
|
+
'tags.remove': 'Remove tag {tag}',
|
|
497
567
|
'tags.duplicate': 'This tag already exists',
|
|
498
568
|
'tags.full': 'Up to {max} tags per entry',
|
|
499
569
|
'filter.all': 'All',
|
|
@@ -521,7 +591,9 @@ window.__ModuleLoader__.load({
|
|
|
521
591
|
'batch.tagPrompt': 'Enter tags to add to {count} entries (separate several with commas)',
|
|
522
592
|
'batch.tagEmpty': 'No valid tag entered',
|
|
523
593
|
'batch.noneSelected': 'Tick the entries to act on first',
|
|
524
|
-
'batch.
|
|
594
|
+
'batch.tagInputPlaceholder': 'Tags to add (separate several with commas)',
|
|
595
|
+
'batch.tagConfirm': 'Add',
|
|
596
|
+
'batch.tagCancel': 'Cancel',
|
|
525
597
|
'profiles.section': 'User persona',
|
|
526
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.',
|
|
527
599
|
'profiles.new': '+ New persona',
|
|
@@ -620,6 +692,35 @@ window.__ModuleLoader__.load({
|
|
|
620
692
|
'diag.issueWarning': 'Storage warning',
|
|
621
693
|
'diag.issueHistory': 'History error',
|
|
622
694
|
'diag.issueSection': 'Injection error',
|
|
695
|
+
// ---- feedback mail ----
|
|
696
|
+
'feedback.section': 'Feedback',
|
|
697
|
+
'feedback.show': 'Feedback',
|
|
698
|
+
'feedback.hide': 'Hide feedback',
|
|
699
|
+
'feedback.intro': 'Found a problem or have a suggestion? Send it to the plugin maintainer by email with the form below.',
|
|
700
|
+
'feedback.typeLabel': 'Category',
|
|
701
|
+
'feedback.typeBug': 'Problem report (bug)',
|
|
702
|
+
'feedback.typeSuggestion': 'Feature request',
|
|
703
|
+
'feedback.typeOther': 'Other',
|
|
704
|
+
'feedback.titleLabel': 'Title',
|
|
705
|
+
'feedback.titlePlaceholder': 'Sum up the problem or suggestion in one line',
|
|
706
|
+
'feedback.bodyLabel': 'Details',
|
|
707
|
+
'feedback.bodyPlaceholder': 'Steps to reproduce, expected result, actual result; for a suggestion, describe the use case',
|
|
708
|
+
'feedback.contactLabel': 'Contact (optional)',
|
|
709
|
+
'feedback.contactPlaceholder': 'Email / WeChat / GitHub handle so the maintainer can reply',
|
|
710
|
+
'feedback.hint': 'The mail automatically includes the plugin version, DSH / Node versions and platform — never your instruction content.',
|
|
711
|
+
'feedback.send': 'Send feedback',
|
|
712
|
+
'feedback.sending': 'Sending…',
|
|
713
|
+
'feedback.clear': 'Clear',
|
|
714
|
+
'feedback.sent': 'Feedback sent — thank you!',
|
|
715
|
+
'feedback.failed': 'Could not send the feedback. Please try again later.',
|
|
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.',
|
|
718
|
+
'feedback.errType': 'Choose a valid category',
|
|
719
|
+
'feedback.errTitle': 'Enter a title',
|
|
720
|
+
'feedback.errTitleLong': 'The title must be at most {max} characters',
|
|
721
|
+
'feedback.errBody': 'Add a description (at least 5 characters)',
|
|
722
|
+
'feedback.errBodyLong': 'The description must be at most {max} characters',
|
|
723
|
+
'feedback.errContact': 'The contact must be at most {max} characters',
|
|
623
724
|
// ---- F7 temporary mute ----
|
|
624
725
|
'mute.menu': 'Pause injection…',
|
|
625
726
|
'mute.30m': '30 minutes',
|
|
@@ -815,32 +916,95 @@ window.__ModuleLoader__.load({
|
|
|
815
916
|
|
|
816
917
|
/* ---------------- data helpers ---------------- */
|
|
817
918
|
|
|
818
|
-
// Mirror the Host's tag limits (lib/index.js)
|
|
819
|
-
//
|
|
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.
|
|
820
922
|
const MAX_TAGS = 8
|
|
821
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
|
+
}
|
|
822
950
|
|
|
823
951
|
/**
|
|
824
952
|
* Parse free text typed into the tag box into normalised tag candidates.
|
|
825
953
|
* Tags are split on commas (ASCII/full-width), semicolons and newlines —
|
|
826
954
|
* never spaces, so a tag may contain spaces. Each piece is trimmed,
|
|
827
|
-
* collapsed to one line, cut to
|
|
828
|
-
*
|
|
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.
|
|
829
958
|
*/
|
|
830
|
-
function parseTagInput(text) {
|
|
959
|
+
function parseTagInput(text, limits) {
|
|
831
960
|
if (typeof text !== 'string') return []
|
|
961
|
+
const caps = fieldLimitsOf(limits)
|
|
832
962
|
const out = []
|
|
833
963
|
const seen = new Set()
|
|
834
964
|
for (const piece of text.split(/[,,;;\n\r]+/)) {
|
|
835
|
-
const tag = piece.replace(/\s+/g, ' ').trim().slice(0,
|
|
965
|
+
const tag = piece.replace(/\s+/g, ' ').trim().slice(0, caps.maxTagLength)
|
|
836
966
|
if (tag === '' || seen.has(tag)) continue
|
|
837
967
|
seen.add(tag)
|
|
838
|
-
if (out.length >=
|
|
968
|
+
if (out.length >= caps.maxTags) break
|
|
839
969
|
out.push(tag)
|
|
840
970
|
}
|
|
841
971
|
return out
|
|
842
972
|
}
|
|
843
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
|
+
|
|
844
1008
|
function cloneEntry(entry) {
|
|
845
1009
|
return {
|
|
846
1010
|
id: entry.id,
|
|
@@ -905,15 +1069,58 @@ window.__ModuleLoader__.load({
|
|
|
905
1069
|
return tagsKey(draft) !== tagsKey(base)
|
|
906
1070
|
}
|
|
907
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
|
+
|
|
908
1116
|
/**
|
|
909
1117
|
* Ask before throwing the draft away. Falls back to "yes" when no dialog
|
|
910
1118
|
* is available (a bare webview without window.confirm): blocking the
|
|
911
|
-
* 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.
|
|
912
1121
|
*/
|
|
913
1122
|
function confirmDiscard() {
|
|
914
|
-
return
|
|
915
|
-
? window.confirm(t('editor.confirmDiscard'))
|
|
916
|
-
: true
|
|
1123
|
+
return askConfirm(t('editor.confirmDiscard'), 'allow') === CONFIRM_YES
|
|
917
1124
|
}
|
|
918
1125
|
|
|
919
1126
|
/**
|
|
@@ -1244,11 +1451,14 @@ window.__ModuleLoader__.load({
|
|
|
1244
1451
|
}
|
|
1245
1452
|
}
|
|
1246
1453
|
|
|
1247
|
-
async function callHost(method, args) {
|
|
1454
|
+
async function callHost(method, args, options) {
|
|
1248
1455
|
// A hung host used to pin the panel on 保存中… forever; fail visibly
|
|
1249
|
-
// 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
|
|
1250
1460
|
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
1251
|
-
const timer = controller !== null ? setTimeout(() => controller.abort(),
|
|
1461
|
+
const timer = controller !== null ? setTimeout(() => controller.abort(), timeoutMs) : null
|
|
1252
1462
|
try {
|
|
1253
1463
|
const response = await fetch(API, {
|
|
1254
1464
|
method: 'POST',
|
|
@@ -1273,40 +1483,78 @@ window.__ModuleLoader__.load({
|
|
|
1273
1483
|
}
|
|
1274
1484
|
return await response.json()
|
|
1275
1485
|
} catch (err) {
|
|
1276
|
-
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
|
+
}
|
|
1277
1491
|
throw err
|
|
1278
1492
|
} finally {
|
|
1279
1493
|
if (timer !== null) clearTimeout(timer)
|
|
1280
1494
|
}
|
|
1281
1495
|
}
|
|
1282
1496
|
|
|
1497
|
+
/**
|
|
1498
|
+
* Client-side mirror of the Host's normalizeFeedback. Returns either
|
|
1499
|
+
* `{ ok: true, value }` (trimmed) or `{ ok: false, message }` (already
|
|
1500
|
+
* translated); the Host re-validates and stays authoritative (a forged
|
|
1501
|
+
* request can bypass every check here).
|
|
1502
|
+
*/
|
|
1503
|
+
function feedbackVerdict(type, title, body, contact, limits) {
|
|
1504
|
+
const source = limits !== null && typeof limits === 'object' ? limits : FEEDBACK_DEFAULTS
|
|
1505
|
+
const maxTitle = typeof source.maxFeedbackTitle === 'number' ? source.maxFeedbackTitle : FEEDBACK_DEFAULTS.maxTitle
|
|
1506
|
+
const maxBody = typeof source.maxFeedbackBody === 'number' ? source.maxFeedbackBody : FEEDBACK_DEFAULTS.maxBody
|
|
1507
|
+
const maxContact = typeof source.maxFeedbackContact === 'number' ? source.maxFeedbackContact : FEEDBACK_DEFAULTS.maxContact
|
|
1508
|
+
const kind = typeof type === 'string' ? type : ''
|
|
1509
|
+
if (!FEEDBACK_TYPES.includes(kind)) return { ok: false, message: t('feedback.errType') }
|
|
1510
|
+
const cleanTitle = typeof title === 'string' ? title.trim() : ''
|
|
1511
|
+
if (cleanTitle === '') return { ok: false, message: t('feedback.errTitle') }
|
|
1512
|
+
if (cleanTitle.length > maxTitle) {
|
|
1513
|
+
return { ok: false, message: t('feedback.errTitleLong', { max: maxTitle }) }
|
|
1514
|
+
}
|
|
1515
|
+
const cleanBody = typeof body === 'string' ? body.trim() : ''
|
|
1516
|
+
if (cleanBody.length < 5) return { ok: false, message: t('feedback.errBody') }
|
|
1517
|
+
if (cleanBody.length > maxBody) {
|
|
1518
|
+
return { ok: false, message: t('feedback.errBodyLong', { max: maxBody }) }
|
|
1519
|
+
}
|
|
1520
|
+
const cleanContact = typeof contact === 'string' ? contact.trim() : ''
|
|
1521
|
+
if (cleanContact.length > maxContact) {
|
|
1522
|
+
return { ok: false, message: t('feedback.errContact', { max: maxContact }) }
|
|
1523
|
+
}
|
|
1524
|
+
return { ok: true, value: { type: kind, title: cleanTitle, body: cleanBody, contact: cleanContact } }
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1283
1527
|
function EntryEditor(props) {
|
|
1284
1528
|
const draft = props.draft
|
|
1529
|
+
const caps = fieldLimitsOf(props.limits)
|
|
1285
1530
|
const set = (patch) => props.onChange(Object.assign(cloneEntry(draft), patch))
|
|
1286
1531
|
const children = [
|
|
1287
1532
|
h('h3', { className: 'im-h', key: 'title' }, draft.id === '' ? t('editor.new') : t('editor.edit')),
|
|
1288
1533
|
h('div', { key: 't' },
|
|
1289
|
-
h('label', { className: 'im-label' }, t('editor.title')),
|
|
1534
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-title' }, t('editor.title')),
|
|
1290
1535
|
h('input', {
|
|
1536
|
+
id: 'im-entry-title',
|
|
1291
1537
|
className: 'im-input',
|
|
1292
1538
|
value: draft.title,
|
|
1293
|
-
maxLength:
|
|
1539
|
+
maxLength: caps.maxTitle,
|
|
1294
1540
|
placeholder: t('editor.titlePlaceholder'),
|
|
1295
1541
|
onChange: (ev) => set({ title: ev.target.value }),
|
|
1296
1542
|
})),
|
|
1297
1543
|
h('div', { key: 'c' },
|
|
1298
|
-
h('label', { className: 'im-label' }, t('editor.content')),
|
|
1544
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-content' }, t('editor.content')),
|
|
1299
1545
|
h('textarea', {
|
|
1546
|
+
id: 'im-entry-content',
|
|
1300
1547
|
className: 'im-textarea',
|
|
1301
1548
|
value: draft.content,
|
|
1302
|
-
maxLength:
|
|
1549
|
+
maxLength: caps.maxContent,
|
|
1303
1550
|
placeholder: t('editor.contentPlaceholder'),
|
|
1304
1551
|
onChange: (ev) => set({ content: ev.target.value }),
|
|
1305
1552
|
})),
|
|
1306
1553
|
h('div', { className: 'im-grid2', key: 'g' },
|
|
1307
1554
|
h('div', null,
|
|
1308
|
-
h('label', { className: 'im-label' }, t('editor.mode')),
|
|
1555
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-mode' }, t('editor.mode')),
|
|
1309
1556
|
h('select', {
|
|
1557
|
+
id: 'im-entry-mode',
|
|
1310
1558
|
className: 'im-select',
|
|
1311
1559
|
value: draft.mode,
|
|
1312
1560
|
onChange: (ev) => set({ mode: ev.target.value }),
|
|
@@ -1314,8 +1562,9 @@ window.__ModuleLoader__.load({
|
|
|
1314
1562
|
h('option', { value: 'always' }, t('editor.modeAlways')),
|
|
1315
1563
|
h('option', { value: 'auto' }, t('editor.modeAuto')))),
|
|
1316
1564
|
h('div', null,
|
|
1317
|
-
h('label', { className: 'im-label' }, t('editor.priority')),
|
|
1565
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-priority' }, t('editor.priority')),
|
|
1318
1566
|
h('select', {
|
|
1567
|
+
id: 'im-entry-priority',
|
|
1319
1568
|
className: 'im-select',
|
|
1320
1569
|
value: String(draft.priority),
|
|
1321
1570
|
onChange: (ev) => set({ priority: Number(ev.target.value) }),
|
|
@@ -1326,19 +1575,22 @@ window.__ModuleLoader__.load({
|
|
|
1326
1575
|
]
|
|
1327
1576
|
if (draft.mode === 'auto') {
|
|
1328
1577
|
children.push(h('div', { key: 'w' },
|
|
1329
|
-
h('label', { className: 'im-label' }, t('editor.when')),
|
|
1578
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-when' }, t('editor.when')),
|
|
1330
1579
|
h('input', {
|
|
1580
|
+
id: 'im-entry-when',
|
|
1331
1581
|
className: 'im-input',
|
|
1332
1582
|
value: draft.when,
|
|
1333
|
-
maxLength:
|
|
1583
|
+
maxLength: caps.maxWhen,
|
|
1334
1584
|
placeholder: t('editor.whenPlaceholder'),
|
|
1335
1585
|
onChange: (ev) => set({ when: ev.target.value }),
|
|
1336
1586
|
})))
|
|
1337
1587
|
}
|
|
1338
1588
|
children.push(h('div', { key: 'tags' },
|
|
1339
|
-
h('label', { className: 'im-label' }, t('tags.label')),
|
|
1589
|
+
h('label', { className: 'im-label', htmlFor: 'im-entry-tags' }, t('tags.label')),
|
|
1340
1590
|
h(TagInput, {
|
|
1591
|
+
inputId: 'im-entry-tags',
|
|
1341
1592
|
tags: draft.tags,
|
|
1593
|
+
limits: props.limits,
|
|
1342
1594
|
disabled: props.busy,
|
|
1343
1595
|
onChange: (next) => set({ tags: next }),
|
|
1344
1596
|
})))
|
|
@@ -1360,15 +1612,16 @@ window.__ModuleLoader__.load({
|
|
|
1360
1612
|
function TagInput(props) {
|
|
1361
1613
|
const [text, setText] = React.useState('')
|
|
1362
1614
|
const tags = Array.isArray(props.tags) ? props.tags : []
|
|
1615
|
+
const caps = fieldLimitsOf(props.limits)
|
|
1363
1616
|
const merge = (pieces) => {
|
|
1364
1617
|
const merged = []
|
|
1365
1618
|
for (const tag of [...tags, ...pieces]) {
|
|
1366
1619
|
if (!merged.includes(tag)) merged.push(tag)
|
|
1367
1620
|
}
|
|
1368
|
-
props.onChange(merged.slice(0,
|
|
1621
|
+
props.onChange(merged.slice(0, caps.maxTags))
|
|
1369
1622
|
}
|
|
1370
1623
|
const commit = () => {
|
|
1371
|
-
const parsed = parseTagInput(text)
|
|
1624
|
+
const parsed = parseTagInput(text, props.limits)
|
|
1372
1625
|
if (parsed.length > 0) merge(parsed)
|
|
1373
1626
|
setText('')
|
|
1374
1627
|
}
|
|
@@ -1382,14 +1635,19 @@ window.__ModuleLoader__.load({
|
|
|
1382
1635
|
tag,
|
|
1383
1636
|
h('button', {
|
|
1384
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 }),
|
|
1385
1642
|
onClick: () => remove(tag),
|
|
1386
1643
|
}, '×'))))
|
|
1387
1644
|
: null,
|
|
1388
1645
|
h('input', {
|
|
1646
|
+
id: props.inputId,
|
|
1389
1647
|
className: 'im-input',
|
|
1390
1648
|
value: text,
|
|
1391
1649
|
disabled: props.disabled,
|
|
1392
|
-
maxLength:
|
|
1650
|
+
maxLength: caps.maxTagLength,
|
|
1393
1651
|
placeholder: t('tags.placeholder'),
|
|
1394
1652
|
onChange: (ev) => {
|
|
1395
1653
|
const v = ev.target.value
|
|
@@ -1398,11 +1656,11 @@ window.__ModuleLoader__.load({
|
|
|
1398
1656
|
// v is the input's full new value (it already contains what was
|
|
1399
1657
|
// typed before the separator) — do NOT prepend `text`, that
|
|
1400
1658
|
// duplicated it ("b" + "b," -> "bb").
|
|
1401
|
-
const parsed = parseTagInput(v)
|
|
1659
|
+
const parsed = parseTagInput(v, props.limits)
|
|
1402
1660
|
if (parsed.length > 0) merge(parsed)
|
|
1403
1661
|
setText('')
|
|
1404
1662
|
} else {
|
|
1405
|
-
setText(v.slice(0,
|
|
1663
|
+
setText(v.slice(0, caps.maxTagLength))
|
|
1406
1664
|
}
|
|
1407
1665
|
},
|
|
1408
1666
|
onKeyDown: (ev) => {
|
|
@@ -1415,7 +1673,7 @@ window.__ModuleLoader__.load({
|
|
|
1415
1673
|
},
|
|
1416
1674
|
onBlur: commit,
|
|
1417
1675
|
}),
|
|
1418
|
-
h('div', { className: 'im-meta' }, t('tags.hint', { max:
|
|
1676
|
+
h('div', { className: 'im-meta' }, t('tags.hint', { max: caps.maxTags, len: caps.maxTagLength })))
|
|
1419
1677
|
}
|
|
1420
1678
|
|
|
1421
1679
|
/**
|
|
@@ -1433,7 +1691,12 @@ window.__ModuleLoader__.load({
|
|
|
1433
1691
|
}, [props.text])
|
|
1434
1692
|
return h('div', null,
|
|
1435
1693
|
h('div', { className: 'im-body' + (open ? ' im-body-open' : ''), ref: bodyRef }, props.text),
|
|
1436
|
-
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)
|
|
1437
1700
|
}
|
|
1438
1701
|
|
|
1439
1702
|
/** How long the undo bar keeps its offer alive after a confirmed delete. */
|
|
@@ -1454,8 +1717,9 @@ window.__ModuleLoader__.load({
|
|
|
1454
1717
|
const canSave = (props.draft.name.trim() !== '' || props.draft.content.trim() !== '')
|
|
1455
1718
|
return h('div', { className: 'im-editor im-profiles' },
|
|
1456
1719
|
h('div', null,
|
|
1457
|
-
h('label', { className: 'im-label' }, t('profiles.name')),
|
|
1720
|
+
h('label', { className: 'im-label', htmlFor: 'im-profile-name' }, t('profiles.name')),
|
|
1458
1721
|
h('input', {
|
|
1722
|
+
id: 'im-profile-name',
|
|
1459
1723
|
className: 'im-input',
|
|
1460
1724
|
value: props.draft.name,
|
|
1461
1725
|
maxLength: maxName,
|
|
@@ -1464,8 +1728,9 @@ window.__ModuleLoader__.load({
|
|
|
1464
1728
|
onChange: (ev) => set({ name: ev.target.value }),
|
|
1465
1729
|
})),
|
|
1466
1730
|
h('div', null,
|
|
1467
|
-
h('label', { className: 'im-label' }, t('profiles.content')),
|
|
1731
|
+
h('label', { className: 'im-label', htmlFor: 'im-profile-content' }, t('profiles.content')),
|
|
1468
1732
|
h('textarea', {
|
|
1733
|
+
id: 'im-profile-content',
|
|
1469
1734
|
className: 'im-textarea',
|
|
1470
1735
|
rows: 6,
|
|
1471
1736
|
value: props.draft.content,
|
|
@@ -1519,6 +1784,13 @@ window.__ModuleLoader__.load({
|
|
|
1519
1784
|
// F3: bulk-select mode and the chosen entry ids in it.
|
|
1520
1785
|
const [batchMode, setBatchMode] = React.useState(false)
|
|
1521
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('')
|
|
1522
1794
|
// F13: the set form (null = closed). One small inline form serves create,
|
|
1523
1795
|
// rename and duplicate, because all three are "type a name and confirm".
|
|
1524
1796
|
const [setForm, setSetForm] = React.useState(null)
|
|
@@ -1548,6 +1820,18 @@ window.__ModuleLoader__.load({
|
|
|
1548
1820
|
// pretending the copy happened.
|
|
1549
1821
|
const [diagOpen, setDiagOpen] = React.useState(false)
|
|
1550
1822
|
const [diagFallback, setDiagFallback] = React.useState(null)
|
|
1823
|
+
// Feedback form: closed by default so the settings card stays compact.
|
|
1824
|
+
// The draft lives in the panel (not in a child) because the send handler
|
|
1825
|
+
// needs the same values the inputs show, and keeping them here survives
|
|
1826
|
+
// an SSE snapshot absorb while the user is typing.
|
|
1827
|
+
const [feedbackOpen, setFeedbackOpen] = React.useState(false)
|
|
1828
|
+
const [feedbackType, setFeedbackType] = React.useState('bug')
|
|
1829
|
+
const [feedbackTitle, setFeedbackTitle] = React.useState('')
|
|
1830
|
+
const [feedbackBody, setFeedbackBody] = React.useState('')
|
|
1831
|
+
const [feedbackContact, setFeedbackContact] = React.useState('')
|
|
1832
|
+
const [feedbackBusy, setFeedbackBusy] = React.useState(false)
|
|
1833
|
+
const [feedbackError, setFeedbackError] = React.useState('')
|
|
1834
|
+
const [feedbackSent, setFeedbackSent] = React.useState('')
|
|
1551
1835
|
// F7: the pause menu (null-state boolean) and a 1s clock tick that exists
|
|
1552
1836
|
// ONLY while a pause is live (its effect cleans up after itself). The
|
|
1553
1837
|
// tick drives nothing but the remaining-time text; authority stays with
|
|
@@ -1705,6 +1989,12 @@ window.__ModuleLoader__.load({
|
|
|
1705
1989
|
function exitBatch() {
|
|
1706
1990
|
setBatchMode(false)
|
|
1707
1991
|
setSelected([])
|
|
1992
|
+
closeBatchTags()
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function closeBatchTags() {
|
|
1996
|
+
setBatchTagOpen(false)
|
|
1997
|
+
setBatchTagText('')
|
|
1708
1998
|
}
|
|
1709
1999
|
|
|
1710
2000
|
async function doBatch(op, value) {
|
|
@@ -1713,10 +2003,12 @@ window.__ModuleLoader__.load({
|
|
|
1713
2003
|
setError(t('batch.noneSelected'))
|
|
1714
2004
|
return
|
|
1715
2005
|
}
|
|
1716
|
-
if (op === 'delete'
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
+
}
|
|
1720
2012
|
}
|
|
1721
2013
|
const result = await send('batch', { op, ids, value, baseRev: revOf() })
|
|
1722
2014
|
if (result !== null && result.ok === true && op === 'delete') {
|
|
@@ -1726,19 +2018,41 @@ window.__ModuleLoader__.load({
|
|
|
1726
2018
|
}
|
|
1727
2019
|
}
|
|
1728
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
|
+
*/
|
|
1729
2039
|
function batchAddTags() {
|
|
1730
|
-
|
|
1731
|
-
|
|
2040
|
+
const ids = liveSelected()
|
|
2041
|
+
if (ids.length === 0) {
|
|
2042
|
+
setError(t('batch.noneSelected'))
|
|
1732
2043
|
return
|
|
1733
2044
|
}
|
|
1734
|
-
const
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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()
|
|
1739
2052
|
return
|
|
1740
2053
|
}
|
|
1741
|
-
|
|
2054
|
+
setError('')
|
|
2055
|
+
setBatchTagOpen(true)
|
|
1742
2056
|
}
|
|
1743
2057
|
|
|
1744
2058
|
// ---- User persona ("用户形象") handlers ----
|
|
@@ -1767,10 +2081,13 @@ window.__ModuleLoader__.load({
|
|
|
1767
2081
|
|
|
1768
2082
|
function removeProfile(profile) {
|
|
1769
2083
|
if (busy) return
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
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
|
+
}
|
|
1774
2091
|
void send('delete-profile', { id: profile.id, baseRev: revOf() })
|
|
1775
2092
|
}
|
|
1776
2093
|
|
|
@@ -1828,8 +2145,11 @@ window.__ModuleLoader__.load({
|
|
|
1828
2145
|
setError(t('sets.deleteActive'))
|
|
1829
2146
|
return
|
|
1830
2147
|
}
|
|
1831
|
-
|
|
1832
|
-
|
|
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
|
+
}
|
|
1833
2153
|
void send('delete-set', { id: set.id, baseRev: revOf() })
|
|
1834
2154
|
}
|
|
1835
2155
|
|
|
@@ -1861,9 +2181,13 @@ window.__ModuleLoader__.load({
|
|
|
1861
2181
|
}
|
|
1862
2182
|
|
|
1863
2183
|
function rollbackTo(item) {
|
|
1864
|
-
if (busy
|
|
2184
|
+
if (busy) return
|
|
1865
2185
|
const when = new Date(item.epoch).toLocaleString()
|
|
1866
|
-
|
|
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
|
+
}
|
|
1867
2191
|
void send('history-restore', { name: item.name, baseRev: revOf() }).then((result) => {
|
|
1868
2192
|
if (result !== null && result.ok === true) void loadHistory()
|
|
1869
2193
|
})
|
|
@@ -1871,12 +2195,97 @@ window.__ModuleLoader__.load({
|
|
|
1871
2195
|
|
|
1872
2196
|
function removeSnapshot(item) {
|
|
1873
2197
|
if (busy) return
|
|
1874
|
-
|
|
2198
|
+
const askDeleteHistory = askConfirm(t('history.deleteConfirm'))
|
|
2199
|
+
if (askDeleteHistory !== CONFIRM_YES) {
|
|
2200
|
+
if (askDeleteHistory === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
|
|
2201
|
+
return
|
|
2202
|
+
}
|
|
1875
2203
|
void send('history-delete', { name: item.name }).then((result) => {
|
|
1876
2204
|
if (result !== null && result.ok === true) void loadHistory()
|
|
1877
2205
|
})
|
|
1878
2206
|
}
|
|
1879
2207
|
|
|
2208
|
+
// ---- 反馈邮件 ----
|
|
2209
|
+
|
|
2210
|
+
function toggleFeedback() {
|
|
2211
|
+
const next = !feedbackOpen
|
|
2212
|
+
setFeedbackOpen(next)
|
|
2213
|
+
setFeedbackError('')
|
|
2214
|
+
if (!next) setFeedbackSent('')
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
function clearFeedback() {
|
|
2218
|
+
setFeedbackType('bug')
|
|
2219
|
+
setFeedbackTitle('')
|
|
2220
|
+
setFeedbackBody('')
|
|
2221
|
+
setFeedbackContact('')
|
|
2222
|
+
setFeedbackError('')
|
|
2223
|
+
setFeedbackSent('')
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/**
|
|
2227
|
+
* Send one report. The host validates again, enforces its own ceiling and
|
|
2228
|
+
* a cool-down, and decides the recipient from configuration — the panel
|
|
2229
|
+
* only supplies text plus the presentation facts (locale / time zone /
|
|
2230
|
+
* user agent). The error log deliberately carries the same reason the
|
|
2231
|
+
* user sees: a send failure is a configuration or network problem, and
|
|
2232
|
+
* the console is where a maintainer will look for it.
|
|
2233
|
+
*/
|
|
2234
|
+
async function submitFeedback() {
|
|
2235
|
+
if (feedbackBusy) return
|
|
2236
|
+
const verdict = feedbackVerdict(feedbackType, feedbackTitle, feedbackBody, feedbackContact, view !== null ? view.limits : null)
|
|
2237
|
+
if (!verdict.ok) {
|
|
2238
|
+
setFeedbackSent('')
|
|
2239
|
+
setFeedbackError(verdict.message)
|
|
2240
|
+
return
|
|
2241
|
+
}
|
|
2242
|
+
setFeedbackBusy(true)
|
|
2243
|
+
setFeedbackError('')
|
|
2244
|
+
setFeedbackSent('')
|
|
2245
|
+
const nav = typeof navigator !== 'undefined' && navigator !== null ? navigator : {}
|
|
2246
|
+
let timezone = ''
|
|
2247
|
+
try {
|
|
2248
|
+
timezone = typeof Intl === 'object' && Intl !== null && typeof Intl.DateTimeFormat === 'function'
|
|
2249
|
+
? String(Intl.DateTimeFormat().resolvedOptions().timeZone || '')
|
|
2250
|
+
: ''
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
timezone = ''
|
|
2253
|
+
}
|
|
2254
|
+
try {
|
|
2255
|
+
const result = await callHost('send-feedback', {
|
|
2256
|
+
type: verdict.value.type,
|
|
2257
|
+
title: verdict.value.title,
|
|
2258
|
+
body: verdict.value.body,
|
|
2259
|
+
contact: verdict.value.contact,
|
|
2260
|
+
locale: typeof nav.language === 'string' ? nav.language : '',
|
|
2261
|
+
userAgent: typeof nav.userAgent === 'string' ? nav.userAgent : '',
|
|
2262
|
+
timezone,
|
|
2263
|
+
}, {
|
|
2264
|
+
timeoutMs: FEEDBACK_SEND_TIMEOUT_MS,
|
|
2265
|
+
timeoutMessage: t('feedback.timeout', { seconds: Math.round(FEEDBACK_SEND_TIMEOUT_MS / 1000) }),
|
|
2266
|
+
})
|
|
2267
|
+
if (result === null || typeof result !== 'object' || result.ok !== true) {
|
|
2268
|
+
const message = result !== null && typeof result === 'object' && typeof result.message === 'string' && result.message !== ''
|
|
2269
|
+
? result.message
|
|
2270
|
+
: t('feedback.failed')
|
|
2271
|
+
setFeedbackError(message)
|
|
2272
|
+
console.error('[instruction-memory] feedback failed:', message)
|
|
2273
|
+
return
|
|
2274
|
+
}
|
|
2275
|
+
setFeedbackSent(t('feedback.sent'))
|
|
2276
|
+
setFeedbackType('bug')
|
|
2277
|
+
setFeedbackTitle('')
|
|
2278
|
+
setFeedbackBody('')
|
|
2279
|
+
setFeedbackContact('')
|
|
2280
|
+
} catch (err) {
|
|
2281
|
+
const detail = String(err && err.message ? err.message : err)
|
|
2282
|
+
setFeedbackError(t('feedback.failedDetail', { detail }))
|
|
2283
|
+
console.error('[instruction-memory] feedback request failed:', detail)
|
|
2284
|
+
} finally {
|
|
2285
|
+
setFeedbackBusy(false)
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
|
|
1880
2289
|
// ---- F16 source mode ----
|
|
1881
2290
|
|
|
1882
2291
|
async function openSource() {
|
|
@@ -1917,7 +2326,7 @@ window.__ModuleLoader__.load({
|
|
|
1917
2326
|
}
|
|
1918
2327
|
|
|
1919
2328
|
function reloadSource() {
|
|
1920
|
-
if (
|
|
2329
|
+
if (askConfirm(t('source.reloadConfirm'), 'allow') !== CONFIRM_YES) return
|
|
1921
2330
|
void openSource()
|
|
1922
2331
|
}
|
|
1923
2332
|
|
|
@@ -1950,7 +2359,11 @@ window.__ModuleLoader__.load({
|
|
|
1950
2359
|
|
|
1951
2360
|
async function handleRestoreFile(file) {
|
|
1952
2361
|
try {
|
|
1953
|
-
|
|
2362
|
+
const askRestore = askConfirm(t('restore.confirm'))
|
|
2363
|
+
if (askRestore !== CONFIRM_YES) {
|
|
2364
|
+
if (askRestore === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
|
|
2365
|
+
return
|
|
2366
|
+
}
|
|
1954
2367
|
const text = typeof file.text === 'function'
|
|
1955
2368
|
? await file.text()
|
|
1956
2369
|
: await new Promise((resolve, reject) => {
|
|
@@ -2094,17 +2507,51 @@ window.__ModuleLoader__.load({
|
|
|
2094
2507
|
*/
|
|
2095
2508
|
React.useEffect(() => {
|
|
2096
2509
|
if (typeof EventSource !== 'function') return
|
|
2097
|
-
|
|
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
|
+
}
|
|
2098
2538
|
source.onmessage = (ev) => {
|
|
2099
2539
|
try {
|
|
2100
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
|
+
}
|
|
2101
2548
|
if (sameSnapshot(pushed, viewRef.current)) return
|
|
2102
2549
|
absorb(pushed)
|
|
2103
2550
|
} catch {
|
|
2104
2551
|
// A malformed frame is dropped; the next frame, or 重新载入, catches up.
|
|
2105
2552
|
}
|
|
2106
2553
|
}
|
|
2107
|
-
return () => { source.close() }
|
|
2554
|
+
return () => { alive = false; source.close() }
|
|
2108
2555
|
}, [])
|
|
2109
2556
|
|
|
2110
2557
|
if (view === null) {
|
|
@@ -2117,6 +2564,10 @@ window.__ModuleLoader__.load({
|
|
|
2117
2564
|
h(EntryEditor, {
|
|
2118
2565
|
draft,
|
|
2119
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,
|
|
2120
2571
|
onChange: setDraft,
|
|
2121
2572
|
onCancel: () => {
|
|
2122
2573
|
if (draftIsDirty(draft, view.data.entries) && !confirmDiscard()) return
|
|
@@ -2131,7 +2582,11 @@ window.__ModuleLoader__.load({
|
|
|
2131
2582
|
if (result && result.ok === true) { setDraft(null); setError('') }
|
|
2132
2583
|
})
|
|
2133
2584
|
},
|
|
2134
|
-
})
|
|
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)
|
|
2135
2590
|
}
|
|
2136
2591
|
|
|
2137
2592
|
const data = view.data
|
|
@@ -2142,6 +2597,14 @@ window.__ModuleLoader__.load({
|
|
|
2142
2597
|
minBudget: 800, maxBudget: 40000, maxEntries: 200, maxProfiles: 50,
|
|
2143
2598
|
maxSets: 20, maxSetName: 30, maxHistory: 20, maxBackupDir: 512,
|
|
2144
2599
|
}
|
|
2600
|
+
// The feedback ceilings only exist on a Host that ships the feedback
|
|
2601
|
+
// route; an older Host hands over limits without them, so the form falls
|
|
2602
|
+
// back to the same defaults the validator uses.
|
|
2603
|
+
const feedbackMax = {
|
|
2604
|
+
title: typeof limits.maxFeedbackTitle === 'number' ? limits.maxFeedbackTitle : FEEDBACK_DEFAULTS.maxTitle,
|
|
2605
|
+
body: typeof limits.maxFeedbackBody === 'number' ? limits.maxFeedbackBody : FEEDBACK_DEFAULTS.maxBody,
|
|
2606
|
+
contact: typeof limits.maxFeedbackContact === 'number' ? limits.maxFeedbackContact : FEEDBACK_DEFAULTS.maxContact,
|
|
2607
|
+
}
|
|
2145
2608
|
// Mirrored for the debounced source check (see its effect above): reading
|
|
2146
2609
|
// it here keeps the effect's dependency list down to the text itself.
|
|
2147
2610
|
sourceCeilingRef.current = typeof limits.maxBodyChars === 'number' ? limits.maxBodyChars : 0
|
|
@@ -2318,9 +2781,91 @@ window.__ModuleLoader__.load({
|
|
|
2318
2781
|
void send('set-options', { disabledUntil: 0, baseRev: revOf() })
|
|
2319
2782
|
}
|
|
2320
2783
|
|
|
2784
|
+
// Feedback is the first row of the settings card so it is immediately
|
|
2785
|
+
// visible; source mode keeps its own header and never reaches it.
|
|
2786
|
+
const feedbackCard = h('div', { key: 'feedback', className: 'im-feedback' },
|
|
2787
|
+
h('div', { className: 'im-feedback-head' },
|
|
2788
|
+
h('span', { className: 'im-section-title' }, t('feedback.section')),
|
|
2789
|
+
h('span', { className: 'im-spacer' }),
|
|
2790
|
+
h('button', {
|
|
2791
|
+
className: feedbackOpen ? 'im-btn' : 'im-btn im-btn-primary',
|
|
2792
|
+
disabled: busy,
|
|
2793
|
+
onClick: toggleFeedback,
|
|
2794
|
+
}, feedbackOpen ? t('feedback.hide') : t('feedback.show'))),
|
|
2795
|
+
feedbackOpen
|
|
2796
|
+
? h('div', { className: 'im-feedback-form' },
|
|
2797
|
+
h('div', { className: 'im-meta' }, t('feedback.intro')),
|
|
2798
|
+
h('div', { className: 'im-grid2' },
|
|
2799
|
+
h('div', null,
|
|
2800
|
+
h('label', { className: 'im-label', htmlFor: 'im-feedback-type' }, t('feedback.typeLabel')),
|
|
2801
|
+
h('select', {
|
|
2802
|
+
id: 'im-feedback-type',
|
|
2803
|
+
className: 'im-select',
|
|
2804
|
+
value: feedbackType,
|
|
2805
|
+
disabled: feedbackBusy,
|
|
2806
|
+
onChange: (ev) => setFeedbackType(String(ev.target.value)),
|
|
2807
|
+
},
|
|
2808
|
+
h('option', { value: 'bug' }, t('feedback.typeBug')),
|
|
2809
|
+
h('option', { value: 'suggestion' }, t('feedback.typeSuggestion')),
|
|
2810
|
+
h('option', { value: 'other' }, t('feedback.typeOther')))),
|
|
2811
|
+
h('div', null,
|
|
2812
|
+
h('label', { className: 'im-label', htmlFor: 'im-feedback-contact' }, t('feedback.contactLabel')),
|
|
2813
|
+
h('input', {
|
|
2814
|
+
id: 'im-feedback-contact',
|
|
2815
|
+
className: 'im-input',
|
|
2816
|
+
type: 'text',
|
|
2817
|
+
value: feedbackContact,
|
|
2818
|
+
maxLength: feedbackMax.contact,
|
|
2819
|
+
placeholder: t('feedback.contactPlaceholder'),
|
|
2820
|
+
disabled: feedbackBusy,
|
|
2821
|
+
onChange: (ev) => setFeedbackContact(String(ev.target.value)),
|
|
2822
|
+
}))),
|
|
2823
|
+
h('div', null,
|
|
2824
|
+
h('label', { className: 'im-label', htmlFor: 'im-feedback-title' }, t('feedback.titleLabel')),
|
|
2825
|
+
h('input', {
|
|
2826
|
+
id: 'im-feedback-title',
|
|
2827
|
+
className: 'im-input',
|
|
2828
|
+
type: 'text',
|
|
2829
|
+
value: feedbackTitle,
|
|
2830
|
+
maxLength: feedbackMax.title,
|
|
2831
|
+
placeholder: t('feedback.titlePlaceholder'),
|
|
2832
|
+
disabled: feedbackBusy,
|
|
2833
|
+
onChange: (ev) => setFeedbackTitle(String(ev.target.value)),
|
|
2834
|
+
})),
|
|
2835
|
+
h('div', null,
|
|
2836
|
+
h('label', { className: 'im-label', htmlFor: 'im-feedback-body' }, t('feedback.bodyLabel')),
|
|
2837
|
+
h('textarea', {
|
|
2838
|
+
id: 'im-feedback-body',
|
|
2839
|
+
className: 'im-textarea',
|
|
2840
|
+
value: feedbackBody,
|
|
2841
|
+
maxLength: feedbackMax.body,
|
|
2842
|
+
placeholder: t('feedback.bodyPlaceholder'),
|
|
2843
|
+
disabled: feedbackBusy,
|
|
2844
|
+
onChange: (ev) => setFeedbackBody(String(ev.target.value)),
|
|
2845
|
+
})),
|
|
2846
|
+
h('div', { className: 'im-meta' }, t('feedback.hint')),
|
|
2847
|
+
// Send failures are shown next to the form and never replace the
|
|
2848
|
+
// panel-wide error line: a mail problem must not look like a
|
|
2849
|
+
// storage problem.
|
|
2850
|
+
feedbackError !== '' ? h('div', { className: 'im-err' }, feedbackError) : null,
|
|
2851
|
+
feedbackSent !== '' ? h('div', { className: 'im-ok' }, feedbackSent) : null,
|
|
2852
|
+
h('div', { className: 'im-feedback-actions' },
|
|
2853
|
+
h('button', {
|
|
2854
|
+
className: 'im-btn im-btn-primary',
|
|
2855
|
+
disabled: feedbackBusy,
|
|
2856
|
+
onClick: () => { void submitFeedback() },
|
|
2857
|
+
}, feedbackBusy ? t('feedback.sending') : t('feedback.send')),
|
|
2858
|
+
h('button', {
|
|
2859
|
+
className: 'im-btn',
|
|
2860
|
+
disabled: feedbackBusy,
|
|
2861
|
+
onClick: clearFeedback,
|
|
2862
|
+
}, t('feedback.clear'))))
|
|
2863
|
+
: null)
|
|
2864
|
+
|
|
2321
2865
|
const header = h('div', { className: 'im-card', key: 'head' },
|
|
2322
|
-
|
|
2323
|
-
//
|
|
2866
|
+
feedbackCard,
|
|
2867
|
+
// F12: the non-loopback warning stays high in the panel — directly
|
|
2868
|
+
// under the feedback entry — so it cannot be missed.
|
|
2324
2869
|
security.hostGuarded === false
|
|
2325
2870
|
? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
|
|
2326
2871
|
t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
|
|
@@ -2362,8 +2907,9 @@ window.__ModuleLoader__.load({
|
|
|
2362
2907
|
h('span', { className: injection.error ? 'im-err' : 'im-meta' }, dotText)),
|
|
2363
2908
|
h('div', { className: 'im-grid2' },
|
|
2364
2909
|
h('div', null,
|
|
2365
|
-
h('label', { className: 'im-label' }, t('budget.label')),
|
|
2910
|
+
h('label', { className: 'im-label', htmlFor: 'im-budget' }, t('budget.label')),
|
|
2366
2911
|
h('input', {
|
|
2912
|
+
id: 'im-budget',
|
|
2367
2913
|
className: 'im-input',
|
|
2368
2914
|
type: 'number',
|
|
2369
2915
|
min: limits.minBudget,
|
|
@@ -2455,8 +3001,9 @@ window.__ModuleLoader__.load({
|
|
|
2455
3001
|
}, t('sets.delete')))),
|
|
2456
3002
|
setForm !== null
|
|
2457
3003
|
? h('div', { className: 'im-inline-form' },
|
|
2458
|
-
h('
|
|
3004
|
+
h('label', { className: 'im-meta', htmlFor: 'im-set-name' }, t('sets.nameLabel')),
|
|
2459
3005
|
h('input', {
|
|
3006
|
+
id: 'im-set-name',
|
|
2460
3007
|
className: 'im-input',
|
|
2461
3008
|
value: setForm.name,
|
|
2462
3009
|
maxLength: limits.maxSetName,
|
|
@@ -2572,6 +3119,39 @@ window.__ModuleLoader__.load({
|
|
|
2572
3119
|
}, t('batch.delete')),
|
|
2573
3120
|
h('button', { className: 'im-btn', disabled: busy, onClick: exitBatch }, t('batch.exit')))
|
|
2574
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,
|
|
2575
3155
|
// ---- F17 本地预演:字面关键词匹配「按需」条目(不是模型真实判断) ----
|
|
2576
3156
|
(data.enabled === true && autoEntries.length > 0)
|
|
2577
3157
|
? h('div', { key: 'sim', className: 'im-profiles' },
|
|
@@ -2710,7 +3290,7 @@ window.__ModuleLoader__.load({
|
|
|
2710
3290
|
h('button', {
|
|
2711
3291
|
className: 'im-btn',
|
|
2712
3292
|
disabled: busy || entries.length === 0,
|
|
2713
|
-
onClick: () => { setBatchMode(!batchMode); setSelected([]) },
|
|
3293
|
+
onClick: () => { setBatchMode(!batchMode); setSelected([]); closeBatchTags() },
|
|
2714
3294
|
}, batchMode ? t('batch.exit') : t('action.batch')),
|
|
2715
3295
|
h('span', { className: 'im-spacer' }),
|
|
2716
3296
|
h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
|
|
@@ -2726,9 +3306,10 @@ window.__ModuleLoader__.load({
|
|
|
2726
3306
|
// ---- F18 备份位置 ----
|
|
2727
3307
|
h('div', { key: 'backup', className: 'im-backup' },
|
|
2728
3308
|
h('div', { className: 'im-section-title' }, t('backup.section')),
|
|
2729
|
-
h('label', { className: 'im-label' }, t('backup.label')),
|
|
3309
|
+
h('label', { className: 'im-label', htmlFor: 'im-backup-dir' }, t('backup.label')),
|
|
2730
3310
|
h('div', { className: 'im-backup-row' },
|
|
2731
3311
|
h('input', {
|
|
3312
|
+
id: 'im-backup-dir',
|
|
2732
3313
|
className: 'im-input',
|
|
2733
3314
|
value: backupText,
|
|
2734
3315
|
disabled: busy,
|
|
@@ -2833,9 +3414,9 @@ window.__ModuleLoader__.load({
|
|
|
2833
3414
|
}))
|
|
2834
3415
|
: null)
|
|
2835
3416
|
: null),
|
|
2836
|
-
error !== '' ? h('div', { className: 'im-err' }, error) : null,
|
|
2837
|
-
error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
|
|
2838
|
-
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,
|
|
2839
3420
|
undo !== null ? h('div', { className: 'im-undo', key: 'undo' },
|
|
2840
3421
|
h('span', null, t('undo.deleted', { title: undo.entry.title !== '' ? undo.entry.title : t('entry.untitled') })),
|
|
2841
3422
|
h('button', { disabled: busy, onClick: () => {
|
|
@@ -2913,12 +3494,12 @@ window.__ModuleLoader__.load({
|
|
|
2913
3494
|
sourceVerdict !== null
|
|
2914
3495
|
? h('div', { className: sourceVerdict.ok ? 'im-source-ok' : 'im-warn' }, sourceVerdict.message)
|
|
2915
3496
|
: null,
|
|
2916
|
-
error !== '' ? h('div', { className: 'im-err' }, error) : null,
|
|
3497
|
+
error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null,
|
|
2917
3498
|
// Saving can be refused (a conflict, a future version, a shape
|
|
2918
3499
|
// problem). The draft is never thrown away by a refusal, and the
|
|
2919
3500
|
// user needs to know that before deciding what to do next.
|
|
2920
3501
|
error !== '' ? h('div', { className: 'im-meta' }, t('source.keepDraft')) : null,
|
|
2921
|
-
error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
|
|
3502
|
+
error === '' && notice !== '' ? h('div', { className: 'im-ok', role: 'status', 'aria-live': 'polite' }, notice) : null,
|
|
2922
3503
|
h('div', { className: 'im-row-wrap' },
|
|
2923
3504
|
h('button', {
|
|
2924
3505
|
className: 'im-btn im-btn-primary',
|
|
@@ -3008,12 +3589,17 @@ window.__ModuleLoader__.load({
|
|
|
3008
3589
|
className: 'im-btn im-btn-danger',
|
|
3009
3590
|
disabled: busy,
|
|
3010
3591
|
onClick: () => {
|
|
3011
|
-
//
|
|
3012
|
-
//
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
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
|
+
}
|
|
3017
3603
|
// Keep the full entry for the undo bar; the POST response
|
|
3018
3604
|
// (not this closure) is what confirms the deletion happened.
|
|
3019
3605
|
setUndo(null)
|
|
@@ -3133,13 +3719,16 @@ window.__ModuleLoader__.load({
|
|
|
3133
3719
|
exports.__draftIsDirty = draftIsDirty
|
|
3134
3720
|
exports.__sameSnapshot = sameSnapshot
|
|
3135
3721
|
exports.__confirmDiscard = confirmDiscard
|
|
3722
|
+
exports.__askConfirm = askConfirm
|
|
3136
3723
|
exports.__importSizeVerdict = importSizeVerdict
|
|
3137
3724
|
exports.__nextBudgetText = nextBudgetText
|
|
3138
3725
|
exports.__budgetMeter = budgetMeter
|
|
3139
3726
|
exports.__parseTagInput = parseTagInput
|
|
3727
|
+
exports.__batchTagOutcome = batchTagOutcome
|
|
3140
3728
|
exports.__relevanceTerms = relevanceTerms
|
|
3141
3729
|
exports.__relevancePreview = relevancePreview
|
|
3142
3730
|
exports.__sourceParseVerdict = sourceParseVerdict
|
|
3731
|
+
exports.__feedbackVerdict = feedbackVerdict
|
|
3143
3732
|
exports.__diagnosticsText = diagnosticsText
|
|
3144
3733
|
exports.__strings = STRINGS
|
|
3145
3734
|
exports.__ns = NS
|