@fishdivinity/dsh-local-search 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README-zh.md +167 -166
- package/README.md +177 -176
- package/cordis.patch.yml +9 -9
- package/icon.svg +35 -7
- package/lib/anytxt.js +272 -252
- package/lib/client.js +623 -0
- package/lib/config.js +101 -0
- package/lib/engines.js +85 -85
- package/lib/errors.js +18 -18
- package/lib/everything.js +277 -219
- package/lib/format.js +101 -92
- package/lib/index.js +77 -71
- package/lib/limits.js +14 -0
- package/lib/probe.js +88 -0
- package/lib/ref.js +38 -38
- package/lib/roots.js +114 -86
- package/lib/route.js +170 -0
- package/lib/tools.js +498 -506
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +86 -65
package/lib/anytxt.js
CHANGED
|
@@ -1,252 +1,272 @@
|
|
|
1
|
-
import { EngineError } from './errors.js'
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
throw new EngineError('Anytxt request
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
throw new EngineError('Anytxt
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
if (
|
|
94
|
-
throw new EngineError(`Anytxt
|
|
95
|
-
hint:
|
|
96
|
-
})
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
st,
|
|
173
|
-
},
|
|
174
|
-
{ timeoutMs, signal, fetchImpl },
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
1
|
+
import { EngineError } from './errors.js'
|
|
2
|
+
import { isLoopbackUrl } from './config.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_URL = 'http://127.0.0.1:9920'
|
|
5
|
+
const EXCLUDED = [/\\\$RECYCLE\.BIN\\/i, /\\System Volume Information\\/i]
|
|
6
|
+
const RPC_TIMEOUT_MS = 15000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Primary URL plus the 9921 fallback used by some Anytxt builds.
|
|
10
|
+
*
|
|
11
|
+
* This is where the loopback rule lives: the endpoint is read per call, so a
|
|
12
|
+
* profile that still names another machine fails only the content search, with
|
|
13
|
+
* a code the model can explain — instead of failing activation and taking
|
|
14
|
+
* file-name search down with it.
|
|
15
|
+
* @param url - configured endpoint
|
|
16
|
+
* @returns the endpoints to try, in order
|
|
17
|
+
* @throws {EngineError} ANYTXT_ENDPOINT_REMOTE when the endpoint is not local
|
|
18
|
+
*/
|
|
19
|
+
export function resolveAnytxtUrls(url = DEFAULT_URL) {
|
|
20
|
+
const base = String(url || DEFAULT_URL).replace(/\/+$/, '')
|
|
21
|
+
if (!isLoopbackUrl(base)) {
|
|
22
|
+
throw new EngineError(`Anytxt endpoint must be local, not ${base}`, 'ANYTXT_ENDPOINT_REMOTE', {
|
|
23
|
+
hint: 'Both indexes describe this machine, so the endpoint must be 127.0.0.1, localhost or [::1].',
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
const urls = [base]
|
|
27
|
+
if (/:9920$/.test(base)) urls.push(base.replace(/:9920$/, ':9921'))
|
|
28
|
+
return urls
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Quoted phrases use exact mode (st=1); other queries use advanced mode. */
|
|
32
|
+
export function quoteAware(query) {
|
|
33
|
+
const trimmed = String(query ?? '').trim()
|
|
34
|
+
if (/^".*"$/s.test(trimmed)) {
|
|
35
|
+
return { pattern: trimmed.slice(1, -1), st: '1' }
|
|
36
|
+
}
|
|
37
|
+
return { pattern: trimmed, st: '2' }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Remove Anytxt's match highlight markers from snippets. */
|
|
41
|
+
export function stripHighlights(text) {
|
|
42
|
+
return String(text ?? '').replace(/\*<<\*|\*>>\*/g, '')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseFileTuple(tuple) {
|
|
46
|
+
if (!Array.isArray(tuple) || tuple.length < 4) return undefined
|
|
47
|
+
const [fid, lastModify, size, file] = tuple
|
|
48
|
+
if (typeof file !== 'string' || EXCLUDED.some((re) => re.test(file))) return undefined
|
|
49
|
+
const numericSize = Number(size)
|
|
50
|
+
const numericModified = Number(lastModify)
|
|
51
|
+
const item = {
|
|
52
|
+
ref: String(fid),
|
|
53
|
+
path: file,
|
|
54
|
+
}
|
|
55
|
+
if (Number.isFinite(numericSize)) item.size = numericSize
|
|
56
|
+
if (Number.isFinite(numericModified) && numericModified > 0) {
|
|
57
|
+
item.modified = new Date(numericModified * 1000).toISOString()
|
|
58
|
+
}
|
|
59
|
+
return item
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** POST one JSON-RPC request and return `result.data.output`. */
|
|
63
|
+
export async function anytxtRpc(url, method, input, { timeoutMs = RPC_TIMEOUT_MS, signal, fetchImpl = fetch } = {}) {
|
|
64
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
65
|
+
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal
|
|
66
|
+
let response
|
|
67
|
+
try {
|
|
68
|
+
response = await fetchImpl(url, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ id: 1, jsonrpc: '2.0', method, params: { input } }),
|
|
72
|
+
signal: combined,
|
|
73
|
+
// A 307/308 would re-POST this body wherever the endpoint points, so a
|
|
74
|
+
// loopback service could bounce the request off-machine and have its
|
|
75
|
+
// answer trusted as index data.
|
|
76
|
+
redirect: 'error',
|
|
77
|
+
})
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (signal?.aborted) {
|
|
80
|
+
throw new EngineError('Anytxt request was aborted', 'ANYTXT_ABORTED', { cause: error })
|
|
81
|
+
}
|
|
82
|
+
if (error?.name === 'TimeoutError') {
|
|
83
|
+
throw new EngineError('Anytxt request timed out', 'ANYTXT_TIMEOUT', {
|
|
84
|
+
cause: error,
|
|
85
|
+
hint: 'Start Anytxt and retry; narrow the query if it is a large search.',
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
throw new EngineError('Anytxt JSON-RPC is unreachable', 'ANYTXT_DOWN', {
|
|
89
|
+
cause: error,
|
|
90
|
+
hint: `Start the Anytxt GUI (ATGUI.exe); the index service alone does not serve ${url}.`,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new EngineError(`Anytxt returned HTTP ${response.status}`, 'ANYTXT_HTTP', {
|
|
95
|
+
hint: `Check the Anytxt JSON-RPC endpoint at ${url}.`,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
let data
|
|
99
|
+
try {
|
|
100
|
+
data = await response.json()
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (signal?.aborted) {
|
|
103
|
+
throw new EngineError('Anytxt request was aborted', 'ANYTXT_ABORTED', { cause: error })
|
|
104
|
+
}
|
|
105
|
+
if (error?.name === 'TimeoutError') {
|
|
106
|
+
throw new EngineError('Anytxt response timed out', 'ANYTXT_TIMEOUT', { cause: error })
|
|
107
|
+
}
|
|
108
|
+
throw new EngineError('Anytxt returned non-JSON', 'ANYTXT_BAD_OUTPUT', { cause: error })
|
|
109
|
+
}
|
|
110
|
+
if (!data || typeof data !== 'object') {
|
|
111
|
+
throw new EngineError('Anytxt returned a non-object JSON value', 'ANYTXT_BAD_OUTPUT')
|
|
112
|
+
}
|
|
113
|
+
if (data.error) {
|
|
114
|
+
throw new EngineError(`Anytxt RPC error: ${JSON.stringify(data.error)}`, 'ANYTXT_RPC', {
|
|
115
|
+
hint: 'Check the query syntax; Anytxt uses & | ! ( ) and "exact phrase".',
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
const output = data.result?.data?.output
|
|
119
|
+
if (output === undefined) {
|
|
120
|
+
throw new EngineError('Anytxt returned no result.data.output', 'ANYTXT_RPC')
|
|
121
|
+
}
|
|
122
|
+
return output
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Try the primary URL, then the 9921 fallback only on connection failures. */
|
|
126
|
+
async function rpcFirst(urls, method, input, options) {
|
|
127
|
+
let lastError
|
|
128
|
+
for (const url of urls) {
|
|
129
|
+
try {
|
|
130
|
+
return await anytxtRpc(url, method, input, options)
|
|
131
|
+
} catch (error) {
|
|
132
|
+
lastError = error
|
|
133
|
+
if (error.code === 'ANYTXT_DOWN' && url !== urls[urls.length - 1]) continue
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
throw lastError
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Search document contents through Anytxt, one request pair per root. */
|
|
141
|
+
export async function searchContent({
|
|
142
|
+
query,
|
|
143
|
+
roots,
|
|
144
|
+
ext,
|
|
145
|
+
limit = 20,
|
|
146
|
+
url = DEFAULT_URL,
|
|
147
|
+
timeoutMs = RPC_TIMEOUT_MS,
|
|
148
|
+
signal,
|
|
149
|
+
fetchImpl = fetch,
|
|
150
|
+
} = {}) {
|
|
151
|
+
if (typeof query !== 'string' || query.trim().length === 0) {
|
|
152
|
+
throw new EngineError('query must be a non-empty string', 'INVALID_ARGUMENT')
|
|
153
|
+
}
|
|
154
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
155
|
+
throw new EngineError('limit must be a positive integer', 'INVALID_ARGUMENT')
|
|
156
|
+
}
|
|
157
|
+
const urls = resolveAnytxtUrls(url)
|
|
158
|
+
const { pattern, st } = quoteAware(query)
|
|
159
|
+
const byRef = new Map()
|
|
160
|
+
let total = 0
|
|
161
|
+
|
|
162
|
+
for (const root of roots) {
|
|
163
|
+
const countOut = await rpcFirst(
|
|
164
|
+
urls,
|
|
165
|
+
'ATRpcServer.Searcher.V1.Search',
|
|
166
|
+
{
|
|
167
|
+
pattern,
|
|
168
|
+
filterDir: root,
|
|
169
|
+
filterExt: ext || '*',
|
|
170
|
+
lastModifyBegin: 0,
|
|
171
|
+
lastModifyEnd: 2147483647,
|
|
172
|
+
st,
|
|
173
|
+
},
|
|
174
|
+
{ timeoutMs, signal, fetchImpl },
|
|
175
|
+
)
|
|
176
|
+
const count = typeof countOut.count === 'number' ? countOut.count : 0
|
|
177
|
+
total += count
|
|
178
|
+
if (count === 0) continue
|
|
179
|
+
|
|
180
|
+
const page = await rpcFirst(
|
|
181
|
+
urls,
|
|
182
|
+
'ATRpcServer.Searcher.V1.GetResult',
|
|
183
|
+
{
|
|
184
|
+
pattern,
|
|
185
|
+
filterDir: root,
|
|
186
|
+
filterExt: ext || '*',
|
|
187
|
+
lastModifyBegin: 0,
|
|
188
|
+
lastModifyEnd: 2147483647,
|
|
189
|
+
limit: String(limit),
|
|
190
|
+
offset: 0,
|
|
191
|
+
order: 0,
|
|
192
|
+
st,
|
|
193
|
+
},
|
|
194
|
+
{ timeoutMs, signal, fetchImpl },
|
|
195
|
+
)
|
|
196
|
+
for (const tuple of page.files || []) {
|
|
197
|
+
const item = parseFileTuple(tuple)
|
|
198
|
+
if (item && !byRef.has(item.ref)) byRef.set(item.ref, item)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const items = [...byRef.values()].slice(0, limit)
|
|
203
|
+
return { items, total, truncated: total > items.length }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Snippets around a match in one indexed file. */
|
|
207
|
+
export async function getFragments({
|
|
208
|
+
ref,
|
|
209
|
+
query,
|
|
210
|
+
url = DEFAULT_URL,
|
|
211
|
+
timeoutMs = RPC_TIMEOUT_MS,
|
|
212
|
+
signal,
|
|
213
|
+
fetchImpl = fetch,
|
|
214
|
+
} = {}) {
|
|
215
|
+
if (!ref) throw new EngineError('ref must be a non-empty string', 'INVALID_ARGUMENT')
|
|
216
|
+
if (typeof query !== 'string' || query.trim().length === 0) {
|
|
217
|
+
throw new EngineError('query must be a non-empty string', 'INVALID_ARGUMENT')
|
|
218
|
+
}
|
|
219
|
+
const output = await rpcFirst(
|
|
220
|
+
resolveAnytxtUrls(url),
|
|
221
|
+
'ATRpcServer.Searcher.V1.GetFragmentAll',
|
|
222
|
+
{ fid: ref, pattern: query },
|
|
223
|
+
{ timeoutMs, signal, fetchImpl },
|
|
224
|
+
)
|
|
225
|
+
return (output.text || []).map(stripHighlights).filter(Boolean)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Full extracted text for one indexed file. */
|
|
229
|
+
export async function getRawText({
|
|
230
|
+
ref,
|
|
231
|
+
url = DEFAULT_URL,
|
|
232
|
+
timeoutMs = RPC_TIMEOUT_MS,
|
|
233
|
+
signal,
|
|
234
|
+
fetchImpl = fetch,
|
|
235
|
+
} = {}) {
|
|
236
|
+
if (!ref) throw new EngineError('ref must be a non-empty string', 'INVALID_ARGUMENT')
|
|
237
|
+
const output = await rpcFirst(
|
|
238
|
+
resolveAnytxtUrls(url),
|
|
239
|
+
'ATRpcServer.Searcher.V1.GetRawTextByFID',
|
|
240
|
+
{ fid: ref },
|
|
241
|
+
{ timeoutMs, signal, fetchImpl },
|
|
242
|
+
)
|
|
243
|
+
return typeof output.text === 'string' ? output.text : ''
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Probe Anytxt without throwing. */
|
|
247
|
+
export async function anytxtStatus({
|
|
248
|
+
url = DEFAULT_URL,
|
|
249
|
+
root = process.env.SystemDrive ? `${process.env.SystemDrive}\\` : 'C:\\',
|
|
250
|
+
timeoutMs = 1500,
|
|
251
|
+
signal,
|
|
252
|
+
fetchImpl = fetch,
|
|
253
|
+
} = {}) {
|
|
254
|
+
const urls = resolveAnytxtUrls(url)
|
|
255
|
+
try {
|
|
256
|
+
await rpcFirst(
|
|
257
|
+
urls,
|
|
258
|
+
'ATRpcServer.Searcher.V1.Search',
|
|
259
|
+
{
|
|
260
|
+
pattern: 'the',
|
|
261
|
+
filterDir: root,
|
|
262
|
+
filterExt: 'txt',
|
|
263
|
+
lastModifyBegin: 0,
|
|
264
|
+
lastModifyEnd: 2147483647,
|
|
265
|
+
},
|
|
266
|
+
{ timeoutMs, signal, fetchImpl },
|
|
267
|
+
)
|
|
268
|
+
return { ok: true, url: urls[0] }
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return { ok: false, code: error.code ?? 'ANYTXT_DOWN', message: error.message, url: urls[0] }
|
|
271
|
+
}
|
|
272
|
+
}
|