2026-06-24 20:53:01 +08:00
|
|
|
|
// 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)';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 00:11:45 +08:00
|
|
|
|
/// 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/// 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;
|
|
|
|
|
|
|
|
|
|
|
|
SidecarText copyWith({
|
|
|
|
|
|
String? id,
|
|
|
|
|
|
double? nx,
|
|
|
|
|
|
double? ny,
|
|
|
|
|
|
String? text,
|
|
|
|
|
|
double? fontSize,
|
|
|
|
|
|
int? color,
|
|
|
|
|
|
}) =>
|
|
|
|
|
|
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,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
|
|
|
|
'id': id,
|
|
|
|
|
|
'nx': nx,
|
|
|
|
|
|
'ny': ny,
|
|
|
|
|
|
'text': text,
|
|
|
|
|
|
'fontSize': fontSize,
|
|
|
|
|
|
'color': color,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
factory SidecarText.fromJson(Map<String, dynamic> 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,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
@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;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
int get hashCode => Object.hash(id, nx, ny, text, fontSize, color);
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
String toString() =>
|
|
|
|
|
|
'SidecarText(id: $id, nx: $nx, ny: $ny, text: $text, '
|
|
|
|
|
|
'fontSize: $fontSize, color: $color)';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 20:53:01 +08:00
|
|
|
|
/// 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,
|
2026-06-24 22:48:18 +08:00
|
|
|
|
this.title,
|
2026-06-24 20:53:01 +08:00
|
|
|
|
this.pageCount,
|
|
|
|
|
|
this.rotation = 0,
|
|
|
|
|
|
this.createdAt,
|
|
|
|
|
|
this.updatedAt,
|
|
|
|
|
|
Map<int, List<EditorStroke>>? strokes,
|
|
|
|
|
|
Map<int, List<SidecarHighlight>>? highlights,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
Map<int, List<SidecarText>>? texts,
|
2026-06-24 20:53:01 +08:00
|
|
|
|
List<Bookmark>? bookmarks,
|
|
|
|
|
|
List<SidecarScratchLink>? scratchLinks,
|
2026-06-24 23:05:10 +08:00
|
|
|
|
Map<int, String>? legacyAnnotations,
|
2026-06-24 23:19:21 +08:00
|
|
|
|
this.ocrText,
|
2026-06-25 00:23:19 +08:00
|
|
|
|
this.pageText,
|
2026-06-24 23:05:10 +08:00
|
|
|
|
this.legacyId,
|
2026-06-24 23:47:29 +08:00
|
|
|
|
this.background,
|
2026-06-24 20:53:01 +08:00
|
|
|
|
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
|
|
|
|
|
|
highlights = highlights ?? <int, List<SidecarHighlight>>{},
|
2026-06-25 00:11:45 +08:00
|
|
|
|
texts = texts ?? <int, List<SidecarText>>{},
|
2026-06-24 20:53:01 +08:00
|
|
|
|
bookmarks = bookmarks ?? <Bookmark>[],
|
2026-06-24 23:05:10 +08:00
|
|
|
|
scratchLinks = scratchLinks ?? <SidecarScratchLink>[],
|
|
|
|
|
|
legacyAnnotations = legacyAnnotations ?? <int, String>{};
|
2026-06-24 20:53:01 +08:00
|
|
|
|
|
|
|
|
|
|
/// 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;
|
|
|
|
|
|
|
2026-06-24 22:48:18 +08:00
|
|
|
|
/// Display title for a standalone (non-file-backed) notebook (`docType ==
|
|
|
|
|
|
/// 'notebook'`). Null for file-backed sidecars, whose title is the filename.
|
|
|
|
|
|
final String? title;
|
|
|
|
|
|
|
2026-06-24 20:53:01 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-25 00:11:45 +08:00
|
|
|
|
/// Page index → typed-text annotations (normalized position, page-relative
|
|
|
|
|
|
/// font size). PDF editor only for now (note text is a later increment).
|
|
|
|
|
|
final Map<int, List<SidecarText>> texts;
|
|
|
|
|
|
|
2026-06-24 20:53:01 +08:00
|
|
|
|
final List<Bookmark> bookmarks;
|
|
|
|
|
|
final List<SidecarScratchLink> scratchLinks;
|
|
|
|
|
|
|
2026-06-24 23:05:10 +08:00
|
|
|
|
/// 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;
|
|
|
|
|
|
|
2026-06-24 23:19:21 +08:00
|
|
|
|
/// 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;
|
|
|
|
|
|
|
2026-06-25 00:23:19 +08:00
|
|
|
|
/// 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;
|
|
|
|
|
|
|
2026-06-24 23:05:10 +08:00
|
|
|
|
/// 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;
|
|
|
|
|
|
|
2026-06-24 23:47:29 +08:00
|
|
|
|
/// 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;
|
|
|
|
|
|
|
2026-06-24 20:53:01 +08:00
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
|
|
|
|
'badnoteSidecarVersion': version,
|
|
|
|
|
|
if (sourceFile != null) 'sourceFile': sourceFile,
|
|
|
|
|
|
if (docType != null) 'docType': docType,
|
2026-06-24 22:48:18 +08:00
|
|
|
|
if (title != null) 'title': title,
|
2026-06-24 20:53:01 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
},
|
2026-06-25 00:11:45 +08:00
|
|
|
|
if (texts.isNotEmpty)
|
|
|
|
|
|
'texts': {
|
|
|
|
|
|
for (final entry in texts.entries)
|
|
|
|
|
|
entry.key.toString():
|
|
|
|
|
|
entry.value.map((t) => t.toJson()).toList(),
|
|
|
|
|
|
},
|
2026-06-24 20:53:01 +08:00
|
|
|
|
'bookmarks': bookmarks.map((b) => b.toJson()).toList(),
|
|
|
|
|
|
'scratchLinks': scratchLinks.map((s) => s.toJson()).toList(),
|
2026-06-24 23:05:10 +08:00
|
|
|
|
if (legacyAnnotations.isNotEmpty)
|
|
|
|
|
|
'legacyAnnotations': {
|
|
|
|
|
|
for (final entry in legacyAnnotations.entries)
|
|
|
|
|
|
entry.key.toString(): entry.value,
|
|
|
|
|
|
},
|
2026-06-24 23:19:21 +08:00
|
|
|
|
if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
|
2026-06-25 00:23:19 +08:00
|
|
|
|
if (pageText != null && pageText!.isNotEmpty) 'pageText': pageText,
|
2026-06-24 23:05:10 +08:00
|
|
|
|
if (legacyId != null) 'legacyId': legacyId,
|
2026-06-24 23:47:29 +08:00
|
|
|
|
if (background != null) 'background': background,
|
2026-06-24 20:53:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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?,
|
2026-06-24 22:48:18 +08:00
|
|
|
|
title: json['title'] as String?,
|
2026-06-24 20:53:01 +08:00
|
|
|
|
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),
|
2026-06-25 00:11:45 +08:00
|
|
|
|
texts: decodePageMap(json['texts'], SidecarText.fromJson),
|
2026-06-24 20:53:01 +08:00
|
|
|
|
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(),
|
2026-06-24 23:05:10 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}(),
|
2026-06-24 23:19:21 +08:00
|
|
|
|
ocrText: json['ocrText'] as String?,
|
2026-06-25 00:23:19 +08:00
|
|
|
|
pageText: json['pageText'] as String?,
|
2026-06-24 23:05:10 +08:00
|
|
|
|
legacyId: json['legacyId'] as String?,
|
2026-06-24 23:47:29 +08:00
|
|
|
|
background: json['background'] as String?,
|
2026-06-24 20:53:01 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|