feat(storage): sidecar model + atomic store (lib only)
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 1 of the file-based storage plan. Pure library, no runtime behavior change yet (editors still use SQLite). - BadnoteSidecar: per-file annotation document (schema-versioned) holding per-page ink (EditorStroke JSON), text highlights, scratch-link anchors (ScratchLink JSON) each with its own scratchpad (InkStroke world-coord JSON), and bookmarks. Reuses the existing toJson formats — no parallel stroke format. - SidecarStore.writeAtomic: temp-file + rename atomic write keeping a .bak; read() falls back to .bak on a missing/corrupt primary. Round-trip + atomic-write + .bak-recovery tests. analyze clean, 322 tests green.
This commit is contained in:
323
lib/storage/badnote_sidecar.dart
Normal file
323
lib/storage/badnote_sidecar.dart
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
// lib/storage/badnote_sidecar.dart
|
||||||
|
//
|
||||||
|
// The on-disk sidecar model: all annotations for one source file, serialized as
|
||||||
|
// `<file>.badnote.json` next to the file ("跟着文件走"). This is Phase 1 of the
|
||||||
|
// file-based storage plan (docs/plans/2026-06-24-file-based-storage.md §A) — a
|
||||||
|
// pure model with NO runtime wiring yet.
|
||||||
|
//
|
||||||
|
// Design rule: REUSE the existing JSON shapes verbatim; do not invent a parallel
|
||||||
|
// stroke format. Specifically:
|
||||||
|
// * per-page ink → List<EditorStroke> (lib/editor/engine/stroke_model.dart;
|
||||||
|
// byte-for-byte the `ink.stroke_json` column today)
|
||||||
|
// * scratchpad ink → List<InkStroke> (lib/models/ink_stroke.dart; the exact
|
||||||
|
// format scratchpads already persist, absolute world px)
|
||||||
|
// * scratch anchors → ScratchLink (lib/models/scratch_link.dart)
|
||||||
|
// * bookmarks → Bookmark (lib/models/bookmark.dart)
|
||||||
|
//
|
||||||
|
// Only the *containers* and the (previously in-memory-only) highlight rect are
|
||||||
|
// new here. Unknown JSON fields are ignored on read so the schema is
|
||||||
|
// forward-compatible (e.g. a future `brush` field — see §A.5 brush TODO).
|
||||||
|
|
||||||
|
import 'dart:ui' show Rect;
|
||||||
|
|
||||||
|
import '../editor/engine/stroke_model.dart';
|
||||||
|
import '../models/bookmark.dart';
|
||||||
|
import '../models/ink_stroke.dart';
|
||||||
|
import '../models/scratch_link.dart';
|
||||||
|
|
||||||
|
/// Current sidecar schema version. Persisted as `badnoteSidecarVersion` for
|
||||||
|
/// forward-compat; readers tolerate unknown extra fields.
|
||||||
|
const int kBadnoteSidecarVersion = 1;
|
||||||
|
|
||||||
|
/// A single highlighted text rectangle on a page, normalized to the page rect
|
||||||
|
/// ([0,1] for l/t/r/b — exactly as `_highlightSelection` computes it in
|
||||||
|
/// pen_editor_screen.dart) plus an ARGB [color]. There is no existing highlight
|
||||||
|
/// MODEL in the codebase (highlights are in-memory `Rect`s today, see
|
||||||
|
/// `TODO(persist-highlights)`), so this small value class is the representation.
|
||||||
|
class SidecarHighlight {
|
||||||
|
const SidecarHighlight({
|
||||||
|
required this.l,
|
||||||
|
required this.t,
|
||||||
|
required this.r,
|
||||||
|
required this.b,
|
||||||
|
this.color = 0xFFFFFF00,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Normalized left edge in [0,1].
|
||||||
|
final double l;
|
||||||
|
|
||||||
|
/// Normalized top edge in [0,1].
|
||||||
|
final double t;
|
||||||
|
|
||||||
|
/// Normalized right edge in [0,1].
|
||||||
|
final double r;
|
||||||
|
|
||||||
|
/// Normalized bottom edge in [0,1].
|
||||||
|
final double b;
|
||||||
|
|
||||||
|
/// ARGB color of the highlight.
|
||||||
|
final int color;
|
||||||
|
|
||||||
|
/// Builds a highlight from a normalized [Rect] (as stored in
|
||||||
|
/// `_highlightsByPage`) and an ARGB color.
|
||||||
|
factory SidecarHighlight.fromRect(Rect rect, {int color = 0xFFFFFF00}) =>
|
||||||
|
SidecarHighlight(
|
||||||
|
l: rect.left,
|
||||||
|
t: rect.top,
|
||||||
|
r: rect.right,
|
||||||
|
b: rect.bottom,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The normalized rect (page-relative) for rendering.
|
||||||
|
Rect toRect() => Rect.fromLTRB(l, t, r, b);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'l': l,
|
||||||
|
't': t,
|
||||||
|
'r': r,
|
||||||
|
'b': b,
|
||||||
|
'color': color,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory SidecarHighlight.fromJson(Map<String, dynamic> json) =>
|
||||||
|
SidecarHighlight(
|
||||||
|
l: (json['l'] as num).toDouble(),
|
||||||
|
t: (json['t'] as num).toDouble(),
|
||||||
|
r: (json['r'] as num).toDouble(),
|
||||||
|
b: (json['b'] as num).toDouble(),
|
||||||
|
color: (json['color'] as num?)?.toInt() ?? 0xFFFFFF00,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is SidecarHighlight &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
l == other.l &&
|
||||||
|
t == other.t &&
|
||||||
|
r == other.r &&
|
||||||
|
b == other.b &&
|
||||||
|
color == other.color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(l, t, r, b, color);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'SidecarHighlight(l: $l, t: $t, r: $r, b: $b, color: $color)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An anchor's private infinite scratchpad: a list of [InkStroke]s in ABSOLUTE
|
||||||
|
/// world pixels (unchanged format from `SplitViewScreen`), plus the world size
|
||||||
|
/// so it restores (today the canvas always resets to 4000×4000).
|
||||||
|
class SidecarScratchpad {
|
||||||
|
const SidecarScratchpad({
|
||||||
|
this.canvasWidth = 4000.0,
|
||||||
|
this.canvasHeight = 4000.0,
|
||||||
|
this.strokes = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final double canvasWidth;
|
||||||
|
final double canvasHeight;
|
||||||
|
|
||||||
|
/// Absolute-world-pixel strokes, in `InkStroke.toJson()` format.
|
||||||
|
final List<InkStroke> strokes;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'canvasWidth': canvasWidth,
|
||||||
|
'canvasHeight': canvasHeight,
|
||||||
|
'strokes': strokes.map((s) => s.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory SidecarScratchpad.fromJson(Map<String, dynamic> json) =>
|
||||||
|
SidecarScratchpad(
|
||||||
|
canvasWidth: (json['canvasWidth'] as num?)?.toDouble() ?? 4000.0,
|
||||||
|
canvasHeight: (json['canvasHeight'] as num?)?.toDouble() ?? 4000.0,
|
||||||
|
strokes: ((json['strokes'] as List<dynamic>?) ?? const [])
|
||||||
|
.map((e) => InkStroke.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is SidecarScratchpad &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
canvasWidth == other.canvasWidth &&
|
||||||
|
canvasHeight == other.canvasHeight &&
|
||||||
|
_listEq(strokes, other.strokes);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
Object.hash(canvasWidth, canvasHeight, Object.hashAll(strokes));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SidecarScratchpad(canvasWidth: $canvasWidth, '
|
||||||
|
'canvasHeight: $canvasHeight, strokes: ${strokes.length})';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scratch link anchor that EMBEDS its private scratchpad (merges today's two
|
||||||
|
/// SQLite tables — `scratch_links` geometry + `scratchpads` ink — see §A.2).
|
||||||
|
class SidecarScratchLink {
|
||||||
|
const SidecarScratchLink({
|
||||||
|
required this.link,
|
||||||
|
this.scratchpad = const SidecarScratchpad(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Anchor geometry (reuses [ScratchLink] verbatim).
|
||||||
|
final ScratchLink link;
|
||||||
|
|
||||||
|
/// The anchor's private scratchpad.
|
||||||
|
final SidecarScratchpad scratchpad;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
...link.toJson(),
|
||||||
|
'scratchpad': scratchpad.toJson(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory SidecarScratchLink.fromJson(Map<String, dynamic> json) =>
|
||||||
|
SidecarScratchLink(
|
||||||
|
link: ScratchLink.fromJson(json),
|
||||||
|
scratchpad: json['scratchpad'] == null
|
||||||
|
? const SidecarScratchpad()
|
||||||
|
: SidecarScratchpad.fromJson(
|
||||||
|
json['scratchpad'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is SidecarScratchLink &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
link == other.link &&
|
||||||
|
scratchpad == other.scratchpad;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(link, scratchpad);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'SidecarScratchLink(link: $link, scratchpad: $scratchpad)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole sidecar: all annotations for one source file.
|
||||||
|
///
|
||||||
|
/// Maps to the JSON in §A.2 of the plan. `strokes` and `highlights` are keyed by
|
||||||
|
/// page index. Strokes reuse [EditorStroke] JSON; bookmarks reuse [Bookmark]
|
||||||
|
/// JSON; scratch links reuse [ScratchLink] JSON (embedding [InkStroke] JSON for
|
||||||
|
/// the scratchpad).
|
||||||
|
class BadnoteSidecar {
|
||||||
|
BadnoteSidecar({
|
||||||
|
this.version = kBadnoteSidecarVersion,
|
||||||
|
this.sourceFile,
|
||||||
|
this.docType,
|
||||||
|
this.pageCount,
|
||||||
|
this.rotation = 0,
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
Map<int, List<EditorStroke>>? strokes,
|
||||||
|
Map<int, List<SidecarHighlight>>? highlights,
|
||||||
|
List<Bookmark>? bookmarks,
|
||||||
|
List<SidecarScratchLink>? scratchLinks,
|
||||||
|
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
|
||||||
|
highlights = highlights ?? <int, List<SidecarHighlight>>{},
|
||||||
|
bookmarks = bookmarks ?? <Bookmark>[],
|
||||||
|
scratchLinks = scratchLinks ?? <SidecarScratchLink>[];
|
||||||
|
|
||||||
|
/// Schema version (`badnoteSidecarVersion`).
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
/// Basename of the annotated source file, e.g. `Calculus Lecture 3.pdf`.
|
||||||
|
final String? sourceFile;
|
||||||
|
|
||||||
|
/// `pdf` / `pptx` / `notebook` etc.
|
||||||
|
final String? docType;
|
||||||
|
|
||||||
|
final int? pageCount;
|
||||||
|
final int rotation;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
/// Page index → committed [EditorStroke]s (normalized page coords).
|
||||||
|
final Map<int, List<EditorStroke>> strokes;
|
||||||
|
|
||||||
|
/// Page index → highlighted text rects (normalized).
|
||||||
|
final Map<int, List<SidecarHighlight>> highlights;
|
||||||
|
|
||||||
|
final List<Bookmark> bookmarks;
|
||||||
|
final List<SidecarScratchLink> scratchLinks;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'badnoteSidecarVersion': version,
|
||||||
|
if (sourceFile != null) 'sourceFile': sourceFile,
|
||||||
|
if (docType != null) 'docType': docType,
|
||||||
|
if (pageCount != null) 'pageCount': pageCount,
|
||||||
|
'rotation': rotation,
|
||||||
|
if (createdAt != null) 'createdAt': createdAt!.toIso8601String(),
|
||||||
|
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
|
||||||
|
'strokes': {
|
||||||
|
for (final entry in strokes.entries)
|
||||||
|
entry.key.toString():
|
||||||
|
entry.value.map((s) => s.toJson()).toList(),
|
||||||
|
},
|
||||||
|
'highlights': {
|
||||||
|
for (final entry in highlights.entries)
|
||||||
|
entry.key.toString():
|
||||||
|
entry.value.map((h) => h.toJson()).toList(),
|
||||||
|
},
|
||||||
|
'bookmarks': bookmarks.map((b) => b.toJson()).toList(),
|
||||||
|
'scratchLinks': scratchLinks.map((s) => s.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory BadnoteSidecar.fromJson(Map<String, dynamic> json) {
|
||||||
|
Map<int, List<T>> decodePageMap<T>(
|
||||||
|
Object? raw,
|
||||||
|
T Function(Map<String, dynamic>) item,
|
||||||
|
) {
|
||||||
|
final out = <int, List<T>>{};
|
||||||
|
if (raw is Map) {
|
||||||
|
raw.forEach((key, value) {
|
||||||
|
final pageIndex = int.tryParse(key.toString());
|
||||||
|
if (pageIndex == null || value is! List) return;
|
||||||
|
out[pageIndex] = value
|
||||||
|
.map((e) => item(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadnoteSidecar(
|
||||||
|
version: (json['badnoteSidecarVersion'] as num?)?.toInt() ??
|
||||||
|
kBadnoteSidecarVersion,
|
||||||
|
sourceFile: json['sourceFile'] as String?,
|
||||||
|
docType: json['docType'] as String?,
|
||||||
|
pageCount: (json['pageCount'] as num?)?.toInt(),
|
||||||
|
rotation: (json['rotation'] as num?)?.toInt() ?? 0,
|
||||||
|
createdAt: json['createdAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(json['createdAt'] as String),
|
||||||
|
updatedAt: json['updatedAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(json['updatedAt'] as String),
|
||||||
|
strokes: decodePageMap(json['strokes'], EditorStroke.fromJson),
|
||||||
|
highlights: decodePageMap(json['highlights'], SidecarHighlight.fromJson),
|
||||||
|
bookmarks: ((json['bookmarks'] as List<dynamic>?) ?? const [])
|
||||||
|
.map((e) => Bookmark.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
scratchLinks: ((json['scratchLinks'] as List<dynamic>?) ?? const [])
|
||||||
|
.map((e) => SidecarScratchLink.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _listEq<T>(List<T> a, List<T> b) {
|
||||||
|
if (identical(a, b)) return true;
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (var i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] != b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
84
lib/storage/sidecar_store.dart
Normal file
84
lib/storage/sidecar_store.dart
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// lib/storage/sidecar_store.dart
|
||||||
|
//
|
||||||
|
// Atomic read/write for `<file>.badnote.json` sidecars (Phase 1 / §F.1 of
|
||||||
|
// docs/plans/2026-06-24-file-based-storage.md). Pure dart:io, NO UI.
|
||||||
|
//
|
||||||
|
// Write protocol (§F.1):
|
||||||
|
// 1. Serialize to pretty JSON, write to `<target>.tmp` with flush:true.
|
||||||
|
// 2. Before clobbering, copy the current good `<target>` to `<target>.bak`
|
||||||
|
// (one-deep backup — cheap insurance against a corrupt write).
|
||||||
|
// 3. `rename` tmp → target. rename is atomic on the same filesystem (NTFS /
|
||||||
|
// POSIX), so a reader never observes a half-written sidecar.
|
||||||
|
//
|
||||||
|
// Read protocol: parse `<target>`; if it is missing OR fails to parse, fall back
|
||||||
|
// to `<target>.bak`. If neither yields valid JSON, return null.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'badnote_sidecar.dart';
|
||||||
|
|
||||||
|
/// Stateless helper namespace for sidecar persistence.
|
||||||
|
class SidecarStore {
|
||||||
|
const SidecarStore._();
|
||||||
|
|
||||||
|
static const JsonEncoder _encoder = JsonEncoder.withIndent(' ');
|
||||||
|
|
||||||
|
/// Suffix for the in-progress temp file.
|
||||||
|
static const String tmpSuffix = '.tmp';
|
||||||
|
|
||||||
|
/// Suffix for the one-deep backup of the last good sidecar.
|
||||||
|
static const String bakSuffix = '.bak';
|
||||||
|
|
||||||
|
/// Atomically writes [sidecar] to [target] (temp + rename), keeping a `.bak`
|
||||||
|
/// of the previous good file. Never leaves a partial sidecar at [target]:
|
||||||
|
/// either the previous content (on failure before rename) or the new content.
|
||||||
|
static Future<void> writeAtomic(File target, BadnoteSidecar sidecar) async {
|
||||||
|
final json = _encoder.convert(sidecar.toJson());
|
||||||
|
await writeAtomicJson(target, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower-level variant for callers that already hold the JSON string.
|
||||||
|
static Future<void> writeAtomicJson(File target, String json) async {
|
||||||
|
await target.parent.create(recursive: true);
|
||||||
|
|
||||||
|
final tmp = File('${target.path}$tmpSuffix');
|
||||||
|
await tmp.writeAsString(json, flush: true);
|
||||||
|
|
||||||
|
// Back up the previous good file before clobbering it.
|
||||||
|
if (await target.exists()) {
|
||||||
|
final bak = File('${target.path}$bakSuffix');
|
||||||
|
try {
|
||||||
|
await target.copy(bak.path);
|
||||||
|
} catch (_) {
|
||||||
|
// A failed backup must not block the write; the atomic rename below
|
||||||
|
// still guarantees the new content lands intact.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic on the same filesystem.
|
||||||
|
await tmp.rename(target.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and parses the sidecar at [target], falling back to `<target>.bak`
|
||||||
|
/// if the primary is missing or corrupt. Returns null if neither is readable.
|
||||||
|
static Future<BadnoteSidecar?> read(File target) async {
|
||||||
|
final primary = await _tryRead(target);
|
||||||
|
if (primary != null) return primary;
|
||||||
|
|
||||||
|
final bak = File('${target.path}$bakSuffix');
|
||||||
|
return _tryRead(bak);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<BadnoteSidecar?> _tryRead(File file) async {
|
||||||
|
try {
|
||||||
|
if (!await file.exists()) return null;
|
||||||
|
final raw = await file.readAsString();
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map<String, dynamic>) return null;
|
||||||
|
return BadnoteSidecar.fromJson(decoded);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
212
test/badnote_sidecar_test.dart
Normal file
212
test/badnote_sidecar_test.dart
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// test/badnote_sidecar_test.dart
|
||||||
|
//
|
||||||
|
// Round-trips a BadnoteSidecar containing per-page strokes + highlights +
|
||||||
|
// bookmarks + scratch-links (each with its own scratchpad of InkStrokes),
|
||||||
|
// asserting the re-parsed model equals the original. Verifies the sidecar reuses
|
||||||
|
// the existing EditorStroke / InkStroke / ScratchLink / Bookmark JSON shapes and
|
||||||
|
// that the schema `version` field survives.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
import 'package:badnote/models/bookmark.dart';
|
||||||
|
import 'package:badnote/models/ink_point.dart';
|
||||||
|
import 'package:badnote/models/ink_stroke.dart';
|
||||||
|
import 'package:badnote/models/pen_tool.dart';
|
||||||
|
import 'package:badnote/models/pointer_device_kind.dart';
|
||||||
|
import 'package:badnote/models/scratch_link.dart';
|
||||||
|
import 'package:badnote/storage/badnote_sidecar.dart';
|
||||||
|
|
||||||
|
EditorStroke _editorStroke(String id, EditorTool tool) => EditorStroke(
|
||||||
|
id: id,
|
||||||
|
points: [
|
||||||
|
const EditorPoint(x: 0.1, y: 0.2, pressure: 0.5, tilt: 0.1),
|
||||||
|
EditorPoint(
|
||||||
|
x: 0.3,
|
||||||
|
y: 0.4,
|
||||||
|
pressure: 0.9,
|
||||||
|
tilt: 0.2,
|
||||||
|
timestamp: 1234,
|
||||||
|
pointerDeviceKind: InputDeviceKind.stylus,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tool: tool,
|
||||||
|
color: 0xFF112233,
|
||||||
|
width: 0.005,
|
||||||
|
);
|
||||||
|
|
||||||
|
InkStroke _inkStroke(String id) => InkStroke(
|
||||||
|
id: id,
|
||||||
|
points: [
|
||||||
|
const InkPoint(
|
||||||
|
x: 100.0,
|
||||||
|
y: 200.0,
|
||||||
|
pressure: 0.7,
|
||||||
|
tilt: 0.3,
|
||||||
|
timestamp: 99,
|
||||||
|
pointerDeviceKind: InputDeviceKind.stylus,
|
||||||
|
),
|
||||||
|
const InkPoint(x: 300.0, y: 400.0, timestamp: 100),
|
||||||
|
],
|
||||||
|
tool: PenTool.pen,
|
||||||
|
color: 0xFF445566,
|
||||||
|
strokeWidth: 3.0,
|
||||||
|
createdAt: DateTime.utc(2026, 6, 24, 10, 0, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
BadnoteSidecar _fullSidecar() => BadnoteSidecar(
|
||||||
|
sourceFile: 'Calculus Lecture 3.pdf',
|
||||||
|
docType: 'pdf',
|
||||||
|
pageCount: 42,
|
||||||
|
rotation: 90,
|
||||||
|
createdAt: DateTime.utc(2026, 6, 24, 10, 0, 0),
|
||||||
|
updatedAt: DateTime.utc(2026, 6, 24, 10, 32, 11),
|
||||||
|
strokes: {
|
||||||
|
0: [_editorStroke('s0', EditorTool.pen)],
|
||||||
|
3: [
|
||||||
|
_editorStroke('s1', EditorTool.highlighter),
|
||||||
|
_editorStroke('s2', EditorTool.pen),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
highlights: {
|
||||||
|
0: const [
|
||||||
|
SidecarHighlight(l: 0.12, t: 0.20, r: 0.88, b: 0.235, color: 0xFFFFEB3B),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
bookmarks: [
|
||||||
|
Bookmark(
|
||||||
|
id: 'bm1',
|
||||||
|
documentId: 'doc1',
|
||||||
|
pageNumber: 5,
|
||||||
|
label: 'Proof',
|
||||||
|
color: 0xFF2196F3,
|
||||||
|
createdAt: DateTime.utc(2026, 6, 24, 9, 0, 0),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
scratchLinks: [
|
||||||
|
SidecarScratchLink(
|
||||||
|
link: const ScratchLink(
|
||||||
|
id: 'anchor1',
|
||||||
|
documentId: 'doc1',
|
||||||
|
pageIndex: 7,
|
||||||
|
nx: 0.83,
|
||||||
|
ny: 0.41,
|
||||||
|
),
|
||||||
|
scratchpad: SidecarScratchpad(
|
||||||
|
canvasWidth: 5000,
|
||||||
|
canvasHeight: 6000,
|
||||||
|
strokes: [_inkStroke('ink1'), _inkStroke('ink2')],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Serializes through `jsonEncode`/`jsonDecode` exactly as `SidecarStore`
|
||||||
|
/// persists/loads on disk (nested freezed objects only flatten via the encoder's
|
||||||
|
/// toEncodable hook, so an in-memory map round-trip would not).
|
||||||
|
BadnoteSidecar _roundTrip(BadnoteSidecar sidecar) => BadnoteSidecar.fromJson(
|
||||||
|
jsonDecode(jsonEncode(sidecar.toJson())) as Map<String, dynamic>);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('full round-trip preserves strokes/highlights/bookmarks/links', () {
|
||||||
|
final original = _fullSidecar();
|
||||||
|
final reparsed = _roundTrip(original);
|
||||||
|
|
||||||
|
expect(reparsed.version, kBadnoteSidecarVersion);
|
||||||
|
expect(reparsed.sourceFile, 'Calculus Lecture 3.pdf');
|
||||||
|
expect(reparsed.docType, 'pdf');
|
||||||
|
expect(reparsed.pageCount, 42);
|
||||||
|
expect(reparsed.rotation, 90);
|
||||||
|
expect(reparsed.createdAt, DateTime.utc(2026, 6, 24, 10, 0, 0));
|
||||||
|
expect(reparsed.updatedAt, DateTime.utc(2026, 6, 24, 10, 32, 11));
|
||||||
|
|
||||||
|
// Strokes (EditorStroke value equality via freezed).
|
||||||
|
expect(reparsed.strokes.keys.toSet(), {0, 3});
|
||||||
|
expect(reparsed.strokes[0], original.strokes[0]);
|
||||||
|
expect(reparsed.strokes[3], original.strokes[3]);
|
||||||
|
|
||||||
|
// Highlights.
|
||||||
|
expect(reparsed.highlights[0], original.highlights[0]);
|
||||||
|
|
||||||
|
// Bookmarks (Bookmark value equality via freezed).
|
||||||
|
expect(reparsed.bookmarks, original.bookmarks);
|
||||||
|
|
||||||
|
// Scratch links + embedded scratchpads.
|
||||||
|
expect(reparsed.scratchLinks, original.scratchLinks);
|
||||||
|
final sp = reparsed.scratchLinks.single.scratchpad;
|
||||||
|
expect(sp.canvasWidth, 5000);
|
||||||
|
expect(sp.canvasHeight, 6000);
|
||||||
|
expect(sp.strokes, original.scratchLinks.single.scratchpad.strokes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strokes JSON is byte-compatible with EditorStroke.toJson', () {
|
||||||
|
final stroke = _editorStroke('s0', EditorTool.pen);
|
||||||
|
final sidecar = BadnoteSidecar(strokes: {2: [stroke]});
|
||||||
|
final json = sidecar.toJson();
|
||||||
|
final pageList = (json['strokes'] as Map)['2'] as List;
|
||||||
|
expect(pageList.single, stroke.toJson());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scratchpad strokes JSON is byte-compatible with InkStroke.toJson', () {
|
||||||
|
final ink = _inkStroke('ink1');
|
||||||
|
final scratchpad = SidecarScratchpad(strokes: [ink]);
|
||||||
|
final json = scratchpad.toJson();
|
||||||
|
expect((json['strokes'] as List).single, ink.toJson());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scratch link JSON includes ScratchLink fields plus scratchpad', () {
|
||||||
|
const link = ScratchLink(
|
||||||
|
id: 'a',
|
||||||
|
documentId: 'd',
|
||||||
|
pageIndex: 3,
|
||||||
|
nx: 0.5,
|
||||||
|
ny: 0.5,
|
||||||
|
);
|
||||||
|
final json = SidecarScratchLink(link: link).toJson();
|
||||||
|
// Reuses ScratchLink.toJson keys verbatim.
|
||||||
|
for (final key in link.toJson().keys) {
|
||||||
|
expect(json.containsKey(key), isTrue, reason: 'missing key $key');
|
||||||
|
}
|
||||||
|
expect(json.containsKey('scratchpad'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty sidecar round-trips to empty containers', () {
|
||||||
|
final reparsed = _roundTrip(BadnoteSidecar());
|
||||||
|
expect(reparsed.strokes, isEmpty);
|
||||||
|
expect(reparsed.highlights, isEmpty);
|
||||||
|
expect(reparsed.bookmarks, isEmpty);
|
||||||
|
expect(reparsed.scratchLinks, isEmpty);
|
||||||
|
expect(reparsed.version, kBadnoteSidecarVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown fields are ignored (forward-compat)', () {
|
||||||
|
final encoded = jsonEncode(BadnoteSidecar(
|
||||||
|
strokes: {0: [_editorStroke('s0', EditorTool.pen)]},
|
||||||
|
).toJson());
|
||||||
|
final json = jsonDecode(encoded) as Map<String, dynamic>;
|
||||||
|
json['futureFieldWeDoNotKnow'] = {'anything': true};
|
||||||
|
((json['strokes'] as Map)['0'] as List)[0]['brush'] =
|
||||||
|
'marker'; // future per-stroke field
|
||||||
|
final reparsed = BadnoteSidecar.fromJson(json);
|
||||||
|
expect(reparsed.strokes[0]!.single.id, 's0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing scratchpad defaults to 4000x4000 empty pad', () {
|
||||||
|
final json = SidecarScratchLink(
|
||||||
|
link: const ScratchLink(
|
||||||
|
id: 'a',
|
||||||
|
documentId: 'd',
|
||||||
|
pageIndex: 0,
|
||||||
|
nx: 0,
|
||||||
|
ny: 0,
|
||||||
|
),
|
||||||
|
).toJson();
|
||||||
|
json.remove('scratchpad');
|
||||||
|
final reparsed = SidecarScratchLink.fromJson(json);
|
||||||
|
expect(reparsed.scratchpad.canvasWidth, 4000.0);
|
||||||
|
expect(reparsed.scratchpad.canvasHeight, 4000.0);
|
||||||
|
expect(reparsed.scratchpad.strokes, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
175
test/sidecar_store_test.dart
Normal file
175
test/sidecar_store_test.dart
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
// test/sidecar_store_test.dart
|
||||||
|
//
|
||||||
|
// Verifies SidecarStore (§F.1 atomic write + .bak fallback):
|
||||||
|
// * round-trip through disk preserves the model;
|
||||||
|
// * a failed (interrupted) write leaves no partial sidecar at the target —
|
||||||
|
// the previous good content survives;
|
||||||
|
// * .bak recovery when the primary file is corrupt or missing.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
import 'package:badnote/models/bookmark.dart';
|
||||||
|
import 'package:badnote/models/ink_point.dart';
|
||||||
|
import 'package:badnote/models/ink_stroke.dart';
|
||||||
|
import 'package:badnote/models/pen_tool.dart';
|
||||||
|
import 'package:badnote/models/scratch_link.dart';
|
||||||
|
import 'package:badnote/storage/badnote_sidecar.dart';
|
||||||
|
import 'package:badnote/storage/sidecar_store.dart';
|
||||||
|
|
||||||
|
BadnoteSidecar _sidecar({String sourceFile = 'doc.pdf'}) => BadnoteSidecar(
|
||||||
|
sourceFile: sourceFile,
|
||||||
|
docType: 'pdf',
|
||||||
|
pageCount: 3,
|
||||||
|
strokes: {
|
||||||
|
0: [
|
||||||
|
EditorStroke(
|
||||||
|
id: 's0',
|
||||||
|
points: const [EditorPoint(x: 0.1, y: 0.2, pressure: 0.5)],
|
||||||
|
color: 0xFF000000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
highlights: {
|
||||||
|
0: const [SidecarHighlight(l: 0.1, t: 0.1, r: 0.9, b: 0.2)],
|
||||||
|
},
|
||||||
|
bookmarks: [
|
||||||
|
Bookmark(
|
||||||
|
id: 'bm',
|
||||||
|
documentId: 'd',
|
||||||
|
pageNumber: 1,
|
||||||
|
createdAt: DateTime.utc(2026, 1, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
scratchLinks: [
|
||||||
|
SidecarScratchLink(
|
||||||
|
link: const ScratchLink(
|
||||||
|
id: 'a',
|
||||||
|
documentId: 'd',
|
||||||
|
pageIndex: 0,
|
||||||
|
nx: 0.5,
|
||||||
|
ny: 0.5,
|
||||||
|
),
|
||||||
|
scratchpad: SidecarScratchpad(
|
||||||
|
strokes: [
|
||||||
|
InkStroke(
|
||||||
|
id: 'ink',
|
||||||
|
points: const [InkPoint(x: 1, y: 2, timestamp: 0)],
|
||||||
|
tool: PenTool.pen,
|
||||||
|
createdAt: DateTime.utc(2026, 1, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory tmpDir;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tmpDir = await Directory.systemTemp.createTemp('sidecar_store_test');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (await tmpDir.exists()) {
|
||||||
|
await tmpDir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
File targetFile() => File('${tmpDir.path}/doc.pdf.badnote.json');
|
||||||
|
|
||||||
|
test('writeAtomic then read round-trips the model', () async {
|
||||||
|
final target = targetFile();
|
||||||
|
final original = _sidecar();
|
||||||
|
|
||||||
|
await SidecarStore.writeAtomic(target, original);
|
||||||
|
expect(await target.exists(), isTrue);
|
||||||
|
|
||||||
|
final loaded = await SidecarStore.read(target);
|
||||||
|
expect(loaded, isNotNull);
|
||||||
|
expect(loaded!.sourceFile, 'doc.pdf');
|
||||||
|
expect(loaded.strokes[0], original.strokes[0]);
|
||||||
|
expect(loaded.highlights[0], original.highlights[0]);
|
||||||
|
expect(loaded.bookmarks, original.bookmarks);
|
||||||
|
expect(loaded.scratchLinks, original.scratchLinks);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write leaves no leftover .tmp file', () async {
|
||||||
|
final target = targetFile();
|
||||||
|
await SidecarStore.writeAtomic(target, _sidecar());
|
||||||
|
final tmp = File('${target.path}${SidecarStore.tmpSuffix}');
|
||||||
|
expect(await tmp.exists(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeAtomic creates parent directories', () async {
|
||||||
|
final nested =
|
||||||
|
File('${tmpDir.path}/nested/folder/doc.pdf.badnote.json');
|
||||||
|
await SidecarStore.writeAtomic(nested, _sidecar());
|
||||||
|
expect(await nested.exists(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('second write keeps the previous content as .bak', () async {
|
||||||
|
final target = targetFile();
|
||||||
|
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v1.pdf'));
|
||||||
|
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v2.pdf'));
|
||||||
|
|
||||||
|
final bak = File('${target.path}${SidecarStore.bakSuffix}');
|
||||||
|
expect(await bak.exists(), isTrue);
|
||||||
|
|
||||||
|
final bakModel =
|
||||||
|
BadnoteSidecar.fromJson(jsonDecode(await bak.readAsString()));
|
||||||
|
expect(bakModel.sourceFile, 'v1.pdf'); // previous good copy
|
||||||
|
|
||||||
|
final current = await SidecarStore.read(target);
|
||||||
|
expect(current!.sourceFile, 'v2.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('simulated interrupted write leaves previous good file intact', () async {
|
||||||
|
final target = targetFile();
|
||||||
|
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'good.pdf'));
|
||||||
|
|
||||||
|
// Simulate a crash mid-write: a partial .tmp is created but the rename
|
||||||
|
// never happened.
|
||||||
|
final tmp = File('${target.path}${SidecarStore.tmpSuffix}');
|
||||||
|
await tmp.writeAsString('{ "badnoteSidecarVersion": 1, "sourceFil');
|
||||||
|
|
||||||
|
// The target still holds the last good content — no partial leakage.
|
||||||
|
final loaded = await SidecarStore.read(target);
|
||||||
|
expect(loaded, isNotNull);
|
||||||
|
expect(loaded!.sourceFile, 'good.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.bak recovery when primary is corrupt', () async {
|
||||||
|
final target = targetFile();
|
||||||
|
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v1.pdf'));
|
||||||
|
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v2.pdf'));
|
||||||
|
|
||||||
|
// Corrupt the primary file.
|
||||||
|
await target.writeAsString('}{ not json at all');
|
||||||
|
|
||||||
|
final recovered = await SidecarStore.read(target);
|
||||||
|
expect(recovered, isNotNull);
|
||||||
|
expect(recovered!.sourceFile, 'v1.pdf'); // fell back to .bak
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.bak recovery when primary is missing', () async {
|
||||||
|
final target = targetFile();
|
||||||
|
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v1.pdf'));
|
||||||
|
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v2.pdf'));
|
||||||
|
|
||||||
|
await target.delete();
|
||||||
|
|
||||||
|
final recovered = await SidecarStore.read(target);
|
||||||
|
expect(recovered, isNotNull);
|
||||||
|
expect(recovered!.sourceFile, 'v1.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read returns null when nothing exists', () async {
|
||||||
|
final loaded = await SidecarStore.read(targetFile());
|
||||||
|
expect(loaded, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user