← Public packages
@charlesmatte/granola
Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.
src/lib/search.ts
127 lines · 4.7 KB · TypeScript/**
* Keyword matching for ./notes/search. Case- and accent-insensitive ("reunion" matches "Réunion").
* A query is split into terms: "quoted phrases" stay whole, other words split on whitespace.
* `-word` excludes notes containing it.
*/
export type ParsedQuery = { terms: Array<string>; excluded: Array<string> }
export function fold(s: string): string {
return s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
}
/** Fold text char by char, keeping a map from folded index → original index (for snippets). */
export function foldWithMap(s: string): { folded: string; map: Array<number> } {
let folded = ''
const map: Array<number> = []
let i = 0
for (const ch of s) {
const f = fold(ch)
for (let k = 0; k < f.length; k++) map.push(i)
folded += f
i += ch.length
}
map.push(s.length)
return { folded, map }
}
export function parseQuery(query: string): ParsedQuery {
const terms: Array<string> = []
const excluded: Array<string> = []
const re = /(-?)"([^"]*)"|(\S+)/g
let m: RegExpExecArray | null
while ((m = re.exec(query))) {
let raw = m[2] !== undefined ? m[2] : (m[3] as string)
let neg = m[1] === '-'
if (m[2] === undefined && raw.startsWith('-') && raw.length > 1) {
neg = true
raw = raw.slice(1)
}
const t = fold(raw.replace(/\s+/g, ' ').trim())
if (!t) continue
;(neg ? excluded : terms).push(t)
}
return { terms: [...new Set(terms)], excluded: [...new Set(excluded)] }
}
/** Non-overlapping occurrences of `term` in already-folded text. */
export function findAll(folded: string, term: string): Array<number> {
const out: Array<number> = []
if (!term) return out
let i = folded.indexOf(term)
while (i !== -1) {
out.push(i)
i = folded.indexOf(term, i + term.length)
}
return out
}
export type FieldHit = { field: string; term: string; count: number }
export type Snippet = { field: string; text: string }
export type FieldText = { field: string; text: string; weight: number }
export type MatchResult = {
matched: boolean
score: number
termsMatched: Array<string>
hits: Record<string, number>
snippets: Array<Snippet>
}
function snippetAround(original: string, start: number, end: number, radius: number): string {
let s = Math.max(0, start - radius)
let e = Math.min(original.length, end + radius)
// widen to word boundaries
while (s > 0 && s > start - radius - 20 && /\S/.test(original[s - 1] as string)) s--
while (e < original.length && e < end + radius + 20 && /\S/.test(original[e] as string)) e++
const body = original.slice(s, e).replace(/\s+/g, ' ').trim()
return `${s > 0 ? '…' : ''}${body}${e < original.length ? '…' : ''}`
}
/**
* Score one note. `mode: 'all'` requires every term somewhere in the note (default); `'any'`
* needs one. Score = Σ hits × field weight (title 3, attendees 2, others 1). Up to
* `maxSnippets` snippets, preferring distinct terms and fields.
*/
export function matchNote(fields: Array<FieldText>, q: ParsedQuery, opts: { mode: 'all' | 'any'; radius: number; maxSnippets: number }): MatchResult {
const hits: Record<string, number> = {}
const termsMatched = new Set<string>()
const candidates: Array<{ field: string; term: string; text: string; start: number; end: number; original: string }> = []
let score = 0
let excludedHit = false
for (const f of fields) {
if (!f.text) continue
const { folded, map } = foldWithMap(f.text)
for (const ex of q.excluded) if (folded.includes(ex)) excludedHit = true
for (const term of q.terms) {
const at = findAll(folded, term)
if (!at.length) continue
termsMatched.add(term)
hits[f.field] = (hits[f.field] ?? 0) + at.length
score += at.length * f.weight
for (const pos of at.slice(0, 3)) {
candidates.push({ field: f.field, term, text: '', start: map[pos] as number, end: map[pos + term.length] as number, original: f.text })
}
}
}
const matched = !excludedHit && (q.terms.length === 0 ? false : opts.mode === 'all' ? termsMatched.size === q.terms.length : termsMatched.size > 0)
const snippets: Array<Snippet> = []
if (matched) {
const usedTerms = new Set<string>()
const usedKeys = new Set<string>()
const ordered = [...candidates.filter((c) => c.field !== 'title' && c.field !== 'attendees'), ...candidates.filter((c) => c.field === 'title' || c.field === 'attendees')]
for (const pass of [0, 1]) {
for (const c of ordered) {
if (snippets.length >= opts.maxSnippets) break
if (pass === 0 && usedTerms.has(c.term)) continue
const text = c.field === 'title' ? c.original : snippetAround(c.original, c.start, c.end, opts.radius)
const key = `${c.field}:${text}`
if (usedKeys.has(key)) continue
usedKeys.add(key)
usedTerms.add(c.term)
snippets.push({ field: c.field, text })
}
}
}
return { matched, score: matched ? score : 0, termsMatched: [...termsMatched], hits, snippets }
}