// 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'; /// 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; /// 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(), ); return SidecarRepository._( sourceFilePath: sourceFilePath, sidecar: sidecar, debounce: debounce, ); } // ── 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; // ── Mutations (synchronous in-memory update + debounced atomic write) ────── /// 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 to land. Future flush() async { if (_timer == null) return; _timer!.cancel(); _timer = null; await _write(); } /// Cancel pending timers. Call [flush] first to persist pending writes. void dispose() { _disposed = true; _timer?.cancel(); _timer = null; } // ── 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({ Map>? strokes, Map>? highlights, List? scratchLinks, }) { if (_disposed) return; _sidecar = BadnoteSidecar( version: _sidecar.version, sourceFile: _sidecar.sourceFile, docType: _sidecar.docType, 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, ); _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(); }); } Future _write() async { final snapshot = _sidecar; await SidecarStore.writeAtomic(sidecarFile, snapshot); } static String _basename(String path) { final norm = path.replaceAll('\\', '/'); final i = norm.lastIndexOf('/'); return i == -1 ? norm : norm.substring(i + 1); } }