feat(storage): app-pause flush + vault search index
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:
2026-06-24 23:19:21 +08:00
parent 4886f1b2df
commit 24d13642fd
9 changed files with 749 additions and 68 deletions

View File

@@ -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) {