@fishdivinity/dsh-local-search 0.2.1 → 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 +1174 -315
- 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 +29 -32
- package/lib/format.js +16 -18
- package/lib/index.js +71 -38
- package/lib/limits.js +6 -7
- package/lib/probe.js +22 -28
- package/lib/ref.js +5 -5
- package/lib/roots.js +21 -24
- package/lib/route.js +252 -71
- package/lib/schema.js +130 -0
- package/lib/tools.js +45 -34
- package/package.json +3 -2
package/lib/config.js
CHANGED
|
@@ -1,47 +1,187 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 配置的路径表、默认值与读取规则,activate 与每次工具调用共用。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* only `get()` sees it. Nothing downstream may keep such a reference, so every
|
|
8
|
-
* operation starts from {@link readConfig}'s plain snapshot.
|
|
4
|
+
* 页面能改的字段都在 schema 里标了 `.volatile()`,Host 传给 `apply` 的因此是活引用而不是
|
|
5
|
+
* 激活时冻结的值;页面保存只改引用本身,只有 `get()` 看得到。下游任何模块都不许留着这种
|
|
6
|
+
* 引用,所以每个操作都从 {@link readConfig} 的纯快照出发。
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* 路径只在这里写一遍:schema(`lib/schema.js`)、页面描述(`lib/describe.js`)、迁移与
|
|
9
|
+
* 自动配置都从 {@link PATHS} 取,避免同一串键名散落在四处。退役的旧路径是另一张表
|
|
10
|
+
* ({@link RETIRED_PATHS}):只追加、不改写,规则见 `docs/config-migration.md`。
|
|
11
|
+
*
|
|
12
|
+
* `timeoutMs` 故意不在 {@link PATHS} 里:`defineTool` 在注册时就固定了每个工具的截止时间,
|
|
13
|
+
* 新值不重新加载那一行不会生效,所以它只能是 profile YAML 的设置。
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
export const
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
16
|
+
/** 可编辑字段的嵌套路径;键名是代码里的称呼,值是配置里的位置。 */
|
|
17
|
+
export const PATHS = {
|
|
18
|
+
filesBackend: ['files', 'backend'],
|
|
19
|
+
esPath: ['files', 'everything', 'esPath'],
|
|
20
|
+
esEncoding: ['files', 'everything', 'esEncoding'],
|
|
21
|
+
contentBackend: ['content', 'backend'],
|
|
22
|
+
anytxtUrl: ['content', 'anytxt', 'url'],
|
|
23
|
+
workspaceRoot: ['scopes', 'workspaceRoot'],
|
|
24
|
+
workRoot: ['scopes', 'workRoot'],
|
|
25
|
+
personalRoot: ['scopes', 'personalRoot'],
|
|
26
|
+
allRoots: ['scopes', 'allRoots'],
|
|
27
|
+
maxResults: ['limits', 'maxResults'],
|
|
28
|
+
readMaxChars: ['limits', 'readMaxChars'],
|
|
29
|
+
autoConfigured: ['autoConfigured'],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 字段默认值。schema 从这里取默认值,`readConfig` 也用它补齐缺失层级,
|
|
34
|
+
* 所以一份不完整的 profile 不会读出一堆 `undefined`。
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULTS = {
|
|
37
|
+
files: { backend: 'everything', everything: { esPath: '', esEncoding: 'gbk' } },
|
|
38
|
+
content: { backend: 'anytxt', anytxt: { url: 'http://127.0.0.1:9920' } },
|
|
39
|
+
scopes: { workspaceRoot: '', workRoot: '', personalRoot: '', allRoots: '' },
|
|
40
|
+
limits: { maxResults: 20, readMaxChars: 8000 },
|
|
41
|
+
// 首用自动配置是否已经做过决定:写死之后,用户清空的字段不会再被自动填回去。
|
|
42
|
+
autoConfigured: false,
|
|
43
|
+
timeoutMs: 30000,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 已退役的路径 → 现在的位置。
|
|
48
|
+
*
|
|
49
|
+
* **一条 = 一次形状变更**,按发布顺序追加,永远不删、不改号;`from` 写当时的字面路径(不引用
|
|
50
|
+
* {@link PATHS}),否则将来改 PATHS 会把这行历史一起改掉。三条消费方都只读这张表:读取兜底
|
|
51
|
+
* ({@link readConfig})、schema 里那几个隐藏的旧键(`lib/schema.js`)、启动迁移
|
|
52
|
+
* (`lib/bootstrap.js`)。规则与取舍见 `docs/config-migration.md`。
|
|
53
|
+
*
|
|
54
|
+
* 0.1 与 0.2 用的是同一批扁平键,所以这一条同时覆盖「0.1 直装 0.3」。
|
|
55
|
+
*/
|
|
56
|
+
export const RETIRED_PATHS = [
|
|
57
|
+
{ from: ['esPath'], to: PATHS.esPath },
|
|
58
|
+
{ from: ['esEncoding'], to: PATHS.esEncoding },
|
|
59
|
+
{ from: ['anytxtUrl'], to: PATHS.anytxtUrl },
|
|
60
|
+
{ from: ['workspaceRoot'], to: PATHS.workspaceRoot },
|
|
61
|
+
{ from: ['workRoot'], to: PATHS.workRoot },
|
|
62
|
+
{ from: ['personalRoot'], to: PATHS.personalRoot },
|
|
63
|
+
{ from: ['allRoots'], to: PATHS.allRoots },
|
|
64
|
+
{ from: ['maxResults'], to: PATHS.maxResults },
|
|
65
|
+
{ from: ['readMaxChars'], to: PATHS.readMaxChars },
|
|
26
66
|
]
|
|
27
67
|
|
|
28
68
|
/**
|
|
29
|
-
*
|
|
30
|
-
* @param {unknown} value -
|
|
31
|
-
* @returns {unknown}
|
|
69
|
+
* 读一个可能仍是活引用的值。
|
|
70
|
+
* @param {unknown} value - 普通值,或带 `get()` 的引用
|
|
71
|
+
* @returns {unknown} 当前值
|
|
32
72
|
*/
|
|
33
73
|
export function liveValue(value) {
|
|
34
74
|
return typeof value?.get === 'function' ? value.get() : value
|
|
35
75
|
}
|
|
36
76
|
|
|
37
77
|
/**
|
|
38
|
-
*
|
|
39
|
-
* @param {object}
|
|
40
|
-
* @
|
|
78
|
+
* 沿路径取一个值。
|
|
79
|
+
* @param {object} object - 根对象,可为 undefined
|
|
80
|
+
* @param {string[]} path - 键名路径
|
|
81
|
+
* @returns {unknown} 命中值,或 undefined
|
|
82
|
+
*/
|
|
83
|
+
export function pathValue(object, path) {
|
|
84
|
+
let node = object
|
|
85
|
+
for (const key of path) {
|
|
86
|
+
if (node === null || typeof node !== 'object') return undefined
|
|
87
|
+
// 逐层解引用:schema 把 volatile 标在叶子上,但一份被外部包装过的配置可能整棵都是引用。
|
|
88
|
+
node = liveValue(node[key])
|
|
89
|
+
}
|
|
90
|
+
return node
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 沿路径写一个值,中间层级按需创建。
|
|
95
|
+
* @param {object} object - 将被就地修改的根对象
|
|
96
|
+
* @param {string[]} path - 键名路径
|
|
97
|
+
* @param {unknown} value - 要写入的值
|
|
98
|
+
*/
|
|
99
|
+
export function setPath(object, path, value) {
|
|
100
|
+
let node = object
|
|
101
|
+
for (const key of path.slice(0, -1)) {
|
|
102
|
+
if (node[key] === null || typeof node[key] !== 'object') node[key] = {}
|
|
103
|
+
node = node[key]
|
|
104
|
+
}
|
|
105
|
+
node[path.at(-1)] = value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 路径是否由该层自己提供的键组成(不看继承值)。
|
|
110
|
+
* @param {object} object - 根对象,可为 undefined
|
|
111
|
+
* @param {string[]} path - 键名路径
|
|
112
|
+
* @returns {boolean} 每一层都显式带这个键时为 true
|
|
113
|
+
*/
|
|
114
|
+
export function hasPath(object, path) {
|
|
115
|
+
let node = object
|
|
116
|
+
for (const key of path) {
|
|
117
|
+
if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, key)) return false
|
|
118
|
+
node = node[key]
|
|
119
|
+
}
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 深拷贝一份默认值,免得调用方改到共享常量。 */
|
|
124
|
+
function defaults() {
|
|
125
|
+
return structuredClone(DEFAULTS)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 这个路径上的值算不算「还没设过」:缺失、空串,或仍等于默认值。
|
|
130
|
+
*
|
|
131
|
+
* 中间路径(上一版的位置)在 {@link DEFAULTS} 里没有记录,那时只有缺失与空串算没设过。
|
|
132
|
+
*/
|
|
133
|
+
function isUnset(snapshot, path) {
|
|
134
|
+
const value = pathValue(snapshot, path)
|
|
135
|
+
if (value === undefined || value === null) return true
|
|
136
|
+
if (typeof value === 'string' && value.trim() === '') return true
|
|
137
|
+
const fallback = pathValue(DEFAULTS, path)
|
|
138
|
+
return fallback !== undefined && String(value) === String(fallback)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 把退役路径上的值按发布顺序折进快照。
|
|
143
|
+
*
|
|
144
|
+
* 顺序就是执行顺序:更早的一次迁移先把值放到它的 `to`(对今天来说是中间位置),后一次再从
|
|
145
|
+
* 那里搬到当前路径。所以「0.1 直装 1.0」不需要中间版本存在过——这条链在同一次启动里跑完。
|
|
146
|
+
* @param config - schema 输出,含活引用
|
|
147
|
+
* @param snapshot - 已经按当前路径填过的快照,会被就地修改
|
|
148
|
+
* @param retired - 退役路径表,测试可注入
|
|
149
|
+
* @returns 快照与被旧路径填过的当前路径(点分)
|
|
150
|
+
*/
|
|
151
|
+
export function foldRetired(config, snapshot = defaults(), retired = RETIRED_PATHS) {
|
|
152
|
+
const filled = []
|
|
153
|
+
for (const { from, to } of retired) {
|
|
154
|
+
// 先看 profile 里的旧路径,再看快照:链上更早的一次迁移会把值放在快照的中间路径上。
|
|
155
|
+
let value = pathValue(config, from)
|
|
156
|
+
if (value === undefined || value === null) value = pathValue(snapshot, from)
|
|
157
|
+
if (value === undefined || value === null) continue
|
|
158
|
+
if (typeof value === 'string' && value.trim() === '') continue
|
|
159
|
+
if (!isUnset(snapshot, to)) continue
|
|
160
|
+
setPath(snapshot, to, value)
|
|
161
|
+
filled.push(to.join('.'))
|
|
162
|
+
}
|
|
163
|
+
return { snapshot, filled }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 当前结构 + 退役路径折过之后的快照。
|
|
168
|
+
*
|
|
169
|
+
* 读侧只是兜底:旧键上恰好等于默认值也不影响结果(同一个值)。要防的是迁移把它当成用户写的值
|
|
170
|
+
* 写回 profile——那件事在 `lib/bootstrap.js` 里按**用户层**判定,不靠这里。
|
|
171
|
+
* @param {object} config - schema 输出,含活引用
|
|
172
|
+
* @returns {object} 本次操作可用的纯值
|
|
41
173
|
*/
|
|
42
174
|
export function readConfig(config) {
|
|
43
|
-
const snapshot =
|
|
44
|
-
for (const
|
|
175
|
+
const snapshot = defaults()
|
|
176
|
+
for (const path of Object.values(PATHS)) {
|
|
177
|
+
const value = pathValue(config, path)
|
|
178
|
+
if (value !== undefined) setPath(snapshot, path, value)
|
|
179
|
+
}
|
|
180
|
+
// 退役路径只在目标位置还是默认值时补位:迁移成功后就清掉了,没清掉说明 settings 写不进去
|
|
181
|
+
// (home patch 覆盖),那时它仍是唯一来源。
|
|
182
|
+
foldRetired(config, snapshot)
|
|
183
|
+
const timeoutMs = liveValue(config?.timeoutMs)
|
|
184
|
+
if (timeoutMs !== undefined) snapshot.timeoutMs = timeoutMs
|
|
45
185
|
return snapshot
|
|
46
186
|
}
|
|
47
187
|
|
|
@@ -51,18 +191,23 @@ function assertPositiveInteger(field, value) {
|
|
|
51
191
|
}
|
|
52
192
|
}
|
|
53
193
|
|
|
54
|
-
|
|
194
|
+
function assertBackend(field, value) {
|
|
195
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
196
|
+
throw new Error(`dsh-local-search: ${field} must name a backend`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 只认本机的 host 表。 */
|
|
55
201
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
|
|
56
202
|
|
|
57
203
|
/**
|
|
58
|
-
*
|
|
204
|
+
* 值是否是指向本机的 `http(s)` 地址。
|
|
59
205
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* @
|
|
65
|
-
* @returns {boolean} true for a loopback `http(s)` URL
|
|
206
|
+
* 两个索引都描述这台机器:`es.exe` 在这里启动,Anytxt 的每条结果都带一个只在这里成立的
|
|
207
|
+
* 路径。远端地址会返回另一台机器的路径,因此拒绝而不是支持——把远端隧道进来最终仍落在
|
|
208
|
+
* loopback 上。
|
|
209
|
+
* @param {unknown} value - 待检查的地址
|
|
210
|
+
* @returns {boolean} 本机 `http(s)` 地址时为 true
|
|
66
211
|
*/
|
|
67
212
|
export function isLoopbackUrl(value) {
|
|
68
213
|
let parsed
|
|
@@ -76,24 +221,23 @@ export function isLoopbackUrl(value) {
|
|
|
76
221
|
}
|
|
77
222
|
|
|
78
223
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* schema before they are persisted, so the range checks that matter live there
|
|
82
|
-
* (`lib/index.js`) and this function covers what the schema cannot express.
|
|
224
|
+
* 校验一份配置并返回它的纯快照。激活时跑一次;页面写入由 Host 按 Config schema 校验后才落盘,
|
|
225
|
+
* 所以这里只覆盖 schema 表达不了的部分。
|
|
83
226
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* @param {
|
|
89
|
-
* @
|
|
90
|
-
* @returns {object} the validated plain snapshot
|
|
227
|
+
* endpoint 的 loopback 规则故意不在这里执行。0.1 接受的 profile 必须还能激活:地址写错应该
|
|
228
|
+
* 让内容引擎报一个能读懂的错误,而不是把文件名检索一起拖下水。规则留在用到它的地方
|
|
229
|
+
* (`lib/anytxt.js`)。
|
|
230
|
+
* @param {object} config - schema 输出,含活引用
|
|
231
|
+
* @param {number} [maxTimerDelayMs] - 定时器 API 接受的最大截止时间
|
|
232
|
+
* @returns {object} 校验过的纯快照
|
|
91
233
|
*/
|
|
92
234
|
export function validateConfig(config, maxTimerDelayMs) {
|
|
93
235
|
const values = readConfig(config)
|
|
94
|
-
|
|
236
|
+
assertBackend('files.backend', values.files.backend)
|
|
237
|
+
assertBackend('content.backend', values.content.backend)
|
|
238
|
+
assertPositiveInteger('limits.maxResults', values.limits.maxResults)
|
|
239
|
+
assertPositiveInteger('limits.readMaxChars', values.limits.readMaxChars)
|
|
95
240
|
assertPositiveInteger('timeoutMs', values.timeoutMs)
|
|
96
|
-
assertPositiveInteger('readMaxChars', values.readMaxChars)
|
|
97
241
|
if (maxTimerDelayMs !== undefined && values.timeoutMs > maxTimerDelayMs) {
|
|
98
242
|
throw new Error(`dsh-local-search: timeoutMs must be no greater than ${maxTimerDelayMs}`)
|
|
99
243
|
}
|
package/lib/describe.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置页的后端描述(`GET /dsh-local-search/api/describe`)。
|
|
3
|
+
*
|
|
4
|
+
* 页面不该知道世界上有 Everything 和 Anytxt:它只知道「有几个能力节、每节可选哪些后端、
|
|
5
|
+
* 选中后端后显示哪些字段」。这些事实由 Host 给,新增后端在 Host 注册表与这里各加一条,
|
|
6
|
+
* 页面逻辑不动;字段的措辞留在客户端字典里,这里只给键名。
|
|
7
|
+
*
|
|
8
|
+
* 能力名(文件名检索 / 文件内容检索)由客户端按语言取词,后端一律用产品名。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { DEFAULTS, PATHS } from './config.js'
|
|
12
|
+
import { engineNames } from './engines.js'
|
|
13
|
+
import { MAX_LIMIT, MAX_READ } from './limits.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 页面那个统一的「重置」要做什么。
|
|
17
|
+
*
|
|
18
|
+
* 它等价于把首用自动配置重跑一遍:清掉引擎与上限、按探测结果把 `esPath` 与 Anytxt 地址填回去、
|
|
19
|
+
* 再盖上「已经做过决定」的标记。`scopes` 不在清空列表里——那是用户手填的机器路径,重置顺手抹掉
|
|
20
|
+
* 的代价太高。`default` 用来判断探到的值是否与默认一致,一致就不必写进 profile。
|
|
21
|
+
*/
|
|
22
|
+
const RESET = {
|
|
23
|
+
clear: [
|
|
24
|
+
PATHS.esPath,
|
|
25
|
+
PATHS.esEncoding,
|
|
26
|
+
PATHS.anytxtUrl,
|
|
27
|
+
PATHS.filesBackend,
|
|
28
|
+
PATHS.contentBackend,
|
|
29
|
+
PATHS.maxResults,
|
|
30
|
+
PATHS.readMaxChars,
|
|
31
|
+
],
|
|
32
|
+
fills: [
|
|
33
|
+
{ path: PATHS.esPath, from: 'everything.path' },
|
|
34
|
+
{ path: PATHS.anytxtUrl, from: 'anytxt.url', default: DEFAULTS.content.anytxt.url },
|
|
35
|
+
],
|
|
36
|
+
marker: PATHS.autoConfigured,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 后端的产品名在字典里的键;未收录的新后端直接显示注册表里的名字。 */
|
|
40
|
+
const ENGINE_LABEL_KEYS = {
|
|
41
|
+
everything: 'engineEverything',
|
|
42
|
+
anytxt: 'engineAnytxt',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 每个后端自己的字段表。
|
|
47
|
+
*
|
|
48
|
+
* `path` 取 {@link PATHS},不写字面量;`aliases` 让页面的二选一控件认旧标签(0.1 写过
|
|
49
|
+
* `utf8`,schema 至今仍接受任意 TextDecoder 标签)。
|
|
50
|
+
*
|
|
51
|
+
* 字段上这几个标记是 Host 与页面之间**唯一的暗号**,由页面据此决定渲染与校验,加字段时别漏:
|
|
52
|
+
* `verifyPath` / `verifyDir` 交给 Host 查存在性(不 spawn),`probeEndpoint` 是测试连接要探的地址,
|
|
53
|
+
* `localUrl` 要求本机 http(s),`type: 'count'` 带 `max` 上限。
|
|
54
|
+
*/
|
|
55
|
+
const ENGINE_FIELDS = {
|
|
56
|
+
files: {
|
|
57
|
+
everything: [
|
|
58
|
+
{
|
|
59
|
+
path: PATHS.esPath,
|
|
60
|
+
type: 'text',
|
|
61
|
+
labelKey: 'esPathLabel',
|
|
62
|
+
hintKey: 'esPathHint',
|
|
63
|
+
// 保存前由 Host 只查「这个文件在不在」:跑一条任意路径等于开一个远程执行口。
|
|
64
|
+
verifyPath: true,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
path: PATHS.esEncoding,
|
|
68
|
+
type: 'choice',
|
|
69
|
+
labelKey: 'esEncodingLabel',
|
|
70
|
+
hintKey: 'esEncodingHint',
|
|
71
|
+
choices: [
|
|
72
|
+
{ value: 'gbk', labelKey: 'encodingGbk' },
|
|
73
|
+
{ value: 'utf-8', labelKey: 'encodingUtf8' },
|
|
74
|
+
],
|
|
75
|
+
aliases: { utf8: 'utf-8', utf: 'utf-8' },
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
content: {
|
|
80
|
+
anytxt: [
|
|
81
|
+
{
|
|
82
|
+
path: PATHS.anytxtUrl,
|
|
83
|
+
type: 'text',
|
|
84
|
+
labelKey: 'anytxtUrlLabel',
|
|
85
|
+
hintKey: 'anytxtUrlHint',
|
|
86
|
+
localUrl: true,
|
|
87
|
+
probeEndpoint: true,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 能力节:标题是能力,节内先选后端。 */
|
|
94
|
+
const CAPABILITY_SECTIONS = [
|
|
95
|
+
{ id: 'files', group: 'files', titleKey: 'sectionFiles', backendPath: PATHS.filesBackend },
|
|
96
|
+
{ id: 'content', group: 'content', titleKey: 'sectionContent', backendPath: PATHS.contentBackend },
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
/** 与后端无关的节,留在页尾。 */
|
|
100
|
+
const PLAIN_SECTIONS = [
|
|
101
|
+
{
|
|
102
|
+
id: 'scopes',
|
|
103
|
+
titleKey: 'sectionScopes',
|
|
104
|
+
fields: [
|
|
105
|
+
{ path: PATHS.workspaceRoot, type: 'text', labelKey: 'workspaceRootLabel', hintKey: 'workspaceRootHint', verifyDir: true },
|
|
106
|
+
{ path: PATHS.workRoot, type: 'text', labelKey: 'workRootLabel', hintKey: 'workRootHint', verifyDir: true },
|
|
107
|
+
{ path: PATHS.personalRoot, type: 'text', labelKey: 'personalRootLabel', hintKey: 'personalRootHint', verifyDir: true },
|
|
108
|
+
{ path: PATHS.allRoots, type: 'text', labelKey: 'allRootsLabel', hintKey: 'allRootsHint', verifyDir: true },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'limits',
|
|
113
|
+
titleKey: 'sectionLimits',
|
|
114
|
+
fields: [
|
|
115
|
+
{ path: PATHS.maxResults, type: 'count', max: MAX_LIMIT, labelKey: 'maxResultsLabel', hintKey: 'maxResultsHint' },
|
|
116
|
+
{ path: PATHS.readMaxChars, type: 'count', max: MAX_READ, labelKey: 'readMaxCharsLabel', hintKey: 'readMaxCharsHint' },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
/** 一个能力的后端列表,只列真正注册过的引擎。 */
|
|
122
|
+
function backendsOf(group) {
|
|
123
|
+
return engineNames()[group].map((name) => {
|
|
124
|
+
const labelKey = ENGINE_LABEL_KEYS[name]
|
|
125
|
+
return labelKey === undefined ? { name } : { name, labelKey }
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 一个能力的字段列表,标上它属于哪个后端。 */
|
|
130
|
+
function fieldsOf(group) {
|
|
131
|
+
const fields = ENGINE_FIELDS[group] ?? {}
|
|
132
|
+
return engineNames()[group].flatMap((name) => (fields[name] ?? []).map((field) => ({ ...field, backend: name })))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 组装页面要渲染的结构。
|
|
137
|
+
* @param {object} [autoconfig] - 首用自动配置的进展,供页面决定是否提示引擎不可用
|
|
138
|
+
* @param {boolean} [autoconfig.ran] - 本次启动是否真的探测并写过配置
|
|
139
|
+
* @param {boolean} [autoconfig.pending] - 探测还没结束
|
|
140
|
+
* @param {Array<{engine: string, code: string}>} [autoconfig.failures] - 不可用的引擎
|
|
141
|
+
* @returns {object} describe 载荷
|
|
142
|
+
*/
|
|
143
|
+
export function describeConfig(autoconfig) {
|
|
144
|
+
const sections = [
|
|
145
|
+
...CAPABILITY_SECTIONS.map(({ id, group, titleKey, backendPath }) => ({
|
|
146
|
+
id,
|
|
147
|
+
titleKey,
|
|
148
|
+
backendPath,
|
|
149
|
+
backends: backendsOf(group),
|
|
150
|
+
fields: fieldsOf(group),
|
|
151
|
+
})),
|
|
152
|
+
...PLAIN_SECTIONS.map(({ id, titleKey, fields }) => ({ id, titleKey, fields })),
|
|
153
|
+
]
|
|
154
|
+
const payload = { sections, reset: RESET }
|
|
155
|
+
if (autoconfig !== undefined) payload.autoconfig = autoconfig
|
|
156
|
+
return payload
|
|
157
|
+
}
|
package/lib/engines.js
CHANGED
|
@@ -4,8 +4,8 @@ import { anytxtStatus, getFragments, getRawText, searchContent } from './anytxt.
|
|
|
4
4
|
import { assertRefEngine, formatRef, parseRef } from './ref.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* 引擎注册表。新增一个文件名或内容后端 = 这里一条 + 它自己的模块;工具表面与 `engine` 的取值
|
|
8
|
+
* 都不用动。
|
|
9
9
|
*
|
|
10
10
|
* files: { name, search(args), status(args) }
|
|
11
11
|
* content: { name, search(args), fragments(args), read(args), status(args) }
|
|
@@ -19,7 +19,7 @@ const filesEngines = new Map([
|
|
|
19
19
|
status: async (args) => ({
|
|
20
20
|
ok: true,
|
|
21
21
|
version: await getEsVersion(args),
|
|
22
|
-
path: resolveEsPath(args.
|
|
22
|
+
path: resolveEsPath({ esPath: args.esPath }),
|
|
23
23
|
}),
|
|
24
24
|
},
|
|
25
25
|
],
|
|
@@ -66,17 +66,17 @@ function resolve(registry, kind, requested) {
|
|
|
66
66
|
return engine
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/**
|
|
69
|
+
/** 解析文件名引擎;`auto` 取注册表里的第一个。 */
|
|
70
70
|
export function resolveFilesEngine(requested = 'auto') {
|
|
71
71
|
return resolve(filesEngines, 'files', requested)
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
74
|
+
/** 解析内容引擎;`auto` 取注册表里的第一个。 */
|
|
75
75
|
export function resolveContentEngine(requested = 'auto') {
|
|
76
76
|
return resolve(contentEngines, 'content', requested)
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/**
|
|
79
|
+
/** 状态输出与配置页用的引擎名。 */
|
|
80
80
|
export function engineNames() {
|
|
81
81
|
return {
|
|
82
82
|
files: [...filesEngines.keys()],
|
package/lib/errors.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* layer converts these to `HarnessError` before returning to the model.
|
|
2
|
+
* 带稳定 code 的引擎层错误。刻意不依赖 DSH runtime,引擎模块与它们的单测才能裸跑在普通 Node
|
|
3
|
+
* 上;把它们转成 `HarnessError` 是工具层在返回模型之前做的事。
|
|
5
4
|
*/
|
|
6
5
|
export class EngineError extends Error {
|
|
7
6
|
/**
|
package/lib/everything.js
CHANGED
|
@@ -10,7 +10,7 @@ const EXCLUDED = [/\\\$RECYCLE\.BIN\\/i, /\\System Volume Information\\/i]
|
|
|
10
10
|
|
|
11
11
|
let cachedEsPath
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/** 按控制台代码页解码 es.exe 的 stdout(简中下是 GBK)。 */
|
|
14
14
|
export function decodeEs(buffer, encoding = 'gbk') {
|
|
15
15
|
try {
|
|
16
16
|
return new TextDecoder(encoding).decode(buffer)
|
|
@@ -19,7 +19,7 @@ export function decodeEs(buffer, encoding = 'gbk') {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/** es.exe 的候选位置,按优先级排列。 */
|
|
23
23
|
export function esCandidates() {
|
|
24
24
|
const localAppData = process.env.LOCALAPPDATA || ''
|
|
25
25
|
return [
|
|
@@ -29,7 +29,7 @@ export function esCandidates() {
|
|
|
29
29
|
].filter(Boolean)
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/** 从配置、环境变量、PATH 或常见安装目录解析出 es.exe。 */
|
|
33
33
|
export function resolveEsPath(config = {}) {
|
|
34
34
|
if (config.esPath) {
|
|
35
35
|
if (!existsSync(config.esPath)) {
|
|
@@ -59,14 +59,14 @@ export function resolveEsPath(config = {}) {
|
|
|
59
59
|
return first
|
|
60
60
|
}
|
|
61
61
|
} catch {
|
|
62
|
-
// where.exe
|
|
62
|
+
// where.exe 不可用或什么也没找到;继续往下走。
|
|
63
63
|
}
|
|
64
64
|
throw new EngineError('es.exe was not found', 'EVERYTHING_ES_NOT_FOUND', {
|
|
65
65
|
hint: 'Install es.exe (https://www.voidtools.com/downloads/), add it to PATH, or set esPath in the plugin config.',
|
|
66
66
|
})
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/**
|
|
69
|
+
/** 拼出固定、只读的 es.exe argv。模型给的文本永远放在 `--` 之后。 */
|
|
70
70
|
export function buildEsArgs({ query, limit = 20, dir } = {}) {
|
|
71
71
|
if (typeof query !== 'string' || query.trim().length === 0) {
|
|
72
72
|
throw new EngineError('query must be a non-empty string', 'INVALID_ARGUMENT')
|
|
@@ -85,7 +85,7 @@ export function buildEsArgs({ query, limit = 20, dir } = {}) {
|
|
|
85
85
|
return args
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/**
|
|
88
|
+
/** 带缓冲上限、超时与取消地跑 es.exe。 */
|
|
89
89
|
export function runEsText(args, { esPath, timeoutMs = 15000, signal, encoding = 'gbk' } = {}) {
|
|
90
90
|
return new Promise((resolve, reject) => {
|
|
91
91
|
const executable = esPath || resolveEsPath({})
|
|
@@ -108,7 +108,7 @@ export function runEsText(args, { esPath, timeoutMs = 15000, signal, encoding =
|
|
|
108
108
|
try {
|
|
109
109
|
child?.kill()
|
|
110
110
|
} catch {
|
|
111
|
-
//
|
|
111
|
+
// 进程已经没了。
|
|
112
112
|
}
|
|
113
113
|
finish(error)
|
|
114
114
|
}
|
|
@@ -180,20 +180,19 @@ export function runEsText(args, { esPath, timeoutMs = 15000, signal, encoding =
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
/**
|
|
183
|
-
*
|
|
183
|
+
* es.exe 这次退出是「没有答案」还是「没有命中」。
|
|
184
184
|
*
|
|
185
|
-
* es.exe
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* @param {
|
|
189
|
-
* @
|
|
190
|
-
* @returns true when the call produced no usable answer
|
|
185
|
+
* es.exe 对「什么也没匹配到」和「连接失败」都用 exit 1,光看 code 判不出来;stdout 为空而
|
|
186
|
+
* stderr 有内容才是失败,其余都算有结果。
|
|
187
|
+
* @param {number} code - 进程的退出码
|
|
188
|
+
* @param {string} output - 去掉首尾空白的 stdout
|
|
189
|
+
* @returns 这次调用没有拿到可用答案时为 true
|
|
191
190
|
*/
|
|
192
191
|
export function isFailedExit(code, output) {
|
|
193
192
|
return code !== 0 && (code !== 1 || output === '')
|
|
194
193
|
}
|
|
195
194
|
|
|
196
|
-
/**
|
|
195
|
+
/** 把 es.exe 的 `-json` 输出解析成行,这一步在任何排除过滤之前。 */
|
|
197
196
|
export function parseEsJson(text) {
|
|
198
197
|
if (!text || text.trim().length === 0) return []
|
|
199
198
|
let rows
|
|
@@ -217,15 +216,15 @@ export function parseEsJson(text) {
|
|
|
217
216
|
}
|
|
218
217
|
|
|
219
218
|
/**
|
|
220
|
-
*
|
|
221
|
-
* @param {Array<{path: string}>} rows -
|
|
222
|
-
* @returns {Array<{path: string}>}
|
|
219
|
+
* 丢掉落在我们从不暴露的索引里的行。
|
|
220
|
+
* @param {Array<{path: string}>} rows - 已解析的行
|
|
221
|
+
* @returns {Array<{path: string}>} 工具可以返回的行
|
|
223
222
|
*/
|
|
224
223
|
export function withoutExcludedPaths(rows) {
|
|
225
224
|
return rows.filter((row) => !EXCLUDED.some((re) => re.test(row.path)))
|
|
226
225
|
}
|
|
227
226
|
|
|
228
|
-
/**
|
|
227
|
+
/** Everything.exe 的候选位置,按优先级排列。 */
|
|
229
228
|
export function everythingCandidates() {
|
|
230
229
|
const programFiles = process.env.ProgramFiles || 'C:\\Program Files'
|
|
231
230
|
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'
|
|
@@ -233,13 +232,12 @@ export function everythingCandidates() {
|
|
|
233
232
|
}
|
|
234
233
|
|
|
235
234
|
/**
|
|
236
|
-
*
|
|
235
|
+
* Everything 本身装了没有,与 es.exe 无关。
|
|
237
236
|
*
|
|
238
|
-
* `sc query`
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* @returns {{installed: boolean, via?: 'exe'|'service', known: boolean}} install evidence
|
|
237
|
+
* 服务不存在时 `sc query` 退出码非零,这是本地化的 Windows 控制台下唯一还立得住的信号。装在
|
|
238
|
+
* Program Files 之外(或便携版)只能靠那个服务找到,所以一个根本没能跑起来的检查报
|
|
239
|
+
* `known: false`,而不是断言「没装」。
|
|
240
|
+
* @returns {{installed: boolean, via?: 'exe'|'service', known: boolean}} 安装证据
|
|
243
241
|
*/
|
|
244
242
|
export function everythingInstalled() {
|
|
245
243
|
for (const candidate of everythingCandidates()) {
|
|
@@ -249,26 +247,25 @@ export function everythingInstalled() {
|
|
|
249
247
|
execFileSync('sc.exe', ['query', 'Everything'], { encoding: 'utf8', windowsHide: true, timeout: 2000 })
|
|
250
248
|
return { installed: true, via: 'service', known: true }
|
|
251
249
|
} catch (error) {
|
|
252
|
-
//
|
|
253
|
-
//
|
|
250
|
+
// `status` 是数字说明 sc.exe 跑起来了、服务确实不在;spawn 失败(EPERM、ENOENT)说明不了
|
|
251
|
+
// 这台机器的任何事。
|
|
254
252
|
const ran = typeof error?.status === 'number'
|
|
255
253
|
return { installed: false, known: ran }
|
|
256
254
|
}
|
|
257
255
|
}
|
|
258
256
|
|
|
259
|
-
/**
|
|
257
|
+
/** 通过 Everything 检索文件名/路径。 */
|
|
260
258
|
export async function searchFiles({ query, dir, limit = 20, esPath, timeoutMs, signal, encoding } = {}) {
|
|
261
259
|
const executable = esPath || resolveEsPath({})
|
|
262
260
|
const args = buildEsArgs({ query, dir, limit })
|
|
263
261
|
const text = await runEsText(args, { esPath: executable, timeoutMs, signal, encoding })
|
|
264
262
|
const rows = parseEsJson(text)
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// plain "no files found".
|
|
263
|
+
// 截断按 es.exe 返回了什么来判,而不是按排除 $RECYCLE.BIN 之后剩下什么:一整页都被排除的
|
|
264
|
+
// 命中不能读成一句平平的 "no files found"。
|
|
268
265
|
return { items: withoutExcludedPaths(rows), truncated: rows.length >= limit }
|
|
269
266
|
}
|
|
270
267
|
|
|
271
|
-
/**
|
|
268
|
+
/** 读正在运行的 Everything 版本。 */
|
|
272
269
|
export async function getEsVersion({ esPath, timeoutMs, signal, encoding } = {}) {
|
|
273
270
|
const executable = esPath || resolveEsPath({})
|
|
274
271
|
const text = await runEsText(['-get-everything-version'], { esPath: executable, timeoutMs, signal, encoding })
|