Skip to content
← 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
Raw
/**
 * 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 }
}