// lib/editor/search/search_ranking.dart // // Pure ranking + aggregation for full-text search (F8). Given many sources (PDF // text pages, typed boxes, OCR'd handwriting) keyed by a ref string, score each // for a query and return the best hits with display snippets. This is the // search_indexer's ranking core; the FTS pre-filter/index lives in the DB. // // Pure (no storage/widgets); builds on search_text (normalize/match) + // search_snippet (excerpt). Fully unit-tested. import 'search_snippet.dart'; import 'search_text.dart'; /// A ranked search result: WHERE it is ([ref]), the display [snippet], and the /// [score] (higher = better). class SearchHit { const SearchHit({required this.ref, required this.snippet, required this.score}); final String ref; final Snippet snippet; final double score; @override String toString() => 'SearchHit($ref, score=${score.toStringAsFixed(3)})'; } /// Score [source] for [query]: more (normalized) occurrences rank higher, and an /// earlier first match breaks ties. 0 when there is no match. double scoreText(String source, String query) { final q = normalizeForIndex(query); if (q.isEmpty) return 0; final s = normalizeForIndex(source); if (s.isEmpty) return 0; var count = 0; var from = 0; var firstPos = -1; while (true) { final idx = s.indexOf(q, from); if (idx < 0) break; if (firstPos < 0) firstPos = idx; count++; from = idx + q.length; } if (count == 0) return 0; // Earliness in (0,1]: a match at position 0 scores 1.0. final earliness = 1.0 - (firstPos / s.length); return count + earliness; } /// Build + rank hits across [sources] (ref → raw text) for [query]: drop /// non-matches, attach a display snippet of the ORIGINAL text, sort best-first. /// Ties (equal score) keep input order (stable). List rankHits( Map sources, String query, { int window = 80, }) { final indexed = <({int order, SearchHit hit})>[]; var order = 0; for (final entry in sources.entries) { final i = order++; final score = scoreText(entry.value, query); if (score <= 0) continue; final snippet = snippetFor(entry.value, query, window: window); if (snippet == null) continue; // matched normalized but not raw (rare) indexed.add(( order: i, hit: SearchHit(ref: entry.key, snippet: snippet, score: score), )); } // Descending score; ties keep input order (Dart's sort isn't stable, so the // input index is an explicit tiebreaker). indexed.sort((a, b) { final byScore = b.hit.score.compareTo(a.hit.score); return byScore != 0 ? byScore : a.order.compareTo(b.order); }); return [for (final e in indexed) e.hit]; }