@fishdivinity/dsh-local-search 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh.md +36 -22
- package/README.md +35 -21
- package/cordis.patch.yml +2 -1
- package/lib/anytxt.js +49 -35
- package/lib/bootstrap.js +201 -0
- package/lib/client.js +1196 -290
- package/lib/config.js +193 -49
- package/lib/describe.js +157 -0
- package/lib/engines.js +6 -6
- package/lib/errors.js +2 -3
- package/lib/everything.js +70 -15
- package/lib/format.js +38 -33
- package/lib/index.js +75 -38
- package/lib/limits.js +13 -0
- package/lib/probe.js +34 -21
- package/lib/ref.js +5 -5
- package/lib/roots.js +34 -16
- package/lib/route.js +253 -70
- package/lib/schema.js +130 -0
- package/lib/tools.js +46 -36
- package/package.json +3 -2
package/lib/client.js
CHANGED
|
@@ -1,32 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-local-search
|
|
2
|
+
* dsh-local-search 的浏览器半边——插件的配置页。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* card: one card, click it, the plugin's own page opens.
|
|
4
|
+
* 它注册进 `plugins.item`,也就是 Plugins 页显示的 **Official plugins** 组,和 `dsh-github`
|
|
5
|
+
* 贡献 GitHub 卡片的方式一样:一张卡,点开是本插件自己的页面。
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* declares. So `id` must stay `fishdivinity-local-search`; the package name
|
|
12
|
-
* would resolve no form.
|
|
7
|
+
* 卡片 id 必须等于设置命名空间(= 这个 bundle 的 patch 声明的行 id `fishdivinity-local-search`),
|
|
8
|
+
* 页面才能用 `formFor(item.id)` 取到表单;写包名会查不到表单,页面就只剩「本部署没有提供设置
|
|
9
|
+
* 命名空间」那一行。
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
11
|
+
* 页面不硬编码任何后端:能力节、每个后端有哪些字段、字段类型与上限,全部由 Host 的
|
|
12
|
+
* `GET /dsh-local-search/api/describe` 给;这里只负责渲染,措辞留在本文件的字典里(zh/en 两列)。
|
|
13
|
+
* 加一个后端时页面逻辑不用改,只补它两个语言的文案。
|
|
16
14
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* maid-atelier skin) restyles the page without this package shipping CSS. The
|
|
20
|
-
* layout follows the official settings pages: sections with a small heading and
|
|
21
|
-
* a responsive field grid, so fields sit side by side instead of one per row.
|
|
15
|
+
* 所有控件来自 `@deepseek-ai/dsh-client-ui-primitives`(读取当前主题的 `--dsw-*` token),
|
|
16
|
+
* 本包不发布任何 CSS;版式照官方设置页:小节标题 + 响应式字段网格。
|
|
22
17
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* probe route declared in `lib/route.js`.
|
|
18
|
+
* 测试连接不能由页面直接 POST 本机 Anytxt JSON-RPC(跨源两边都拒),所以它问 Host 的只读
|
|
19
|
+
* 探测路由(`lib/route.js`)。
|
|
26
20
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
21
|
+
* 确认与结果都在 primitives 的 `Modal` 里:它自己 portal 到 body,Esc 与焦点由壳子管;本包没有
|
|
22
|
+
* 自绘浮层的样式,所以部署里缺 `Modal` 时退回页内提示(与 `Toast` 同一条规矩)。
|
|
23
|
+
*
|
|
24
|
+
* 分区(grep 这些词直接跳):
|
|
25
|
+
* FIELD_COPY / dictionary —— 全部中英文案
|
|
26
|
+
* statusUrl / verifyQuery / draftPlan / applyCanonical / normalizeWinPath —— 校验与路径改写
|
|
27
|
+
* ConfigPage —— 页面(state → edit → checkField → verify/save/reset → dialogNode → children)
|
|
28
|
+
* EngineNotice / engineNotice / apply —— 首用提示与注册
|
|
29
|
+
*
|
|
30
|
+
* 本文件是发布出去的客户端 bundle,不是源码:客户端模块系统按 lazy-CJS 工厂格式把它当普通
|
|
31
|
+
* 脚本执行,所以这里没有 ESM 的 import/export。
|
|
30
32
|
*/
|
|
31
33
|
window.__ModuleLoader__.load({
|
|
32
34
|
id: '@fishdivinity/dsh-local-search',
|
|
@@ -34,130 +36,187 @@ window.__ModuleLoader__.load({
|
|
|
34
36
|
var module = { exports: {} };
|
|
35
37
|
var exports = module.exports;
|
|
36
38
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
37
|
-
const { createElement: h, useState } = require('react');
|
|
39
|
+
const { createElement: h, useState, useEffect } = require('react');
|
|
38
40
|
const {
|
|
39
|
-
Button, SegmentedControl, SettingsForm, SettingsValueField,
|
|
41
|
+
Button, Modal, SegmentedControl, SettingsForm, SettingsValueField, Toast,
|
|
40
42
|
} = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
41
43
|
|
|
42
|
-
/**
|
|
44
|
+
/** 本插件拥有的字典命名空间。 */
|
|
43
45
|
const NS = 'localSearchConfig';
|
|
44
|
-
/**
|
|
46
|
+
/** 卡片 id,必须等于设置命名空间 = bundle patch 声明的行 id。 */
|
|
45
47
|
const ITEM_ID = 'fishdivinity-local-search';
|
|
46
|
-
/**
|
|
48
|
+
/** Host 的后端描述路由,按本页自己的 base URI 解析。 */
|
|
49
|
+
const DESCRIBE_ROUTE = '/dsh-local-search/api/describe';
|
|
50
|
+
/** Host 的只读探测路由。 */
|
|
47
51
|
const STATUS_ROUTE = '/dsh-local-search/api/status';
|
|
48
|
-
/**
|
|
52
|
+
/** 探测路由接受未保存地址的参数名。 */
|
|
53
|
+
const DRAFT_PARAM = 'anytxtUrl';
|
|
54
|
+
/** 探测路由校验未保存 `es.exe` 路径用的参数名。 */
|
|
55
|
+
const DRAFT_PATH_PARAM = 'esPath';
|
|
56
|
+
/** 探测路由校验未保存目录用的参数名(可重复)。 */
|
|
57
|
+
const DRAFT_DIR_PARAM = 'dir';
|
|
58
|
+
/** 只要校验草稿、不跑引擎探测的参数名:失焦校验用它。 */
|
|
59
|
+
const CHECK_ONLY_PARAM = 'checkOnly';
|
|
60
|
+
/** 本半边需要的客户端服务;两个都由 web shell 提供。 */
|
|
49
61
|
const inject = ['slots', 'locale'];
|
|
50
62
|
|
|
51
|
-
/**
|
|
63
|
+
/** 每个引擎的下载页。本插件从不下载它们。 */
|
|
52
64
|
const DOWNLOADS = {
|
|
53
65
|
everything: 'https://www.voidtools.com/downloads/',
|
|
54
66
|
anytxt: 'https://anytxt.net/download/',
|
|
55
67
|
};
|
|
56
68
|
|
|
57
|
-
/** Code pages the page offers. The Host accepts any TextDecoder label. */
|
|
58
|
-
const ENCODINGS = ['gbk', 'utf-8'];
|
|
59
|
-
|
|
60
69
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* showing GBK and then rewriting the value on save.
|
|
64
|
-
* @param value - the saved label
|
|
65
|
-
* @returns `gbk` or `utf-8`
|
|
70
|
+
* 已保存地址允许指向的 host。这个 bundle 不能 import `lib/config.js`(普通脚本没有 ESM
|
|
71
|
+
* import),所以这份列表是 Host 那份的副本;有一条测试比对两者。
|
|
66
72
|
*/
|
|
67
|
-
|
|
68
|
-
const label = String(value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
69
|
-
return label === 'utf8' || label === 'utf' ? 'utf-8' : 'gbk';
|
|
70
|
-
}
|
|
73
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', '::1', 'localhost'];
|
|
71
74
|
|
|
72
|
-
/**
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
workRoot: { key: 'workRoot', kind: 'text' },
|
|
79
|
-
personalRoot: { key: 'personalRoot', kind: 'text' },
|
|
80
|
-
allRoots: { key: 'allRoots', kind: 'text' },
|
|
81
|
-
maxResults: { key: 'maxResults', kind: 'count', max: 100 },
|
|
82
|
-
readMaxChars: { key: 'readMaxChars', kind: 'count', max: 20000 },
|
|
83
|
-
};
|
|
75
|
+
/** 引擎不可用提示的「已提示过」标记,按浏览器会话记一次。 */
|
|
76
|
+
const NOTICE_SEEN_KEY = 'dsh-local-search.engine-notice';
|
|
77
|
+
/** 首用探测在后台跑,页面要等它结束才决定提不提示。 */
|
|
78
|
+
const NOTICE_POLL_MS = 1500;
|
|
79
|
+
/** 轮询上限:五次足够覆盖一次 es.exe + 一次 HTTP 的探测预算。 */
|
|
80
|
+
const NOTICE_POLL_LIMIT = 5;
|
|
84
81
|
|
|
85
|
-
/**
|
|
82
|
+
/** 逐字段文案:[标签, 说明]。键名与 Host describe 的 labelKey / hintKey 一一对应。 */
|
|
86
83
|
const FIELD_COPY = {
|
|
87
84
|
esPath: {
|
|
88
|
-
en: ['es.exe path', '
|
|
89
|
-
zh: ['es.exe 路径', 'es.exe
|
|
85
|
+
en: ['es.exe path', 'Full path, e.g. C:\\Tools\\es.exe; leave empty to detect it.'],
|
|
86
|
+
zh: ['es.exe 路径', '完整路径,例如 C:\\Tools\\es.exe;留空自动查找。'],
|
|
90
87
|
},
|
|
91
88
|
esEncoding: {
|
|
92
|
-
en: ['es.exe output encoding', '
|
|
93
|
-
zh: ['es.exe 输出编码', '
|
|
89
|
+
en: ['es.exe output encoding', 'GBK for a zh-CN console, UTF-8 otherwise.'],
|
|
90
|
+
zh: ['es.exe 输出编码', '简中控制台选 GBK,其余选 UTF-8。'],
|
|
94
91
|
},
|
|
95
92
|
anytxtUrl: {
|
|
96
|
-
en: ['Anytxt endpoint', '
|
|
97
|
-
zh: ['Anytxt 地址', '
|
|
93
|
+
en: ['Anytxt endpoint', 'A local address, e.g. http://127.0.0.1:9920.'],
|
|
94
|
+
zh: ['Anytxt 地址', '本机地址,例如 http://127.0.0.1:9920。'],
|
|
98
95
|
},
|
|
99
96
|
workspaceRoot: {
|
|
100
|
-
en: ['Workspace root', '
|
|
101
|
-
zh: ['工作区根', '
|
|
97
|
+
en: ['Workspace root', 'A full directory, e.g. D:\\Alpha; empty follows the working directory.'],
|
|
98
|
+
zh: ['工作区根', '完整目录,例如 D:\\Alpha;留空跟随当前工作目录。'],
|
|
102
99
|
},
|
|
103
100
|
workRoot: {
|
|
104
|
-
en: ['Work root', '
|
|
105
|
-
zh: ['工作根', '
|
|
101
|
+
en: ['Work root', 'A full directory, e.g. E:\\Beta; the scope is unavailable while empty.'],
|
|
102
|
+
zh: ['工作根', '完整目录,例如 E:\\Beta;留空则这个范围不可用。'],
|
|
106
103
|
},
|
|
107
104
|
personalRoot: {
|
|
108
|
-
en: ['Personal root', '
|
|
109
|
-
zh: ['个人根', '
|
|
105
|
+
en: ['Personal root', 'A full directory; the scope is unavailable while empty.'],
|
|
106
|
+
zh: ['个人根', '完整目录;留空则这个范围不可用。'],
|
|
110
107
|
},
|
|
111
108
|
allRoots: {
|
|
112
|
-
en: ['All-scope roots', '
|
|
113
|
-
zh: ['全局检索根', '
|
|
109
|
+
en: ['All-scope roots', 'Full directories separated by semicolons, e.g. D:\\Alpha;E:\\Beta; empty uses C: and D:.'],
|
|
110
|
+
zh: ['全局检索根', '多个完整目录,分号隔开,例如 D:\\Alpha;E:\\Beta;留空用 C: 与 D:。'],
|
|
114
111
|
},
|
|
115
112
|
maxResults: {
|
|
116
|
-
en: ['Results per call', '
|
|
117
|
-
zh: ['每次结果数', '
|
|
113
|
+
en: ['Results per call', 'Results per call, 1–100.'],
|
|
114
|
+
zh: ['每次结果数', '每次返回多少条,1–100。'],
|
|
118
115
|
},
|
|
119
116
|
readMaxChars: {
|
|
120
|
-
en: ['Read window (characters)', '
|
|
121
|
-
zh: ['读取窗口(字符)', '
|
|
117
|
+
en: ['Read window (characters)', 'Characters per read, 1–20,000.'],
|
|
118
|
+
zh: ['读取窗口(字符)', '每次读取多少字符,1–20,000。'],
|
|
122
119
|
},
|
|
123
120
|
};
|
|
124
121
|
|
|
125
|
-
/**
|
|
122
|
+
/** 版式样式,全部取自主题 token;皮肤要改就覆盖 token。 */
|
|
126
123
|
const H3_STYLE = { margin: 0, fontSize: '13px', fontWeight: '600', lineHeight: '1.5', color: 'var(--dsw-alias-label-primary)' };
|
|
127
124
|
const LABEL_STYLE = { fontSize: '13px', fontWeight: '600', color: 'var(--dsw-alias-label-primary)' };
|
|
128
125
|
const HINT_STYLE = { margin: '4px 0 0', fontSize: '12px', lineHeight: '1.6', color: 'var(--dsw-alias-label-secondary)' };
|
|
129
|
-
const LINK_STYLE = { color: 'var(--dsw-alias-brand-primary)', textDecoration: 'none' };
|
|
130
|
-
const ROW_STYLE = { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px' };
|
|
131
126
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
127
|
+
* 被拦下的保存、以及重置的提示共用这一行样式:红色、正常字重。
|
|
128
|
+
*
|
|
129
|
+
* token 名必须是主题真有的:`--dsw-alias-state-warning-primary` 不存在,写错只会静默
|
|
130
|
+
* 回落到普通文字色(看起来像没上色)。状态的专用 token 是
|
|
131
|
+
* `--dsw-alias-state-{error,warn,success}-primary`。
|
|
136
132
|
*/
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
133
|
+
const NOTICE_STYLE = {
|
|
134
|
+
margin: '8px 0 0',
|
|
135
|
+
fontSize: '13px',
|
|
136
|
+
lineHeight: '1.6',
|
|
137
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
142
138
|
};
|
|
139
|
+
/**
|
|
140
|
+
* 探测结果的无序列表:一行一个引擎,失败的那行下面跟一句修法。
|
|
141
|
+
*
|
|
142
|
+
* 字号/行高/颜色照壳子弹窗正文那句 `_description`(14px / 22px / label-primary)写:
|
|
143
|
+
* 不写就会继承到别处的基准字号(16px),跟「重置」「仍要保存?」那些弹窗的正文差一档。
|
|
144
|
+
*/
|
|
145
|
+
const PROBE_LIST_STYLE = {
|
|
146
|
+
margin: 0,
|
|
147
|
+
paddingLeft: '18px',
|
|
148
|
+
fontSize: '14px',
|
|
149
|
+
lineHeight: '22px',
|
|
150
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
151
|
+
};
|
|
152
|
+
const PROBE_ITEM_STYLE = { marginTop: '2px' };
|
|
153
|
+
/** 修法那行:比结论浅一档,是补充说明,不跟结论抢同一行。 */
|
|
154
|
+
const PROBE_FIX_STYLE = {
|
|
155
|
+
display: 'block',
|
|
156
|
+
fontSize: '12px',
|
|
157
|
+
lineHeight: '1.6',
|
|
158
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* 探测结果那块的外壳。
|
|
162
|
+
*
|
|
163
|
+
* 弹窗没有 `description` 时,壳子给 `.body` 固定加 20px 上边距,于是列表比「重置」那种
|
|
164
|
+
* 弹窗的正文低一截,标题下面空一块。把这个负边距还回去,列表就从和 description 一样的
|
|
165
|
+
* 位置开始;`body` 是 flex 列、不与子元素折叠外边距,所以这 20px 会被真的抵消。
|
|
166
|
+
*/
|
|
167
|
+
const PROBE_BODY_STYLE = { marginTop: '-20px' };
|
|
168
|
+
/** 链接用主题的链接色:品牌色在亮色主题下是近黑,看起来像没上色。 */
|
|
169
|
+
const LINK_STYLE = { color: 'var(--dsw-alias-link)', textDecoration: 'none' };
|
|
170
|
+
const ROW_STYLE = { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px' };
|
|
171
|
+
/**
|
|
172
|
+
* 字段网格:两列,整块限宽 1100px,容器窄于 {@link WIDE_QUERY} 时回落一列。
|
|
173
|
+
*
|
|
174
|
+
* 为什么不是 `auto-fit` + 最小列宽:一条最小列宽没法同时满足「700px 的容器也排两列」和
|
|
175
|
+
* 「全屏不要第三列」——前者要求 ≤342px,后者要求 >366px(1100/3)。所以列数直接由宽度
|
|
176
|
+
* 判断(`matchMedia`),两列时每列 `1fr`,全屏就是原来的 2×542。
|
|
177
|
+
*/
|
|
178
|
+
const WIDE_QUERY = '(min-width: 656px)';
|
|
179
|
+
const GRID_MAX_WIDTH = '1100px';
|
|
180
|
+
|
|
181
|
+
function gridStyle(wide) {
|
|
182
|
+
return {
|
|
183
|
+
display: 'grid',
|
|
184
|
+
gridTemplateColumns: wide ? 'repeat(2, minmax(0, 1fr))' : 'minmax(0, 1fr)',
|
|
185
|
+
gap: '16px',
|
|
186
|
+
marginTop: '8px',
|
|
187
|
+
maxWidth: GRID_MAX_WIDTH,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
143
191
|
const SECTION_STYLE = { minWidth: 0, padding: '16px 0' };
|
|
192
|
+
/** 一个字段占的格子;上限让单列布局不至于横贯整屏。 */
|
|
193
|
+
const CELL_STYLE = { minWidth: 0, maxWidth: '560px' };
|
|
194
|
+
/** 后端选择器只占一小条,别拉满整行。 */
|
|
195
|
+
const SELECTOR_STYLE = { marginTop: '8px', maxWidth: '320px' };
|
|
196
|
+
/** 重置那行:描述正下方,靠下留白与第一个小节分开。 */
|
|
197
|
+
const RESET_TOP_STYLE = { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' };
|
|
144
198
|
|
|
145
199
|
function dictionary(copy) {
|
|
146
200
|
const words = {
|
|
147
201
|
description: copy('只读的 Everything 文件名检索与 Anytxt 文档内容检索。', 'Read-only Everything filename search and Anytxt document-content search.'),
|
|
148
202
|
itemLabel: copy('本地搜索', 'Local Search'),
|
|
149
|
-
|
|
150
|
-
|
|
203
|
+
sectionFiles: copy('文件名检索', 'File names'),
|
|
204
|
+
sectionContent: copy('文件内容检索', 'File contents'),
|
|
151
205
|
sectionScopes: copy('检索范围', 'Search scopes'),
|
|
152
206
|
sectionLimits: copy('上限', 'Limits'),
|
|
207
|
+
engineEverything: 'Everything',
|
|
208
|
+
engineAnytxt: 'Anytxt',
|
|
153
209
|
note: copy(
|
|
154
|
-
'
|
|
155
|
-
'The per-call timeout
|
|
210
|
+
'单次调用的超时只能改 profile 的 YAML,改完要重载插件。',
|
|
211
|
+
'The per-call timeout lives in the profile YAML only; change it there and reload the plugin.',
|
|
156
212
|
),
|
|
157
|
-
overridden: copy('本页设置', 'Set on this page'),
|
|
158
|
-
reset: copy('重置', 'Reset'),
|
|
159
213
|
invalidValue: copy('这个值不合法。', 'Not a valid value.'),
|
|
160
214
|
invalidNumber: copy('请填范围内的整数。', 'Enter a whole number in range.'),
|
|
215
|
+
invalidFile: copy('这个文件不存在。', 'No such file.'),
|
|
216
|
+
invalidDir: copy('这个目录不存在。', 'No such directory.'),
|
|
217
|
+
invalidNotFile: copy('这里要填文件,不是目录。', 'A file goes here, not a directory.'),
|
|
218
|
+
invalidNotDir: copy('这里要填目录,不是文件。', 'A directory goes here, not a file.'),
|
|
219
|
+
invalidLocal: copy('只支持本机地址。', 'A local address only.'),
|
|
161
220
|
unavailable: copy(
|
|
162
221
|
'当前部署没有提供本插件的设置命名空间,请改在 profile 的 cordis.patch.yml 里配置。',
|
|
163
222
|
"This deployment does not serve this plugin's settings; set the values in the profile's cordis.patch.yml instead.",
|
|
@@ -166,10 +225,40 @@ window.__ModuleLoader__.load({
|
|
|
166
225
|
saveFailed: copy('宿主没有接受这些值。', 'The Host did not accept these values.'),
|
|
167
226
|
save: copy('保存', 'Save'),
|
|
168
227
|
saving: copy('保存中…', 'Saving…'),
|
|
228
|
+
// 拼接用的标点:中文全角、英文半角加空格。写死在模板里,英文界面就会冒出「。」。
|
|
229
|
+
listSeparator: copy('、', ', '),
|
|
230
|
+
colon: copy(':', ': '),
|
|
231
|
+
fullStop: copy('。', '.'),
|
|
232
|
+
saveAnyway: copy('仍然保存', 'Save anyway'),
|
|
233
|
+
saveEngineDown: copy('引擎现在不可用:', 'The engine is unavailable now: '),
|
|
234
|
+
saveNoWrite: copy(',没有写入。', ', nothing was written.'),
|
|
235
|
+
savePathMissing: copy('es.exe 路径上找不到文件', 'No file at that es.exe path'),
|
|
236
|
+
savePathNotFile: copy('es.exe 路径是个目录,不是文件', 'The es.exe path is a directory, not a file'),
|
|
237
|
+
saveDirMissing: copy('这个目录不存在', 'No such directory'),
|
|
238
|
+
saveDirNotDir: copy('这个路径是个文件,不是目录', 'That path is a file, not a directory'),
|
|
239
|
+
saveEndpointInvalid: copy('这个地址不被接受', 'That address was rejected'),
|
|
240
|
+
saveBlockedTitle: copy('无法保存', 'Could not save'),
|
|
241
|
+
saveAnywayTitle: copy('仍要保存?', 'Save anyway?'),
|
|
242
|
+
close: copy('关闭', 'Close'),
|
|
243
|
+
cancel: copy('取消', 'Cancel'),
|
|
244
|
+
reset: copy('重置', 'Reset'),
|
|
245
|
+
resetTitle: copy('重置', 'Reset'),
|
|
246
|
+
resetConfirm: copy('确认重置', 'Confirm reset'),
|
|
247
|
+
resetHint: copy(
|
|
248
|
+
'按探测结果重新填入;检索范围保留,其余恢复默认。',
|
|
249
|
+
'Refills from a fresh probe; search scopes stay and everything else returns to its default.',
|
|
250
|
+
),
|
|
251
|
+
resetting: copy('重置中…', 'Resetting…'),
|
|
252
|
+
resetDone: copy('已重置。', 'Reset done.'),
|
|
253
|
+
resetFailed: copy('重置没有写进去。', 'The reset was not written.'),
|
|
254
|
+
describeLoading: copy('正在读取后端列表…', 'Loading the backend list…'),
|
|
255
|
+
describeFailed: copy('读不到后端列表:', 'Could not read the backend list:'),
|
|
256
|
+
retry: copy('重试', 'Retry'),
|
|
169
257
|
encodingGbk: copy('GBK(简中)', 'GBK (zh-CN)'),
|
|
170
258
|
encodingUtf8: 'UTF-8',
|
|
171
259
|
test: copy('测试连接', 'Test connection'),
|
|
172
260
|
testing: copy('测试中…', 'Testing…'),
|
|
261
|
+
testAgain: copy('再测一次', 'Test again'),
|
|
173
262
|
testHint: copy(
|
|
174
263
|
'用上面输入框里的地址试一次,不用先保存;只探测,不改配置。',
|
|
175
264
|
'Probes the address above without saving it first; changes nothing.',
|
|
@@ -179,13 +268,25 @@ window.__ModuleLoader__.load({
|
|
|
179
268
|
'The address must be a local http(s) url: 127.0.0.1, localhost or [::1].',
|
|
180
269
|
),
|
|
181
270
|
testFailed: copy('测试请求失败', 'The test request failed'),
|
|
182
|
-
testWhere: copy('测试请求发往', 'The test request goes to'),
|
|
183
271
|
everythingOk: copy('Everything 正常', 'Everything: OK'),
|
|
184
272
|
everythingDown: copy('Everything 不可用', 'Everything: DOWN'),
|
|
185
273
|
anytxtOk: copy('Anytxt 正常', 'Anytxt: OK'),
|
|
186
274
|
anytxtDown: copy('Anytxt 不可达', 'Anytxt: DOWN'),
|
|
187
275
|
downloadEverything: copy('下载 Everything', 'Download Everything'),
|
|
188
276
|
downloadAnytxt: copy('下载 Anytxt', 'Download Anytxt'),
|
|
277
|
+
fixEverythingNotRunning: copy(
|
|
278
|
+
'已安装但没在运行:启动它',
|
|
279
|
+
'Installed but not running: start it',
|
|
280
|
+
),
|
|
281
|
+
fixEverythingNotInstalled: copy(
|
|
282
|
+
'没装:先安装 Everything',
|
|
283
|
+
'Not installed: install Everything first',
|
|
284
|
+
),
|
|
285
|
+
fixEverythingEsMissing: copy(
|
|
286
|
+
'缺 es.exe:装上后填到上面的路径',
|
|
287
|
+
'es.exe is missing: install it and set the path',
|
|
288
|
+
),
|
|
289
|
+
noticeEnginesDown: copy('这些检索后端现在不可用:', 'These search backends are unavailable: '),
|
|
189
290
|
};
|
|
190
291
|
for (const [key, entry] of Object.entries(FIELD_COPY)) {
|
|
191
292
|
words[`${key}Label`] = copy(entry.zh[0], entry.en[0]);
|
|
@@ -198,10 +299,9 @@ window.__ModuleLoader__.load({
|
|
|
198
299
|
const zh = dictionary((zhText) => zhText);
|
|
199
300
|
|
|
200
301
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* @
|
|
204
|
-
* @returns the request path
|
|
302
|
+
* 本页 base URI 下的一条路由,因此挂载在 origin 子路径上的部署也能到达 Host。
|
|
303
|
+
* @param path - Host 上的绝对路径
|
|
304
|
+
* @returns 请求路径
|
|
205
305
|
*/
|
|
206
306
|
function api(path) {
|
|
207
307
|
const relative = String(path).replace(/^\/+/, '');
|
|
@@ -209,40 +309,249 @@ window.__ModuleLoader__.load({
|
|
|
209
309
|
return new URL(relative, document.baseURI).pathname;
|
|
210
310
|
}
|
|
211
311
|
|
|
312
|
+
/** 取后端描述。 */
|
|
313
|
+
function describeUrl() {
|
|
314
|
+
return api(DESCRIBE_ROUTE);
|
|
315
|
+
}
|
|
316
|
+
|
|
212
317
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* @param
|
|
217
|
-
* @returns
|
|
318
|
+
* 探测请求的地址。草稿走查询串,所以测试永远不需要先保存;Host 只接受本机 `http(s)`
|
|
319
|
+
* 的地址、只查存在性的 `es.exe` 路径。
|
|
320
|
+
* @param draft - 输入框里的地址,空串表示探测已保存的值
|
|
321
|
+
* @param esPath - 输入框里的 `es.exe` 路径,空串表示用已保存的(或自动查找)
|
|
322
|
+
* @returns 请求路径,含查询串
|
|
218
323
|
*/
|
|
219
|
-
function statusUrl(draft) {
|
|
324
|
+
function statusUrl(draft, esPath) {
|
|
220
325
|
const path = api(STATUS_ROUTE);
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
326
|
+
const params = new URLSearchParams();
|
|
327
|
+
const address = String(draft ?? '').trim();
|
|
328
|
+
if (address !== '') params.set(DRAFT_PARAM, address);
|
|
329
|
+
const binary = String(esPath ?? '').trim();
|
|
330
|
+
if (binary !== '') params.set(DRAFT_PATH_PARAM, binary);
|
|
331
|
+
const query = params.toString();
|
|
332
|
+
return query === '' ? path : `${path}?${query}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 路径的字符串键;staged 用它当 Map 键。 */
|
|
336
|
+
function pathKey(path) {
|
|
337
|
+
return path.join('.');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** 沿路径取值。 */
|
|
341
|
+
function pathValue(object, path) {
|
|
342
|
+
let node = object;
|
|
343
|
+
for (const key of path) {
|
|
344
|
+
if (node === null || typeof node !== 'object') return undefined;
|
|
345
|
+
node = node[key];
|
|
346
|
+
}
|
|
347
|
+
return node;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** 盘符路径 / UNC 路径。只重写这两种写法,POSIX 与相对路径原样留着。 */
|
|
351
|
+
const WINDOWS_PATH = /^[a-z]:[\\/]/i;
|
|
352
|
+
const UNC_PATH = /^[\\/]{2}/;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 把一个路径草稿改写成标准写法:盘符大写、只用 `\`、去掉多余与末尾的分隔符。
|
|
356
|
+
*
|
|
357
|
+
* 只认 `D:\` / `D:/` 与 `\\server\share` 两种开头——别的部署写 `/home/me` 时,把分隔符
|
|
358
|
+
* 换成 `\` 只会造出一个不存在的路径。名字里的空格、中文、`drawio a` 那种带空格的目录名
|
|
359
|
+
* 都不动:Windows 允许,宿主也是按值传给 `es.exe` 与 `fs`,不经过 shell。
|
|
360
|
+
* @param text - 输入框里的路径
|
|
361
|
+
* @returns 标准写法;空串仍是空串
|
|
362
|
+
*/
|
|
363
|
+
function normalizeWinPath(text) {
|
|
364
|
+
const trimmed = String(text ?? '').trim();
|
|
365
|
+
if (trimmed === '') return '';
|
|
366
|
+
if (!WINDOWS_PATH.test(trimmed) && !UNC_PATH.test(trimmed)) return trimmed;
|
|
367
|
+
const unc = UNC_PATH.test(trimmed);
|
|
368
|
+
let value = trimmed.replace(/\//g, '\\').replace(/\\{2,}/g, '\\');
|
|
369
|
+
// 折叠分隔符会把 UNC 开头的两个也压成一个,这里补回来。
|
|
370
|
+
if (unc) value = `\\${value}`;
|
|
371
|
+
if (/^[a-z]:/.test(value)) value = `${value[0].toUpperCase()}${value.slice(1)}`;
|
|
372
|
+
// 目录末尾的分隔符没有信息量;盘符根 `D:\` 只有三个字符,留着。
|
|
373
|
+
if (value.length > 3 && value.endsWith('\\')) value = value.replace(/\\+$/, '');
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* 分号列表的同一个改写;空项丢掉(它们本来就会被 verifyQuery 跳过)。
|
|
379
|
+
* @param text - 分号分隔的目录列表
|
|
380
|
+
* @returns 标准写法,分号分隔
|
|
381
|
+
*/
|
|
382
|
+
function normalizeWinPaths(text) {
|
|
383
|
+
return String(text ?? '')
|
|
384
|
+
.split(';')
|
|
385
|
+
.map((value) => normalizeWinPath(value))
|
|
386
|
+
.filter((value) => value !== '')
|
|
387
|
+
.join(';');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 保存前把带路径语义的草稿改写成标准写法。
|
|
392
|
+
* @param staged - 路径键到草稿
|
|
393
|
+
* @param fields - 路径键到 describe 字段
|
|
394
|
+
* @returns 改写后的草稿,以及是否真的改动了
|
|
395
|
+
*/
|
|
396
|
+
function normalizeStaged(staged, fields) {
|
|
397
|
+
let changed = false;
|
|
398
|
+
const next = {};
|
|
399
|
+
for (const [key, entry] of Object.entries(staged)) {
|
|
400
|
+
const field = fields.get(key);
|
|
401
|
+
if (entry.op !== 'set' || field === undefined) {
|
|
402
|
+
next[key] = entry;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const text = String(entry.text);
|
|
406
|
+
const normalized = field.verifyPath === true
|
|
407
|
+
? normalizeWinPath(text)
|
|
408
|
+
: field.verifyDir === true ? normalizeWinPaths(text) : text;
|
|
409
|
+
if (normalized === text) {
|
|
410
|
+
next[key] = entry;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
changed = true;
|
|
414
|
+
next[key] = { ...entry, text: normalized };
|
|
415
|
+
}
|
|
416
|
+
return { next, changed };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 一个字段的草稿要翻成哪些查询参数。
|
|
421
|
+
*
|
|
422
|
+
* 保存前的整表校验、失焦时的单字段校验、测试连接都走这里:参数名与拆分规则只写一遍
|
|
423
|
+
* ——以前三处各写一份,加字段时总会漏掉一处。
|
|
424
|
+
* @param field - describe 给的字段
|
|
425
|
+
* @param text - 输入框里的值
|
|
426
|
+
* @returns 参数名到值的列表;不可校验或空值时为空
|
|
427
|
+
*/
|
|
428
|
+
function draftEntries(field, text) {
|
|
429
|
+
const value = String(text ?? '').trim();
|
|
430
|
+
if (value === '') return [];
|
|
431
|
+
if (field.probeEndpoint === true) return [[DRAFT_PARAM, value]];
|
|
432
|
+
if (field.verifyPath === true) return [[DRAFT_PATH_PARAM, normalizeWinPath(value)]];
|
|
433
|
+
if (field.verifyDir === true) {
|
|
434
|
+
// `allRoots` 是分号列表:每个目录单独校验,空项跳过。
|
|
435
|
+
return normalizeWinPaths(value).split(';').filter(Boolean).map((dir) => [DRAFT_DIR_PARAM, dir]);
|
|
436
|
+
}
|
|
437
|
+
return [];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 一次保存要交给 Host 校验的草稿,按发送顺序列出。
|
|
442
|
+
*
|
|
443
|
+
* 顺序是回填的依据:Host 按同样的顺序回答每个目录的规范写法(磁盘上的真实大小写),所以
|
|
444
|
+
* 查询串与回填都从这份清单来,不能各算一遍。
|
|
445
|
+
* @param staged - 路径键到草稿
|
|
446
|
+
* @param fields - 路径键到 describe 字段
|
|
447
|
+
* @returns 每一项的路径键、种类(endpoint/path/dir)、规范后的值
|
|
448
|
+
*/
|
|
449
|
+
function draftPlan(staged, fields) {
|
|
450
|
+
const plan = [];
|
|
451
|
+
for (const [key, entry] of Object.entries(staged)) {
|
|
452
|
+
if (entry.op !== 'set') continue;
|
|
453
|
+
const field = fields.get(key);
|
|
454
|
+
if (field === undefined) continue;
|
|
455
|
+
const entries = draftEntries(field, entry.text);
|
|
456
|
+
if (entries.length === 0) continue;
|
|
457
|
+
const kind = field.probeEndpoint === true ? 'endpoint' : field.verifyPath === true ? 'path' : 'dir';
|
|
458
|
+
const values = entries.map(([, value]) => value);
|
|
459
|
+
plan.push({
|
|
460
|
+
key,
|
|
461
|
+
kind,
|
|
462
|
+
text: kind === 'dir' ? values.join(';') : values[0],
|
|
463
|
+
dirs: kind === 'dir' ? values : [],
|
|
464
|
+
entries,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return plan;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* 保存前要交给 Host 校验的草稿查询串。
|
|
472
|
+
*
|
|
473
|
+
* 只有「能安全校验」的字段进得来:地址(Host 用草稿探测)、`es.exe` 路径(Host 只查
|
|
474
|
+
* 文件存在,绝不启动它)、以及目录类字段(只查是不是一个已存在的目录)。没有可校验字段
|
|
475
|
+
* 时返回空串,保存就不必先多发一次请求。
|
|
476
|
+
* @param staged - 路径键到草稿
|
|
477
|
+
* @param fields - 路径键到 describe 字段
|
|
478
|
+
* @returns 查询串(不含 `?`),或空串
|
|
479
|
+
*/
|
|
480
|
+
function verifyQuery(staged, fields) {
|
|
481
|
+
const params = new URLSearchParams();
|
|
482
|
+
for (const item of draftPlan(staged, fields)) {
|
|
483
|
+
for (const [name, value] of item.entries) params.append(name, value);
|
|
484
|
+
}
|
|
485
|
+
return params.toString();
|
|
224
486
|
}
|
|
225
487
|
|
|
226
488
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* @param
|
|
231
|
-
* @
|
|
489
|
+
* 把 Host 回来的规范写法(磁盘上的真实大小写、真实分隔符)写回草稿。
|
|
490
|
+
*
|
|
491
|
+
* 只改写法,不改语义:`value` 已经校验过存在,`resolved` 指向同一个文件/目录。
|
|
492
|
+
* @param draft - 规范化之后的草稿
|
|
493
|
+
* @param fields - 路径键到 describe 字段
|
|
494
|
+
* @param drafts - status 路由回答里的 `drafts`
|
|
495
|
+
* @returns 改写后的草稿;没有可回填的值时原样返回
|
|
496
|
+
*/
|
|
497
|
+
function applyCanonical(draft, fields, drafts) {
|
|
498
|
+
if (drafts === undefined || drafts === null) return draft;
|
|
499
|
+
const plan = draftPlan(draft, fields);
|
|
500
|
+
let cursor = 0;
|
|
501
|
+
let changed = false;
|
|
502
|
+
const next = { ...draft };
|
|
503
|
+
for (const item of plan) {
|
|
504
|
+
if (item.kind === 'path') {
|
|
505
|
+
const value = drafts.esPath;
|
|
506
|
+
if (typeof value !== 'string' || value === '' || value === item.text) continue;
|
|
507
|
+
next[item.key] = { ...next[item.key], text: value };
|
|
508
|
+
changed = true;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (item.kind !== 'dir') continue;
|
|
512
|
+
const slice = (drafts.dirs ?? []).slice(cursor, cursor + item.dirs.length);
|
|
513
|
+
cursor += item.dirs.length;
|
|
514
|
+
if (slice.length !== item.dirs.length) continue;
|
|
515
|
+
const joined = slice.join(';');
|
|
516
|
+
if (joined === item.text) continue;
|
|
517
|
+
next[item.key] = { ...next[item.key], text: joined };
|
|
518
|
+
changed = true;
|
|
519
|
+
}
|
|
520
|
+
return changed ? next : draft;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 从探测结果里取「要写回某个字段的值」。
|
|
525
|
+
* @param probed - status 路由的回答
|
|
526
|
+
* @param from - 点分路径,如 `everything.path`
|
|
527
|
+
* @returns 非空字符串,或 undefined
|
|
528
|
+
*/
|
|
529
|
+
function fillValue(probed, from) {
|
|
530
|
+
let node = probed;
|
|
531
|
+
for (const key of String(from ?? '').split('.')) {
|
|
532
|
+
if (node === null || typeof node !== 'object') return undefined;
|
|
533
|
+
node = node[key];
|
|
534
|
+
}
|
|
535
|
+
return typeof node === 'string' && node !== '' ? node : undefined;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 一次保存提交的原子操作列表。清空的字段是 `unset`,回落到 profile 层,而不是钉一个
|
|
540
|
+
* 空覆盖值。
|
|
541
|
+
* @param staged - 路径键到 `{ op: 'set', text }` 或 `{ op: 'unset' }`
|
|
542
|
+
* @returns 保存提交的操作
|
|
232
543
|
*/
|
|
233
544
|
function operations(staged) {
|
|
234
545
|
return Object.entries(staged).map(([key, entry]) => {
|
|
235
|
-
if (entry.op === 'unset') return { op: 'unset', path:
|
|
236
|
-
|
|
237
|
-
return { op: 'set', path: [key], value: field.kind === 'count' ? Number(entry.text) : entry.text };
|
|
546
|
+
if (entry.op === 'unset') return { op: 'unset', path: key.split('.') };
|
|
547
|
+
return { op: 'set', path: key.split('.'), value: entry.numeric === true ? Number(entry.text) : entry.text };
|
|
238
548
|
});
|
|
239
549
|
}
|
|
240
550
|
|
|
241
551
|
/**
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* @
|
|
245
|
-
* @returns true for a loopback `http(s)` URL
|
|
552
|
+
* 值是否是本机 `http(s)` 地址——Host 只接受这种形状,所以字段先拒绝同一批值。
|
|
553
|
+
* @param value - 候选地址
|
|
554
|
+
* @returns 本机 `http(s)` 地址时为 true
|
|
246
555
|
*/
|
|
247
556
|
function isLoopbackUrl(value) {
|
|
248
557
|
let parsed;
|
|
@@ -253,21 +562,42 @@ window.__ModuleLoader__.load({
|
|
|
253
562
|
}
|
|
254
563
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
|
255
564
|
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
256
|
-
return
|
|
565
|
+
return LOOPBACK_HOSTS.includes(host);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 已保存的值该落在那一段上。
|
|
570
|
+
*
|
|
571
|
+
* 0.1 接受任意 `TextDecoder` 标签,所以旧的 `utf8`(或 `gb18030`)要落到正确的段,
|
|
572
|
+
* 而不是显示成 GBK 再在保存时把值改写。别名表由 Host 的 describe 给。
|
|
573
|
+
* @param field - describe 给的字段描述
|
|
574
|
+
* @param value - 已保存的值
|
|
575
|
+
* @returns 选中的 choice 值
|
|
576
|
+
*/
|
|
577
|
+
function choiceValue(field, value) {
|
|
578
|
+
const normalize = (raw) => String(raw ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
579
|
+
const key = normalize(value);
|
|
580
|
+
const exact = field.choices.find((choice) => normalize(choice.value) === key);
|
|
581
|
+
if (exact !== undefined) return exact.value;
|
|
582
|
+
const alias = field.aliases === undefined ? undefined : field.aliases[key];
|
|
583
|
+
return alias === undefined ? field.choices[0].value : alias;
|
|
257
584
|
}
|
|
258
585
|
|
|
259
586
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
587
|
+
* 保存会被拒绝的第一个草稿字段。
|
|
588
|
+
*
|
|
589
|
+
* 只管「不借助文件系统就能判」的那些:整数范围在这里挡,地址留到失焦或保存前的 Host
|
|
590
|
+
* 校验(`isLoopbackUrl` 判得了形状,但把红字提前到每敲一个字符就跳出来只会烦人)。
|
|
591
|
+
* @param staged - 路径键到草稿
|
|
592
|
+
* @param fields - 路径键到 describe 字段
|
|
593
|
+
* @returns 路径键;全部可接受时 undefined
|
|
263
594
|
*/
|
|
264
|
-
function invalidField(staged) {
|
|
595
|
+
function invalidField(staged, fields) {
|
|
265
596
|
for (const [key, entry] of Object.entries(staged)) {
|
|
266
597
|
if (entry.op === 'unset') continue;
|
|
267
|
-
const
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
if (field.kind === 'count') {
|
|
598
|
+
const field = fields.get(key);
|
|
599
|
+
if (field === undefined) continue;
|
|
600
|
+
if (field.type === 'count') {
|
|
271
601
|
const value = Number(entry.text);
|
|
272
602
|
if (!Number.isInteger(value) || value < 1 || value > field.max) return key;
|
|
273
603
|
}
|
|
@@ -276,179 +606,497 @@ window.__ModuleLoader__.load({
|
|
|
276
606
|
}
|
|
277
607
|
|
|
278
608
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* @
|
|
609
|
+
* 探测结果:一行一个引擎,通过的带版本,失败的不带错误码——修法比错误码有用。
|
|
610
|
+
*
|
|
611
|
+
* 修法挂在失败的那一行下面,所以两处都不再重复引擎名(原来两行结论下面拼一行
|
|
612
|
+
* 「Everything 已安装但没在运行:启动它。 · 下载 Anytxt」,既重复又对不上是哪个引擎)。
|
|
613
|
+
* @param t - 字典查找
|
|
614
|
+
* @param body - 路由的 JSON 回答
|
|
615
|
+
* @returns 每个引擎的状态行与(可选的)修法
|
|
284
616
|
*/
|
|
285
617
|
function probeResult(t, body) {
|
|
286
618
|
const everything = body.everything ?? {};
|
|
287
619
|
const anytxt = body.anytxt ?? {};
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
620
|
+
return [
|
|
621
|
+
{
|
|
622
|
+
key: 'everything',
|
|
623
|
+
text: everything.ok
|
|
624
|
+
? `${t('everythingOk')}${everything.version ? ` · ${everything.version}` : ''}`
|
|
625
|
+
: t('everythingDown'),
|
|
626
|
+
fix: everything.ok ? undefined : everythingGuidance(t, everything),
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
key: 'anytxt',
|
|
630
|
+
text: anytxt.ok ? t('anytxtOk') : t('anytxtDown'),
|
|
631
|
+
fix: anytxt.ok ? undefined : { key: 'anytxt', text: t('downloadAnytxt'), href: DOWNLOADS.anytxt },
|
|
632
|
+
},
|
|
297
633
|
];
|
|
298
|
-
const guidance = [];
|
|
299
|
-
if (!everything.ok) guidance.push({ key: 'everything', text: t('downloadEverything'), href: DOWNLOADS.everything });
|
|
300
|
-
if (!anytxt.ok) guidance.push({ key: 'anytxt', text: t('downloadAnytxt'), href: DOWNLOADS.anytxt });
|
|
301
|
-
return { text: parts.join(' · '), guidance };
|
|
302
634
|
}
|
|
303
635
|
|
|
304
636
|
/**
|
|
305
|
-
*
|
|
306
|
-
* @param
|
|
307
|
-
* @
|
|
637
|
+
* 修好一次失败探测所需的唯一动作。
|
|
638
|
+
* @param t - 页面的翻译函数
|
|
639
|
+
* @param everything - 路由的 Everything 回答,含 `detail`
|
|
640
|
+
* @returns 一行指引,只有下载才是解法时才带链接
|
|
641
|
+
*/
|
|
642
|
+
function everythingGuidance(t, everything) {
|
|
643
|
+
if (everything.detail === 'not-running') return { key: 'everything', text: t('fixEverythingNotRunning') };
|
|
644
|
+
if (everything.detail === 'not-installed') {
|
|
645
|
+
return { key: 'everything', text: t('fixEverythingNotInstalled'), href: DOWNLOADS.everything };
|
|
646
|
+
}
|
|
647
|
+
if (everything.detail === 'es-missing') {
|
|
648
|
+
return { key: 'everything', text: t('fixEverythingEsMissing'), href: DOWNLOADS.everything };
|
|
649
|
+
}
|
|
650
|
+
return { key: 'everything', text: t('downloadEverything'), href: DOWNLOADS.everything };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* 一个小节:小标题、可选的后端选择器、官方网格上的字段,以及网格下的附加块。
|
|
655
|
+
* @param props - 标题、标题 id、选择器、网格样式、字段与页脚
|
|
656
|
+
* @returns 小节元素
|
|
308
657
|
*/
|
|
309
658
|
function Section(props) {
|
|
310
659
|
return h('section', { style: SECTION_STYLE, 'aria-labelledby': props.id },
|
|
311
660
|
h('h3', { id: props.id, style: H3_STYLE }, props.title),
|
|
312
|
-
props.
|
|
313
|
-
|
|
314
|
-
|
|
661
|
+
props.header === undefined ? null : props.header,
|
|
662
|
+
h('div', { style: props.grid }, props.children),
|
|
663
|
+
props.footer === undefined ? null : props.footer,
|
|
315
664
|
);
|
|
316
665
|
}
|
|
317
666
|
|
|
318
667
|
/**
|
|
319
|
-
*
|
|
320
|
-
*
|
|
668
|
+
* 二选一字段。取值集合是封闭的,所以用控件而不是文本框——文本框会让用户敲出一个
|
|
669
|
+
* 静默回退到 UTF-8 的错值。
|
|
321
670
|
*/
|
|
322
|
-
function
|
|
323
|
-
const { t, id, value,
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
671
|
+
function ChoiceField(props) {
|
|
672
|
+
const { t, field, id, value, invalid, disabled, onEdit } = props;
|
|
673
|
+
const label = t(field.labelKey);
|
|
674
|
+
const options = field.choices.map((choice) => ({
|
|
675
|
+
value: choice.value,
|
|
676
|
+
label: choice.labelKey === undefined ? String(choice.value) : t(choice.labelKey),
|
|
327
677
|
}));
|
|
328
678
|
return h('div', { style: { minWidth: 0 } },
|
|
329
679
|
h('div', { style: ROW_STYLE },
|
|
330
|
-
h('span', { style: LABEL_STYLE },
|
|
331
|
-
overridden ? h(Tag, { tone: 'neutral' }, t('overridden')) : null,
|
|
332
|
-
overridden
|
|
333
|
-
? h(Button, { variant: 'ghost', size: 'sm', disabled, onClick: onReset }, t('reset'))
|
|
334
|
-
: null,
|
|
680
|
+
h('span', { style: LABEL_STYLE }, label),
|
|
335
681
|
),
|
|
336
682
|
h('div', { style: { marginTop: '6px' } },
|
|
337
683
|
h(SegmentedControl, {
|
|
338
684
|
id,
|
|
339
|
-
value:
|
|
685
|
+
value: choiceValue(field, value),
|
|
340
686
|
options,
|
|
341
|
-
onChange,
|
|
342
|
-
label
|
|
687
|
+
onChange: onEdit,
|
|
688
|
+
label,
|
|
343
689
|
disabled,
|
|
344
690
|
}),
|
|
345
691
|
),
|
|
346
|
-
h('p', { style: HINT_STYLE }, t(
|
|
692
|
+
h('p', { style: HINT_STYLE }, t(field.hintKey)),
|
|
693
|
+
invalid ? h('p', { role: 'alert', style: HINT_STYLE }, t('invalidValue')) : null,
|
|
347
694
|
);
|
|
348
695
|
}
|
|
349
696
|
|
|
350
697
|
/**
|
|
351
|
-
*
|
|
352
|
-
* @param props -
|
|
353
|
-
* @returns
|
|
698
|
+
* 两种表面共用的配置表单。
|
|
699
|
+
* @param props - 语言、视图与配置表单
|
|
700
|
+
* @returns 页面的 React 节点
|
|
354
701
|
*/
|
|
355
702
|
function ConfigPage(props) {
|
|
356
703
|
const { t, form, view } = props;
|
|
357
704
|
const [staged, setStaged] = useState({});
|
|
358
705
|
const [saving, setSaving] = useState(false);
|
|
359
706
|
const [failed, setFailed] = useState(false);
|
|
707
|
+
// 探测:idle → running → done / rejected(地址被拒)/ path-missing(es.exe 不在)/
|
|
708
|
+
// path-not-file(es.exe 位置上是个目录)/ failed。
|
|
360
709
|
const [probe, setProbe] = useState({ status: 'idle' });
|
|
710
|
+
const [described, setDescribed] = useState({ status: 'idle' });
|
|
711
|
+
const [reload, setReload] = useState(0);
|
|
712
|
+
// 保存前校验:idle → running → idle / failed。失败时由弹窗问一次「仍然保存」。
|
|
713
|
+
const [check, setCheck] = useState({ status: 'idle' });
|
|
714
|
+
// 只在没有 Modal 的部署里用:按过一次「仍然保存」后,下一次保存跳过校验。
|
|
715
|
+
const [override, setOverride] = useState(false);
|
|
716
|
+
// 统一重置:idle → running → done / failed。没有 Modal 的部署才多一个 confirm(点两下)。
|
|
717
|
+
const [resetState, setResetState] = useState({ status: 'idle' });
|
|
718
|
+
// 开着的弹窗:{ kind: 'reset' }、{ kind: 'save-anyway', message }、
|
|
719
|
+
// { kind: 'blocked', message } 或 { kind: 'probe', field }。
|
|
720
|
+
const [dialog, setDialog] = useState(null);
|
|
721
|
+
// 失焦校验的结论:路径键 → 'missing' | 'not-file' | 'not-dir' | 'bad-url'。值一改就清掉。
|
|
722
|
+
const [pathChecks, setPathChecks] = useState({});
|
|
723
|
+
// 列数由宽度决定:656px 以下一列,以上两列,两列时每列 1fr(全屏就是 2×542)。
|
|
724
|
+
const [wide, setWide] = useState(true);
|
|
361
725
|
|
|
362
|
-
|
|
726
|
+
useEffect(() => {
|
|
727
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
|
|
728
|
+
const query = window.matchMedia(WIDE_QUERY);
|
|
729
|
+
setWide(query.matches);
|
|
730
|
+
const onChange = (event) => setWide(event.matches);
|
|
731
|
+
query.addEventListener('change', onChange);
|
|
732
|
+
return () => query.removeEventListener('change', onChange);
|
|
733
|
+
}, []);
|
|
734
|
+
|
|
735
|
+
// 后端描述只在页面视图里取;拿不到就显示一行可重试的提示,而不是空白表单。
|
|
736
|
+
//
|
|
737
|
+
// 依赖数组里只能放「什么时候该重取」的因素(视图、重试次数),绝不能放结果状态:
|
|
738
|
+
// 一旦 status 进了依赖,effect 里那句「标记为读取中」会立刻触发一次 cleanup,把本次
|
|
739
|
+
// 请求的 live 置 false,于是请求回来时被丢掉,页面就永远停在「正在读取后端列表…」。
|
|
740
|
+
useEffect(() => {
|
|
741
|
+
if (view !== 'page') return undefined;
|
|
742
|
+
let live = true;
|
|
743
|
+
fetch(describeUrl(), { cache: 'no-store' })
|
|
744
|
+
.then((response) => {
|
|
745
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
746
|
+
return response.json();
|
|
747
|
+
})
|
|
748
|
+
.then((body) => {
|
|
749
|
+
if (live) setDescribed({ status: 'done', body });
|
|
750
|
+
})
|
|
751
|
+
.catch((error) => {
|
|
752
|
+
if (!live) return;
|
|
753
|
+
setDescribed({ status: 'failed', message: error instanceof Error ? error.message : String(error) });
|
|
754
|
+
});
|
|
755
|
+
return () => {
|
|
756
|
+
live = false;
|
|
757
|
+
};
|
|
758
|
+
}, [view, reload]);
|
|
759
|
+
|
|
760
|
+
// 摘要渲染拿不到 form,所以它不能假装持有值状态。
|
|
363
761
|
if (view === 'summary') return h('span', null, t('description'));
|
|
364
762
|
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
const primitivesReady = [SettingsForm, SettingsValueField, Button, SegmentedControl, Tag]
|
|
763
|
+
// 部署里的 primitives 可能比这些控件旧:这时显示「不可用」那一行,而不是让宿主页面
|
|
764
|
+
// 的树里抛异常。每一项都必须在,但不能按「是不是函数」判断——`Button` 是
|
|
765
|
+
// `forwardRef` 的返回值(对象)。
|
|
766
|
+
const primitivesReady = [SettingsForm, SettingsValueField, Button, SegmentedControl]
|
|
370
767
|
.every((primitive) => primitive !== undefined && primitive !== null);
|
|
371
768
|
if (!primitivesReady) {
|
|
372
769
|
return h('p', { role: 'status' }, t('unavailable'));
|
|
373
770
|
}
|
|
374
771
|
|
|
375
772
|
const values = form?.state.value ?? {};
|
|
376
|
-
const user = form?.state.user ?? {};
|
|
377
773
|
const writable = form?.state.writable ?? false;
|
|
378
|
-
|
|
774
|
+
// 弹窗与 `Toast` 一样不是每个部署都有:没有就退回页内提示,而不是让这棵 React 树抛异常。
|
|
775
|
+
const dialogReady = Modal !== undefined && Modal !== null;
|
|
776
|
+
const sections = described.status === 'done' ? described.body.sections ?? [] : [];
|
|
777
|
+
const resetInfo = described.status === 'done' ? described.body.reset : undefined;
|
|
778
|
+
// 所有后端的所有字段,按路径键索引:校验和取值都要用它,不能只看当前选中后端的。
|
|
779
|
+
const fields = new Map();
|
|
780
|
+
for (const section of sections) {
|
|
781
|
+
for (const field of section.fields ?? []) fields.set(pathKey(field.path), field);
|
|
782
|
+
}
|
|
783
|
+
const invalid = invalidField(staged, fields);
|
|
379
784
|
const dirty = Object.keys(staged).length > 0;
|
|
380
|
-
const busy = saving || probe.status === 'running';
|
|
785
|
+
const busy = saving || probe.status === 'running' || check.status === 'running' || resetState.status === 'running';
|
|
381
786
|
const disabled = !writable || busy;
|
|
382
787
|
|
|
383
|
-
const edit = (
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
788
|
+
const edit = (path, text, numeric) => {
|
|
789
|
+
const key = pathKey(path);
|
|
790
|
+
// 清空 = unset(回落到 profile 层或默认值),不是写一个空覆盖值把 profile 挡住。
|
|
791
|
+
const cleared = String(text ?? '').trim() === '';
|
|
792
|
+
setStaged((previous) => ({
|
|
793
|
+
...previous,
|
|
794
|
+
[key]: cleared ? { op: 'unset' } : { op: 'set', text, numeric: numeric === true },
|
|
795
|
+
}));
|
|
796
|
+
// 草稿一变,上一次的「仍然保存」、校验结论、失焦结论与开着的弹窗就都过期了。
|
|
797
|
+
setCheck({ status: 'idle' });
|
|
798
|
+
setOverride(false);
|
|
799
|
+
setResetState({ status: 'idle' });
|
|
800
|
+
setDialog(null);
|
|
801
|
+
clearPathCheck(key);
|
|
802
|
+
};
|
|
803
|
+
const valueOf = (path) => {
|
|
804
|
+
const entry = staged[pathKey(path)];
|
|
805
|
+
if (entry === undefined) return pathValue(values, path);
|
|
806
|
+
// 清空的字段显示成空串——别把已保存的值弹回输入框,那会让「清空」看起来没生效。
|
|
807
|
+
return entry.op === 'unset' ? '' : entry.text;
|
|
808
|
+
};
|
|
809
|
+
// 选中的后端:草稿优先,空值回落到 describe 的第一个后端。
|
|
810
|
+
const backendOf = (section) => {
|
|
811
|
+
if (section.backendPath === undefined) return undefined;
|
|
812
|
+
const value = String(valueOf(section.backendPath) ?? '');
|
|
813
|
+
if (value !== '') return value;
|
|
814
|
+
return section.backends?.[0]?.name;
|
|
815
|
+
};
|
|
816
|
+
const visibleFields = (section) => (section.fields ?? [])
|
|
817
|
+
.filter((field) => field.backend === undefined || field.backend === backendOf(section));
|
|
818
|
+
|
|
819
|
+
const clearPathCheck = (key) => {
|
|
820
|
+
setPathChecks((previous) => {
|
|
821
|
+
if (previous[key] === undefined) return previous;
|
|
388
822
|
const next = { ...previous };
|
|
389
823
|
delete next[key];
|
|
390
824
|
return next;
|
|
825
|
+
});
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* 失焦时判一次这个字段:路径问 Host 还在不在,地址在本地判形状。
|
|
830
|
+
*
|
|
831
|
+
* 边打字边校验会在半截值上一直报错,所以只在这里做;路径那条请求带 `checkOnly`,
|
|
832
|
+
* 不花一次 es.exe 启动。取不到答案(网络失败)时不标红——那是 Host 的问题,不是值的问题。
|
|
833
|
+
* @param field - describe 给的字段
|
|
834
|
+
*/
|
|
835
|
+
const checkField = async (field) => {
|
|
836
|
+
const key = pathKey(field.path);
|
|
837
|
+
const text = String(valueOf(field.path) ?? '').trim();
|
|
838
|
+
if (field.localUrl === true) {
|
|
839
|
+
if (text === '' || isLoopbackUrl(text)) clearPathCheck(key);
|
|
840
|
+
else setPathChecks((previous) => ({ ...previous, [key]: 'bad-url' }));
|
|
841
|
+
return;
|
|
391
842
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
843
|
+
const entries = draftEntries(field, text);
|
|
844
|
+
if (entries.length === 0) {
|
|
845
|
+
clearPathCheck(key);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const params = new URLSearchParams();
|
|
849
|
+
for (const [name, value] of entries) params.append(name, value);
|
|
850
|
+
params.set(CHECK_ONLY_PARAM, '1');
|
|
851
|
+
let reason;
|
|
852
|
+
try {
|
|
853
|
+
const response = await fetch(`${api(STATUS_ROUTE)}?${params}`, { cache: 'no-store' });
|
|
854
|
+
if (response.ok) {
|
|
855
|
+
clearPathCheck(key);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const body = await response.json().catch(() => undefined);
|
|
859
|
+
reason = body?.code === 'ES_PATH_NOT_FILE' ? 'not-file'
|
|
860
|
+
: body?.code === 'DIR_NOT_DIRECTORY' ? 'not-dir' : 'missing';
|
|
861
|
+
} catch {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
setPathChecks((previous) => ({ ...previous, [key]: reason }));
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
// 字段原件用 `.field + .field` 画分隔线,直接进网格会在格子里画一条半宽横线。
|
|
868
|
+
// 所以每个字段都包一层普通 div,官方 subagent 页也是这么做的。失焦挂在这一层上:
|
|
869
|
+
// 原件没有 onBlur 入参,而 focusout 会从输入框冒到这里。
|
|
870
|
+
const cell = (field) => h('div', {
|
|
871
|
+
key: pathKey(field.path),
|
|
872
|
+
style: CELL_STYLE,
|
|
873
|
+
onBlur: () => { void checkField(field); },
|
|
874
|
+
}, fieldNode(field));
|
|
875
|
+
|
|
876
|
+
/** 失焦结论 → 红字文案。 */
|
|
877
|
+
const invalidCopy = (field, reason) => {
|
|
878
|
+
if (reason === 'not-file') return t('invalidNotFile');
|
|
879
|
+
if (reason === 'not-dir') return t('invalidNotDir');
|
|
880
|
+
if (reason === 'bad-url') return t('invalidLocal');
|
|
881
|
+
if (field.verifyPath === true) return t('invalidFile');
|
|
882
|
+
if (field.verifyDir === true) return t('invalidDir');
|
|
883
|
+
return t('invalidValue');
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
// 字段上不挂徽标与重置:这个插件只有一个写入面,逐行标「本页设置」没有信息量;
|
|
887
|
+
// 回默认值交给描述下方那个统一按钮,那里能一次说清会动到什么。
|
|
888
|
+
const textField = (field) => {
|
|
889
|
+
const key = pathKey(field.path);
|
|
890
|
+
// 失焦校验的结论与整数越界共用原件的 invalid:原件会把说明换成这行红字。
|
|
891
|
+
const reason = pathChecks[key];
|
|
892
|
+
const invalidLabel = invalid === key ? t('invalidNumber') : invalidCopy(field, reason);
|
|
893
|
+
return h(SettingsValueField, {
|
|
894
|
+
key,
|
|
895
|
+
id: `plugin-config-local-search-${key}`,
|
|
896
|
+
label: t(field.labelKey),
|
|
897
|
+
hint: t(field.hintKey),
|
|
898
|
+
text: String(valueOf(field.path) ?? ''),
|
|
899
|
+
overridden: false,
|
|
900
|
+
invalid: reason !== undefined || invalid === key,
|
|
901
|
+
invalidLabel,
|
|
902
|
+
disabled,
|
|
903
|
+
numeric: field.type === 'count',
|
|
904
|
+
onEdit: (next) => edit(field.path, next, field.type === 'count'),
|
|
905
|
+
});
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const fieldNode = (field) => (field.type === 'choice'
|
|
909
|
+
? h(ChoiceField, {
|
|
910
|
+
key: pathKey(field.path),
|
|
911
|
+
t,
|
|
912
|
+
field,
|
|
913
|
+
id: `plugin-config-local-search-${pathKey(field.path)}`,
|
|
914
|
+
value: valueOf(field.path),
|
|
915
|
+
invalid: invalid === pathKey(field.path),
|
|
916
|
+
disabled,
|
|
917
|
+
onEdit: (next) => edit(field.path, next, false),
|
|
918
|
+
})
|
|
919
|
+
: textField(field));
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* 保存前问一次 Host:草稿里的地址能通、`es.exe` 路径真的存在。
|
|
923
|
+
*
|
|
924
|
+
* 校验只覆盖「能安全校验」的字段(Host 侧不 spawn 任何草稿路径)。返回 ok 就直接写;
|
|
925
|
+
* 失败时不写,只把结论挂出来。
|
|
926
|
+
*
|
|
927
|
+
* `hard` 区分两种失败:400(路径上没有文件 / 目录不存在 / 地址被拒)是值本身错了,
|
|
928
|
+
* 没有「稍后会好」的可能,给不出「仍然保存」;引擎不可用与请求失败是暂时的,可以强行保存。
|
|
929
|
+
* @returns 校验结论、是否硬错,与失败文案
|
|
930
|
+
*/
|
|
931
|
+
const verify = async (draft) => {
|
|
932
|
+
const query = verifyQuery(draft, fields);
|
|
933
|
+
if (query === '') return { ok: true };
|
|
934
|
+
setCheck({ status: 'running' });
|
|
935
|
+
let body;
|
|
936
|
+
let response;
|
|
937
|
+
try {
|
|
938
|
+
response = await fetch(`${api(STATUS_ROUTE)}?${query}`, { cache: 'no-store' });
|
|
939
|
+
body = await response.json().catch(() => undefined);
|
|
940
|
+
} catch (error) {
|
|
941
|
+
return { ok: false, reason: `${t('testFailed')}: ${error instanceof Error ? error.message : String(error)}` };
|
|
942
|
+
}
|
|
943
|
+
if (response.status === 400) {
|
|
944
|
+
const reason = body?.code === 'ES_PATH_REJECTED' ? t('savePathMissing')
|
|
945
|
+
: body?.code === 'ES_PATH_NOT_FILE' ? t('savePathNotFile')
|
|
946
|
+
: body?.code === 'DIR_REJECTED' ? t('saveDirMissing')
|
|
947
|
+
: body?.code === 'DIR_NOT_DIRECTORY' ? t('saveDirNotDir') : t('saveEndpointInvalid');
|
|
948
|
+
return { ok: false, hard: true, reason };
|
|
949
|
+
}
|
|
950
|
+
if (!response.ok) return { ok: false, reason: `${t('testFailed')}: HTTP ${response.status}` };
|
|
951
|
+
if (body?.ok === true) return { ok: true, drafts: body.drafts };
|
|
952
|
+
const down = [body?.everything?.ok === false ? t('everythingDown') : '', body?.anytxt?.ok === false ? t('anytxtDown') : '']
|
|
953
|
+
.filter((line) => line !== '');
|
|
954
|
+
return { ok: false, reason: `${t('saveEngineDown')}${down.join(t('listSeparator'))}` };
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* 重置的原子写入:清掉引擎与上限,再按探测结果把 esPath 与 Anytxt 地址填回去。
|
|
959
|
+
*
|
|
960
|
+
* 它就是「把首用自动配置重跑一遍」。`scopes` 不在清空列表里(那是手填的机器路径)。
|
|
961
|
+
*/
|
|
962
|
+
const writeReset = async () => {
|
|
963
|
+
if (form === undefined || resetInfo === undefined) return;
|
|
964
|
+
setResetState({ status: 'running' });
|
|
965
|
+
let probed;
|
|
966
|
+
try {
|
|
967
|
+
const response = await fetch(statusUrl(''), { cache: 'no-store' });
|
|
968
|
+
probed = response.ok ? await response.json() : undefined;
|
|
969
|
+
} catch {
|
|
970
|
+
probed = undefined;
|
|
971
|
+
}
|
|
972
|
+
const ops = (resetInfo.clear ?? []).map((path) => ({ op: 'unset', path }));
|
|
973
|
+
for (const fill of resetInfo.fills ?? []) {
|
|
974
|
+
const value = fillValue(probed, fill.from);
|
|
975
|
+
if (value === undefined) continue;
|
|
976
|
+
// 探到的值与默认一致就不写:写一条等于默认的覆盖,只会让 profile 变脏。
|
|
977
|
+
if (fill.default !== undefined && String(value) === String(fill.default)) continue;
|
|
978
|
+
ops.push({ op: 'set', path: fill.path, value });
|
|
979
|
+
}
|
|
980
|
+
if (resetInfo.marker !== undefined) ops.push({ op: 'set', path: resetInfo.marker, value: true });
|
|
981
|
+
let landed = false;
|
|
982
|
+
try {
|
|
983
|
+
landed = await form.mutate(ops);
|
|
984
|
+
} catch {
|
|
985
|
+
landed = false;
|
|
986
|
+
}
|
|
987
|
+
if (landed) {
|
|
988
|
+
setStaged({});
|
|
989
|
+
setPathChecks({});
|
|
990
|
+
}
|
|
991
|
+
setResetState({ status: landed ? 'done' : 'failed' });
|
|
397
992
|
};
|
|
398
|
-
// The badge and the reset offer follow the saved user layer, not the
|
|
399
|
-
// staged edit: hiding them the moment a reset is staged would leave no
|
|
400
|
-
// way back before saving.
|
|
401
|
-
const overriddenOf = (key) => Object.prototype.hasOwnProperty.call(user, key);
|
|
402
|
-
|
|
403
|
-
const textField = (key) => h(SettingsValueField, {
|
|
404
|
-
key,
|
|
405
|
-
id: `plugin-config-local-search-${key}`,
|
|
406
|
-
label: t(`${key}Label`),
|
|
407
|
-
hint: t(`${key}Hint`),
|
|
408
|
-
text: valueOf(key),
|
|
409
|
-
overridden: overriddenOf(key),
|
|
410
|
-
invalid: invalid === key,
|
|
411
|
-
overriddenLabel: t('overridden'),
|
|
412
|
-
resetLabel: t('reset'),
|
|
413
|
-
invalidLabel: t(FIELDS[key].kind === 'count' ? 'invalidNumber' : 'invalidValue'),
|
|
414
|
-
disabled,
|
|
415
|
-
numeric: FIELDS[key].kind === 'count',
|
|
416
|
-
onEdit: (next) => edit(key, next),
|
|
417
|
-
onReset: () => reset(key),
|
|
418
|
-
});
|
|
419
993
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
994
|
+
/**
|
|
995
|
+
* 重置按钮:只负责开确认弹窗(或让没有 Modal 的部署点两下)。真写由弹窗里的确认触发。
|
|
996
|
+
*/
|
|
997
|
+
const runReset = () => {
|
|
998
|
+
if (form === undefined || busy || resetInfo === undefined) return;
|
|
999
|
+
if (dialogReady) {
|
|
1000
|
+
setDialog({ kind: 'reset' });
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (resetState.status !== 'confirm') {
|
|
1004
|
+
setResetState({ status: 'confirm' });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
void writeReset();
|
|
1008
|
+
};
|
|
424
1009
|
|
|
425
|
-
|
|
1010
|
+
/**
|
|
1011
|
+
* @param force - 用户在弹窗里选了「仍然保存」,跳过这次校验直接写
|
|
1012
|
+
*/
|
|
1013
|
+
const save = async (force) => {
|
|
426
1014
|
if (form === undefined || busy || !dirty || invalid !== undefined) return;
|
|
1015
|
+
// 路径先改成标准写法再校验、再写:`d:/alpha/` 落盘的是 `D:\alpha`,草稿也跟着更新,
|
|
1016
|
+
// 所以用户看到的就是存进去的样子。
|
|
1017
|
+
const { next: normalized, changed } = normalizeStaged(staged, fields);
|
|
1018
|
+
let draft = normalized;
|
|
1019
|
+
if (changed) setStaged(draft);
|
|
1020
|
+
// 没有 Modal 的部署靠按钮上的「仍然保存」表达同一件事:第二次按就不再校验。
|
|
1021
|
+
const skipCheck = force === true || (dialogReady !== true && override === true);
|
|
1022
|
+
if (skipCheck !== true) {
|
|
1023
|
+
const result = await verify(draft);
|
|
1024
|
+
if (!result.ok) {
|
|
1025
|
+
// 校验的 running 必须在这里收掉:它会算进 `busy`,否则接下来的写入会被自己挡住。
|
|
1026
|
+
setCheck({ status: 'idle' });
|
|
1027
|
+
// 没有 Modal 的部署沿用页内那一行红字;硬错则不再把保存按钮改成「仍然保存」。
|
|
1028
|
+
if (!dialogReady) {
|
|
1029
|
+
setCheck({ status: 'failed', message: `${result.reason}${t('saveNoWrite')}` });
|
|
1030
|
+
setOverride(result.hard !== true);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
setDialog(result.hard === true
|
|
1034
|
+
? { kind: 'blocked', message: `${result.reason}${t('fullStop')}` }
|
|
1035
|
+
: { kind: 'save-anyway', message: `${result.reason}${t('fullStop')}` });
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
// Host 会把草稿解析成磁盘上的真实写法(`d:/alpha` → `D:\Alpha`),回填后再落盘。
|
|
1039
|
+
const canonical = applyCanonical(draft, fields, result.drafts);
|
|
1040
|
+
if (canonical !== draft) {
|
|
1041
|
+
draft = canonical;
|
|
1042
|
+
setStaged(draft);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
setCheck({ status: 'idle' });
|
|
1046
|
+
setDialog(null);
|
|
427
1047
|
setSaving(true);
|
|
428
1048
|
setFailed(false);
|
|
429
1049
|
let landed = false;
|
|
430
1050
|
try {
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
landed = await form.mutate(operations(
|
|
1051
|
+
// 不显式带 revision:表单会用它知道的最新 revision 圈住这次写入,所以这个保存
|
|
1052
|
+
// 不会因为读到的是旧值而被判过期。
|
|
1053
|
+
landed = await form.mutate(operations(draft));
|
|
434
1054
|
} catch {
|
|
435
1055
|
landed = false;
|
|
436
1056
|
}
|
|
437
1057
|
setSaving(false);
|
|
438
|
-
if (landed)
|
|
439
|
-
|
|
1058
|
+
if (landed) {
|
|
1059
|
+
setStaged({});
|
|
1060
|
+
setOverride(false);
|
|
1061
|
+
setPathChecks({});
|
|
1062
|
+
} else {
|
|
1063
|
+
setFailed(true);
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
/** 输入框里的 `es.exe` 路径;空串表示用已保存的(或自动查找)。 */
|
|
1068
|
+
const esPathDraft = () => {
|
|
1069
|
+
for (const field of fields.values()) {
|
|
1070
|
+
if (field.verifyPath !== true) continue;
|
|
1071
|
+
const entries = draftEntries(field, valueOf(field.path));
|
|
1072
|
+
return entries.length === 0 ? '' : entries[0][1];
|
|
1073
|
+
}
|
|
1074
|
+
return '';
|
|
440
1075
|
};
|
|
441
1076
|
|
|
442
|
-
const runProbe = async () => {
|
|
1077
|
+
const runProbe = async (field) => {
|
|
443
1078
|
if (probe.status === 'running') return;
|
|
444
1079
|
setProbe({ status: 'running' });
|
|
445
|
-
const target = valueOf('
|
|
1080
|
+
const target = String(valueOf(field.path) ?? '').trim();
|
|
446
1081
|
try {
|
|
447
|
-
|
|
1082
|
+
// 连输入框里的 `es.exe` 路径一起交给 Host:它只查存在性,所以路径打错时这次
|
|
1083
|
+
// 测试会直接报出来,而不是拿已保存的那个(或自动找到的那个)报「正常」。
|
|
1084
|
+
const response = await fetch(statusUrl(target, esPathDraft()), { cache: 'no-store' });
|
|
448
1085
|
const body = await response.json().catch(() => undefined);
|
|
449
|
-
if (response.status === 400
|
|
450
|
-
|
|
451
|
-
|
|
1086
|
+
if (response.status === 400) {
|
|
1087
|
+
if (body?.code === 'ANYTXT_URL_REJECTED') {
|
|
1088
|
+
setProbe({ status: 'rejected' });
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
// 路径的两种拒绝要分开说:不存在与「是个目录」是两种改法。
|
|
1092
|
+
if (body?.code === 'ES_PATH_REJECTED') {
|
|
1093
|
+
setProbe({ status: 'path-missing' });
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
if (body?.code === 'ES_PATH_NOT_FILE') {
|
|
1097
|
+
setProbe({ status: 'path-not-file' });
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
452
1100
|
}
|
|
453
1101
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
454
1102
|
setProbe({ status: 'done', body });
|
|
@@ -457,45 +1105,231 @@ window.__ModuleLoader__.load({
|
|
|
457
1105
|
}
|
|
458
1106
|
};
|
|
459
1107
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
1108
|
+
/**
|
|
1109
|
+
* 测试按钮:开弹窗并立刻探一次。没有 Modal 的部署把结果留在页内。
|
|
1110
|
+
* @param field - 带 `probeEndpoint` 的那个字段
|
|
1111
|
+
*/
|
|
1112
|
+
const openProbe = (field) => {
|
|
1113
|
+
if (dialogReady) setDialog({ kind: 'probe', field });
|
|
1114
|
+
void runProbe(field);
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* 探测的单行结论:被拒的草稿、请求失败。
|
|
1119
|
+
*
|
|
1120
|
+
* 放进弹窗的 `description`,字号与「重置」「仍要保存?」的正文一致;探测跑完时没有
|
|
1121
|
+
* 单行结论(那时正文是列表)。
|
|
1122
|
+
* @returns 一行文字;没有时为 undefined
|
|
1123
|
+
*/
|
|
1124
|
+
const probeMessage = () => {
|
|
1125
|
+
if (probe.status === 'path-missing') return `${t('savePathMissing')}${t('fullStop')}`;
|
|
1126
|
+
if (probe.status === 'path-not-file') return `${t('savePathNotFile')}${t('fullStop')}`;
|
|
1127
|
+
if (probe.status === 'rejected') return t('testLocalOnly');
|
|
1128
|
+
if (probe.status === 'failed') return `${t('testFailed')}${t('colon')}${probe.message}${t('fullStop')}`;
|
|
1129
|
+
return undefined;
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* 探测结果的列表:每个引擎一行,失败的那行下面再挂一句可照做的修法。
|
|
1134
|
+
* @returns 列表节点;探测没跑完时为空数组
|
|
1135
|
+
*/
|
|
1136
|
+
const probeBody = () => {
|
|
1137
|
+
if (probe.status !== 'done') return [];
|
|
1138
|
+
const rows = probeResult(t, probe.body);
|
|
1139
|
+
return [h('ul', { key: 'status', style: PROBE_LIST_STYLE }, rows.map((row) => h('li', {
|
|
1140
|
+
key: row.key,
|
|
1141
|
+
style: PROBE_ITEM_STYLE,
|
|
1142
|
+
},
|
|
1143
|
+
h('span', null, row.text),
|
|
1144
|
+
row.fix === undefined ? null : h('span', { key: 'fix', style: PROBE_FIX_STYLE },
|
|
1145
|
+
row.fix.href === undefined
|
|
1146
|
+
? row.fix.text
|
|
1147
|
+
: h('a', { href: row.fix.href, target: '_blank', rel: 'noreferrer noopener', style: LINK_STYLE }, row.fix.text)),
|
|
1148
|
+
)))];
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* 没有 `Modal` 的部署用的页内提示:列表或那一行结论,二者其一。
|
|
1153
|
+
*/
|
|
1154
|
+
const probeLines = () => {
|
|
1155
|
+
const body = probeBody();
|
|
1156
|
+
if (body.length > 0) return body;
|
|
1157
|
+
const message = probeMessage();
|
|
1158
|
+
return message === undefined ? [] : [h('p', { key: 'message', style: HINT_STYLE }, message)];
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
// 测试按钮只出现在带 `probeEndpoint` 的字段所在小节,且紧跟其网格:它回答的是那个
|
|
1162
|
+
// 地址的问题,不该随页面漂移。表单脏着也可用,因为它探测的是输入框里的值。
|
|
1163
|
+
// 有 Modal 的部署把结论放进弹窗;页内就不再重复一遍。
|
|
1164
|
+
const probeBlock = (section) => {
|
|
1165
|
+
const field = visibleFields(section).find((candidate) => candidate.probeEndpoint === true);
|
|
1166
|
+
if (field === undefined) return undefined;
|
|
1167
|
+
const block = [
|
|
1168
|
+
h('div', { key: 'action', style: { marginTop: '8px' } },
|
|
1169
|
+
h(Button, {
|
|
1170
|
+
variant: 'outline',
|
|
1171
|
+
size: 'sm',
|
|
1172
|
+
disabled,
|
|
1173
|
+
onClick: () => { openProbe(field); },
|
|
1174
|
+
}, probe.status === 'running' ? t('testing') : t('test')),
|
|
1175
|
+
),
|
|
1176
|
+
];
|
|
1177
|
+
if (!dialogReady) {
|
|
1178
|
+
const lines = probeLines();
|
|
1179
|
+
block.push(...(lines.length > 0 ? lines : [h('p', { key: 'message', style: HINT_STYLE }, t('testHint'))]));
|
|
1180
|
+
}
|
|
1181
|
+
return h('div', { key: 'probe' }, block);
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* 当前开着的那个弹窗。
|
|
1186
|
+
*
|
|
1187
|
+
* 全部用 primitives 的 `Modal`:它自己 portal 到 `document.body`,自带 Esc 关闭、Tab 焦点
|
|
1188
|
+
* 陷阱与关闭后的焦点归还,所以本包仍然一行 CSS 都不发。`title` 同时是对话框的可访问名,
|
|
1189
|
+
* `closeLabel` 是关闭按钮的名字,两个都得给。
|
|
1190
|
+
* @returns 弹窗节点;没开或部署没有 `Modal` 时为 null
|
|
1191
|
+
*/
|
|
1192
|
+
const dialogNode = () => {
|
|
1193
|
+
if (!dialogReady || dialog === null) return null;
|
|
1194
|
+
const close = () => { setDialog(null); };
|
|
1195
|
+
const button = (key, variant, label, onClick, extra) => h(Button, {
|
|
1196
|
+
key, variant, onClick, ...extra,
|
|
1197
|
+
}, label);
|
|
1198
|
+
if (dialog.kind === 'reset') {
|
|
1199
|
+
return h(Modal, {
|
|
1200
|
+
key: 'dialog-reset',
|
|
1201
|
+
open: true,
|
|
1202
|
+
title: t('resetTitle'),
|
|
1203
|
+
closeLabel: t('close'),
|
|
1204
|
+
description: t('resetHint'),
|
|
1205
|
+
onClose: close,
|
|
1206
|
+
footer: [
|
|
1207
|
+
button('cancel', 'outline', t('cancel'), close),
|
|
1208
|
+
// 确认即关弹窗:进度由页面上那个按钮显示(重置中…),弹窗不该盖着一页按钮。
|
|
1209
|
+
button('confirm', 'primary', t('reset'), () => {
|
|
1210
|
+
setDialog(null);
|
|
1211
|
+
void writeReset();
|
|
1212
|
+
}),
|
|
1213
|
+
],
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
// 硬错没有「稍后会好」的可能:说清是哪个值不行,只给关闭。
|
|
1217
|
+
if (dialog.kind === 'blocked') {
|
|
1218
|
+
return h(Modal, {
|
|
1219
|
+
key: 'dialog-blocked',
|
|
1220
|
+
open: true,
|
|
1221
|
+
title: t('saveBlockedTitle'),
|
|
1222
|
+
closeLabel: t('close'),
|
|
1223
|
+
description: dialog.message,
|
|
1224
|
+
onClose: close,
|
|
1225
|
+
footer: [button('close', 'primary', t('close'), close)],
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
if (dialog.kind === 'save-anyway') {
|
|
1229
|
+
return h(Modal, {
|
|
1230
|
+
key: 'dialog-save-anyway',
|
|
1231
|
+
open: true,
|
|
1232
|
+
title: t('saveAnywayTitle'),
|
|
1233
|
+
closeLabel: t('close'),
|
|
1234
|
+
description: dialog.message,
|
|
1235
|
+
onClose: close,
|
|
1236
|
+
footer: [
|
|
1237
|
+
button('cancel', 'outline', t('cancel'), close),
|
|
1238
|
+
button('confirm', 'primary', t('saveAnyway'), () => { void save(true); }),
|
|
1239
|
+
],
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
const body = probeBody();
|
|
1243
|
+
return h(Modal, {
|
|
1244
|
+
key: 'dialog-probe',
|
|
1245
|
+
open: true,
|
|
1246
|
+
title: t('test'),
|
|
1247
|
+
closeLabel: t('close'),
|
|
1248
|
+
// 单行结论走 description(和别的弹窗正文同一个字号),探测结果走列表。
|
|
1249
|
+
description: probe.status === 'running' ? t('testing') : probeMessage(),
|
|
1250
|
+
onClose: close,
|
|
1251
|
+
children: body.length > 0 ? h('div', { key: 'body', style: PROBE_BODY_STYLE }, body) : undefined,
|
|
1252
|
+
footer: [
|
|
1253
|
+
button('again', 'outline', t('testAgain'), () => { void runProbe(dialog.field); }, { disabled: probe.status === 'running' }),
|
|
1254
|
+
button('close', 'primary', t('close'), close),
|
|
1255
|
+
],
|
|
1256
|
+
});
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
const sectionNode = (section) => {
|
|
1260
|
+
const backends = section.backends ?? [];
|
|
1261
|
+
const header = backends.length === 0 ? undefined : h('div', { style: SELECTOR_STYLE },
|
|
1262
|
+
h(SegmentedControl, {
|
|
1263
|
+
value: backendOf(section),
|
|
1264
|
+
options: backends.map((backend) => ({
|
|
1265
|
+
value: backend.name,
|
|
1266
|
+
label: backend.labelKey === undefined ? backend.name : t(backend.labelKey),
|
|
1267
|
+
})),
|
|
1268
|
+
onChange: (next) => edit(section.backendPath, next, false),
|
|
1269
|
+
label: t(section.titleKey),
|
|
1270
|
+
disabled,
|
|
1271
|
+
}));
|
|
1272
|
+
return h(Section, {
|
|
1273
|
+
key: section.id,
|
|
1274
|
+
id: `plugin-config-local-search-${section.id}`,
|
|
1275
|
+
title: t(section.titleKey),
|
|
1276
|
+
header,
|
|
1277
|
+
grid: gridStyle(wide),
|
|
1278
|
+
footer: probeBlock(section),
|
|
1279
|
+
}, visibleFields(section).map((field) => cell(field)));
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
// 描述还没到手时,表单至少要有内容:加载中一行,失败一行带重试。
|
|
1283
|
+
let children;
|
|
1284
|
+
if (described.status === 'failed') {
|
|
1285
|
+
children = [h('p', { key: 'describe', role: 'status', style: HINT_STYLE },
|
|
1286
|
+
`${t('describeFailed')} ${described.message} `,
|
|
466
1287
|
h(Button, {
|
|
467
|
-
variant: '
|
|
1288
|
+
variant: 'ghost',
|
|
468
1289
|
size: 'sm',
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if (
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
1290
|
+
onClick: () => {
|
|
1291
|
+
setDescribed({ status: 'idle' });
|
|
1292
|
+
setReload((count) => count + 1);
|
|
1293
|
+
},
|
|
1294
|
+
}, t('retry')))];
|
|
1295
|
+
} else if (described.status === 'done') {
|
|
1296
|
+
const resetButton = h(Button, {
|
|
1297
|
+
key: 'reset',
|
|
1298
|
+
variant: 'primary',
|
|
1299
|
+
size: 'sm',
|
|
1300
|
+
disabled,
|
|
1301
|
+
onClick: () => { runReset(); },
|
|
1302
|
+
}, resetState.status === 'running'
|
|
1303
|
+
? t('resetting')
|
|
1304
|
+
: resetState.status === 'confirm' ? t('resetConfirm') : t('reset'));
|
|
1305
|
+
children = [
|
|
1306
|
+
...(resetInfo === undefined ? [] : [h('div', { key: 'reset-1', style: RESET_TOP_STYLE }, resetButton)]),
|
|
1307
|
+
...sections.map(sectionNode),
|
|
1308
|
+
h('p', { key: 'note', style: HINT_STYLE }, t('note')),
|
|
1309
|
+
];
|
|
1310
|
+
if (resetState.status === 'confirm') {
|
|
1311
|
+
children = [...children, h('p', { key: 'reset-hint', style: NOTICE_STYLE }, t('resetHint'))];
|
|
1312
|
+
} else if (resetState.status === 'done') {
|
|
1313
|
+
children = [...children, h('p', { key: 'reset-done', style: NOTICE_STYLE }, t('resetDone'))];
|
|
1314
|
+
} else if (resetState.status === 'failed') {
|
|
1315
|
+
children = [...children, h('p', { key: 'reset-failed', role: 'alert', style: NOTICE_STYLE }, t('resetFailed'))];
|
|
482
1316
|
}
|
|
483
|
-
} else if (probe.status === 'rejected') {
|
|
484
|
-
probeBlock.push(h('p', { key: 'message', style: HINT_STYLE }, t('testLocalOnly')));
|
|
485
|
-
} else if (probe.status === 'failed') {
|
|
486
|
-
probeBlock.push(h('p', { key: 'message', style: HINT_STYLE },
|
|
487
|
-
`${t('testFailed')}: ${probe.message}`, ' ',
|
|
488
|
-
h('span', null, `${t('testWhere')} ${api(STATUS_ROUTE)}`)));
|
|
489
1317
|
} else {
|
|
490
|
-
|
|
1318
|
+
children = [h('p', { key: 'describe', role: 'status', style: HINT_STYLE }, t('describeLoading'))];
|
|
491
1319
|
}
|
|
1320
|
+
// 没有 Modal 的部署才把校验结论留在页内;有弹窗时结论就在弹窗里。
|
|
1321
|
+
if (check.status === 'failed') {
|
|
1322
|
+
children = [...children, h('p', { key: 'check', role: 'alert', style: NOTICE_STYLE }, check.message)];
|
|
1323
|
+
}
|
|
1324
|
+
const modal = dialogNode();
|
|
1325
|
+
if (modal !== null) children = [...children, modal];
|
|
492
1326
|
|
|
493
1327
|
return h(SettingsForm, {
|
|
494
1328
|
labels: {
|
|
495
1329
|
unavailable: t('unavailable'),
|
|
496
1330
|
readOnly: t('readOnly'),
|
|
497
1331
|
saveFailed: t('saveFailed'),
|
|
498
|
-
save: t('save'),
|
|
1332
|
+
save: override ? t('saveAnyway') : t('save'),
|
|
499
1333
|
saving: t('saving'),
|
|
500
1334
|
},
|
|
501
1335
|
state: {
|
|
@@ -507,49 +1341,44 @@ window.__ModuleLoader__.load({
|
|
|
507
1341
|
failed,
|
|
508
1342
|
},
|
|
509
1343
|
onSave: () => { void save(); },
|
|
510
|
-
// SettingsForm
|
|
511
|
-
// component instance, so a fresh mount starts clean either way.
|
|
1344
|
+
// SettingsForm 只在卸载时调它;草稿属于这个组件实例,重新挂载本来就是干净的。
|
|
512
1345
|
onDiscard: () => { setStaged({}); },
|
|
513
|
-
children
|
|
514
|
-
h(Section, { key: 'everything', id: 'plugin-config-local-search-everything', title: t('sectionEverything') }, [
|
|
515
|
-
cell('esPath', textField('esPath')),
|
|
516
|
-
cell('esEncoding', h(EncodingField, {
|
|
517
|
-
t,
|
|
518
|
-
id: 'plugin-config-local-search-esEncoding',
|
|
519
|
-
value: valueOf('esEncoding'),
|
|
520
|
-
overridden: overriddenOf('esEncoding'),
|
|
521
|
-
disabled,
|
|
522
|
-
onChange: (next) => edit('esEncoding', next),
|
|
523
|
-
onReset: () => reset('esEncoding'),
|
|
524
|
-
})),
|
|
525
|
-
]),
|
|
526
|
-
h(Section, {
|
|
527
|
-
key: 'anytxt',
|
|
528
|
-
id: 'plugin-config-local-search-anytxt',
|
|
529
|
-
title: t('sectionAnytxt'),
|
|
530
|
-
grid: false,
|
|
531
|
-
}, [
|
|
532
|
-
cell('anytxt', [textField('anytxtUrl'), ...probeBlock]),
|
|
533
|
-
]),
|
|
534
|
-
h(Section, { key: 'scopes', id: 'plugin-config-local-search-scopes', title: t('sectionScopes') }, [
|
|
535
|
-
cell('workspaceRoot', textField('workspaceRoot')),
|
|
536
|
-
cell('workRoot', textField('workRoot')),
|
|
537
|
-
cell('personalRoot', textField('personalRoot')),
|
|
538
|
-
cell('allRoots', textField('allRoots')),
|
|
539
|
-
]),
|
|
540
|
-
h(Section, { key: 'limits', id: 'plugin-config-local-search-limits', title: t('sectionLimits') }, [
|
|
541
|
-
cell('maxResults', textField('maxResults')),
|
|
542
|
-
cell('readMaxChars', textField('readMaxChars')),
|
|
543
|
-
]),
|
|
544
|
-
h('p', { key: 'note', style: HINT_STYLE }, t('note')),
|
|
545
|
-
],
|
|
1346
|
+
children,
|
|
546
1347
|
});
|
|
547
1348
|
}
|
|
548
1349
|
|
|
549
1350
|
/**
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
1351
|
+
* 首用探测发现引擎不可用时的提示。
|
|
1352
|
+
*
|
|
1353
|
+
* 只在 primitives 提供 `Toast` 时出现:本包不发布 CSS,没有 `Toast` 的部署就干脆不提示,
|
|
1354
|
+
* 而不是自己画一个带内联样式的浮层。
|
|
1355
|
+
*/
|
|
1356
|
+
function EngineNotice(props) {
|
|
1357
|
+
const { t, engines, onDone } = props;
|
|
1358
|
+
if (Toast === undefined || Toast === null) return null;
|
|
1359
|
+
return h(Toast, { text: `${t('noticeEnginesDown')}${engines.join(', ')}`, holdMs: 6000, onDone });
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** 这个浏览器会话里提示过没有。 */
|
|
1363
|
+
function noticeSeen() {
|
|
1364
|
+
try {
|
|
1365
|
+
return window.sessionStorage?.getItem(NOTICE_SEEN_KEY) === '1';
|
|
1366
|
+
} catch {
|
|
1367
|
+
return false;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function markNoticeSeen() {
|
|
1372
|
+
try {
|
|
1373
|
+
window.sessionStorage?.setItem(NOTICE_SEEN_KEY, '1');
|
|
1374
|
+
} catch {
|
|
1375
|
+
// 隐私模式下 sessionStorage 会抛,提示退化成每次启动都出现。
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* 挂载配置卡进 Plugins 页的 Official plugins 组,并在首用探测报错时提示一次。
|
|
1381
|
+
* @param ctx - 浏览器插件上下文
|
|
553
1382
|
*/
|
|
554
1383
|
function apply(ctx) {
|
|
555
1384
|
const t = ctx.locale.bind(NS);
|
|
@@ -561,14 +1390,91 @@ window.__ModuleLoader__.load({
|
|
|
561
1390
|
label: () => t('itemLabel'),
|
|
562
1391
|
locale: NS,
|
|
563
1392
|
}, ConfigPage)), 'local-search: config card');
|
|
1393
|
+
ctx.effect(() => engineNotice(ctx, t), 'local-search: engine notice');
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* 等首用探测结束,若报告有引擎不可用且本会话还没提示过,就在 `shell.overlay` 里提示一次。
|
|
1398
|
+
*
|
|
1399
|
+
* 探测在 Host 侧异步跑,所以这里按小间隔轮询 describe,直到 `autoconfig.pending` 变 false
|
|
1400
|
+
* 或轮询次数用完;中途任何失败都安静退出。
|
|
1401
|
+
* @param ctx - 浏览器插件上下文
|
|
1402
|
+
* @param t - 字典查找
|
|
1403
|
+
* @returns 清理函数
|
|
1404
|
+
*/
|
|
1405
|
+
function engineNotice(ctx, t) {
|
|
1406
|
+
let disposed = false;
|
|
1407
|
+
let disposeSlot;
|
|
1408
|
+
let timer;
|
|
1409
|
+
|
|
1410
|
+
const stop = () => {
|
|
1411
|
+
disposed = true;
|
|
1412
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
1413
|
+
disposeSlot?.();
|
|
1414
|
+
disposeSlot = undefined;
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
const show = (engines) => {
|
|
1418
|
+
// 没有 Toast 就不注册任何东西:本包不发 CSS,画不出一个像样的提示。
|
|
1419
|
+
if (Toast === undefined || Toast === null) return false;
|
|
1420
|
+
if (typeof ctx.slots?.inject !== 'function') return false;
|
|
1421
|
+
try {
|
|
1422
|
+
disposeSlot = ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
1423
|
+
name: 'shell.overlay',
|
|
1424
|
+
id: 'fishdivinity-local-search.engine-notice',
|
|
1425
|
+
order: 900,
|
|
1426
|
+
locale: NS,
|
|
1427
|
+
}, () => h(EngineNotice, { t, engines, onDone: stop })));
|
|
1428
|
+
return true;
|
|
1429
|
+
} catch {
|
|
1430
|
+
// 没有这个槽位的部署就不提示;也不会把「已提示」标上,免得换一个部署后永远不提示。
|
|
1431
|
+
return false;
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
const tick = async (attempt) => {
|
|
1436
|
+
if (disposed) return;
|
|
1437
|
+
let body;
|
|
1438
|
+
try {
|
|
1439
|
+
const response = await fetch(describeUrl(), { cache: 'no-store' });
|
|
1440
|
+
if (!response.ok || disposed) return;
|
|
1441
|
+
body = await response.json();
|
|
1442
|
+
} catch {
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
const autoconfig = body?.autoconfig;
|
|
1446
|
+
if (autoconfig === undefined || disposed) return;
|
|
1447
|
+
if (autoconfig.pending === true && attempt < NOTICE_POLL_LIMIT) {
|
|
1448
|
+
timer = setTimeout(() => { void tick(attempt + 1); }, NOTICE_POLL_MS);
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
const failures = Array.isArray(autoconfig.failures) ? autoconfig.failures : [];
|
|
1452
|
+
if (failures.length === 0 || noticeSeen()) return;
|
|
1453
|
+
if (show(failures.map((failure) => String(failure.engine ?? 'engine')))) markNoticeSeen();
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
timer = setTimeout(() => { void tick(0); }, NOTICE_POLL_MS);
|
|
1457
|
+
return stop;
|
|
564
1458
|
}
|
|
565
1459
|
|
|
566
1460
|
exports.apply = apply;
|
|
567
1461
|
exports.inject = inject;
|
|
568
1462
|
exports.NS = NS;
|
|
569
1463
|
exports.ITEM_ID = ITEM_ID;
|
|
1464
|
+
exports.DESCRIBE_ROUTE = DESCRIBE_ROUTE;
|
|
570
1465
|
exports.STATUS_ROUTE = STATUS_ROUTE;
|
|
1466
|
+
exports.DRAFT_PARAM = DRAFT_PARAM;
|
|
1467
|
+
exports.DRAFT_PATH_PARAM = DRAFT_PATH_PARAM;
|
|
1468
|
+
exports.DRAFT_DIR_PARAM = DRAFT_DIR_PARAM;
|
|
571
1469
|
exports.ConfigPage = ConfigPage;
|
|
1470
|
+
// 暴露出来,让测试能证明它们与 Host 的规则一致 / 能把草稿翻成嵌套路径。
|
|
1471
|
+
exports.LOOPBACK_HOSTS = LOOPBACK_HOSTS;
|
|
1472
|
+
exports.internals = {
|
|
1473
|
+
pathKey, pathValue, operations, invalidField, choiceValue, verifyQuery, fillValue, EngineNotice,
|
|
1474
|
+
normalizeWinPath, normalizeWinPaths, normalizeStaged,
|
|
1475
|
+
};
|
|
1476
|
+
// 样式常量也暴露出来,测试才能钉住「用的是主题真有的 token」。
|
|
1477
|
+
exports.styles = { NOTICE_STYLE, LINK_STYLE, gridStyle, GRID_MAX_WIDTH, WIDE_QUERY, CELL_STYLE, RESET_TOP_STYLE };
|
|
572
1478
|
exports.en = en;
|
|
573
1479
|
exports.zh = zh;
|
|
574
1480
|
return module.exports;
|