// lib/storage/badnote_sidecar.dart // // The on-disk sidecar model: all annotations for one source file, serialized as // `.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 (lib/editor/engine/stroke_model.dart; // byte-for-byte the `ink.stroke_json` column today) // * scratchpad ink → List (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 toJson() => { 'l': l, 't': t, 'r': r, 'b': b, 'color': color, }; factory SidecarHighlight.fromJson(Map 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 strokes; Map toJson() => { 'canvasWidth': canvasWidth, 'canvasHeight': canvasHeight, 'strokes': strokes.map((s) => s.toJson()).toList(), }; factory SidecarScratchpad.fromJson(Map json) => SidecarScratchpad( canvasWidth: (json['canvasWidth'] as num?)?.toDouble() ?? 4000.0, canvasHeight: (json['canvasHeight'] as num?)?.toDouble() ?? 4000.0, strokes: ((json['strokes'] as List?) ?? const []) .map((e) => InkStroke.fromJson(e as Map)) .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 toJson() => { ...link.toJson(), 'scratchpad': scratchpad.toJson(), }; factory SidecarScratchLink.fromJson(Map json) => SidecarScratchLink( link: ScratchLink.fromJson(json), scratchpad: json['scratchpad'] == null ? const SidecarScratchpad() : SidecarScratchpad.fromJson( json['scratchpad'] as Map), ); @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>? strokes, Map>? highlights, List? bookmarks, List? scratchLinks, Map? legacyAnnotations, this.ocrText, this.legacyId, }) : strokes = strokes ?? >{}, highlights = highlights ?? >{}, bookmarks = bookmarks ?? [], scratchLinks = scratchLinks ?? [], legacyAnnotations = legacyAnnotations ?? {}; /// 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> strokes; /// Page index → highlighted text rects (normalized). final Map> highlights; final List bookmarks; final List 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 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; Map 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, }; factory BadnoteSidecar.fromJson(Map json) { Map> decodePageMap( Object? raw, T Function(Map) item, ) { final out = >{}; 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)) .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?) ?? const []) .map((e) => Bookmark.fromJson(e as Map)) .toList(), scratchLinks: ((json['scratchLinks'] as List?) ?? const []) .map((e) => SidecarScratchLink.fromJson(e as Map)) .toList(), legacyAnnotations: () { final raw = json['legacyAnnotations']; final out = {}; 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?, ); } } bool _listEq(List a, List 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; }