feat(f8): search snippet extraction pure core
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
For library-wide full-text search (the user's #1 named differentiator): snippetFor() finds the first case-insensitive match of a query in a source string (PDF text page / typed box / OCR'd handwriting) and returns a windowed excerpt centered on it, preserving the match offset + length and truncatedStart/End flags so the results list can render "…ctx **match** ctx…" and jump to the hit. The full match is always included; near-edge matches don't over-truncate. Returns null for empty/absent query. The FTS index + ranking live in the DB (search_indexer, later); this is the pure, storage-free excerpt math, fully unit-tested. flutter analyze lib/editor clean; 164/164 tests (+8). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
73
lib/editor/search/search_snippet.dart
Normal file
73
lib/editor/search/search_snippet.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
65
test/search_snippet_test.dart
Normal file
65
test/search_snippet_test.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
// Tests for pure search-snippet extraction (F8).
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:badnote/editor/search/search_snippet.dart';
|
||||
|
||||
void main() {
|
||||
test('returns null for empty query, empty source, or no match', () {
|
||||
expect(snippetFor('hello world', ''), isNull);
|
||||
expect(snippetFor('', 'x'), isNull);
|
||||
expect(snippetFor('hello world', 'zzz'), isNull);
|
||||
});
|
||||
|
||||
test('case-insensitive match, offset preserved within the snippet', () {
|
||||
final s = snippetFor('The Quick Brown Fox', 'quick', window: 80)!;
|
||||
expect(s.match.toLowerCase(), 'quick');
|
||||
expect(s.text.substring(s.matchStart, s.matchStart + s.matchLength),
|
||||
'Quick');
|
||||
});
|
||||
|
||||
test('short source is returned whole, no truncation', () {
|
||||
final s = snippetFor('alpha beta gamma', 'beta', window: 80)!;
|
||||
expect(s.text, 'alpha beta gamma');
|
||||
expect(s.truncatedStart, isFalse);
|
||||
expect(s.truncatedEnd, isFalse);
|
||||
expect(s.matchStart, 'alpha '.length);
|
||||
});
|
||||
|
||||
test('long source is windowed and truncated on both sides', () {
|
||||
final source = '${'a' * 200} needle ${'b' * 200}';
|
||||
final s = snippetFor(source, 'needle', window: 40)!;
|
||||
expect(s.truncatedStart, isTrue);
|
||||
expect(s.truncatedEnd, isTrue);
|
||||
expect(s.match, 'needle');
|
||||
// Window is ~40 + match; nowhere near the full 400+ source.
|
||||
expect(s.text.length, lessThan(80));
|
||||
});
|
||||
|
||||
test('match near the start has no leading truncation', () {
|
||||
final source = 'needle ${'b' * 200}';
|
||||
final s = snippetFor(source, 'needle', window: 40)!;
|
||||
expect(s.truncatedStart, isFalse);
|
||||
expect(s.matchStart, 0);
|
||||
expect(s.truncatedEnd, isTrue);
|
||||
});
|
||||
|
||||
test('match near the end has no trailing truncation', () {
|
||||
final source = '${'a' * 200} needle';
|
||||
final s = snippetFor(source, 'needle', window: 40)!;
|
||||
expect(s.truncatedEnd, isFalse);
|
||||
expect(s.truncatedStart, isTrue);
|
||||
});
|
||||
|
||||
test('the full match is included even when longer than the window', () {
|
||||
final long = 'x' * 100;
|
||||
final s = snippetFor('pre ${long} post', long, window: 10)!;
|
||||
expect(s.match, long);
|
||||
expect(s.matchLength, 100);
|
||||
});
|
||||
|
||||
test('finds the FIRST occurrence', () {
|
||||
final s = snippetFor('cat dog cat', 'cat', window: 80)!;
|
||||
expect(s.matchStart, 0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user