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:
@@ -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<DatabaseService>((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<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) => '');
|
||||
|
||||
/// 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 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<Note>;
|
||||
final docHits = searches[1] as List<Map<String, dynamic>>;
|
||||
final hits = await index.search(query);
|
||||
|
||||
final results = <SearchResult>[];
|
||||
|
||||
// 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 = <String>{
|
||||
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 = <String, Document>{
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user