forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrewindSearchQuery.ts
More file actions
113 lines (100 loc) · 4.89 KB
/
Copy pathrewindSearchQuery.ts
File metadata and controls
113 lines (100 loc) · 4.89 KB
1
2
3
4
5
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
34
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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// Rewind search query builder — FTS5 MATCH (BM25-ranked) over `rewind_frames_fts`.
// Ports the macOS `expandSearchQuery` behaviour (RewindDatabase.swift): each
// whitespace token is split on camelCase and digit/non-digit boundaries, the
// original token plus every sub-part (deduped, length >= 2) are prefix-matched
// with a trailing `*` and OR'd, and multiple tokens are AND'd (space = implicit
// AND in FTS5). Windows hardens Mac by quoting every part as an FTS5 phrase so
// user input can never break the MATCH grammar (see `ftsPrefixTerm`).
export const REWIND_SEARCH_TOKEN_LIMIT = 8
export const REWIND_SEARCH_QUERY_CHAR_LIMIT = 512
/** Whitespace-split the raw query, bounded by the char + token limits. */
export function tokenizeRewindSearchQuery(query: string): string[] {
return query
.trim()
.slice(0, REWIND_SEARCH_QUERY_CHAR_LIMIT)
.split(/\s+/)
.filter(Boolean)
.slice(0, REWIND_SEARCH_TOKEN_LIMIT)
}
/** True for a cased letter that is currently uppercase (unicode-aware, unlike
* `/[A-Z]/`; digits/punctuation are not "uppercase" since lower === upper). */
function isUpper(ch: string): boolean {
return ch.toLowerCase() !== ch.toUpperCase() && ch === ch.toUpperCase()
}
function isDigit(ch: string): boolean {
return ch >= '0' && ch <= '9'
}
/** Scan `word` left-to-right, starting a new part whenever `breakBefore(ch, prevCh)`
* is true (never at the first char). Parts shorter than 2 chars are dropped (matches Mac). */
function splitWord(word: string, breakBefore: (ch: string, prevCh: string) => boolean): string[] {
const parts: string[] = []
let cur = ''
for (const ch of word) {
if (cur !== '' && breakBefore(ch, cur[cur.length - 1])) {
parts.push(cur)
cur = ch
} else {
cur += ch
}
}
if (cur !== '') parts.push(cur)
return parts.filter((p) => p.length >= 2)
}
/** Split camelCase on uppercase boundaries: "ActivityPerformance" -> [Activity, Performance]. */
export function splitCamelCase(word: string): string[] {
return splitWord(word, (ch) => isUpper(ch))
}
/** Split on digit/non-digit boundaries: "test123" -> [test, 123]. */
export function splitOnDigits(word: string): string[] {
return splitWord(word, (ch, prev) => isDigit(ch) !== isDigit(prev))
}
/** True when the string contains at least one letter or number the FTS5
* tokenizer would index (a part of pure punctuation matches nothing). */
function hasAlnum(s: string): boolean {
return /[\p{L}\p{N}]/u.test(s)
}
/** A single FTS5 prefix term: the part is wrapped in a double-quoted phrase
* (internal quotes doubled) so any special characters are treated as literal
* token separators rather than query syntax, then a trailing `*` makes it a
* prefix query on the phrase's last token. e.g. `Activity` -> `"Activity"*`. */
function ftsPrefixTerm(part: string): string {
return `"${part.replace(/"/g, '""')}"*`
}
/** The deduped, indexable parts one whitespace token expands into — the original
* word plus its camelCase and digit sub-parts (length >= 2, at least one indexable
* char), in that order. This is the raw material both the FTS MATCH builder and the
* literal-highlight term list are derived from, so they can never drift apart. */
export function rewindWordParts(word: string): string[] {
const parts = [word, ...splitCamelCase(word), ...splitOnDigits(word)]
return [...new Set(parts)].filter((p) => p.length >= 2 && hasAlnum(p))
}
/** Expand one whitespace token into an FTS5 sub-expression, or null when it
* carries no indexable content. Insertion order (original word, then camelCase
* parts, then digit parts) is preserved and deduped. */
export function expandRewindSearchWord(word: string): string | null {
const unique = rewindWordParts(word)
if (unique.length === 0) return null
const terms = unique.map(ftsPrefixTerm)
return terms.length === 1 ? terms[0] : `(${terms.join(' OR ')})`
}
/** The lowercased literal terms a result frame's OCR text can be substring-tested
* against for snippet/representative selection and highlighting — every part
* `buildRewindFtsMatch` prefix-searches, minus the FTS quoting/`*`. A frame that
* matched ONLY via a sub-part (camelCase/digit split or a prefix) still highlights,
* where testing the raw query as one literal substring would have missed it. */
export function rewindMatchTerms(query: string): string[] {
const out = new Set<string>()
for (const word of tokenizeRewindSearchQuery(query)) {
for (const part of rewindWordParts(word)) out.add(part.toLowerCase())
}
return [...out]
}
/** Build the full FTS5 MATCH expression for a raw query, or null when the query
* has no searchable tokens. Tokens are AND'd (space-joined). */
export function buildRewindFtsMatch(query: string): string | null {
const expanded = tokenizeRewindSearchQuery(query)
.map(expandRewindSearchWord)
.filter((x): x is string => x !== null)
if (expanded.length === 0) return null
return expanded.join(' ')
}