Files
BadNote/lib/storage/badnote_sidecar.dart
Akiba So 24d13642fd
Some checks failed
CI / Windows build (push) Has been cancelled
feat(storage): app-pause flush + vault search index
Phase 6 (final storage phase).

- SidecarRepositoryRegistry tracks every open repo; SidecarFlushObserver
  (a WidgetsBindingObserver in main) flushes them all on
  inactive/hidden/paused/detached, awaiting each flush — the last
  strokes can't be lost on app close, not just on the 800ms timer.
- VaultSearchIndex rebuilds by scanning vault sidecars (the source of
  truth) — note titles, OCR text and document names — and search_provider
  queries it, so search spans notes + PDFs. Rebuilt on launch / after
  import.

The vault file-based storage migration (Phases 0-6) is complete:
annotations travel with the file, picked vault folder, atomic autosave,
one Import-file entry, SQLite migrated to sidecars. analyze clean,
tests green.
2026-06-24 23:19:21 +08:00

376 lines
13 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,
}) : 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;
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,
};
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?,
);
}
}
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;
}