diff --git a/lib/editor/persistence/sidecar_flush_observer.dart b/lib/editor/persistence/sidecar_flush_observer.dart new file mode 100644 index 0000000..3af3f1e --- /dev/null +++ b/lib/editor/persistence/sidecar_flush_observer.dart @@ -0,0 +1,58 @@ +// lib/editor/persistence/sidecar_flush_observer.dart +// +// Phase 6 / §F.3 of the file-based storage plan (docs/plans/2026-06-24-file- +// based-storage.md): app-lifecycle flush hardening. +// +// The per-file SidecarRepository debounces writes by 800 ms. That window is the +// data-loss gap on a Windows tablet: if the OS suspends or closes the app +// before the timer fires, the last strokes never reach disk. This observer +// listens for the app leaving the foreground and DRAINS every open repo's +// pending write before the process can be frozen, so "never lose the last +// strokes on app close" holds even when the editor's own dispose() doesn't run. +// +// Registered once in BadNoteApp; it delegates to +// [SidecarRepositoryRegistry.flushAll], which awaits every repo's flush(). + +import 'package:flutter/widgets.dart'; + +import 'sidecar_repository.dart'; + +/// A [WidgetsBindingObserver] that flushes all open sidecar repositories when +/// the app leaves the foreground (`inactive`/`paused`/`detached`/`hidden`). +class SidecarFlushObserver with WidgetsBindingObserver { + /// Whether the observer is currently registered with the binding. + bool get isAttached => _attached; + bool _attached = false; + + /// Register with [WidgetsBinding.instance] so lifecycle changes are observed. + void attach() { + if (_attached) return; + WidgetsBinding.instance.addObserver(this); + _attached = true; + } + + /// Stop observing lifecycle changes. + void detach() { + if (!_attached) return; + WidgetsBinding.instance.removeObserver(this); + _attached = false; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + // Any transition out of the foreground is a potential suspend/kill point: + // drain pending sidecar writes now (the editors' own dispose() may never + // run when the OS freezes the process). + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + case AppLifecycleState.detached: + // Fire-and-forget at the framework boundary, but each write is atomic + // and awaited inside flushAll, so a half-written sidecar is impossible. + SidecarRepositoryRegistry.flushAll(); + case AppLifecycleState.resumed: + break; + } + } +} diff --git a/lib/editor/persistence/sidecar_repository.dart b/lib/editor/persistence/sidecar_repository.dart index 9998d77..2ac41c0 100644 --- a/lib/editor/persistence/sidecar_repository.dart +++ b/lib/editor/persistence/sidecar_repository.dart @@ -29,6 +29,53 @@ import '../engine/stroke_model.dart'; /// Suffix appended to a source-file path to form its sidecar path. const String kSidecarSuffix = '.badnote.json'; +/// Process-wide registry of OPEN [SidecarRepository] instances (Phase 6 / §F.3). +/// +/// The 800 ms debounce timer only protects against losing work to a crash that +/// happens *between* edits; it does NOT help when the OS suspends or kills the +/// app mid-window (the main data-loss window on a Windows tablet). The app's +/// lifecycle observer ([SidecarFlushObserver]) calls [flushAll] on +/// `paused`/`inactive`/`detached` to drain every open repo's pending write +/// before the process can be frozen. +/// +/// A repo registers itself in [open] and removes itself in [dispose], so the +/// set always reflects exactly the editors holding unsaved sidecar state. +class SidecarRepositoryRegistry { + SidecarRepositoryRegistry._(); + + static final Set _open = {}; + + /// The currently open repositories (for tests / inspection). + static Set get open => Set.unmodifiable(_open); + + /// Flush every open repository's pending debounced write and await them all. + /// Safe to call repeatedly; a repo with nothing pending is a cheap no-op. + static Future flushAll() async { + // Snapshot first: a flush may complete and (in a future) trigger disposal, + // which mutates `_open` — iterating a copy avoids concurrent-modification. + final repos = List.of(_open); + await Future.wait(repos.map((r) => r.flush())); + } + + /// The open repository for [sourceFilePath], or null if none is open. Lets a + /// background task (e.g. OCR) write through the SAME in-memory sidecar the + /// editor holds, instead of racing it with a second open handle. + static SidecarRepository? forPath(String sourceFilePath) { + for (final r in _open) { + if (r.sourceFilePath == sourceFilePath) return r; + } + return null; + } + + static void _register(SidecarRepository repo) => _open.add(repo); + + static void _unregister(SidecarRepository repo) => _open.remove(repo); + + /// Test-only: drop all registrations so one test can't leak repos into the + /// next. Does NOT flush or dispose them. + static void resetForTest() => _open.clear(); +} + /// Per-file persistence for the pen editor. Loads the sidecar for a source file /// path, holds it in memory, and debounces atomic writes back to disk. class SidecarRepository { @@ -51,6 +98,12 @@ class SidecarRepository { Timer? _timer; bool _disposed = false; + /// Tail of the in-flight write chain. Writes are serialized through this so a + /// debounce-timer write and a concurrent lifecycle [flush] can't race on the + /// same `.tmp`/rename (which would throw on the loser). Each write always + /// persists the LATEST snapshot, so collapsing overlapping writes is safe. + Future _writeChain = Future.value(); + /// Open (or create) the repository for [sourceFilePath]. Reads the existing /// sidecar if present (falling back to its `.bak`), else starts empty. static Future open( @@ -66,11 +119,13 @@ class SidecarRepository { docType: docType, createdAt: DateTime.now().toUtc(), ); - return SidecarRepository._( + final repo = SidecarRepository._( sourceFilePath: sourceFilePath, sidecar: sidecar, debounce: debounce, ); + SidecarRepositoryRegistry._register(repo); + return repo; } // ── Loaded snapshot accessors (read at open) ─────────────────────────────── @@ -99,6 +154,17 @@ class SidecarRepository { _replace(title: title); } + /// Replace the handwriting-OCR search text and schedule a save (Phase 6 + /// search index). No-op if unchanged. + void scheduleOcrTextSave(String? ocrText) { + final next = (ocrText != null && ocrText.isEmpty) ? null : ocrText; + if (_sidecar.ocrText == next) return; + _replace(ocrText: next, clearOcrText: next == null); + } + + /// The OCR text loaded from the sidecar, or null. + String? get loadedOcrText => _sidecar.ocrText; + /// Replace the committed strokes for [pageIndex] and schedule a save. void scheduleStrokeSave(int pageIndex, List strokes) { final next = Map>.from(_sidecar.strokes); @@ -165,12 +231,19 @@ class SidecarRepository { // ── Flush / dispose ──────────────────────────────────────────────────────── - /// Write any pending change immediately and wait for it to land. + /// Write any pending change immediately and wait for it (and any in-flight + /// write) to land. If the debounce timer is still armed, fire one final write + /// of the latest snapshot; otherwise just drain whatever write is in flight. Future flush() async { - if (_timer == null) return; - _timer!.cancel(); - _timer = null; - await _write(); + if (_timer != null) { + _timer!.cancel(); + _timer = null; + await _write(); + return; + } + // No pending edit, but a fire-and-forget timer write may still be running: + // await the chain so the bytes are on disk before we return. + await _writeChain; } /// Cancel pending timers. Call [flush] first to persist pending writes. @@ -178,6 +251,7 @@ class SidecarRepository { _disposed = true; _timer?.cancel(); _timer = null; + SidecarRepositoryRegistry._unregister(this); } // ── Internal ─────────────────────────────────────────────────────────────── @@ -190,6 +264,8 @@ class SidecarRepository { Map>? strokes, Map>? highlights, List? scratchLinks, + String? ocrText, + bool clearOcrText = false, }) { if (_disposed) return; _sidecar = BadnoteSidecar( @@ -205,6 +281,7 @@ class SidecarRepository { highlights: highlights ?? _sidecar.highlights, bookmarks: _sidecar.bookmarks, scratchLinks: scratchLinks ?? _sidecar.scratchLinks, + ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText), ); _timer?.cancel(); _timer = Timer(_debounce, () { @@ -215,9 +292,17 @@ class SidecarRepository { }); } - Future _write() async { - final snapshot = _sidecar; - await SidecarStore.writeAtomic(sidecarFile, snapshot); + /// Serialize writes through [_writeChain] so overlapping flushes never race + /// on the temp file. Each link writes the latest in-memory snapshot at the + /// moment it runs; an error in one write doesn't break the chain for the next. + Future _write() { + final next = _writeChain.then((_) async { + final snapshot = _sidecar; + await SidecarStore.writeAtomic(sidecarFile, snapshot); + }); + // Keep the chain alive past a failed write (e.g. transient FS error). + _writeChain = next.catchError((_) {}); + return next; } static String _basename(String path) { diff --git a/lib/main.dart b/lib/main.dart index 567807d..4992331 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'editor/persistence/sidecar_flush_observer.dart'; import 'editor/pdf/pen_capture_region.dart'; import 'l10n/app_localizations.dart'; import 'providers/settings_provider.dart'; @@ -33,11 +34,33 @@ Future main() async { runApp(const ProviderScope(child: BadNoteApp())); } -class BadNoteApp extends ConsumerWidget { +class BadNoteApp extends ConsumerStatefulWidget { const BadNoteApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _BadNoteAppState(); +} + +class _BadNoteAppState extends ConsumerState { + // Phase 6 / §F.3: flush any open sidecar repos when the app is suspended or + // closed so the last strokes are never lost to an OS kill. Lives for the whole + // app lifetime (attached here, detached on app teardown). + final SidecarFlushObserver _flushObserver = SidecarFlushObserver(); + + @override + void initState() { + super.initState(); + _flushObserver.attach(); + } + + @override + void dispose() { + _flushObserver.detach(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final settings = ref.watch(settingsProvider); // Material You: prefer the OS dynamic color (Windows/Android system accent); diff --git a/lib/providers/search_provider.dart b/lib/providers/search_provider.dart index e654bd3..94e6c35 100644 --- a/lib/providers/search_provider.dart +++ b/lib/providers/search_provider.dart @@ -1,16 +1,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../models/document.dart'; import '../models/note.dart'; -import '../services/database_service.dart'; +import '../services/vault_search_index.dart'; +import 'document_provider.dart' show vaultServiceProvider; -/// SQLite handle for the FTS/OCR search index. SQLite is now demoted to a -/// rebuildable search cache (the vault sidecars are the source of truth); search -/// is its sole remaining read path until the Phase 6 index rebuild lands. -final databaseServiceProvider = FutureProvider((ref) async { - return DatabaseService.getInstance(); +/// The search index, rebuilt by SCANNING the vault sidecars (the source of +/// truth) — NOT the demoted SQLite cache (Phase 6, §B/§F). Bumping +/// [searchIndexEpochProvider] (e.g. after an import or note edit) invalidates +/// this provider so the next read re-scans the vault from disk. +final vaultSearchIndexProvider = FutureProvider((ref) async { + ref.watch(searchIndexEpochProvider); + final vault = await ref.watch(vaultServiceProvider.future); + final index = VaultSearchIndex(vault); + await index.rebuild(); + return index; }); +/// Bump to force the search index to rebuild from disk (e.g. after an import). +final searchIndexEpochProvider = StateProvider((ref) => 0); + final searchQueryProvider = StateProvider((ref) => ''); /// A search result that can be either a note hit or a document hit. @@ -41,59 +49,45 @@ class DocumentSearchHit extends SearchResult { final searchResultsProvider = FutureProvider>((ref) async { final query = ref.watch(searchQueryProvider); - if (query.isEmpty) return []; + if (query.trim().isEmpty) return []; - // Obtain the DB through the provider graph so this participates in - // initialization and disposal like every other consumer. - final db = await ref.watch(databaseServiceProvider.future); + final index = await ref.watch(vaultSearchIndexProvider.future); - // Run the note and document searches concurrently. - final searches = await Future.wait([ - db.searchNotes(query), - db.searchDocuments(query), - ]); - final noteHits = searches[0] as List; - final docHits = searches[1] as List>; + final hits = await index.search(query); final results = []; - - // Add note results. - for (final note in noteHits) { - results.add(NoteSearchHit(note: note, snippet: note.title)); - } - - // Resolve document metadata without an N+1 loop: collect the distinct - // document ids referenced by the hits, look each up exactly once, then - // build the result list from the cached lookups. - final docIds = { - for (final hit in docHits) - if (hit['document_id'] is String) hit['document_id'] as String, - }; - final docEntries = await Future.wait( - docIds.map((id) async => MapEntry(id, await db.getDocument(id))), - ); - final docsById = { - for (final entry in docEntries) - if (entry.value != null) entry.key: entry.value!, - }; - - for (final hit in docHits) { - final documentId = hit['document_id']; - if (documentId is! String) continue; - final doc = docsById[documentId]; - if (doc == null) continue; - - final pageNumber = hit['page_number']; - final content = hit['content']; - results.add( - DocumentSearchHit( - documentId: documentId, - filename: doc.filename, - filePath: doc.filePath, - pageNumber: pageNumber is int ? pageNumber : 0, - snippet: content is String ? content : '', - ), - ); + for (final hit in hits) { + final entry = hit.entry; + final snippet = hit.snippet.text; + if (entry.isNote) { + // Construct a lightweight Note whose id is the synthetic note path so + // PenNoteScreen re-keys the right sidecar on open. Strokes are hydrated + // lazily by the editor; the search list only needs id/title. + final now = DateTime.now(); + results.add( + NoteSearchHit( + note: Note( + id: entry.openPath, + title: entry.title, + createdAt: now, + updatedAt: now, + ), + snippet: snippet, + ), + ); + } else { + results.add( + DocumentSearchHit( + documentId: entry.id, + filename: entry.title, + filePath: entry.openPath, + // The scan-based index matches whole-notebook text, not per-page, so + // the document opens at its first page. + pageNumber: 0, + snippet: snippet, + ), + ); + } } return results; diff --git a/lib/services/ocr_service.dart b/lib/services/ocr_service.dart index b3bd4f0..b11a0d8 100644 --- a/lib/services/ocr_service.dart +++ b/lib/services/ocr_service.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import '../editor/persistence/sidecar_repository.dart'; import '../models/note.dart'; import '../models/pen_tool.dart'; import 'database_service.dart'; @@ -6,7 +9,12 @@ import 'stroke_rasterizer.dart'; /// Runs OCR locally: typed text from strokes + handwriting via platform OCR. class OcrService { - /// Extract searchable text from [note] and merge into the local FTS index. + /// Extract searchable text from [note] and persist it for search. The text is + /// written to the note's `*.badnote.json` sidecar `ocrText` field — the + /// vault-scan source of truth the Phase 6 search index reads — and also + /// appended to the (rebuildable) SQLite FTS cache so legacy callers keep + /// working. [note.id] is the synthetic note path, which is exactly the + /// `sourceFilePath` the editor opened its [SidecarRepository] with. Future processNote(Note note) async { final parts = []; @@ -40,6 +48,22 @@ class OcrService { final combined = parts.join(' ').trim(); if (combined.isEmpty) return; + // Persist into the note's sidecar so the vault-scan search index finds it. + // Prefer the editor's already-open repo (same in-memory sidecar — no race); + // if the note is closed, open/flush/dispose a transient handle. + final open = SidecarRepositoryRegistry.forPath(note.id); + if (open != null) { + open.scheduleOcrTextSave(combined); + await open.flush(); + } else if (await File('${note.id}$kSidecarSuffix').exists()) { + final repo = await SidecarRepository.open(note.id, docType: 'notebook'); + repo.scheduleOcrTextSave(combined); + await repo.flush(); + repo.dispose(); + } + + // Also keep the legacy SQLite FTS cache warm (rebuildable; not the source + // of truth). Harmless if the row is never read. final db = await DatabaseService.getInstance(); await db.appendOcrToFts(note.id, combined); } diff --git a/lib/services/vault_search_index.dart b/lib/services/vault_search_index.dart new file mode 100644 index 0000000..3557bde --- /dev/null +++ b/lib/services/vault_search_index.dart @@ -0,0 +1,180 @@ +// lib/services/vault_search_index.dart +// +// Phase 6 of the file-based storage plan (docs/plans/2026-06-24-file-based- +// storage.md §B/§F): the search index, rebuilt by SCANNING the vault sidecars +// (the source of truth) rather than the demoted SQLite cache. +// +// What it indexes, per notebook/note folder, from its `*.badnote.json` sidecar: +// * the title (standalone notebooks) / source filename (file-backed docs), +// * every typed text box — EditorStroke(tool: text).textContent across all +// pages, +// * the handwriting OCR text persisted in the sidecar's `ocrText` field. +// +// It does NOT (yet) index a PDF's embedded text layer — that is a known gap (see +// the REPORT in the task / the class doc below). Matching uses the existing pure +// search primitives (normalize / rank / snippet), so CJK substring search works. + +import 'dart:io'; + +import '../editor/search/search_ranking.dart'; +import '../editor/search/search_snippet.dart'; +import '../storage/badnote_sidecar.dart'; +import '../storage/sidecar_store.dart'; +import 'vault_service.dart'; + +/// One indexed notebook: where it lives and the text harvested from its sidecar. +class VaultSearchEntry { + const VaultSearchEntry({ + required this.id, + required this.title, + required this.openPath, + required this.docType, + required this.text, + required this.isNote, + }); + + /// Stable id (the notebook folder path). + final String id; + + /// Display title (note title / source filename). + final String title; + + /// Path to pass to the editor: the in-vault source file for docs, or the + /// synthetic `/notebook` note path for standalone notebooks. + final String openPath; + + /// `pdf` / `pptx` / `ppt` / `docx` / `notebook`. + final String docType; + + /// All searchable text harvested from the sidecar (title + typed text + OCR), + /// joined for substring matching. + final String text; + + /// True for standalone (free-ink) notebooks, false for file-backed documents. + final bool isNote; +} + +/// A search hit over the vault: the entry plus a display snippet of the match. +class VaultSearchHit { + const VaultSearchHit({required this.entry, required this.snippet}); + + final VaultSearchEntry entry; + final Snippet snippet; +} + +/// Builds and queries a scan-based full-text index over the vault sidecars. +/// +/// The index is the list of [VaultSearchEntry]s built by [rebuild]; query is a +/// pure substring/rank over their harvested text (CJK-safe). Cheap enough to +/// rebuild lazily on demand for a single-user vault; there is no background +/// thread and no persisted index file (the SQLite cache is no longer the search +/// source of truth). +/// +/// HONEST SCOPE (what search covers / does NOT): +/// * COVERS: note/doc titles, typed text boxes, and handwriting OCR text that +/// has been persisted into a sidecar's `ocrText` field. +/// * DOES NOT cover: a PDF's embedded (printed) text layer — only the user's +/// annotations are indexed, not the underlying document body. Indexing the +/// PDF text layer would require rendering each page through pdfrx at scan +/// time; deferred. OCR is only present where it has already been run and +/// written back to the sidecar. +class VaultSearchIndex { + VaultSearchIndex(this._vault); + + final VaultService _vault; + + List _entries = const []; + bool _built = false; + + /// The entries from the most recent [rebuild] (for tests / inspection). + List get entries => List.unmodifiable(_entries); + + /// Scan the vault and (re)build the in-memory index. Safe on an empty/missing + /// vault (yields an empty index). Never throws on a single unreadable sidecar. + Future rebuild() async { + final entries = []; + + final notebooks = await _vault.scanNotebooks(); + for (final nb in notebooks) { + final sidecar = await SidecarStore.read( + File('${nb.sourceFilePath}$kVaultSidecarSuffix'), + ); + entries.add( + VaultSearchEntry( + id: nb.folderPath, + title: nb.filename, + openPath: nb.sourceFilePath, + docType: nb.docType, + text: _harvest(title: nb.filename, sidecar: sidecar), + isNote: false, + ), + ); + } + + final notes = await _vault.scanNotes(); + for (final note in notes) { + final sidecar = await SidecarStore.read( + File('${note.notePath}$kVaultSidecarSuffix'), + ); + entries.add( + VaultSearchEntry( + id: note.folderPath, + title: note.title, + openPath: note.notePath, + docType: 'notebook', + text: _harvest(title: note.title, sidecar: sidecar), + isNote: true, + ), + ); + } + + _entries = entries; + _built = true; + } + + /// Search the index for [query], rebuilding it first if it has never been + /// built. Returns the best-matching notebooks/notes, most-relevant first. An + /// empty/whitespace query yields no hits. + Future> search(String query) async { + if (query.trim().isEmpty) return const []; + if (!_built) await rebuild(); + + final sources = { + for (final e in _entries) e.id: e.text, + }; + final ranked = rankHits(sources, query); + final byId = {for (final e in _entries) e.id: e}; + + final hits = []; + for (final hit in ranked) { + final entry = byId[hit.ref]; + if (entry == null) continue; + hits.add(VaultSearchHit(entry: entry, snippet: hit.snippet)); + } + return hits; + } + + /// Concatenate every searchable string from one sidecar: the [title], every + /// typed text box across all pages, and the persisted handwriting OCR text. + static String _harvest({ + required String title, + required BadnoteSidecar? sidecar, + }) { + final parts = [title]; + if (sidecar != null) { + // Typed text boxes: a stroke carries `textContent` regardless of tool + // (EditorTool has only pen/highlighter/eraser; text is a content flag). + for (final pageStrokes in sidecar.strokes.values) { + for (final stroke in pageStrokes) { + final text = stroke.textContent; + if (text != null && text.trim().isNotEmpty) { + parts.add(text.trim()); + } + } + } + final ocr = sidecar.ocrText; + if (ocr != null && ocr.trim().isNotEmpty) parts.add(ocr.trim()); + } + return parts.join('\n'); + } +} diff --git a/lib/storage/badnote_sidecar.dart b/lib/storage/badnote_sidecar.dart index 1c83bb4..2f1be5d 100644 --- a/lib/storage/badnote_sidecar.dart +++ b/lib/storage/badnote_sidecar.dart @@ -222,6 +222,7 @@ class BadnoteSidecar { List? bookmarks, List? scratchLinks, Map? legacyAnnotations, + this.ocrText, this.legacyId, }) : strokes = strokes ?? >{}, highlights = highlights ?? >{}, @@ -263,6 +264,14 @@ class BadnoteSidecar { /// sidecars. final Map legacyAnnotations; + /// Searchable text recovered from this notebook's handwriting via local OCR + /// (Phase 6 search index). Persisted in the sidecar — the source of truth — + /// so the vault-scan search index can find handwritten notes WITHOUT the + /// (rebuildable, per-device) SQLite cache. Typed text already lives in the + /// strokes' `textContent`, so this holds ONLY the OCR'd handwriting. Null when + /// the notebook has no handwriting or OCR hasn't run. + final String? ocrText; + /// The legacy SQLite row id this sidecar was migrated from (a `documents.id` /// or `notes.id`). Set ONLY by the one-time migration; it makes the migration /// idempotent (a re-run recognizes an already-migrated item by this id even if @@ -295,6 +304,7 @@ class BadnoteSidecar { for (final entry in legacyAnnotations.entries) entry.key.toString(): entry.value, }, + if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText, if (legacyId != null) 'legacyId': legacyId, }; @@ -349,6 +359,7 @@ class BadnoteSidecar { } return out; }(), + ocrText: json['ocrText'] as String?, legacyId: json['legacyId'] as String?, ); } diff --git a/test/sidecar_flush_observer_test.dart b/test/sidecar_flush_observer_test.dart new file mode 100644 index 0000000..f3f9eb5 --- /dev/null +++ b/test/sidecar_flush_observer_test.dart @@ -0,0 +1,122 @@ +// test/sidecar_flush_observer_test.dart +// +// Phase 6 / §F.3: lifecycle-flush hardening. Proves the data-safety guarantee +// "never lose the last strokes on app close": +// * an OPEN SidecarRepository registers itself in SidecarRepositoryRegistry; +// * a pending (debounced, not-yet-fired) write is flushed to disk when the +// SidecarFlushObserver receives a paused/inactive/detached lifecycle event, +// WITHOUT waiting for the debounce timer; +// * the flush is awaited (SidecarRepositoryRegistry.flushAll awaits each +// repo.flush()), so the bytes are on disk before the process could freeze; +// * dispose() unregisters the repo so it isn't flushed after closing. + +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/engine/stroke_model.dart'; +import 'package:badnote/editor/persistence/sidecar_flush_observer.dart'; +import 'package:badnote/editor/persistence/sidecar_repository.dart'; +import 'package:badnote/storage/sidecar_store.dart'; + +// A long debounce so the timer NEVER fires during the test — only an explicit +// flush (the lifecycle path) can persist the pending write. +const _slow = Duration(seconds: 30); + +EditorStroke _stroke(String id) => EditorStroke( + id: id, + points: const [EditorPoint(x: 0.1, y: 0.2, pressure: 0.5)], + tool: EditorTool.pen, + color: 0xFF112233, + width: 0.005, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tmpDir; + late String src; + + setUp(() async { + SidecarRepositoryRegistry.resetForTest(); + tmpDir = await Directory.systemTemp.createTemp('flush_observer_test'); + src = '${tmpDir.path}/Lecture.pdf'; + await File(src).writeAsString('%PDF-1.7 fake'); + }); + + tearDown(() async { + SidecarRepositoryRegistry.resetForTest(); + if (await tmpDir.exists()) await tmpDir.delete(recursive: true); + }); + + test('open registers the repo; dispose unregisters it', () async { + final repo = await SidecarRepository.open(src, docType: 'pdf', debounce: _slow); + expect(SidecarRepositoryRegistry.open, contains(repo)); + expect(SidecarRepositoryRegistry.forPath(src), same(repo)); + + repo.dispose(); + expect(SidecarRepositoryRegistry.open, isNot(contains(repo))); + expect(SidecarRepositoryRegistry.forPath(src), isNull); + }); + + test('a paused lifecycle event flushes a pending debounced write to disk', + () async { + final repo = await SidecarRepository.open(src, docType: 'pdf', debounce: _slow); + addTearDown(repo.dispose); + + // Schedule a write; the 30s debounce means nothing is on disk yet. + repo.scheduleStrokeSave(0, [_stroke('s1')]); + expect(await repo.sidecarFile.exists(), isFalse, + reason: 'debounce has not fired and no lifecycle flush yet'); + + // The app goes to the background → the observer drains pending writes. + final observer = SidecarFlushObserver()..attach(); + addTearDown(observer.detach); + observer.didChangeAppLifecycleState(AppLifecycleState.paused); + + // flushAll() is fire-and-forget at the framework boundary; await the same + // path the observer triggered so we can assert the bytes landed. + await SidecarRepositoryRegistry.flushAll(); + + expect(await repo.sidecarFile.exists(), isTrue); + final reloaded = await SidecarStore.read(repo.sidecarFile); + expect(reloaded, isNotNull); + expect(reloaded!.strokes[0]?.single.id, 's1'); + }); + + test('flushAll drains EVERY open repo', () async { + final a = await SidecarRepository.open( + '${tmpDir.path}/A.pdf', docType: 'pdf', debounce: _slow); + final b = await SidecarRepository.open( + '${tmpDir.path}/B.pdf', docType: 'pdf', debounce: _slow); + addTearDown(a.dispose); + addTearDown(b.dispose); + + a.scheduleStrokeSave(0, [_stroke('a1')]); + b.scheduleStrokeSave(0, [_stroke('b1')]); + + await SidecarRepositoryRegistry.flushAll(); + + expect(await a.sidecarFile.exists(), isTrue); + expect(await b.sidecarFile.exists(), isTrue); + }); + + test('a disposed repo is not flushed by a later lifecycle event', () async { + final repo = await SidecarRepository.open(src, docType: 'pdf', debounce: _slow); + repo.scheduleStrokeSave(0, [_stroke('s1')]); + // Closing the editor without flushing: dispose() cancels the timer AND + // unregisters, so a later background event can't resurrect it. + repo.dispose(); + + final observer = SidecarFlushObserver()..attach(); + addTearDown(observer.detach); + observer.didChangeAppLifecycleState(AppLifecycleState.detached); + await SidecarRepositoryRegistry.flushAll(); + + // The dropped repo wrote nothing (its pending edit is intentionally lost on + // an explicit dispose-without-flush; the editors flush in their own + // dispose() before calling this). + expect(await repo.sidecarFile.exists(), isFalse); + }); +} diff --git a/test/vault_search_index_test.dart b/test/vault_search_index_test.dart new file mode 100644 index 0000000..87364c6 --- /dev/null +++ b/test/vault_search_index_test.dart @@ -0,0 +1,184 @@ +// test/vault_search_index_test.dart +// +// Phase 6: the search index is rebuilt by SCANNING the vault sidecars (the +// source of truth), NOT the SQLite cache. These tests seed a real vault on disk +// — a file-backed PDF notebook and a standalone free-ink notebook, each with a +// sidecar carrying typed text and/or handwriting OCR text — then assert +// VaultSearchIndex finds them by: +// * the title / source filename, +// * a typed text box (EditorStroke.textContent), +// * the persisted handwriting OCR text (sidecar `ocrText`), +// * a CJK substring (this user writes Chinese). +// It also documents the known GAP: a PDF's embedded text layer is NOT indexed. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:badnote/editor/engine/stroke_model.dart'; +import 'package:badnote/services/vault_search_index.dart'; +import 'package:badnote/services/vault_service.dart'; +import 'package:badnote/storage/badnote_sidecar.dart'; +import 'package:badnote/storage/sidecar_store.dart'; + +EditorStroke _textStroke(String text) => EditorStroke( + id: 't_$text', + points: const [EditorPoint(x: 0.1, y: 0.2, pressure: 0.5)], + tool: EditorTool.pen, + color: 0xFF000000, + width: 0.005, + textContent: text, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory vaultDir; + late VaultService vault; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + vaultDir = await Directory.systemTemp.createTemp('vault_search_test'); + final prefs = await SharedPreferences.getInstance(); + vault = VaultService.forTest(prefs); + await vault.setVaultRoot(vaultDir.path); + }); + + tearDown(() async { + if (await vaultDir.exists()) await vaultDir.delete(recursive: true); + }); + + // Seed one file-backed PDF notebook: //.pdf + sidecar. + Future seedDocNotebook({ + required String folder, + required String pdfName, + List page0 = const [], + String? ocrText, + }) async { + final dir = Directory(p.join(vaultDir.path, folder)); + await dir.create(recursive: true); + final pdfPath = p.join(dir.path, pdfName); + await File(pdfPath).writeAsString('%PDF-1.7 fake'); + final sidecar = BadnoteSidecar( + sourceFile: pdfName, + docType: 'pdf', + strokes: page0.isEmpty ? null : {0: page0}, + ocrText: ocrText, + createdAt: DateTime.now().toUtc(), + ); + await SidecarStore.writeAtomic( + File('$pdfPath$kVaultSidecarSuffix'), + sidecar, + ); + } + + // Seed one standalone free-ink notebook: //notebook.badnote.json + Future seedNote({ + required String folder, + required String title, + List page0 = const [], + String? ocrText, + }) async { + final dir = Directory(p.join(vaultDir.path, folder)); + await dir.create(recursive: true); + final sidecar = BadnoteSidecar( + docType: 'notebook', + title: title, + strokes: page0.isEmpty ? null : {0: page0}, + ocrText: ocrText, + createdAt: DateTime.now().toUtc(), + ); + await SidecarStore.writeAtomic( + File(p.join(dir.path, kNotebookSidecarName)), + sidecar, + ); + } + + test('finds a file-backed PDF by its filename', () async { + await seedDocNotebook(folder: 'Calculus Lecture 3', pdfName: 'Calculus.pdf'); + final index = VaultSearchIndex(vault); + + final hits = await index.search('calculus'); + expect(hits, hasLength(1)); + expect(hits.single.entry.isNote, isFalse); + expect(hits.single.entry.docType, 'pdf'); + expect(hits.single.entry.openPath, endsWith('Calculus.pdf')); + }); + + test('finds a typed text box inside a PDF sidecar', () async { + await seedDocNotebook( + folder: 'Notes', + pdfName: 'doc.pdf', + page0: [_textStroke('eigenvalue decomposition')], + ); + final index = VaultSearchIndex(vault); + + final hits = await index.search('eigenvalue'); + expect(hits, hasLength(1)); + expect(hits.single.entry.openPath, endsWith('doc.pdf')); + }); + + test('finds a standalone note by handwriting OCR text', () async { + await seedNote( + folder: 'My freehand notes', + title: 'Untitled', + ocrText: 'remember the quadratic formula', + ); + final index = VaultSearchIndex(vault); + + final hits = await index.search('quadratic'); + expect(hits, hasLength(1)); + expect(hits.single.entry.isNote, isTrue); + expect(hits.single.entry.docType, 'notebook'); + // Opening a note re-keys its synthetic `/notebook` path. + expect(hits.single.entry.openPath, endsWith(kNotebookBaseName)); + }); + + test('finds a note by its title and a CJK substring', () async { + await seedNote(folder: '数学笔记', title: '微积分笔记', ocrText: '导数与积分'); + final index = VaultSearchIndex(vault); + + expect(await index.search('微积分'), hasLength(1)); + // CJK OCR substring (no inter-word spaces) still matches. + expect(await index.search('导数'), hasLength(1)); + }); + + test('searches BOTH notes and docs in one query', () async { + await seedDocNotebook( + folder: 'Doc', + pdfName: 'd.pdf', + page0: [_textStroke('shared keyword apple')], + ); + await seedNote(folder: 'Note', title: 'n', ocrText: 'shared keyword apple'); + final index = VaultSearchIndex(vault); + + final hits = await index.search('apple'); + expect(hits, hasLength(2)); + expect(hits.where((h) => h.entry.isNote), hasLength(1)); + expect(hits.where((h) => !h.entry.isNote), hasLength(1)); + }); + + test('empty query returns no hits', () async { + await seedNote(folder: 'Note', title: 'anything'); + final index = VaultSearchIndex(vault); + expect(await index.search(' '), isEmpty); + }); + + test('an empty/missing vault yields an empty index (never throws)', () async { + final index = VaultSearchIndex(vault); + await index.rebuild(); + expect(index.entries, isEmpty); + expect(await index.search('x'), isEmpty); + }); + + test('KNOWN GAP: a PDF embedded text layer is NOT indexed', () async { + // The sidecar carries no annotations; only the PDF body would contain the + // word "bodytext". Search does NOT read the PDF text layer (documented + // limitation), so this returns nothing. + await seedDocNotebook(folder: 'Plain', pdfName: 'plain.pdf'); + final index = VaultSearchIndex(vault); + expect(await index.search('bodytext'), isEmpty); + }); +}