Files
BadNote/lib/storage/badnote_sidecar.dart
Akiba So c800295c12
Some checks failed
CI / Windows build (push) Has been cancelled
feat(note): page background templates (rnote-style)
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.
2026-06-24 23:47:29 +08:00

385 lines
14 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.title,
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,
Map<int, String>? legacyAnnotations,
this.ocrText,
this.legacyId,
this.background,
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
highlights = highlights ?? <int, List<SidecarHighlight>>{},
bookmarks = bookmarks ?? <Bookmark>[],
scratchLinks = scratchLinks ?? <SidecarScratchLink>[],
legacyAnnotations = legacyAnnotations ?? <int, String>{};
/// 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;
/// Display title for a standalone (non-file-backed) notebook (`docType ==
/// 'notebook'`). Null for file-backed sidecars, whose title is the filename.
final String? title;
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;
/// Raw legacy per-page `annotation_json` blobs preserved verbatim from the
/// DEAD pre-editor `annotations` SQLite table (keyed by page number). Populated
/// only by the one-time SQLite→sidecar migration so no legacy data is silently
/// dropped; the live editor ignores it. Empty for all freshly authored
/// sidecars.
final Map<int, String> legacyAnnotations;
/// Searchable text recovered from this notebook's handwriting via local OCR
/// (Phase 6 search index). Persisted in the sidecar — the source of truth —
/// so the vault-scan search index can find handwritten notes WITHOUT the
/// (rebuildable, per-device) SQLite cache. Typed text already lives in the
/// strokes' `textContent`, so this holds ONLY the OCR'd handwriting. Null when
/// the notebook has no handwriting or OCR hasn't run.
final String? ocrText;
/// The legacy SQLite row id this sidecar was migrated from (a `documents.id`
/// or `notes.id`). Set ONLY by the one-time migration; it makes the migration
/// idempotent (a re-run recognizes an already-migrated item by this id even if
/// its folder name collided). Null for all freshly authored sidecars.
final String? legacyId;
/// The page-background template for a standalone notebook, stored as the
/// [NoteBackground] enum `name` (e.g. `dots`, `cornell`). Kept as a raw String
/// here so the storage model stays UI-decoupled; the editor decodes it via
/// `noteBackgroundFromName` (missing/unknown → blank, back-compat).
final String? background;
Map<String, dynamic> toJson() => {
'badnoteSidecarVersion': version,
if (sourceFile != null) 'sourceFile': sourceFile,
if (docType != null) 'docType': docType,
if (title != null) 'title': title,
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(),
if (legacyAnnotations.isNotEmpty)
'legacyAnnotations': {
for (final entry in legacyAnnotations.entries)
entry.key.toString(): entry.value,
},
if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
if (legacyId != null) 'legacyId': legacyId,
if (background != null) 'background': background,
};
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?,
title: json['title'] 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(),
legacyAnnotations: () {
final raw = json['legacyAnnotations'];
final out = <int, String>{};
if (raw is Map) {
raw.forEach((key, value) {
final page = int.tryParse(key.toString());
if (page != null && value is String) out[page] = value;
});
}
return out;
}(),
ocrText: json['ocrText'] as String?,
legacyId: json['legacyId'] as String?,
background: json['background'] as String?,
);
}
}
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;
}