@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/lib/config.js CHANGED
@@ -1,47 +1,187 @@
1
1
  /**
2
- * Config access shared by activation and every tool call.
2
+ * 配置的路径表、默认值与读取规则,activate 与每次工具调用共用。
3
3
  *
4
- * The fields the Plugins page may edit are declared `.volatile()` in the
5
- * schema, so the Host hands `apply` a live reference instead of the value
6
- * frozen at activation. A save on the page updates that reference in place;
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
- * `timeoutMs` is deliberately not in {@link LIVE_KEYS}: `defineTool` fixes each
11
- * tool's deadline when the tool is registered, so a new value could not take
12
- * effect without reloading the row. It stays a profile-YAML setting.
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
- /** Config keys the Plugins page edits. */
16
- export const LIVE_KEYS = [
17
- 'esPath',
18
- 'esEncoding',
19
- 'anytxtUrl',
20
- 'workspaceRoot',
21
- 'workRoot',
22
- 'personalRoot',
23
- 'allRoots',
24
- 'maxResults',
25
- 'readMaxChars',
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
- * Read a value that may still be a live volatile reference.
30
- * @param {unknown} value - plain value, or a reference carrying `get()`
31
- * @returns {unknown} the current value
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
- * Snapshot the whole config as plain values.
39
- * @param {object} config - schema output, volatile references included
40
- * @returns {object} plain values for this operation
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 key of [...LIVE_KEYS, 'timeoutMs']) snapshot[key] = liveValue(config[key])
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
- /** Hosts that name this machine. */
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
- * Whether a value is an `http(s)` URL that names this machine.
204
+ * 值是否是指向本机的 `http(s)` 地址。
59
205
  *
60
- * Both indexes describe the local machine: `es.exe` is spawned here, and every
61
- * Anytxt result carries a path that only resolves here. A remote endpoint would
62
- * answer with another machine's paths, so it is refused rather than supported —
63
- * tunnelling one in lands it on loopback anyway.
64
- * @param {unknown} value - candidate endpoint
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
- * Validate a candidate config and return its plain snapshot. Run once at
80
- * activation; the page's writes are validated by the Host against the Config
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
- * The endpoint's loopback rule is deliberately *not* enforced here. A profile
85
- * that 0.1 accepted must still activate: a wrong address should fail the content
86
- * engine with a readable error, not take file-name search down with it. The
87
- * rule lives where the endpoint is used (`lib/anytxt.js`).
88
- * @param {object} config - schema output, volatile references included
89
- * @param {number} [maxTimerDelayMs] - largest deadline the timer API accepts
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
- assertPositiveInteger('maxResults', values.maxResults)
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
  }
@@ -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
- * Engine registry. A new filename or content backend is one entry here plus
8
- * its module; the tool surface and `engine` values stay unchanged.
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.config ?? {}),
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
- /** Resolve a filename engine; `auto` selects the first registered engine. */
69
+ /** 解析文件名引擎;`auto` 取注册表里的第一个。 */
70
70
  export function resolveFilesEngine(requested = 'auto') {
71
71
  return resolve(filesEngines, 'files', requested)
72
72
  }
73
73
 
74
- /** Resolve a content engine; `auto` selects the first registered engine. */
74
+ /** 解析内容引擎;`auto` 取注册表里的第一个。 */
75
75
  export function resolveContentEngine(requested = 'auto') {
76
76
  return resolve(contentEngines, 'content', requested)
77
77
  }
78
78
 
79
- /** Engine names for status output and future config pages. */
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
- * Engine-level error with a stable code. Kept independent of the DSH runtime
3
- * so engine modules and their unit tests can run under plain Node; the tool
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
- /** Decode es.exe stdout using the console code page (GBK on zh-CN). */
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
- /** Candidate es.exe locations, in priority order. */
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
- /** Resolve es.exe from config, env, PATH, or common install directories. */
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 is unavailable or found nothing; fall through.
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
- /** Build the fixed, read-only es.exe argv. Model text is always after `--`. */
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
- /** Run es.exe with a bounded buffer, timeout, and cancellation. */
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
- // Process already gone.
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
- * Whether an es.exe exit means "no answer" rather than "no match".
183
+ * es.exe 这次退出是「没有答案」还是「没有命中」。
184
184
  *
185
- * es.exe uses exit 1 both for "nothing matched" and for a failed connection, so
186
- * the code alone cannot decide; an empty stdout next to stderr text is the
187
- * failure, anything else is a result.
188
- * @param {number} code - the process exit code
189
- * @param {string} output - trimmed stdout
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
- /** Parse es.exe `-json` output into rows, before any exclusion filter. */
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
- * Drop rows that live in an index we never surface.
221
- * @param {Array<{path: string}>} rows - parsed rows
222
- * @returns {Array<{path: string}>} the rows a tool may return
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
- /** Candidate Everything.exe locations, in priority order. */
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
- * Whether Everything itself is installed, independent of es.exe.
235
+ * Everything 本身装了没有,与 es.exe 无关。
237
236
  *
238
- * `sc query` exits non-zero when the service is absent, which is the one signal
239
- * that survives a localized Windows console. An install outside Program Files
240
- * (or a portable one) is only found through that service, so a check that could
241
- * not run at all reports `known: false` instead of claiming "not installed".
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
- // A numeric `status` means sc.exe ran and the service is absent; a spawn
253
- // failure (EPERM, ENOENT) proves nothing about the machine.
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
- /** Search file names/paths through Everything. */
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
- // Truncation is decided on what es.exe returned, not on what survived the
266
- // $RECYCLE.BIN filter: a capped page of excluded hits must not read as a
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
- /** Read the running Everything version. */
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 })