Some checks failed
CI / Windows build (push) Has been cancelled
A blank note can show a page-background template painted behind the ink, picked from the toolbar and persisted per notebook. - NoteBackground: blank / dots / ruled / grid / cornell, drawn in page space (scales with zoom), subtle grey. Cornell = left margin + bottom summary rule over a ruled body. - Stored as the enum name in the notebook sidecar (back-compat: missing/unknown -> blank), saved/loaded via SidecarRepository so it restores on reopen. - Picker added to the note tool palette. PDF backgrounds skipped (PDFs have their own page content). analyze clean, 386 tests green.
327 lines
13 KiB
Dart
327 lines
13 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/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;
|
|
|
|
/// 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(
|
|
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<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;
|
|
|
|
/// 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<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 (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() {
|
|
_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<int, List<EditorStroke>>? strokes,
|
|
Map<int, List<SidecarHighlight>>? highlights,
|
|
List<SidecarScratchLink>? 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<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);
|
|
}
|
|
}
|