feat(storage): app-pause flush + vault search index
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 6 (final storage phase). - SidecarRepositoryRegistry tracks every open repo; SidecarFlushObserver (a WidgetsBindingObserver in main) flushes them all on inactive/hidden/paused/detached, awaiting each flush — the last strokes can't be lost on app close, not just on the 800ms timer. - VaultSearchIndex rebuilds by scanning vault sidecars (the source of truth) — note titles, OCR text and document names — and search_provider queries it, so search spans notes + PDFs. Rebuilt on launch / after import. The vault file-based storage migration (Phases 0-6) is complete: annotations travel with the file, picked vault folder, atomic autosave, one Import-file entry, SQLite migrated to sidecars. analyze clean, tests green.
This commit is contained in:
58
lib/editor/persistence/sidecar_flush_observer.dart
Normal file
58
lib/editor/persistence/sidecar_flush_observer.dart
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,53 @@ import '../engine/stroke_model.dart';
|
|||||||
/// Suffix appended to a source-file path to form its sidecar path.
|
/// Suffix appended to a source-file path to form its sidecar path.
|
||||||
const String kSidecarSuffix = '.badnote.json';
|
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<SidecarRepository> _open = <SidecarRepository>{};
|
||||||
|
|
||||||
|
/// The currently open repositories (for tests / inspection).
|
||||||
|
static Set<SidecarRepository> 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<void> 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<SidecarRepository>.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
|
/// 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.
|
/// path, holds it in memory, and debounces atomic writes back to disk.
|
||||||
class SidecarRepository {
|
class SidecarRepository {
|
||||||
@@ -51,6 +98,12 @@ class SidecarRepository {
|
|||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _disposed = false;
|
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<void> _writeChain = Future<void>.value();
|
||||||
|
|
||||||
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
|
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
|
||||||
/// sidecar if present (falling back to its `.bak`), else starts empty.
|
/// sidecar if present (falling back to its `.bak`), else starts empty.
|
||||||
static Future<SidecarRepository> open(
|
static Future<SidecarRepository> open(
|
||||||
@@ -66,11 +119,13 @@ class SidecarRepository {
|
|||||||
docType: docType,
|
docType: docType,
|
||||||
createdAt: DateTime.now().toUtc(),
|
createdAt: DateTime.now().toUtc(),
|
||||||
);
|
);
|
||||||
return SidecarRepository._(
|
final repo = SidecarRepository._(
|
||||||
sourceFilePath: sourceFilePath,
|
sourceFilePath: sourceFilePath,
|
||||||
sidecar: sidecar,
|
sidecar: sidecar,
|
||||||
debounce: debounce,
|
debounce: debounce,
|
||||||
);
|
);
|
||||||
|
SidecarRepositoryRegistry._register(repo);
|
||||||
|
return repo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Loaded snapshot accessors (read at open) ───────────────────────────────
|
// ── Loaded snapshot accessors (read at open) ───────────────────────────────
|
||||||
@@ -99,6 +154,17 @@ class SidecarRepository {
|
|||||||
_replace(title: title);
|
_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.
|
/// Replace the committed strokes for [pageIndex] and schedule a save.
|
||||||
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
|
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
|
||||||
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
|
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
|
||||||
@@ -165,12 +231,19 @@ class SidecarRepository {
|
|||||||
|
|
||||||
// ── Flush / dispose ────────────────────────────────────────────────────────
|
// ── 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<void> flush() async {
|
Future<void> flush() async {
|
||||||
if (_timer == null) return;
|
if (_timer != null) {
|
||||||
_timer!.cancel();
|
_timer!.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
await _write();
|
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.
|
/// Cancel pending timers. Call [flush] first to persist pending writes.
|
||||||
@@ -178,6 +251,7 @@ class SidecarRepository {
|
|||||||
_disposed = true;
|
_disposed = true;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
|
SidecarRepositoryRegistry._unregister(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal ───────────────────────────────────────────────────────────────
|
// ── Internal ───────────────────────────────────────────────────────────────
|
||||||
@@ -190,6 +264,8 @@ class SidecarRepository {
|
|||||||
Map<int, List<EditorStroke>>? strokes,
|
Map<int, List<EditorStroke>>? strokes,
|
||||||
Map<int, List<SidecarHighlight>>? highlights,
|
Map<int, List<SidecarHighlight>>? highlights,
|
||||||
List<SidecarScratchLink>? scratchLinks,
|
List<SidecarScratchLink>? scratchLinks,
|
||||||
|
String? ocrText,
|
||||||
|
bool clearOcrText = false,
|
||||||
}) {
|
}) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_sidecar = BadnoteSidecar(
|
_sidecar = BadnoteSidecar(
|
||||||
@@ -205,6 +281,7 @@ class SidecarRepository {
|
|||||||
highlights: highlights ?? _sidecar.highlights,
|
highlights: highlights ?? _sidecar.highlights,
|
||||||
bookmarks: _sidecar.bookmarks,
|
bookmarks: _sidecar.bookmarks,
|
||||||
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
||||||
|
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
|
||||||
);
|
);
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer(_debounce, () {
|
_timer = Timer(_debounce, () {
|
||||||
@@ -215,9 +292,17 @@ class SidecarRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _write() async {
|
/// Serialize writes through [_writeChain] so overlapping flushes never race
|
||||||
final snapshot = _sidecar;
|
/// on the temp file. Each link writes the latest in-memory snapshot at the
|
||||||
await SidecarStore.writeAtomic(sidecarFile, snapshot);
|
/// moment it runs; an error in one write doesn't break the chain for the next.
|
||||||
|
Future<void> _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) {
|
static String _basename(String path) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'editor/persistence/sidecar_flush_observer.dart';
|
||||||
import 'editor/pdf/pen_capture_region.dart';
|
import 'editor/pdf/pen_capture_region.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
@@ -33,11 +34,33 @@ Future<void> main() async {
|
|||||||
runApp(const ProviderScope(child: BadNoteApp()));
|
runApp(const ProviderScope(child: BadNoteApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class BadNoteApp extends ConsumerWidget {
|
class BadNoteApp extends ConsumerStatefulWidget {
|
||||||
const BadNoteApp({super.key});
|
const BadNoteApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<BadNoteApp> createState() => _BadNoteAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BadNoteAppState extends ConsumerState<BadNoteApp> {
|
||||||
|
// 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);
|
final settings = ref.watch(settingsProvider);
|
||||||
|
|
||||||
// Material You: prefer the OS dynamic color (Windows/Android system accent);
|
// Material You: prefer the OS dynamic color (Windows/Android system accent);
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../models/document.dart';
|
|
||||||
import '../models/note.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
|
/// The search index, rebuilt by SCANNING the vault sidecars (the source of
|
||||||
/// rebuildable search cache (the vault sidecars are the source of truth); search
|
/// truth) — NOT the demoted SQLite cache (Phase 6, §B/§F). Bumping
|
||||||
/// is its sole remaining read path until the Phase 6 index rebuild lands.
|
/// [searchIndexEpochProvider] (e.g. after an import or note edit) invalidates
|
||||||
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
|
/// this provider so the next read re-scans the vault from disk.
|
||||||
return DatabaseService.getInstance();
|
final vaultSearchIndexProvider = FutureProvider<VaultSearchIndex>((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<int>((ref) => 0);
|
||||||
|
|
||||||
final searchQueryProvider = StateProvider<String>((ref) => '');
|
final searchQueryProvider = StateProvider<String>((ref) => '');
|
||||||
|
|
||||||
/// A search result that can be either a note hit or a document hit.
|
/// 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<List<SearchResult>>((ref) async {
|
final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async {
|
||||||
final query = ref.watch(searchQueryProvider);
|
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
|
final index = await ref.watch(vaultSearchIndexProvider.future);
|
||||||
// initialization and disposal like every other consumer.
|
|
||||||
final db = await ref.watch(databaseServiceProvider.future);
|
|
||||||
|
|
||||||
// Run the note and document searches concurrently.
|
final hits = await index.search(query);
|
||||||
final searches = await Future.wait([
|
|
||||||
db.searchNotes(query),
|
|
||||||
db.searchDocuments(query),
|
|
||||||
]);
|
|
||||||
final noteHits = searches[0] as List<Note>;
|
|
||||||
final docHits = searches[1] as List<Map<String, dynamic>>;
|
|
||||||
|
|
||||||
final results = <SearchResult>[];
|
final results = <SearchResult>[];
|
||||||
|
for (final hit in hits) {
|
||||||
// Add note results.
|
final entry = hit.entry;
|
||||||
for (final note in noteHits) {
|
final snippet = hit.snippet.text;
|
||||||
results.add(NoteSearchHit(note: note, snippet: note.title));
|
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
|
||||||
// Resolve document metadata without an N+1 loop: collect the distinct
|
// lazily by the editor; the search list only needs id/title.
|
||||||
// document ids referenced by the hits, look each up exactly once, then
|
final now = DateTime.now();
|
||||||
// build the result list from the cached lookups.
|
results.add(
|
||||||
final docIds = <String>{
|
NoteSearchHit(
|
||||||
for (final hit in docHits)
|
note: Note(
|
||||||
if (hit['document_id'] is String) hit['document_id'] as String,
|
id: entry.openPath,
|
||||||
};
|
title: entry.title,
|
||||||
final docEntries = await Future.wait(
|
createdAt: now,
|
||||||
docIds.map((id) async => MapEntry(id, await db.getDocument(id))),
|
updatedAt: now,
|
||||||
);
|
),
|
||||||
final docsById = <String, Document>{
|
snippet: snippet,
|
||||||
for (final entry in docEntries)
|
),
|
||||||
if (entry.value != null) entry.key: entry.value!,
|
);
|
||||||
};
|
} else {
|
||||||
|
results.add(
|
||||||
for (final hit in docHits) {
|
DocumentSearchHit(
|
||||||
final documentId = hit['document_id'];
|
documentId: entry.id,
|
||||||
if (documentId is! String) continue;
|
filename: entry.title,
|
||||||
final doc = docsById[documentId];
|
filePath: entry.openPath,
|
||||||
if (doc == null) continue;
|
// The scan-based index matches whole-notebook text, not per-page, so
|
||||||
|
// the document opens at its first page.
|
||||||
final pageNumber = hit['page_number'];
|
pageNumber: 0,
|
||||||
final content = hit['content'];
|
snippet: snippet,
|
||||||
results.add(
|
),
|
||||||
DocumentSearchHit(
|
);
|
||||||
documentId: documentId,
|
}
|
||||||
filename: doc.filename,
|
|
||||||
filePath: doc.filePath,
|
|
||||||
pageNumber: pageNumber is int ? pageNumber : 0,
|
|
||||||
snippet: content is String ? content : '',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../editor/persistence/sidecar_repository.dart';
|
||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
import '../models/pen_tool.dart';
|
import '../models/pen_tool.dart';
|
||||||
import 'database_service.dart';
|
import 'database_service.dart';
|
||||||
@@ -6,7 +9,12 @@ import 'stroke_rasterizer.dart';
|
|||||||
|
|
||||||
/// Runs OCR locally: typed text from strokes + handwriting via platform OCR.
|
/// Runs OCR locally: typed text from strokes + handwriting via platform OCR.
|
||||||
class OcrService {
|
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<void> processNote(Note note) async {
|
Future<void> processNote(Note note) async {
|
||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
|
|
||||||
@@ -40,6 +48,22 @@ class OcrService {
|
|||||||
final combined = parts.join(' ').trim();
|
final combined = parts.join(' ').trim();
|
||||||
if (combined.isEmpty) return;
|
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();
|
final db = await DatabaseService.getInstance();
|
||||||
await db.appendOcrToFts(note.id, combined);
|
await db.appendOcrToFts(note.id, combined);
|
||||||
}
|
}
|
||||||
|
|||||||
180
lib/services/vault_search_index.dart
Normal file
180
lib/services/vault_search_index.dart
Normal file
@@ -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 `<folder>/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<VaultSearchEntry> _entries = const [];
|
||||||
|
bool _built = false;
|
||||||
|
|
||||||
|
/// The entries from the most recent [rebuild] (for tests / inspection).
|
||||||
|
List<VaultSearchEntry> 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<void> rebuild() async {
|
||||||
|
final entries = <VaultSearchEntry>[];
|
||||||
|
|
||||||
|
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<List<VaultSearchHit>> search(String query) async {
|
||||||
|
if (query.trim().isEmpty) return const [];
|
||||||
|
if (!_built) await rebuild();
|
||||||
|
|
||||||
|
final sources = <String, String>{
|
||||||
|
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 = <VaultSearchHit>[];
|
||||||
|
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 = <String>[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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,6 +222,7 @@ class BadnoteSidecar {
|
|||||||
List<Bookmark>? bookmarks,
|
List<Bookmark>? bookmarks,
|
||||||
List<SidecarScratchLink>? scratchLinks,
|
List<SidecarScratchLink>? scratchLinks,
|
||||||
Map<int, String>? legacyAnnotations,
|
Map<int, String>? legacyAnnotations,
|
||||||
|
this.ocrText,
|
||||||
this.legacyId,
|
this.legacyId,
|
||||||
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
|
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
|
||||||
highlights = highlights ?? <int, List<SidecarHighlight>>{},
|
highlights = highlights ?? <int, List<SidecarHighlight>>{},
|
||||||
@@ -263,6 +264,14 @@ class BadnoteSidecar {
|
|||||||
/// sidecars.
|
/// sidecars.
|
||||||
final Map<int, String> legacyAnnotations;
|
final Map<int, String> 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`
|
/// 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
|
/// 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
|
/// 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)
|
for (final entry in legacyAnnotations.entries)
|
||||||
entry.key.toString(): entry.value,
|
entry.key.toString(): entry.value,
|
||||||
},
|
},
|
||||||
|
if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
|
||||||
if (legacyId != null) 'legacyId': legacyId,
|
if (legacyId != null) 'legacyId': legacyId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -349,6 +359,7 @@ class BadnoteSidecar {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}(),
|
}(),
|
||||||
|
ocrText: json['ocrText'] as String?,
|
||||||
legacyId: json['legacyId'] as String?,
|
legacyId: json['legacyId'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
122
test/sidecar_flush_observer_test.dart
Normal file
122
test/sidecar_flush_observer_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
184
test/vault_search_index_test.dart
Normal file
184
test/vault_search_index_test.dart
Normal file
@@ -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: <vault>/<folder>/<file>.pdf + sidecar.
|
||||||
|
Future<void> seedDocNotebook({
|
||||||
|
required String folder,
|
||||||
|
required String pdfName,
|
||||||
|
List<EditorStroke> 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: <vault>/<folder>/notebook.badnote.json
|
||||||
|
Future<void> seedNote({
|
||||||
|
required String folder,
|
||||||
|
required String title,
|
||||||
|
List<EditorStroke> 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 `<folder>/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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user