Files
BadNote/lib/storage/badnote_sidecar.dart
Akiba So 953c7b700f
Some checks failed
CI / Windows build (push) Has been cancelled
feat(storage): sidecar model + atomic store (lib only)
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.
2026-06-24 20:53:01 +08:00

324 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}