dsh-locator 0.8.2 → 0.9.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/CHANGELOG.md +13 -0
- package/README-es.md +21 -5
- package/README-hi.md +22 -5
- package/README-pt.md +22 -5
- package/README.en.md +22 -5
- package/README.md +22 -5
- package/client.js +931 -41
- package/lib/combined.d.ts +20 -3
- package/lib/combined.d.ts.map +1 -1
- package/lib/combined.js +72 -12
- package/lib/combined.js.map +1 -1
- package/lib/content-search-worker.d.ts +3 -3
- package/lib/content-search-worker.js +11 -6
- package/lib/content-search-worker.js.map +1 -1
- package/lib/content-search.d.ts +81 -7
- package/lib/content-search.d.ts.map +1 -1
- package/lib/content-search.js +454 -36
- package/lib/content-search.js.map +1 -1
- package/lib/content.d.ts +20 -0
- package/lib/content.d.ts.map +1 -1
- package/lib/content.js +71 -21
- package/lib/content.js.map +1 -1
- package/lib/find.d.ts.map +1 -1
- package/lib/find.js +2 -1
- package/lib/find.js.map +1 -1
- package/lib/http.d.ts.map +1 -1
- package/lib/http.js +9 -2
- package/lib/http.js.map +1 -1
- package/package.json +1 -1
- package/src/combined.ts +95 -14
- package/src/content-search-worker.ts +15 -8
- package/src/content-search.ts +559 -36
- package/src/content.ts +81 -18
- package/src/find.ts +3 -2
- package/src/http.ts +11 -2
package/src/content-search.ts
CHANGED
|
@@ -1,48 +1,571 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Content FTS5 query
|
|
2
|
+
* Content FTS5 query engine: normal substring search, Notepad++-style extended
|
|
3
|
+
* escaping, case-sensitive GLOB matching and whole-word post-filtering, with
|
|
4
|
+
* exact or bounded counts.
|
|
5
|
+
*
|
|
6
|
+
* Shared by the host thread, the search worker and the combined panel search.
|
|
7
|
+
* The page path is deliberately two-phase: rank the rowids first, then read the
|
|
8
|
+
* few bodies and build the snippet in JS. Putting `snippet()` in the same
|
|
9
|
+
* `ORDER BY rank` statement made a SQLite sorter evaluate it for every match
|
|
10
|
+
* (measured 2.7 s for "the" on a 1 GB corpus; 60 ms with this split).
|
|
3
11
|
* @module dsh-locator/content-search
|
|
4
12
|
*/
|
|
5
13
|
|
|
6
14
|
import type { DatabaseSync } from 'node:sqlite'
|
|
7
15
|
import type { ContentHit } from './content.ts'
|
|
8
16
|
|
|
17
|
+
/** How the query text is interpreted. */
|
|
18
|
+
export type ContentMatchMode = 'normal' | 'extended' | 'regex'
|
|
19
|
+
|
|
20
|
+
/** How much work the caller wants the count to spend. */
|
|
21
|
+
export type ContentCountMode = 'exact' | 'bounded' | 'none'
|
|
22
|
+
|
|
23
|
+
/** A match count, with its confidence. `bounded` means "at least value". */
|
|
24
|
+
export interface ContentCount {
|
|
25
|
+
value: number
|
|
26
|
+
kind: 'exact' | 'bounded' | 'unknown'
|
|
27
|
+
limit?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Options accepted by {@link searchContent} and the content search worker. */
|
|
31
|
+
export interface ContentSearchOptions {
|
|
32
|
+
matchMode?: ContentMatchMode
|
|
33
|
+
caseSensitive?: boolean
|
|
34
|
+
wholeWord?: boolean
|
|
35
|
+
/** Resolved by ContentIndex from the request's sessionId. */
|
|
36
|
+
sessionId?: string
|
|
37
|
+
/** Session log path; set by ContentIndex after resolving sessionId. */
|
|
38
|
+
sessionPath?: string | null
|
|
39
|
+
count?: ContentCountMode
|
|
40
|
+
countLimit?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Result of one content query. */
|
|
44
|
+
export interface ContentSearchResult {
|
|
45
|
+
hits: ContentHit[]
|
|
46
|
+
count: ContentCount
|
|
47
|
+
warnings: string[]
|
|
48
|
+
error?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Shortest text a trigram tokenizer can index. */
|
|
52
|
+
const TRIGRAM_MIN = 3
|
|
53
|
+
const MAX_LIMIT = 100
|
|
54
|
+
const DEFAULT_COUNT_LIMIT = 1000
|
|
55
|
+
const MAX_COUNT_LIMIT = 5000
|
|
56
|
+
const SNIPPET_BEFORE = 60
|
|
57
|
+
const SNIPPET_AFTER = 120
|
|
58
|
+
const SNIPPET_MAX = 400
|
|
59
|
+
|
|
60
|
+
/** Bytes that count as "inside a word", matching the name engine. */
|
|
61
|
+
const WORD_BYTE = /[A-Za-z0-9_]/
|
|
62
|
+
const CONTROL_CHARS = /[\u0000-\u001f]/
|
|
63
|
+
|
|
64
|
+
/** Text escaped for an FTS5 quoted phrase (`"` doubled). */
|
|
65
|
+
export function quoteFts(text: string): string {
|
|
66
|
+
return '"' + text.replace(/"/g, '""') + '"'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Escape a literal for SQLite GLOB (`*`, `?`, `[`, `]`). */
|
|
70
|
+
export function escapeGlob(text: string): string {
|
|
71
|
+
return text.replace(/[*?[\]]/g, ch => (ch === ']' ? '[]]' : '[' + ch + ']'))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Escape a literal for SQL LIKE with `ESCAPE '\'`. */
|
|
75
|
+
export function escapeLike(text: string): string {
|
|
76
|
+
return text.replace(/[\\%_]/g, ch => '\\' + ch)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Decoded extended-mode query text. */
|
|
80
|
+
export interface DecodedExtended {
|
|
81
|
+
ok: boolean
|
|
82
|
+
text: string
|
|
83
|
+
warnings: string[]
|
|
84
|
+
/** `invalid-escape` when `\\xNN` is malformed. */
|
|
85
|
+
error?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decode Notepad++-style extended escapes: `\n`, `\r`, `\t`, `\0`, `\\`,
|
|
90
|
+
* `\xNN`. Unknown escapes are kept literally with a warning.
|
|
91
|
+
*/
|
|
92
|
+
export function decodeExtended(input: string): DecodedExtended {
|
|
93
|
+
const warnings = new Set<string>()
|
|
94
|
+
let text = ''
|
|
95
|
+
for (let i = 0; i < input.length; i++) {
|
|
96
|
+
const ch = input[i]!
|
|
97
|
+
if (ch !== '\\') {
|
|
98
|
+
text += ch
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
const next = input[i + 1]
|
|
102
|
+
if (next === undefined) {
|
|
103
|
+
warnings.add('extended-trailing-backslash')
|
|
104
|
+
text += '\\'
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
switch (next) {
|
|
108
|
+
case 'n':
|
|
109
|
+
text += '\n'
|
|
110
|
+
i++
|
|
111
|
+
continue
|
|
112
|
+
case 'r':
|
|
113
|
+
text += '\r'
|
|
114
|
+
i++
|
|
115
|
+
continue
|
|
116
|
+
case 't':
|
|
117
|
+
text += '\t'
|
|
118
|
+
i++
|
|
119
|
+
continue
|
|
120
|
+
case '0':
|
|
121
|
+
text += '\0'
|
|
122
|
+
warnings.add('extended-control-char')
|
|
123
|
+
i++
|
|
124
|
+
continue
|
|
125
|
+
case '\\':
|
|
126
|
+
text += '\\'
|
|
127
|
+
i++
|
|
128
|
+
continue
|
|
129
|
+
case 'x': {
|
|
130
|
+
const hex = input.slice(i + 2, i + 4)
|
|
131
|
+
if (!/^[0-9a-fA-F]{2}$/.test(hex)) {
|
|
132
|
+
return { ok: false, text: '', warnings: [...warnings], error: 'invalid-escape' }
|
|
133
|
+
}
|
|
134
|
+
const code = Number.parseInt(hex, 16)
|
|
135
|
+
text += String.fromCharCode(code)
|
|
136
|
+
if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) warnings.add('extended-control-char')
|
|
137
|
+
i += 3
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
warnings.add('extended-unknown-escape')
|
|
142
|
+
text += '\\' + next
|
|
143
|
+
i++
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, text, warnings: [...warnings] }
|
|
147
|
+
}
|
|
148
|
+
|
|
9
149
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* @param limit - maximum hits.
|
|
14
|
-
* @returns hits, ordered by relevance.
|
|
150
|
+
* Extended decoding for file names: names cannot contain control characters, so
|
|
151
|
+
* they are dropped from the pattern and reported instead of silently matching
|
|
152
|
+
* nothing.
|
|
15
153
|
*/
|
|
16
|
-
export function
|
|
154
|
+
export function decodeExtendedForNames(input: string): DecodedExtended {
|
|
155
|
+
const decoded = decodeExtended(input)
|
|
156
|
+
if (!decoded.ok) return decoded
|
|
157
|
+
const kept = decoded.text.replace(CONTROL_CHARS, '')
|
|
158
|
+
if (kept !== decoded.text) decoded.warnings.push('name-extended-control-char')
|
|
159
|
+
return { ...decoded, text: kept }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Whether `index` starts a whole-word occurrence of `length` bytes. */
|
|
163
|
+
export function isWholeWord(hay: string, index: number, length: number): boolean {
|
|
164
|
+
const left = index === 0 || !WORD_BYTE.test(hay[index - 1] ?? '')
|
|
165
|
+
const end = index + length
|
|
166
|
+
const right = end >= hay.length || !WORD_BYTE.test(hay[end] ?? '')
|
|
167
|
+
return left && right
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One located match: byte offset and length. */
|
|
171
|
+
interface Located {
|
|
172
|
+
index: number
|
|
173
|
+
length: number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** First whole-word (or plain) occurrence of any term, or null. */
|
|
177
|
+
export function locateMatch(
|
|
178
|
+
body: string,
|
|
179
|
+
terms: readonly string[],
|
|
180
|
+
caseSensitive: boolean,
|
|
181
|
+
wholeWord: boolean,
|
|
182
|
+
): Located | null {
|
|
183
|
+
const hay = caseSensitive ? body : body.toLowerCase()
|
|
184
|
+
let best: Located | null = null
|
|
185
|
+
for (const term of terms) {
|
|
186
|
+
if (term === '') continue
|
|
187
|
+
const needle = caseSensitive ? term : term.toLowerCase()
|
|
188
|
+
let from = 0
|
|
189
|
+
while (from <= hay.length) {
|
|
190
|
+
const index = hay.indexOf(needle, from)
|
|
191
|
+
if (index < 0) break
|
|
192
|
+
if (!wholeWord || isWholeWord(hay, index, needle.length)) {
|
|
193
|
+
if (best === null || index < best.index) best = { index, length: needle.length }
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
from = index + 1
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return best
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Every term must appear as a whole word (normal mode) or the literal must
|
|
203
|
+
* have non-word edges (extended mode is a single "term"). */
|
|
204
|
+
function matchesWholeWord(body: string, terms: readonly string[], caseSensitive: boolean): boolean {
|
|
205
|
+
for (const term of terms) {
|
|
206
|
+
if (term === '') continue
|
|
207
|
+
if (locateMatch(body, [term], caseSensitive, true) === null) return false
|
|
208
|
+
}
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 1-based line number of a byte offset. */
|
|
213
|
+
export function lineOf(body: string, index: number): number {
|
|
214
|
+
let line = 1
|
|
215
|
+
let from = 0
|
|
216
|
+
for (;;) {
|
|
217
|
+
const next = body.indexOf('\n', from)
|
|
218
|
+
if (next < 0 || next >= index) return line
|
|
219
|
+
line++
|
|
220
|
+
from = next + 1
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Bounded, single-line snippet around the first match. */
|
|
225
|
+
function makeSnippet(body: string, located: Located | null): string {
|
|
226
|
+
if (located === null) {
|
|
227
|
+
const flat = body.replace(/\s+/g, ' ').trim()
|
|
228
|
+
return flat.length > SNIPPET_MAX ? flat.slice(0, SNIPPET_MAX - 1) + '…' : flat
|
|
229
|
+
}
|
|
230
|
+
const start = Math.max(0, located.index - SNIPPET_BEFORE)
|
|
231
|
+
const end = Math.min(body.length, located.index + located.length + SNIPPET_AFTER)
|
|
232
|
+
const flat = body.slice(start, end).replace(/\s+/g, ' ')
|
|
233
|
+
const text = (start > 0 ? '…' : '') + flat + (end < body.length ? '…' : '')
|
|
234
|
+
return text.length > SNIPPET_MAX ? text.slice(0, SNIPPET_MAX - 1) + '…' : text
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function emptyCount(mode: ContentCountMode): ContentCount {
|
|
238
|
+
if (mode === 'none') return { value: 0, kind: 'unknown' }
|
|
239
|
+
return { value: 0, kind: 'exact' }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function clampLimit(limit: number): number {
|
|
243
|
+
return Math.max(1, Math.min(MAX_LIMIT, Number.isFinite(limit) ? Math.trunc(limit) : MAX_LIMIT))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function clampCountLimit(limit: number | undefined): number {
|
|
247
|
+
if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_COUNT_LIMIT
|
|
248
|
+
return Math.max(1, Math.min(MAX_COUNT_LIMIT, Math.trunc(limit)))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Split a normal-mode query on whitespace, dropping empty terms. */
|
|
252
|
+
function splitTerms(text: string): string[] {
|
|
253
|
+
return text.split(/\s+/).map(part => part.replace(/^"|"$/g, '')).filter(part => part !== '')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** One query plan: the SQL WHERE fragment plus how to page/count it. */
|
|
257
|
+
interface QueryPlan {
|
|
258
|
+
where: string
|
|
259
|
+
params: Array<string | number>
|
|
260
|
+
/** Literal terms used for whole-word checks and snippet location. */
|
|
261
|
+
terms: string[]
|
|
262
|
+
caseSensitive: boolean
|
|
263
|
+
wholeWord: boolean
|
|
264
|
+
/** Match the whole phrase as one literal (extended mode). */
|
|
265
|
+
phrase: boolean
|
|
266
|
+
rankOrder: boolean
|
|
267
|
+
exactCount: boolean
|
|
268
|
+
/** GLOB counts are exact only below countLimit; above it they are bounded. */
|
|
269
|
+
boundedCount: boolean
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function buildPlan(terms: readonly string[], options: {
|
|
273
|
+
caseSensitive: boolean
|
|
274
|
+
wholeWord: boolean
|
|
275
|
+
phrase: boolean
|
|
276
|
+
sessionPath: string | null
|
|
277
|
+
}): QueryPlan | null {
|
|
278
|
+
const { caseSensitive, wholeWord, phrase, sessionPath } = options
|
|
279
|
+
if (terms.length === 0) return null
|
|
280
|
+
const sessionClause = sessionPath === null ? '' : ' AND rowid IN (SELECT rowid FROM docs_meta WHERE path = ?)'
|
|
281
|
+
const sessionParams: Array<string | number> = sessionPath === null ? [] : [sessionPath]
|
|
282
|
+
const tooShort = terms.some(term => term.length < TRIGRAM_MIN)
|
|
283
|
+
|
|
284
|
+
if (phrase) {
|
|
285
|
+
const literal = terms[0]!
|
|
286
|
+
if (caseSensitive) {
|
|
287
|
+
return {
|
|
288
|
+
where: 'body GLOB ?' + sessionClause,
|
|
289
|
+
params: ['*' + escapeGlob(literal) + '*', ...sessionParams],
|
|
290
|
+
terms: [literal],
|
|
291
|
+
caseSensitive,
|
|
292
|
+
wholeWord,
|
|
293
|
+
phrase,
|
|
294
|
+
rankOrder: false,
|
|
295
|
+
exactCount: true,
|
|
296
|
+
boundedCount: true,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (literal.length < TRIGRAM_MIN) {
|
|
300
|
+
return {
|
|
301
|
+
where: "body LIKE ? ESCAPE '\\'" + sessionClause,
|
|
302
|
+
params: ['%' + escapeLike(literal) + '%', ...sessionParams],
|
|
303
|
+
terms: [literal],
|
|
304
|
+
caseSensitive,
|
|
305
|
+
wholeWord,
|
|
306
|
+
phrase,
|
|
307
|
+
rankOrder: false,
|
|
308
|
+
exactCount: false,
|
|
309
|
+
boundedCount: true,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
where: 'docs MATCH ?' + sessionClause,
|
|
314
|
+
params: [quoteFts(literal), ...sessionParams],
|
|
315
|
+
terms: [literal],
|
|
316
|
+
caseSensitive,
|
|
317
|
+
wholeWord,
|
|
318
|
+
phrase,
|
|
319
|
+
rankOrder: true,
|
|
320
|
+
exactCount: true,
|
|
321
|
+
boundedCount: false,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (caseSensitive) {
|
|
326
|
+
return {
|
|
327
|
+
where: terms.map(() => 'body GLOB ?').join(' AND ') + sessionClause,
|
|
328
|
+
params: [...terms.map(term => '*' + escapeGlob(term) + '*'), ...sessionParams],
|
|
329
|
+
terms: [...terms],
|
|
330
|
+
caseSensitive,
|
|
331
|
+
wholeWord,
|
|
332
|
+
phrase,
|
|
333
|
+
rankOrder: false,
|
|
334
|
+
exactCount: true,
|
|
335
|
+
boundedCount: true,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (tooShort) {
|
|
339
|
+
return {
|
|
340
|
+
where: terms.map(() => "body LIKE ? ESCAPE '\\'").join(' AND ') + sessionClause,
|
|
341
|
+
params: [...terms.map(term => '%' + escapeLike(term) + '%'), ...sessionParams],
|
|
342
|
+
terms: [...terms],
|
|
343
|
+
caseSensitive,
|
|
344
|
+
wholeWord,
|
|
345
|
+
phrase,
|
|
346
|
+
rankOrder: false,
|
|
347
|
+
exactCount: false,
|
|
348
|
+
boundedCount: true,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
where: 'docs MATCH ?' + sessionClause,
|
|
353
|
+
params: [terms.map(quoteFts).join(' AND '), ...sessionParams],
|
|
354
|
+
terms: [...terms],
|
|
355
|
+
caseSensitive,
|
|
356
|
+
wholeWord,
|
|
357
|
+
phrase,
|
|
358
|
+
rankOrder: true,
|
|
359
|
+
exactCount: true,
|
|
360
|
+
boundedCount: false,
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** One document row fetched for a hit. */
|
|
365
|
+
interface DocRow {
|
|
366
|
+
kind?: string
|
|
367
|
+
path?: string
|
|
368
|
+
title?: string
|
|
369
|
+
session_id?: string | null
|
|
370
|
+
message_id?: string | null
|
|
371
|
+
body?: string
|
|
372
|
+
node_key?: string | null
|
|
373
|
+
score?: number
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const DOC_SQL = `SELECT d.kind AS kind, d.path AS path, d.title AS title,
|
|
377
|
+
d.session_id AS session_id, d.message_id AS message_id, d.body AS body,
|
|
378
|
+
m.node_key AS node_key
|
|
379
|
+
FROM docs d LEFT JOIN docs_meta m ON m.rowid = d.rowid
|
|
380
|
+
WHERE d.rowid = ?`
|
|
381
|
+
|
|
382
|
+
/** Build hits from rowids: metadata, snippet, first-match line and score. */
|
|
383
|
+
function buildHits(db: DatabaseSync, plan: QueryPlan, rowids: readonly number[], scores: ReadonlyMap<number, number>): ContentHit[] {
|
|
384
|
+
const statement = db.prepare(DOC_SQL)
|
|
385
|
+
const hits: ContentHit[] = []
|
|
386
|
+
for (const rowid of rowids) {
|
|
387
|
+
const row = statement.get(rowid) as DocRow | undefined
|
|
388
|
+
if (row === undefined || typeof row.body !== 'string') continue
|
|
389
|
+
const located = locateMatch(row.body, plan.terms, plan.caseSensitive, plan.wholeWord)
|
|
390
|
+
const line = located === null ? undefined : lineOf(row.body, located.index)
|
|
391
|
+
const score = scores.get(rowid)
|
|
392
|
+
const hit: ContentHit = {
|
|
393
|
+
kind: row.kind === 'session' ? 'session' : 'file',
|
|
394
|
+
path: typeof row.path === 'string' ? row.path : '',
|
|
395
|
+
title: typeof row.title === 'string' ? row.title : '',
|
|
396
|
+
snippet: makeSnippet(row.body, located),
|
|
397
|
+
score: score === undefined ? 0 : score,
|
|
398
|
+
...(typeof row.session_id === 'string' && row.session_id !== '' ? { sessionId: row.session_id } : {}),
|
|
399
|
+
...(typeof row.message_id === 'string' && row.message_id !== '' ? { messageId: row.message_id } : {}),
|
|
400
|
+
...(typeof row.node_key === 'string' && row.node_key !== '' ? { nodeKey: row.node_key } : {}),
|
|
401
|
+
...(line === undefined ? {} : { line }),
|
|
402
|
+
}
|
|
403
|
+
hits.push(hit)
|
|
404
|
+
}
|
|
405
|
+
return hits
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Candidate rowids for one page (no post-filter). */
|
|
409
|
+
function pageRowids(db: DatabaseSync, plan: QueryPlan, limit: number): { rowids: number[]; scores: Map<number, number> } {
|
|
410
|
+
const scores = new Map<number, number>()
|
|
411
|
+
const sql = plan.rankOrder
|
|
412
|
+
? `SELECT rowid AS rowid, rank AS score FROM docs WHERE ${plan.where} ORDER BY rank LIMIT ?`
|
|
413
|
+
: `SELECT rowid AS rowid FROM docs WHERE ${plan.where} LIMIT ?`
|
|
414
|
+
const rows = db.prepare(sql).all(...plan.params, limit) as Array<{ rowid?: number; score?: number }>
|
|
415
|
+
const rowids: number[] = []
|
|
416
|
+
for (const row of rows) {
|
|
417
|
+
if (typeof row.rowid !== 'number') continue
|
|
418
|
+
rowids.push(row.rowid)
|
|
419
|
+
if (typeof row.score === 'number') scores.set(row.rowid, row.score)
|
|
420
|
+
}
|
|
421
|
+
return { rowids, scores }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Exact COUNT over the plan's WHERE fragment. */
|
|
425
|
+
function exactCount(db: DatabaseSync, plan: QueryPlan): number {
|
|
426
|
+
const row = db.prepare(`SELECT count(*) AS n FROM docs WHERE ${plan.where}`).get(...plan.params) as { n?: number } | undefined
|
|
427
|
+
return Number(row?.n ?? 0)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Count up to `limit + 1` rows: exact below the cap, bounded at the cap. */
|
|
431
|
+
function boundedCount(db: DatabaseSync, plan: QueryPlan, limit: number): ContentCount {
|
|
432
|
+
const row = db
|
|
433
|
+
.prepare(`SELECT count(*) AS n FROM (SELECT rowid FROM docs WHERE ${plan.where} LIMIT ?)`)
|
|
434
|
+
.get(...plan.params, limit + 1) as { n?: number } | undefined
|
|
435
|
+
const value = Number(row?.n ?? 0)
|
|
436
|
+
if (value > limit) return { value: limit, kind: 'bounded', limit }
|
|
437
|
+
return { value, kind: 'exact' }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** A plan with a whole-word post-filter. */
|
|
441
|
+
interface WholeWordScan {
|
|
442
|
+
rowids: number[]
|
|
443
|
+
count: ContentCount
|
|
444
|
+
warnings: string[]
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function scanWholeWord(
|
|
448
|
+
db: DatabaseSync,
|
|
449
|
+
plan: QueryPlan,
|
|
450
|
+
limit: number,
|
|
451
|
+
countMode: ContentCountMode,
|
|
452
|
+
countLimit: number,
|
|
453
|
+
sessionScoped: boolean,
|
|
454
|
+
): WholeWordScan {
|
|
455
|
+
const warnings: string[] = []
|
|
456
|
+
const rowids: number[] = []
|
|
457
|
+
const sql = plan.rankOrder
|
|
458
|
+
? `SELECT rowid AS rowid FROM docs WHERE ${plan.where} ORDER BY rank`
|
|
459
|
+
: `SELECT rowid AS rowid FROM docs WHERE ${plan.where}`
|
|
460
|
+
const iterator = db.prepare(sql).iterate(...plan.params)
|
|
461
|
+
const bodyStatement = db.prepare('SELECT body FROM docs WHERE rowid = ?')
|
|
462
|
+
let count = 0
|
|
463
|
+
let exhausted = true
|
|
464
|
+
for (const raw of iterator) {
|
|
465
|
+
const rowid = (raw as { rowid?: number }).rowid
|
|
466
|
+
if (typeof rowid !== 'number') continue
|
|
467
|
+
const row = bodyStatement.get(rowid) as { body?: string } | undefined
|
|
468
|
+
if (row === undefined || typeof row.body !== 'string') continue
|
|
469
|
+
if (!matchesWholeWord(row.body, plan.terms, plan.caseSensitive)) continue
|
|
470
|
+
count++
|
|
471
|
+
if (rowids.length < limit) rowids.push(rowid)
|
|
472
|
+
if (countMode === 'none') {
|
|
473
|
+
if (rowids.length >= limit) break
|
|
474
|
+
} else if (!sessionScoped && count > countLimit) {
|
|
475
|
+
exhausted = false
|
|
476
|
+
break
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
let result: ContentCount
|
|
480
|
+
if (countMode === 'none') {
|
|
481
|
+
result = { value: 0, kind: 'unknown' }
|
|
482
|
+
} else if (!exhausted || count > countLimit) {
|
|
483
|
+
// Above the cap: report the known floor, never an exact count we did not
|
|
484
|
+
// finish. Global whole-word counts are deliberately bounded.
|
|
485
|
+
result = { value: countLimit, kind: 'bounded', limit: countLimit }
|
|
486
|
+
warnings.push('bounded-count')
|
|
487
|
+
} else {
|
|
488
|
+
result = { value: count, kind: 'exact' }
|
|
489
|
+
}
|
|
490
|
+
return { rowids, count: result, warnings: [...new Set(warnings)] }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Run one content query.
|
|
495
|
+
*
|
|
496
|
+
* Modes: `normal` (whitespace-separated substring terms), `extended`
|
|
497
|
+
* (Notepad++ escapes, one literal phrase), `regex` (not executed server-side in
|
|
498
|
+
* 0.9.0: returns `content-regex-not-supported`).
|
|
499
|
+
*/
|
|
500
|
+
export function searchContent(
|
|
501
|
+
db: DatabaseSync,
|
|
502
|
+
query: string,
|
|
503
|
+
limit: number,
|
|
504
|
+
options: ContentSearchOptions = {},
|
|
505
|
+
): ContentSearchResult {
|
|
17
506
|
const text = query.trim()
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
507
|
+
const capped = clampLimit(limit)
|
|
508
|
+
const matchMode = options.matchMode ?? 'normal'
|
|
509
|
+
const countMode = options.count ?? 'exact'
|
|
510
|
+
const countLimit = clampCountLimit(options.countLimit)
|
|
511
|
+
const warnings: string[] = []
|
|
512
|
+
|
|
513
|
+
if (text === '') return { hits: [], count: emptyCount(countMode), warnings }
|
|
514
|
+
if (matchMode === 'regex') {
|
|
515
|
+
return {
|
|
516
|
+
hits: [],
|
|
517
|
+
count: { value: 0, kind: 'unknown' },
|
|
518
|
+
warnings: ['content-regex-paused'],
|
|
519
|
+
error: 'content-regex-not-supported',
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let terms: string[]
|
|
524
|
+
let phrase = false
|
|
525
|
+
if (matchMode === 'extended') {
|
|
526
|
+
const decoded = decodeExtended(text)
|
|
527
|
+
warnings.push(...decoded.warnings)
|
|
528
|
+
if (!decoded.ok) {
|
|
529
|
+
return { hits: [], count: { value: 0, kind: 'unknown' }, warnings, error: decoded.error ?? 'invalid-escape' }
|
|
530
|
+
}
|
|
531
|
+
phrase = true
|
|
532
|
+
terms = decoded.text === '' ? [] : [decoded.text]
|
|
28
533
|
} else {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
534
|
+
terms = splitTerms(text)
|
|
535
|
+
}
|
|
536
|
+
if (terms.length === 0) return { hits: [], count: emptyCount(countMode), warnings }
|
|
537
|
+
|
|
538
|
+
const sessionPath = options.sessionPath ?? null
|
|
539
|
+
const plan = buildPlan(terms, {
|
|
540
|
+
caseSensitive: options.caseSensitive === true,
|
|
541
|
+
wholeWord: options.wholeWord === true,
|
|
542
|
+
phrase,
|
|
543
|
+
sessionPath,
|
|
544
|
+
})
|
|
545
|
+
if (plan === null) return { hits: [], count: emptyCount(countMode), warnings }
|
|
546
|
+
|
|
547
|
+
if (plan.wholeWord) {
|
|
548
|
+
const scanned = scanWholeWord(db, plan, capped, countMode, countLimit, sessionPath !== null)
|
|
549
|
+
warnings.push(...scanned.warnings)
|
|
550
|
+
const hits = buildHits(db, plan, scanned.rowids, new Map())
|
|
551
|
+
return { hits, count: scanned.count, warnings: [...new Set(warnings)] }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let count: ContentCount
|
|
555
|
+
if (countMode === 'none') {
|
|
556
|
+
count = { value: 0, kind: 'unknown' }
|
|
557
|
+
} else if (countMode === 'bounded') {
|
|
558
|
+
count = boundedCount(db, plan, countLimit)
|
|
559
|
+
if (count.kind === 'bounded') warnings.push('bounded-count')
|
|
560
|
+
} else if (plan.exactCount && !plan.boundedCount) {
|
|
561
|
+
count = { value: exactCount(db, plan), kind: 'exact' }
|
|
562
|
+
} else {
|
|
563
|
+
// LIKE plans and very common GLOB patterns cannot count cheaply: cap the
|
|
564
|
+
// work and report the known floor plus a warning.
|
|
565
|
+
count = boundedCount(db, plan, countLimit)
|
|
566
|
+
if (count.kind === 'bounded') warnings.push('bounded-count')
|
|
567
|
+
}
|
|
568
|
+
const page = pageRowids(db, plan, capped)
|
|
569
|
+
const hits = buildHits(db, plan, page.rowids, page.scores)
|
|
570
|
+
return { hits, count, warnings: [...new Set(warnings)] }
|
|
48
571
|
}
|