// 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)'; } /// A single typed-text annotation on a page (PDF editor for now). Position is /// NORMALIZED to the page rect ([nx],[ny] in [0,1]) so the box stays glued under /// zoom/scroll, exactly like [SidecarHighlight] / [ScratchLink]. [fontSize] is /// PAGE-RELATIVE (a fraction of the page width), so the rendered text scales /// with the page; the editor multiplies it by the on-screen page width. class SidecarText { const SidecarText({ required this.id, required this.nx, required this.ny, required this.text, this.fontSize = 0.03, this.color = 0xFF000000, this.fontWeight = 400, this.fontFamily, }); /// Stable id (uuid) so edits/deletes address a specific box. final String id; /// Normalized x of the box's top-left in [0,1]. final double nx; /// Normalized y of the box's top-left in [0,1]. final double ny; /// The typed text. final String text; /// Font size as a fraction of page WIDTH (page-relative; scales with zoom). final double fontSize; /// ARGB text color. final int color; /// CSS-like numeric weight (100–900). Default 400 (regular). final int fontWeight; /// Optional family name. Null → editor default (IBM Plex Sans). final String? fontFamily; SidecarText copyWith({ String? id, double? nx, double? ny, String? text, double? fontSize, int? color, int? fontWeight, String? fontFamily, bool clearFontFamily = false, }) => SidecarText( id: id ?? this.id, nx: nx ?? this.nx, ny: ny ?? this.ny, text: text ?? this.text, fontSize: fontSize ?? this.fontSize, color: color ?? this.color, fontWeight: fontWeight ?? this.fontWeight, fontFamily: clearFontFamily ? null : (fontFamily ?? this.fontFamily), ); Map toJson() => { 'id': id, 'nx': nx, 'ny': ny, 'text': text, 'fontSize': fontSize, 'color': color, 'fontWeight': fontWeight, if (fontFamily != null) 'fontFamily': fontFamily, }; factory SidecarText.fromJson(Map json) => SidecarText( id: json['id'] as String, nx: (json['nx'] as num).toDouble(), ny: (json['ny'] as num).toDouble(), text: (json['text'] as String?) ?? '', fontSize: (json['fontSize'] as num?)?.toDouble() ?? 0.03, color: (json['color'] as num?)?.toInt() ?? 0xFF000000, fontWeight: (json['fontWeight'] as num?)?.toInt() ?? 400, fontFamily: json['fontFamily'] as String?, ); @override bool operator ==(Object other) => identical(this, other) || other is SidecarText && runtimeType == other.runtimeType && id == other.id && nx == other.nx && ny == other.ny && text == other.text && fontSize == other.fontSize && color == other.color && fontWeight == other.fontWeight && fontFamily == other.fontFamily; @override int get hashCode => Object.hash( id, nx, ny, text, fontSize, color, fontWeight, fontFamily, ); @override String toString() => 'SidecarText(id: $id, nx: $nx, ny: $ny, text: $text, ' 'fontSize: $fontSize, weight: $fontWeight, family: $fontFamily)'; } /// 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, Map>? texts, List? bookmarks, List? scratchLinks, Map? legacyAnnotations, this.ocrText, this.pageText, this.legacyId, this.background, }) : strokes = strokes ?? >{}, highlights = highlights ?? >{}, texts = texts ?? >{}, 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; /// Page index → typed-text annotations (normalized position, page-relative /// font size). PDF editor only for now (note text is a later increment). final Map> texts; 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; /// Searchable text of the underlying DOCUMENT BODY for a file-backed notebook /// (a PDF), captured ONCE at import time so the vault-scan search index covers /// the document — not just the user's annotations. It is either the PDF's /// embedded (printed) text layer, or — for a RASTERIZED / scanned PDF with no /// text layer — the result of a background OCR pass over the rendered pages. /// Pages are joined with `\f` (form feed) but the index treats it as a flat /// blob. Null when the document has not been indexed yet (back-compat: an old /// sidecar simply omits the field) or has no extractable/recognized text. This /// is distinct from [ocrText], which holds ONLY handwriting OCR. final String? pageText; /// 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 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(), }, if (texts.isNotEmpty) 'texts': { for (final entry in texts.entries) entry.key.toString(): entry.value.map((t) => t.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 (pageText != null && pageText!.isNotEmpty) 'pageText': pageText, if (legacyId != null) 'legacyId': legacyId, if (background != null) 'background': background, }; 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), texts: decodePageMap(json['texts'], SidecarText.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?, pageText: json['pageText'] as String?, legacyId: json['legacyId'] as String?, background: json['background'] 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; }