From 3e0b81be92b515285e355bb0ac63587c11015919 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 03:25:19 +0800 Subject: [PATCH] feat(f8): search snippet extraction pure core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/editor/search/search_snippet.dart | 73 +++++++++++++++++++++++++++ test/search_snippet_test.dart | 65 ++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 lib/editor/search/search_snippet.dart create mode 100644 test/search_snippet_test.dart diff --git a/lib/editor/search/search_snippet.dart b/lib/editor/search/search_snippet.dart new file mode 100644 index 0000000..c82e782 --- /dev/null +++ b/lib/editor/search/search_snippet.dart @@ -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, + ); +} diff --git a/test/search_snippet_test.dart b/test/search_snippet_test.dart new file mode 100644 index 0000000..4c6b571 --- /dev/null +++ b/test/search_snippet_test.dart @@ -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); + }); +}