74 lines
2.4 KiB
Dart
74 lines
2.4 KiB
Dart
|
|
// lib/editor/search/search_snippet.dart
|
||
|
|
//
|
||
|
|
// Pure snippet extraction for library-wide full-text search (F8 — the user's #1
|
||
|
|
// named differentiator). Given a source string (a PDF text page, a typed text
|
||
|
|
// box, or OCR'd handwriting) and a query, produce a windowed excerpt centered on
|
||
|
|
// the first match with the match offset preserved, so the results list can show
|
||
|
|
// "…context **match** context…" and jump to the hit.
|
||
|
|
//
|
||
|
|
// The FTS index / ranking lives in the DB (search_indexer); THIS is the pure,
|
||
|
|
// storage-free excerpt math, fully unit-tested.
|
||
|
|
|
||
|
|
/// A windowed excerpt around a query match.
|
||
|
|
class Snippet {
|
||
|
|
const Snippet({
|
||
|
|
required this.text,
|
||
|
|
required this.matchStart,
|
||
|
|
required this.matchLength,
|
||
|
|
required this.truncatedStart,
|
||
|
|
required this.truncatedEnd,
|
||
|
|
});
|
||
|
|
|
||
|
|
/// The excerpt (a substring of the source).
|
||
|
|
final String text;
|
||
|
|
|
||
|
|
/// Offset of the match WITHIN [text].
|
||
|
|
final int matchStart;
|
||
|
|
|
||
|
|
/// Length of the matched run.
|
||
|
|
final int matchLength;
|
||
|
|
|
||
|
|
/// True when [text] begins before the source start was reached (show a
|
||
|
|
/// leading ellipsis).
|
||
|
|
final bool truncatedStart;
|
||
|
|
|
||
|
|
/// True when [text] ends before the source end (show a trailing ellipsis).
|
||
|
|
final bool truncatedEnd;
|
||
|
|
|
||
|
|
/// Convenience: the matched substring.
|
||
|
|
String get match => text.substring(matchStart, matchStart + matchLength);
|
||
|
|
|
||
|
|
@override
|
||
|
|
String toString() =>
|
||
|
|
'${truncatedStart ? '…' : ''}$text${truncatedEnd ? '…' : ''}'
|
||
|
|
' [match @$matchStart+$matchLength]';
|
||
|
|
}
|
||
|
|
|
||
|
|
/// First case-insensitive match of [query] in [source], as a snippet of up to
|
||
|
|
/// roughly [window] characters centered on the match. Returns null when [query]
|
||
|
|
/// is empty or absent. The full match is always included even if longer than
|
||
|
|
/// [window].
|
||
|
|
Snippet? snippetFor(String source, String query, {int window = 80}) {
|
||
|
|
if (query.isEmpty || source.isEmpty) return null;
|
||
|
|
assert(window >= 0);
|
||
|
|
|
||
|
|
final matchIndex = source.toLowerCase().indexOf(query.toLowerCase());
|
||
|
|
if (matchIndex < 0) return null;
|
||
|
|
|
||
|
|
final matchLen = query.length;
|
||
|
|
final contextEach = ((window - matchLen) ~/ 2).clamp(0, window);
|
||
|
|
|
||
|
|
var start = matchIndex - contextEach;
|
||
|
|
if (start < 0) start = 0;
|
||
|
|
var end = matchIndex + matchLen + contextEach;
|
||
|
|
if (end > source.length) end = source.length;
|
||
|
|
|
||
|
|
return Snippet(
|
||
|
|
text: source.substring(start, end),
|
||
|
|
matchStart: matchIndex - start,
|
||
|
|
matchLength: matchLen,
|
||
|
|
truncatedStart: start > 0,
|
||
|
|
truncatedEnd: end < source.length,
|
||
|
|
);
|
||
|
|
}
|