@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/format.js
CHANGED
|
@@ -1,92 +1,101 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Model-facing formatting. Results stay bounded: callers cap items and text
|
|
3
|
-
* before these functions run.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/** Convert a Windows FILETIME (100 ns ticks since 1601) to an ISO string. */
|
|
7
|
-
export function filetimeToIso(ft) {
|
|
8
|
-
if (typeof ft !== 'number' || ft <= 0) return undefined
|
|
9
|
-
const date = new Date(ft / 10000 - 11644473600000)
|
|
10
|
-
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function sizeSuffix(size) {
|
|
14
|
-
return typeof size === 'number' && Number.isFinite(size) ? `\t${size} B` : ''
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function modifiedSuffix(modified) {
|
|
18
|
-
return typeof modified === 'string' && modified.length > 0 ? `\t${modified}` : ''
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Format the files result (Everything). */
|
|
22
|
-
export function formatFiles(value) {
|
|
23
|
-
const items = Array.isArray(value.items) ? value.items : []
|
|
24
|
-
if (items.length === 0)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
return
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Format
|
|
51
|
-
export function
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
lines
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing formatting. Results stay bounded: callers cap items and text
|
|
3
|
+
* before these functions run.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Convert a Windows FILETIME (100 ns ticks since 1601) to an ISO string. */
|
|
7
|
+
export function filetimeToIso(ft) {
|
|
8
|
+
if (typeof ft !== 'number' || ft <= 0) return undefined
|
|
9
|
+
const date = new Date(ft / 10000 - 11644473600000)
|
|
10
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sizeSuffix(size) {
|
|
14
|
+
return typeof size === 'number' && Number.isFinite(size) ? `\t${size} B` : ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function modifiedSuffix(modified) {
|
|
18
|
+
return typeof modified === 'string' && modified.length > 0 ? `\t${modified}` : ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Format the files result (Everything). */
|
|
22
|
+
export function formatFiles(value) {
|
|
23
|
+
const items = Array.isArray(value.items) ? value.items : []
|
|
24
|
+
if (items.length === 0) {
|
|
25
|
+
// The cap is applied before the index filter, so an empty window can still
|
|
26
|
+
// mean "more existed": say so instead of a flat "no files found".
|
|
27
|
+
return value.truncated
|
|
28
|
+
? 'No files found in the returned window (the result set was capped; narrow the query).'
|
|
29
|
+
: 'No files found.'
|
|
30
|
+
}
|
|
31
|
+
const header = value.truncated
|
|
32
|
+
? `Found ${items.length} path(s) (capped)`
|
|
33
|
+
: `Found ${items.length} path(s)`
|
|
34
|
+
const body = items
|
|
35
|
+
.map((item) => `${item.path}${sizeSuffix(item.size)}${modifiedSuffix(item.modified)}`)
|
|
36
|
+
.join('\n')
|
|
37
|
+
return `${header}\n\n${body}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Format the content result (Anytxt). */
|
|
41
|
+
export function formatContent(value) {
|
|
42
|
+
const items = Array.isArray(value.items) ? value.items : []
|
|
43
|
+
if (items.length === 0) return `No content matches (indexed total ${value.total ?? 0}).`
|
|
44
|
+
const header = `${items.length} of ${value.total ?? items.length} indexed file(s) match`
|
|
45
|
+
const body = items.map((item) => `${item.ref}\t${item.path}`).join('\n')
|
|
46
|
+
const footer = value.truncated ? '\n\n(Showing the first results; narrow the query or raise limit to see more.)' : ''
|
|
47
|
+
return `${header}\n\n${body}${footer}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Format fragments from one indexed document. */
|
|
51
|
+
export function formatFragments(value) {
|
|
52
|
+
const fragments = Array.isArray(value.fragments) ? value.fragments.filter(Boolean) : []
|
|
53
|
+
return fragments.length > 0 ? fragments.join('\n---\n') : 'No fragments returned.'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Format a document excerpt with its total length. */
|
|
57
|
+
export function formatRead(value) {
|
|
58
|
+
const total = typeof value.totalChars === 'number' ? value.totalChars : 0
|
|
59
|
+
const offset = typeof value.offset === 'number' ? value.offset : 0
|
|
60
|
+
const returned = typeof value.returned === 'number' ? value.returned : 0
|
|
61
|
+
if (returned === 0) return `chars ${total}; nothing to show at offset ${offset}`
|
|
62
|
+
// The end is inclusive so a reader can subtract the two numbers directly.
|
|
63
|
+
return `chars ${total}; showing ${offset}..${offset + returned - 1}\n\n${value.text ?? ''}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One engine's status line.
|
|
68
|
+
*
|
|
69
|
+
* An aborted probe means this call was cancelled, not that the index is down —
|
|
70
|
+
* reporting it as DOWN sends the reader after the wrong problem.
|
|
71
|
+
* @param {string} label - engine label
|
|
72
|
+
* @param {object} status - the probe result
|
|
73
|
+
* @param {(status: object) => string} okDetail - the suffix a healthy engine adds
|
|
74
|
+
* @returns {string} the line
|
|
75
|
+
*/
|
|
76
|
+
function engineLine(label, status, okDetail) {
|
|
77
|
+
if (status.ok) return `${label}: OK${okDetail(status)}`
|
|
78
|
+
if (status.code === 'SKIPPED') return `${label}: SKIPPED${status.message ? ` — ${status.message}` : ''}`
|
|
79
|
+
if (typeof status.code === 'string' && status.code.endsWith('_ABORTED')) {
|
|
80
|
+
return `${label}: ABORTED — ${status.code}`
|
|
81
|
+
}
|
|
82
|
+
return `${label}: DOWN — ${status.code ?? 'UNKNOWN'}${status.message ? `: ${status.message}` : ''}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Format both engine statuses and the configured roots. */
|
|
86
|
+
export function formatStatus(value) {
|
|
87
|
+
const lines = [
|
|
88
|
+
engineLine('Everything', value.everything ?? {}, (status) =>
|
|
89
|
+
`${status.version ? ` (${status.version})` : ''}${status.path ? ` via ${status.path}` : ''}`),
|
|
90
|
+
engineLine('Anytxt', value.anytxt ?? {}, (status) => (status.url ? ` at ${status.url}` : '')),
|
|
91
|
+
]
|
|
92
|
+
const roots = value.roots ?? {}
|
|
93
|
+
lines.push(`workspace=${roots.workspace ?? ''}`)
|
|
94
|
+
lines.push(`work=${roots.work ?? ''}`)
|
|
95
|
+
lines.push(`personal=${roots.personal ?? ''}`)
|
|
96
|
+
lines.push(`all=${Array.isArray(roots.all) ? roots.all.join(',') : ''}`)
|
|
97
|
+
if (value.engines) {
|
|
98
|
+
lines.push(`engines=files:${(value.engines.files ?? []).join(',')} content:${(value.engines.content ?? []).join(',')}`)
|
|
99
|
+
}
|
|
100
|
+
return lines.join('\n')
|
|
101
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,71 +1,77 @@
|
|
|
1
|
-
import z from '@deepseek-ai/schemastery'
|
|
2
|
-
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
3
|
-
import { createTools } from './tools.js'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
export const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
|
|
1
|
+
import z from '@deepseek-ai/schemastery'
|
|
2
|
+
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
3
|
+
import { createTools } from './tools.js'
|
|
4
|
+
import { validateConfig } from './config.js'
|
|
5
|
+
import { registerStatusRoute } from './route.js'
|
|
6
|
+
import { MAX_LIMIT, MAX_READ } from './limits.js'
|
|
7
|
+
|
|
8
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
9
|
+
export const name = 'local-search'
|
|
10
|
+
|
|
11
|
+
/** Services required by the tools. */
|
|
12
|
+
export const inject = ['tools', 'systemPrompt', 'workingDirectory']
|
|
13
|
+
|
|
14
|
+
// Every field the Plugins page edits is volatile: that is what makes the
|
|
15
|
+
// settings namespace served and a page write accepted. timeoutMs stays a
|
|
16
|
+
// fixed number because tool registration captures it (see lib/config.js).
|
|
17
|
+
// esEncoding stays a plain string on purpose: 0.1 accepted any label and fell
|
|
18
|
+
// back to UTF-8 in `decodeEs`, so narrowing it to a union would refuse to
|
|
19
|
+
// activate a profile that worked yesterday. The page offers the two labels
|
|
20
|
+
// that matter; YAML keeps the old tolerance.
|
|
21
|
+
export const Config = z.object({
|
|
22
|
+
esPath: z.string().default('').volatile().description('Absolute path to es.exe; empty auto-detects PATH and common install folders.'),
|
|
23
|
+
anytxtUrl: z.string().default('http://127.0.0.1:9920').volatile().description('Local Anytxt JSON-RPC endpoint on this machine (127.0.0.1, localhost or [::1]); :9921 is tried as a fallback.'),
|
|
24
|
+
workspaceRoot: z.string().default('').volatile().description('Root for scope=workspace; empty uses the session working directory.'),
|
|
25
|
+
workRoot: z.string().default('').volatile().description('Root for scope=work; required before using that scope.'),
|
|
26
|
+
personalRoot: z.string().default('').volatile().description('Root for scope=personal; required before using that scope.'),
|
|
27
|
+
allRoots: z.string().default('').volatile().description('Semicolon-separated roots for Anytxt scope=all; empty uses C: and D: when present.'),
|
|
28
|
+
maxResults: z.number().min(1).max(MAX_LIMIT).default(20).volatile().description(`Default result cap; the hard cap is ${MAX_LIMIT}.`),
|
|
29
|
+
timeoutMs: z.number().default(30000).description('Per-call timeout budget in milliseconds; profile YAML only, because each tool fixes its deadline at registration.'),
|
|
30
|
+
readMaxChars: z.number().min(1).max(MAX_READ).default(8000).volatile().description(`Default local_search_read window; the hard cap is ${MAX_READ} characters.`),
|
|
31
|
+
esEncoding: z.string().default('gbk').volatile().description('Code page used to decode es.exe output: gbk for a zh-CN console, utf-8 otherwise. Any TextDecoder label is accepted; the page offers these two.'),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export { validateConfig }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register the five local-search tools, their routing guidance, and the
|
|
38
|
+
* configuration page's probe route.
|
|
39
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
40
|
+
* @param {object} config - schema output; volatile fields are live references
|
|
41
|
+
*/
|
|
42
|
+
export async function apply(ctx, config) {
|
|
43
|
+
validateConfig(config, MAX_TIMER_DELAY_MS)
|
|
44
|
+
|
|
45
|
+
ctx.effect(() => {
|
|
46
|
+
const disposers = createTools(ctx, config).map((tool) => ctx.tools.register(tool))
|
|
47
|
+
return () => {
|
|
48
|
+
for (const dispose of disposers) dispose()
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Routing hint, not a tool catalogue: each tool description already says what
|
|
53
|
+
// it is for, so this only orders the choice and names the fallback. Four
|
|
54
|
+
// clauses, ~250 characters — half the previous text for the same decision.
|
|
55
|
+
ctx.effect(() =>
|
|
56
|
+
ctx.systemPrompt.section({
|
|
57
|
+
name: 'tool:local_search',
|
|
58
|
+
order: 106,
|
|
59
|
+
text:
|
|
60
|
+
'Names anywhere outside the workspace: local_search_files. ' +
|
|
61
|
+
'Document text (PDF/Office/EPUB, whole disk): local_search_content, then fragments/read. ' +
|
|
62
|
+
'Workspace code and text: grep/glob. ' +
|
|
63
|
+
'On *_DOWN, fall back to grep/glob and say so.',
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// The configuration page's Test control: the browser cannot call the local
|
|
68
|
+
// Anytxt JSON-RPC itself, so the Host answers for it. A deployment without a
|
|
69
|
+
// web server simply has no Test control and no route.
|
|
70
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
71
|
+
const server = webCtx.get('webServer')
|
|
72
|
+
webCtx.effect(
|
|
73
|
+
() => registerStatusRoute(server, config),
|
|
74
|
+
'local-search: configuration page probe route',
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
}
|
package/lib/limits.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hard caps one call may ask for.
|
|
3
|
+
*
|
|
4
|
+
* The schema caps the saved default, the tools clamp a per-call argument, and
|
|
5
|
+
* the configuration page mirrors the same numbers so a save cannot be rejected.
|
|
6
|
+
* They live here to stay one value; the page cannot import this file (a classic
|
|
7
|
+
* script has no ESM imports), so its copy is checked by a test instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Hard cap on a result count. */
|
|
11
|
+
export const MAX_LIMIT = 100
|
|
12
|
+
|
|
13
|
+
/** Hard cap on characters one read returns. */
|
|
14
|
+
export const MAX_READ = 20000
|
package/lib/probe.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One probe of both local indexes, shared by the model-facing
|
|
3
|
+
* `local_search_status` tool and the configuration page's Test control.
|
|
4
|
+
*
|
|
5
|
+
* They answer the same question — "can this machine reach its indexes right
|
|
6
|
+
* now?" — so they must not drift into two slightly different probes. The
|
|
7
|
+
* probe goes through the engine registry, so a new backend is still one entry
|
|
8
|
+
* in `lib/engines.js`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readConfig } from './config.js'
|
|
12
|
+
import { resolveContentEngine, resolveFilesEngine } from './engines.js'
|
|
13
|
+
import { everythingInstalled } from './everything.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Which link of the Everything chain is missing.
|
|
17
|
+
*
|
|
18
|
+
* This detail is for the person reading the configuration page, who can install
|
|
19
|
+
* or start something; the model keeps the two stable codes. When the install
|
|
20
|
+
* check itself could not run, the answer is `unknown` and the page falls back to
|
|
21
|
+
* its generic hint rather than telling someone to install what they already have.
|
|
22
|
+
* @param {string} code - the error code the probe produced
|
|
23
|
+
* @returns {'not-installed'|'not-running'|'es-missing'|'unknown'} the actionable cause
|
|
24
|
+
*/
|
|
25
|
+
export function diagnoseEverything(code, presence = everythingInstalled()) {
|
|
26
|
+
if (!presence.known) return 'unknown'
|
|
27
|
+
if (!presence.installed) return 'not-installed'
|
|
28
|
+
return code === 'EVERYTHING_ES_NOT_FOUND' ? 'es-missing' : 'not-running'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Probe the configured indexes once, never throwing.
|
|
33
|
+
* @param {object} config - schema output, volatile references included
|
|
34
|
+
* @param {object} [options]
|
|
35
|
+
* @param {string} [options.only] - `auto` probes both; `anytxt` / `everything` skips the other
|
|
36
|
+
* @param {string} [options.workspace] - root Anytxt is asked about
|
|
37
|
+
* @param {string} [options.anytxtUrl] - endpoint to probe instead of the saved one (a page draft)
|
|
38
|
+
* @param {boolean} [options.detail] - add the page-facing cause to a failed engine
|
|
39
|
+
* @param {AbortSignal} [options.signal] - caller cancellation
|
|
40
|
+
* @returns {Promise<{everything: object, anytxt: object}>} both statuses
|
|
41
|
+
*/
|
|
42
|
+
export async function probeEngines(config, { only = 'auto', workspace, anytxtUrl, signal, detail = false } = {}) {
|
|
43
|
+
const cfg = readConfig(config)
|
|
44
|
+
const everything = { ok: false }
|
|
45
|
+
const anytxt = { ok: false }
|
|
46
|
+
const budget = Number.isFinite(cfg.timeoutMs) ? cfg.timeoutMs : 5000
|
|
47
|
+
// A probe is a diagnostic, not a search: it gets a floor as well as a ceiling,
|
|
48
|
+
// because a tiny timeoutMs would report a healthy engine as DOWN.
|
|
49
|
+
const probeBudget = Math.max(1500, Math.min(budget, 5000))
|
|
50
|
+
|
|
51
|
+
if (only !== 'anytxt') {
|
|
52
|
+
try {
|
|
53
|
+
Object.assign(everything, await resolveFilesEngine('everything').status({
|
|
54
|
+
config: cfg,
|
|
55
|
+
esPath: cfg.esPath,
|
|
56
|
+
timeoutMs: probeBudget,
|
|
57
|
+
signal,
|
|
58
|
+
encoding: cfg.esEncoding,
|
|
59
|
+
}))
|
|
60
|
+
} catch (error) {
|
|
61
|
+
everything.code = error.code || 'EVERYTHING_DOWN'
|
|
62
|
+
everything.message = error.message
|
|
63
|
+
if (detail && everything.code !== 'EVERYTHING_ABORTED') everything.detail = diagnoseEverything(everything.code)
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
everything.code = 'SKIPPED'
|
|
67
|
+
everything.message = 'not checked because engine=anytxt'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (only !== 'everything') {
|
|
71
|
+
try {
|
|
72
|
+
Object.assign(anytxt, await resolveContentEngine('anytxt').status({
|
|
73
|
+
url: anytxtUrl || cfg.anytxtUrl,
|
|
74
|
+
root: workspace || cfg.workspaceRoot || process.cwd(),
|
|
75
|
+
timeoutMs: probeBudget,
|
|
76
|
+
signal,
|
|
77
|
+
}))
|
|
78
|
+
} catch (error) {
|
|
79
|
+
anytxt.code = error.code || 'ANYTXT_DOWN'
|
|
80
|
+
anytxt.message = error.message
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
anytxt.code = 'SKIPPED'
|
|
84
|
+
anytxt.message = 'not checked because engine=everything'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { everything, anytxt }
|
|
88
|
+
}
|
package/lib/ref.js
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import { EngineError } from './errors.js'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Refs are self-describing: `<engine>:<id>`. This keeps a Recoll docid, an
|
|
5
|
-
* Everything fid, and a future Windows Search item from colliding when the
|
|
6
|
-
* model passes a stale ref back to local_search_read/local_search_fragments.
|
|
7
|
-
*/
|
|
8
|
-
export function formatRef(engine, id) {
|
|
9
|
-
if (!engine || typeof engine !== 'string') {
|
|
10
|
-
throw new EngineError('engine name is required to format a ref', 'INVALID_REF')
|
|
11
|
-
}
|
|
12
|
-
if (id === undefined || id === null || String(id).length === 0) {
|
|
13
|
-
throw new EngineError('ref id is required', 'INVALID_REF')
|
|
14
|
-
}
|
|
15
|
-
return `${engine}:${String(id)}`
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Parse `<engine>:<id>`; reject bare ids with an actionable hint. */
|
|
19
|
-
export function parseRef(ref) {
|
|
20
|
-
const text = String(ref ?? '')
|
|
21
|
-
const separator = text.indexOf(':')
|
|
22
|
-
if (separator <= 0 || separator === text.length - 1) {
|
|
23
|
-
throw new EngineError(`ref must look like "<engine>:<id>", got "${text}"`, 'INVALID_REF', {
|
|
24
|
-
hint: 'Rerun local_search_content and pass the item ref unchanged.',
|
|
25
|
-
})
|
|
26
|
-
}
|
|
27
|
-
return { engine: text.slice(0, separator), id: text.slice(separator + 1) }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Ensure a ref belongs to the resolved engine. */
|
|
31
|
-
export function assertRefEngine(parsed, engineName) {
|
|
32
|
-
if (parsed.engine !== engineName) {
|
|
33
|
-
throw new EngineError(`ref belongs to ${parsed.engine}, not ${engineName}`, 'INVALID_REF', {
|
|
34
|
-
hint: 'Pass the item ref and its engine together, unchanged.',
|
|
35
|
-
})
|
|
36
|
-
}
|
|
37
|
-
return parsed
|
|
38
|
-
}
|
|
1
|
+
import { EngineError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Refs are self-describing: `<engine>:<id>`. This keeps a Recoll docid, an
|
|
5
|
+
* Everything fid, and a future Windows Search item from colliding when the
|
|
6
|
+
* model passes a stale ref back to local_search_read/local_search_fragments.
|
|
7
|
+
*/
|
|
8
|
+
export function formatRef(engine, id) {
|
|
9
|
+
if (!engine || typeof engine !== 'string') {
|
|
10
|
+
throw new EngineError('engine name is required to format a ref', 'INVALID_REF')
|
|
11
|
+
}
|
|
12
|
+
if (id === undefined || id === null || String(id).length === 0) {
|
|
13
|
+
throw new EngineError('ref id is required', 'INVALID_REF')
|
|
14
|
+
}
|
|
15
|
+
return `${engine}:${String(id)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Parse `<engine>:<id>`; reject bare ids with an actionable hint. */
|
|
19
|
+
export function parseRef(ref) {
|
|
20
|
+
const text = String(ref ?? '')
|
|
21
|
+
const separator = text.indexOf(':')
|
|
22
|
+
if (separator <= 0 || separator === text.length - 1) {
|
|
23
|
+
throw new EngineError(`ref must look like "<engine>:<id>", got "${text}"`, 'INVALID_REF', {
|
|
24
|
+
hint: 'Rerun local_search_content and pass the item ref unchanged.',
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
return { engine: text.slice(0, separator), id: text.slice(separator + 1) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Ensure a ref belongs to the resolved engine. */
|
|
31
|
+
export function assertRefEngine(parsed, engineName) {
|
|
32
|
+
if (parsed.engine !== engineName) {
|
|
33
|
+
throw new EngineError(`ref belongs to ${parsed.engine}, not ${engineName}`, 'INVALID_REF', {
|
|
34
|
+
hint: 'Pass the item ref and its engine together, unchanged.',
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
return parsed
|
|
38
|
+
}
|