// lib/editor/persistence/sidecar_repository.dart // // Phase 2 of the file-based storage plan (docs/plans/2026-06-24-file-based- // storage.md §F): the PDF editor's persistence sink. Replaces the SQLite-backed // EditorRepository/SaveScheduler/DatabaseService trio for the pen editor with a // single per-file SIDECAR (`.badnote.json`) living ALONGSIDE the // source file, so annotations travel with the file ("跟着文件走"). // // Design: // * The repository owns the canonical in-memory [BadnoteSidecar]. Callers // mutate it through the schedule* methods, which (1) update the in-memory // model SYNCHRONOUSLY (so the snapshot can't be corrupted by a later edit // mid-write — the SaveScheduler discipline, §F.2) and (2) arm a single // debounce timer that writes the WHOLE sidecar atomically (§F.1, via // SidecarStore.writeAtomic — temp + rename + .bak). // * The unit of debounce is the whole document sidecar (sidecars are small — // sparse normalized strokes), one atomic write per debounce window. // * Identity is the SOURCE FILE PATH, not the old djb2 path-hash document id. // The sidecar IS the identity. import 'dart:async'; import 'dart:io'; import '../../models/scratch_link.dart'; import '../../storage/badnote_sidecar.dart'; import '../../storage/sidecar_store.dart'; 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 { SidecarRepository._({ required this.sourceFilePath, required BadnoteSidecar sidecar, Duration debounce = const Duration(milliseconds: 800), }) : _sidecar = sidecar, _debounce = debounce; /// Absolute path to the annotated source file (e.g. the vault PDF copy). final String sourceFilePath; /// The sidecar file: `.badnote.json`. File get sidecarFile => File('$sourceFilePath$kSidecarSuffix'); final Duration _debounce; BadnoteSidecar _sidecar; 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( String sourceFilePath, { String? docType, Duration debounce = const Duration(milliseconds: 800), }) async { final file = File('$sourceFilePath$kSidecarSuffix'); final existing = await SidecarStore.read(file); final sidecar = existing ?? BadnoteSidecar( sourceFile: _basename(sourceFilePath), docType: docType, createdAt: DateTime.now().toUtc(), ); final repo = SidecarRepository._( sourceFilePath: sourceFilePath, sidecar: sidecar, debounce: debounce, ); SidecarRepositoryRegistry._register(repo); return repo; } // ── Loaded snapshot accessors (read at open) ─────────────────────────────── /// Page index → committed strokes loaded from the sidecar. Map> get loadedStrokes => _sidecar.strokes; /// Page index → highlight rects loaded from the sidecar. Map> get loadedHighlights => _sidecar.highlights; /// Scratch-link anchors loaded from the sidecar. List get loadedScratchLinks => _sidecar.scratchLinks; /// The current in-memory sidecar (for tests / inspection). BadnoteSidecar get sidecar => _sidecar; /// The standalone-notebook title loaded from the sidecar, or null. String? get loadedTitle => _sidecar.title; /// The page-background template name loaded from the sidecar, or null /// (missing → blank, decoded by the editor). String? get loadedBackground => _sidecar.background; // ── Mutations (synchronous in-memory update + debounced atomic write) ────── /// Replace the standalone-notebook title and schedule a save. No-op if the /// title is unchanged. void scheduleTitleSave(String title) { if (_sidecar.title == title) return; _replace(title: title); } /// Replace the page-background template (a [NoteBackground] enum name) and /// schedule a save. No-op if unchanged. void scheduleBackgroundSave(String background) { if (_sidecar.background == background) return; _replace(background: background); } /// 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); if (strokes.isEmpty) { next.remove(pageIndex); } else { next[pageIndex] = List.of(strokes); } _replace(strokes: next); } /// Replace the highlight rects for [pageIndex] and schedule a save. void scheduleHighlightSave(int pageIndex, List highlights) { final next = Map>.from(_sidecar.highlights); if (highlights.isEmpty) { next.remove(pageIndex); } else { next[pageIndex] = List.of(highlights); } _replace(highlights: next); } /// Add (or update) a scratch-link anchor, preserving any existing scratchpad, /// and schedule a save. void scheduleScratchLinkUpsert(ScratchLink link) { final next = List.of(_sidecar.scratchLinks); final idx = next.indexWhere((s) => s.link.id == link.id); if (idx == -1) { next.add(SidecarScratchLink(link: link)); } else { next[idx] = SidecarScratchLink( link: link, scratchpad: next[idx].scratchpad, ); } _replace(scratchLinks: next); } /// Remove the scratch-link anchor (and its embedded scratchpad) by [linkId]. void scheduleScratchLinkDelete(String linkId) { final next = _sidecar.scratchLinks .where((s) => s.link.id != linkId) .toList(growable: false); _replace(scratchLinks: List.of(next)); } /// Replace the embedded scratchpad of the anchor [linkId] and schedule a save. /// No-op if the anchor isn't present. void scheduleScratchpadSave(String linkId, SidecarScratchpad scratchpad) { final next = List.of(_sidecar.scratchLinks); final idx = next.indexWhere((s) => s.link.id == linkId); if (idx == -1) return; next[idx] = SidecarScratchLink(link: next[idx].link, scratchpad: scratchpad); _replace(scratchLinks: next); } /// The embedded scratchpad for [linkId], or null if the anchor is unknown. SidecarScratchpad? scratchpadFor(String linkId) { for (final s in _sidecar.scratchLinks) { if (s.link.id == linkId) return s.scratchpad; } return null; } // ── Flush / dispose ──────────────────────────────────────────────────────── /// 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) { _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. void dispose() { _disposed = true; _timer?.cancel(); _timer = null; SidecarRepositoryRegistry._unregister(this); } // ── Internal ─────────────────────────────────────────────────────────────── /// Build a new sidecar (touching `updatedAt`) from the current one with the /// given fields replaced, then arm the debounce timer. Snapshot is captured /// synchronously here so a later edit can't corrupt an in-flight write. void _replace({ String? title, Map>? strokes, Map>? highlights, List? scratchLinks, String? ocrText, bool clearOcrText = false, String? background, }) { if (_disposed) return; _sidecar = BadnoteSidecar( version: _sidecar.version, sourceFile: _sidecar.sourceFile, docType: _sidecar.docType, title: title ?? _sidecar.title, pageCount: _sidecar.pageCount, rotation: _sidecar.rotation, createdAt: _sidecar.createdAt, updatedAt: DateTime.now().toUtc(), strokes: strokes ?? _sidecar.strokes, highlights: highlights ?? _sidecar.highlights, bookmarks: _sidecar.bookmarks, scratchLinks: scratchLinks ?? _sidecar.scratchLinks, ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText), background: background ?? _sidecar.background, ); _timer?.cancel(); _timer = Timer(_debounce, () { _timer = null; // Fire-and-forget; the next schedule simply re-arms the timer and the // atomic write guarantees no torn file. _write(); }); } /// 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) { final norm = path.replaceAll('\\', '/'); final i = norm.lastIndexOf('/'); return i == -1 ? norm : norm.substring(i + 1); } }