All checks were successful
CI / Windows build (push) Successful in 9m55s
Reinstall PenCaptureBinding so stylus ink hits again; keep finger Listener translucent under pinch; page-anchor sticky with drag/resize; OneNote pen slots (brush+width+color); blank-note multi-page; default side button to hold-select-text. Co-authored-by: Cursor <cursoragent@cursor.com>
441 lines
17 KiB
Dart
441 lines
17 KiB
Dart
// 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/bookmark.dart';
|
|
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<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 {
|
|
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;
|
|
|
|
/// How many editors currently hold this repo. [open] reuses an existing
|
|
/// instance and bumps the count; [dispose] only tears down at zero so a
|
|
/// split-view / sticky overlay cannot clobber the PDF editor's sidecar.
|
|
int _retainCount = 1;
|
|
|
|
/// 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.
|
|
///
|
|
/// Reuses an already-open repo for the same path (retain-counted) so a
|
|
/// scratchpad overlay / split view cannot race the PDF editor with a second
|
|
/// in-memory snapshot that would overwrite scratchpad ink on flush.
|
|
static Future<SidecarRepository> open(
|
|
String sourceFilePath, {
|
|
String? docType,
|
|
Duration debounce = const Duration(milliseconds: 800),
|
|
}) async {
|
|
final existing = SidecarRepositoryRegistry.forPath(sourceFilePath);
|
|
if (existing != null && !existing._disposed) {
|
|
existing._retainCount++;
|
|
return existing;
|
|
}
|
|
final file = File('$sourceFilePath$kSidecarSuffix');
|
|
final loaded = await SidecarStore.read(file);
|
|
var sidecar = loaded ??
|
|
BadnoteSidecar(
|
|
sourceFile: _basename(sourceFilePath),
|
|
docType: docType,
|
|
pageCount: docType == 'notebook' ? 1 : null,
|
|
createdAt: DateTime.now().toUtc(),
|
|
);
|
|
// Standalone notebooks always carry an explicit pageCount (min 1). Older
|
|
// sidecars that omit it are normalized in-memory on open.
|
|
if (docType == 'notebook' &&
|
|
(sidecar.pageCount == null || sidecar.pageCount! < 1)) {
|
|
sidecar = BadnoteSidecar(
|
|
version: sidecar.version,
|
|
sourceFile: sidecar.sourceFile,
|
|
docType: sidecar.docType,
|
|
title: sidecar.title,
|
|
pageCount: 1,
|
|
rotation: sidecar.rotation,
|
|
createdAt: sidecar.createdAt,
|
|
updatedAt: sidecar.updatedAt,
|
|
strokes: sidecar.strokes,
|
|
highlights: sidecar.highlights,
|
|
texts: sidecar.texts,
|
|
bookmarks: sidecar.bookmarks,
|
|
scratchLinks: sidecar.scratchLinks,
|
|
legacyAnnotations: sidecar.legacyAnnotations,
|
|
ocrText: sidecar.ocrText,
|
|
pageText: sidecar.pageText,
|
|
legacyId: sidecar.legacyId,
|
|
background: sidecar.background,
|
|
);
|
|
}
|
|
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<int, List<EditorStroke>> get loadedStrokes => _sidecar.strokes;
|
|
|
|
/// Page index → highlight rects loaded from the sidecar.
|
|
Map<int, List<SidecarHighlight>> get loadedHighlights => _sidecar.highlights;
|
|
|
|
/// Page index → typed-text annotations loaded from the sidecar.
|
|
Map<int, List<SidecarText>> get loadedTexts => _sidecar.texts;
|
|
|
|
/// Scratch-link anchors loaded from the sidecar.
|
|
List<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;
|
|
|
|
/// Bookmarks loaded from the sidecar.
|
|
List<Bookmark> get loadedBookmarks => _sidecar.bookmarks;
|
|
|
|
/// 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 document-body search text (PDF embedded text layer, or
|
|
/// background OCR of a rasterized PDF — see [PdfTextIndexer]) and schedule a
|
|
/// save. No-op if unchanged. An empty string is normalized to null.
|
|
void schedulePageTextSave(String? pageText) {
|
|
final next = (pageText != null && pageText.isEmpty) ? null : pageText;
|
|
if (_sidecar.pageText == next) return;
|
|
_replace(pageText: next, clearPageText: next == null);
|
|
}
|
|
|
|
/// The document-body search text loaded from the sidecar, or null.
|
|
String? get loadedPageText => _sidecar.pageText;
|
|
|
|
/// 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 standalone-notebook page count and schedule a save. No-op if
|
|
/// unchanged. [count] is clamped to at least 1.
|
|
void schedulePageCountSave(int count) {
|
|
final next = count < 1 ? 1 : count;
|
|
if (_sidecar.pageCount == next) return;
|
|
_replace(pageCount: 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);
|
|
}
|
|
|
|
/// Replace the typed-text annotations for [pageIndex] and schedule a save.
|
|
void scheduleTextsSave(int pageIndex, List<SidecarText> texts) {
|
|
final next = Map<int, List<SidecarText>>.from(_sidecar.texts);
|
|
if (texts.isEmpty) {
|
|
next.remove(pageIndex);
|
|
} else {
|
|
next[pageIndex] = List<SidecarText>.of(texts);
|
|
}
|
|
_replace(texts: 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);
|
|
}
|
|
|
|
/// Add (or update, by id) a bookmark and schedule a save.
|
|
void scheduleBookmarkUpsert(Bookmark bookmark) {
|
|
final next = List<Bookmark>.of(_sidecar.bookmarks);
|
|
final idx = next.indexWhere((b) => b.id == bookmark.id);
|
|
if (idx == -1) {
|
|
next.add(bookmark);
|
|
} else {
|
|
next[idx] = bookmark;
|
|
}
|
|
_replace(bookmarks: next);
|
|
}
|
|
|
|
/// Remove the bookmark by [bookmarkId] and schedule a save.
|
|
void scheduleBookmarkDelete(String bookmarkId) {
|
|
final next =
|
|
_sidecar.bookmarks.where((b) => b.id != bookmarkId).toList();
|
|
_replace(bookmarks: next);
|
|
}
|
|
|
|
/// Replace the whole bookmark list and schedule a save.
|
|
void scheduleBookmarksSave(List<Bookmark> bookmarks) {
|
|
_replace(bookmarks: List<Bookmark>.of(bookmarks));
|
|
}
|
|
|
|
/// 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<void> 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() {
|
|
if (_disposed) return;
|
|
if (_retainCount > 1) {
|
|
_retainCount--;
|
|
return;
|
|
}
|
|
_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,
|
|
int? pageCount,
|
|
Map<int, List<EditorStroke>>? strokes,
|
|
Map<int, List<SidecarHighlight>>? highlights,
|
|
Map<int, List<SidecarText>>? texts,
|
|
List<Bookmark>? bookmarks,
|
|
List<SidecarScratchLink>? scratchLinks,
|
|
String? ocrText,
|
|
bool clearOcrText = false,
|
|
String? pageText,
|
|
bool clearPageText = false,
|
|
String? background,
|
|
}) {
|
|
if (_disposed) return;
|
|
_sidecar = BadnoteSidecar(
|
|
version: _sidecar.version,
|
|
sourceFile: _sidecar.sourceFile,
|
|
docType: _sidecar.docType,
|
|
title: title ?? _sidecar.title,
|
|
pageCount: pageCount ?? _sidecar.pageCount,
|
|
rotation: _sidecar.rotation,
|
|
createdAt: _sidecar.createdAt,
|
|
updatedAt: DateTime.now().toUtc(),
|
|
strokes: strokes ?? _sidecar.strokes,
|
|
highlights: highlights ?? _sidecar.highlights,
|
|
texts: texts ?? _sidecar.texts,
|
|
bookmarks: bookmarks ?? _sidecar.bookmarks,
|
|
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
|
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
|
|
pageText: clearPageText ? null : (pageText ?? _sidecar.pageText),
|
|
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<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) {
|
|
final norm = path.replaceAll('\\', '/');
|
|
final i = norm.lastIndexOf('/');
|
|
return i == -1 ? norm : norm.substring(i + 1);
|
|
}
|
|
}
|