feat(storage): PDF editor persists to per-file sidecar
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 2 (core swap). The PDF editor and split-view scratchpad stop writing SQLite and persist to a per-file sidecar `<pdfPath>.badnote.json` (debounced, atomic temp+rename+.bak) — so annotations travel with the file. The source path is the identity (no more djb2 doc-id). - SidecarRepository wraps the Phase-1 store with debounced autosave. - pen_editor: per-page ink, scratch-links AND highlights now persist to the sidecar and restore on reopen (closes persist-highlights). - New "un-highlight" tool: tap a stored highlight to remove it — the highlight could not be removed before. - split_view: each anchor's scratchpad lives in the sidecar's scratchLinks[id].scratchpad, keyed by anchor id. Note: pre-existing SQLite annotations are migrated later (Phase 5); note/slide editors swap in Phase 4. analyze clean, tests green.
This commit is contained in:
216
lib/editor/persistence/sidecar_repository.dart
Normal file
216
lib/editor/persistence/sidecar_repository.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
// 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 (`<sourceFile>.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: `<sourceFilePath>.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<SidecarRepository> 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<int, List<EditorStroke>> get loadedStrokes => _sidecar.strokes;
|
||||
|
||||
/// Page index → highlight rects loaded from the sidecar.
|
||||
Map<int, List<SidecarHighlight>> get loadedHighlights => _sidecar.highlights;
|
||||
|
||||
/// Scratch-link anchors loaded from the sidecar.
|
||||
List<SidecarScratchLink> 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<EditorStroke> strokes) {
|
||||
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
|
||||
if (strokes.isEmpty) {
|
||||
next.remove(pageIndex);
|
||||
} else {
|
||||
next[pageIndex] = List<EditorStroke>.of(strokes);
|
||||
}
|
||||
_replace(strokes: next);
|
||||
}
|
||||
|
||||
/// Replace the highlight rects for [pageIndex] and schedule a save.
|
||||
void scheduleHighlightSave(int pageIndex, List<SidecarHighlight> highlights) {
|
||||
final next = Map<int, List<SidecarHighlight>>.from(_sidecar.highlights);
|
||||
if (highlights.isEmpty) {
|
||||
next.remove(pageIndex);
|
||||
} else {
|
||||
next[pageIndex] = List<SidecarHighlight>.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<SidecarScratchLink>.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<SidecarScratchLink>.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<SidecarScratchLink>.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<void> 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<int, List<EditorStroke>>? strokes,
|
||||
Map<int, List<SidecarHighlight>>? highlights,
|
||||
List<SidecarScratchLink>? 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<void> _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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user