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