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:
@@ -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<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
|
||||
/// 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<void> _writeChain = Future<void>.value();
|
||||
|
||||
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
|
||||
/// sidecar if present (falling back to its `.bak`), else starts empty.
|
||||
static Future<SidecarRepository> 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<EditorStroke> strokes) {
|
||||
final next = Map<int, List<EditorStroke>>.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<void> 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<int, List<EditorStroke>>? strokes,
|
||||
Map<int, List<SidecarHighlight>>? highlights,
|
||||
List<SidecarScratchLink>? 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<void> _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<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) {
|
||||
|
||||
Reference in New Issue
Block a user