feat(storage): notes are vault sidecar notebooks
All checks were successful
CI / Windows build (push) Successful in 12m55s

Phase 4. Standalone notes move off SQLite into the vault, like the
PDF annotations.

- "Create notebook" makes a vault folder with a notebook.badnote.json
  (BadnoteSidecar docType 'notebook' + a title field), opened via
  SidecarRepository.
- PenNoteScreen loads/saves its strokes (page 0) + title to that
  sidecar instead of the SQLite Note model.
- note_provider lists notes from a vault scan (VaultService.scanNotes
  = folders with notebook.badnote.json and no source file); the doc
  scan still excludes them. Delete removes the folder.

PDF/slide editors unchanged; pre-existing SQLite notes migrate in
Phase 5. analyze clean, tests green.
This commit is contained in:
2026-06-24 22:48:18 +08:00
parent 2f0fda5f95
commit f4f0853eae
14 changed files with 598 additions and 70 deletions

View File

@@ -15,6 +15,8 @@ import '../../providers/note_provider.dart';
import '../../providers/ocr_provider.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../engine/stroke_model.dart';
import '../persistence/sidecar_repository.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
@@ -85,7 +87,17 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
bool _dirty = false;
bool _needsCenter = true;
String? _noteId;
/// The note's synthetic source path `<folder>/notebook` (also the note id).
/// Persistence flows through this note's `notebook.badnote.json` sidecar.
String? _notePath;
/// Per-file sidecar persistence sink (strokes page 0 + title), debounced and
/// atomic — replaces the old SQLite Note/noteListProvider write path here.
SidecarRepository? _repo;
/// Page index a standalone note's strokes live under in the sidecar.
static const int _notePageIndex = 0;
final TextEditingController _titleController = TextEditingController();
PenConfigController? _penConfig;
@@ -108,15 +120,56 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
PenInputService.instance.start();
final note = widget.note;
if (note != null) {
_noteId = note.id;
_notePath = note.id;
_titleController.text = note.title;
// Seed from the in-memory note's strokes (e.g. tests) until the sidecar
// load resolves and (if present) overrides with persisted strokes.
_strokes = penStrokesFromInk(note.strokes, kNoteLogicalPage);
} else {
_titleController.text = 'Untitled';
}
_initPenConfig();
if (_notePath != null) _initPersistence(_notePath!);
}
/// Open the note's `notebook.badnote.json` sidecar and, if it holds persisted
/// strokes / a title, hydrate the canvas from them. Strokes load as page-0
/// [EditorStroke]s converted to [PenStroke] (mirrors the PDF editor).
Future<void> _initPersistence(String notePath) async {
final repo = await SidecarRepository.open(notePath, docType: 'notebook');
if (!mounted) {
repo.dispose();
return;
}
_repo = repo;
final loaded = repo.loadedStrokes[_notePageIndex];
setState(() {
if (loaded != null && loaded.isNotEmpty) {
_strokes = [for (final es in loaded) _penStrokeFromEditor(es)];
}
final title = repo.loadedTitle;
if (title != null && title.isNotEmpty) {
_titleController.text = title;
}
});
}
/// EditorStroke → live PenStroke (mirror of the PDF editor's loader). Brush
/// is not persisted (TODO(brush-persist)); derive it from the tool.
PenStroke _penStrokeFromEditor(EditorStroke es) => PenStroke(
points: es.points
.map((ep) => PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
.toList(),
color: es.color,
width: es.width,
kind: es.tool == EditorTool.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen,
brush: es.tool == EditorTool.highlighter
? BrushKind.highlighter
: BrushKind.fountainPen,
);
Future<void> _initPenConfig() async {
final controller = await PenConfigController.load();
if (!mounted) {
@@ -136,6 +189,13 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
@override
void dispose() {
// Flush any pending sidecar write before tearing down (atomic write
// completes off the widget tree).
final repo = _repo;
if (repo != null) {
repo.flush();
repo.dispose();
}
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
_titleController.dispose();
@@ -197,8 +257,11 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
// ── Persistence ──────────────────────────────────────────────────────────────
/// Convert the live pen strokes back to InkStroke and write the note. Creates
/// the note row on first save. Triggers local OCR for search indexing.
/// Persist the live pen strokes + title to the note's `notebook.badnote.json`
/// sidecar (strokes as page-0 [EditorStroke]s; title via the sidecar's title
/// field), debounced/atomic via [SidecarRepository]. Creates the notebook
/// folder lazily on first save when the screen was opened without a path.
/// Refreshes the home list and triggers local OCR for search indexing.
Future<void> _save() async {
if (!_dirty) return;
final notifier = ref.read(noteListProvider.notifier);
@@ -206,40 +269,46 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
final title = _titleController.text.trim().isEmpty
? 'Untitled'
: _titleController.text.trim();
final inkStrokes = <InkStroke>[
for (final s in _strokes)
inkStrokeFromPen(s, kNoteLogicalPage,
id: _uuid.v4(), createdAt: now),
];
Note saved;
if (_noteId == null) {
// Lazily create the notebook folder + sidecar repo on first save.
if (_repo == null) {
final created = await notifier.createNote(title: title);
saved = created.copyWith(strokes: inkStrokes, updatedAt: now);
await notifier.updateNote(saved);
_noteId = saved.id;
} else {
saved = (widget.note ?? await _noteById(_noteId!)).copyWith(
title: title,
strokes: inkStrokes,
updatedAt: now,
);
await notifier.updateNote(saved);
if (!mounted) return;
_notePath = created.id;
final repo =
await SidecarRepository.open(created.id, docType: 'notebook');
if (!mounted) {
repo.dispose();
return;
}
_repo = repo;
}
final repo = _repo!;
final editorStrokes = <EditorStroke>[
for (final s in _strokes) EditorStroke.fromPenStroke(s),
];
repo.scheduleTitleSave(title);
repo.scheduleStrokeSave(_notePageIndex, editorStrokes);
await repo.flush();
// Refresh the home list so the title/recency update is visible on return.
await notifier.loadNotes();
if (!mounted) return;
setState(() => _dirty = false);
_runLocalOcr(saved);
}
Future<Note> _noteById(String id) async {
final notes = ref.read(noteListProvider).valueOrNull ?? const [];
return notes.firstWhere((n) => n.id == id,
orElse: () => Note(
id: id,
title: _titleController.text,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
));
// Build an in-memory Note (id = note path) for OCR/FTS indexing only.
final inkStrokes = <InkStroke>[
for (final s in _strokes)
inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now),
];
_runLocalOcr(Note(
id: _notePath!,
title: title,
strokes: inkStrokes,
createdAt: now,
updatedAt: now,
));
}
void _runLocalOcr(Note note) {

View File

@@ -87,8 +87,18 @@ class SidecarRepository {
/// The current in-memory sidecar (for tests / inspection).
BadnoteSidecar get sidecar => _sidecar;
/// The standalone-notebook title loaded from the sidecar, or null.
String? get loadedTitle => _sidecar.title;
// ── Mutations (synchronous in-memory update + debounced atomic write) ──────
/// Replace the standalone-notebook title and schedule a save. No-op if the
/// title is unchanged.
void scheduleTitleSave(String title) {
if (_sidecar.title == title) return;
_replace(title: title);
}
/// Replace the committed strokes for [pageIndex] and schedule a save.
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
@@ -176,6 +186,7 @@ class SidecarRepository {
/// given fields replaced, then arm the debounce timer. Snapshot is captured
/// synchronously here so a later edit can't corrupt an in-flight write.
void _replace({
String? title,
Map<int, List<EditorStroke>>? strokes,
Map<int, List<SidecarHighlight>>? highlights,
List<SidecarScratchLink>? scratchLinks,
@@ -185,6 +196,7 @@ class SidecarRepository {
version: _sidecar.version,
sourceFile: _sidecar.sourceFile,
docType: _sidecar.docType,
title: title ?? _sidecar.title,
pageCount: _sidecar.pageCount,
rotation: _sidecar.rotation,
createdAt: _sidecar.createdAt,