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 '../../providers/ocr_provider.dart';
import '../engine/brush.dart'; import '../engine/brush.dart';
import '../engine/shape_geometry.dart'; import '../engine/shape_geometry.dart';
import '../engine/stroke_model.dart';
import '../persistence/sidecar_repository.dart';
import '../input/pen_config.dart'; import '../input/pen_config.dart';
import '../input/pen_input_service.dart'; import '../input/pen_input_service.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma; import '../input/pressure_curve.dart' show kNaturalPressureGamma;
@@ -85,7 +87,17 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
bool _dirty = false; bool _dirty = false;
bool _needsCenter = true; 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(); final TextEditingController _titleController = TextEditingController();
PenConfigController? _penConfig; PenConfigController? _penConfig;
@@ -108,15 +120,56 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
PenInputService.instance.start(); PenInputService.instance.start();
final note = widget.note; final note = widget.note;
if (note != null) { if (note != null) {
_noteId = note.id; _notePath = note.id;
_titleController.text = note.title; _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); _strokes = penStrokesFromInk(note.strokes, kNoteLogicalPage);
} else { } else {
_titleController.text = 'Untitled'; _titleController.text = 'Untitled';
} }
_initPenConfig(); _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 { Future<void> _initPenConfig() async {
final controller = await PenConfigController.load(); final controller = await PenConfigController.load();
if (!mounted) { if (!mounted) {
@@ -136,6 +189,13 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
@override @override
void dispose() { 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?.removeListener(_onPenConfigChanged);
_penConfig?.dispose(); _penConfig?.dispose();
_titleController.dispose(); _titleController.dispose();
@@ -197,8 +257,11 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
// ── Persistence ────────────────────────────────────────────────────────────── // ── Persistence ──────────────────────────────────────────────────────────────
/// Convert the live pen strokes back to InkStroke and write the note. Creates /// Persist the live pen strokes + title to the note's `notebook.badnote.json`
/// the note row on first save. Triggers local OCR for search indexing. /// 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 { Future<void> _save() async {
if (!_dirty) return; if (!_dirty) return;
final notifier = ref.read(noteListProvider.notifier); final notifier = ref.read(noteListProvider.notifier);
@@ -206,40 +269,46 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
final title = _titleController.text.trim().isEmpty final title = _titleController.text.trim().isEmpty
? 'Untitled' ? 'Untitled'
: _titleController.text.trim(); : _titleController.text.trim();
final inkStrokes = <InkStroke>[
for (final s in _strokes)
inkStrokeFromPen(s, kNoteLogicalPage,
id: _uuid.v4(), createdAt: now),
];
Note saved; // Lazily create the notebook folder + sidecar repo on first save.
if (_noteId == null) { if (_repo == null) {
final created = await notifier.createNote(title: title); final created = await notifier.createNote(title: title);
saved = created.copyWith(strokes: inkStrokes, updatedAt: now); if (!mounted) return;
await notifier.updateNote(saved); _notePath = created.id;
_noteId = saved.id; final repo =
} else { await SidecarRepository.open(created.id, docType: 'notebook');
saved = (widget.note ?? await _noteById(_noteId!)).copyWith( if (!mounted) {
title: title, repo.dispose();
strokes: inkStrokes, return;
updatedAt: now, }
); _repo = repo;
await notifier.updateNote(saved);
} }
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; if (!mounted) return;
setState(() => _dirty = false); setState(() => _dirty = false);
_runLocalOcr(saved);
}
Future<Note> _noteById(String id) async { // Build an in-memory Note (id = note path) for OCR/FTS indexing only.
final notes = ref.read(noteListProvider).valueOrNull ?? const []; final inkStrokes = <InkStroke>[
return notes.firstWhere((n) => n.id == id, for (final s in _strokes)
orElse: () => Note( inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now),
id: id, ];
title: _titleController.text, _runLocalOcr(Note(
createdAt: DateTime.now(), id: _notePath!,
updatedAt: DateTime.now(), title: title,
)); strokes: inkStrokes,
createdAt: now,
updatedAt: now,
));
} }
void _runLocalOcr(Note note) { void _runLocalOcr(Note note) {

View File

@@ -87,8 +87,18 @@ class SidecarRepository {
/// The current in-memory sidecar (for tests / inspection). /// The current in-memory sidecar (for tests / inspection).
BadnoteSidecar get sidecar => _sidecar; 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) ────── // ── 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. /// Replace the committed strokes for [pageIndex] and schedule a save.
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) { void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
final next = Map<int, List<EditorStroke>>.from(_sidecar.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 /// given fields replaced, then arm the debounce timer. Snapshot is captured
/// synchronously here so a later edit can't corrupt an in-flight write. /// synchronously here so a later edit can't corrupt an in-flight write.
void _replace({ void _replace({
String? title,
Map<int, List<EditorStroke>>? strokes, Map<int, List<EditorStroke>>? strokes,
Map<int, List<SidecarHighlight>>? highlights, Map<int, List<SidecarHighlight>>? highlights,
List<SidecarScratchLink>? scratchLinks, List<SidecarScratchLink>? scratchLinks,
@@ -185,6 +196,7 @@ class SidecarRepository {
version: _sidecar.version, version: _sidecar.version,
sourceFile: _sidecar.sourceFile, sourceFile: _sidecar.sourceFile,
docType: _sidecar.docType, docType: _sidecar.docType,
title: title ?? _sidecar.title,
pageCount: _sidecar.pageCount, pageCount: _sidecar.pageCount,
rotation: _sidecar.rotation, rotation: _sidecar.rotation,
createdAt: _sidecar.createdAt, createdAt: _sidecar.createdAt,

View File

@@ -7,6 +7,11 @@
"importPpt": "Import PPT", "importPpt": "Import PPT",
"importFile": "Import file", "importFile": "Import file",
"createNotebook": "Create notebook", "createNotebook": "Create notebook",
"newNotebookTitle": "New notebook",
"notebookTitleHint": "Notebook title",
"create": "Create",
"untitledNote": "Untitled",
"noNotesYetHint": "No ink notes yet — tap + to create one",
"noDocumentsYet": "No documents yet — tap Import file", "noDocumentsYet": "No documents yet — tap Import file",
"processingImport": "Importing…", "processingImport": "Importing…",
"importFailed": "Couldn't import that file: {error}", "importFailed": "Couldn't import that file: {error}",

View File

@@ -140,6 +140,36 @@ abstract class AppLocalizations {
/// **'Create notebook'** /// **'Create notebook'**
String get createNotebook; String get createNotebook;
/// No description provided for @newNotebookTitle.
///
/// In en, this message translates to:
/// **'New notebook'**
String get newNotebookTitle;
/// No description provided for @notebookTitleHint.
///
/// In en, this message translates to:
/// **'Notebook title'**
String get notebookTitleHint;
/// No description provided for @create.
///
/// In en, this message translates to:
/// **'Create'**
String get create;
/// No description provided for @untitledNote.
///
/// In en, this message translates to:
/// **'Untitled'**
String get untitledNote;
/// No description provided for @noNotesYetHint.
///
/// In en, this message translates to:
/// **'No ink notes yet — tap + to create one'**
String get noNotesYetHint;
/// No description provided for @noDocumentsYet. /// No description provided for @noDocumentsYet.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -29,6 +29,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get createNotebook => 'Create notebook'; String get createNotebook => 'Create notebook';
@override
String get newNotebookTitle => 'New notebook';
@override
String get notebookTitleHint => 'Notebook title';
@override
String get create => 'Create';
@override
String get untitledNote => 'Untitled';
@override
String get noNotesYetHint => 'No ink notes yet — tap + to create one';
@override @override
String get noDocumentsYet => 'No documents yet — tap Import file'; String get noDocumentsYet => 'No documents yet — tap Import file';

View File

@@ -29,6 +29,21 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get createNotebook => '新建笔记本'; String get createNotebook => '新建笔记本';
@override
String get newNotebookTitle => '新建笔记本';
@override
String get notebookTitleHint => '笔记本标题';
@override
String get create => '创建';
@override
String get untitledNote => '未命名';
@override
String get noNotesYetHint => '还没有手写笔记——点按 + 新建';
@override @override
String get noDocumentsYet => '暂无文档——点按“导入文件”'; String get noDocumentsYet => '暂无文档——点按“导入文件”';

View File

@@ -7,6 +7,11 @@
"importPpt": "导入 PPT", "importPpt": "导入 PPT",
"importFile": "导入文件", "importFile": "导入文件",
"createNotebook": "新建笔记本", "createNotebook": "新建笔记本",
"newNotebookTitle": "新建笔记本",
"notebookTitleHint": "笔记本标题",
"create": "创建",
"untitledNote": "未命名",
"noNotesYetHint": "还没有手写笔记——点按 + 新建",
"noDocumentsYet": "暂无文档——点按“导入文件”", "noDocumentsYet": "暂无文档——点按“导入文件”",
"processingImport": "正在导入…", "processingImport": "正在导入…",
"importFailed": "无法导入该文件:{error}", "importFailed": "无法导入该文件:{error}",

View File

@@ -1,68 +1,81 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../models/note.dart'; import '../models/note.dart';
import '../services/database_service.dart'; import '../services/vault_service.dart';
import 'document_provider.dart' show vaultServiceProvider;
const _uuid = Uuid();
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
return DatabaseService.getInstance();
});
/// The home-screen note list is now sourced from a VAULT SCAN of standalone
/// (free-ink) notebook folders — each a folder holding a `notebook.badnote.json`
/// and NO importable source file — NOT the SQLite `notes` table. The sidecar
/// that lives in the folder is the source of truth ("跟着文件走").
///
/// Each scanned note is adapted into the existing [Note] model the home screen
/// already renders: `id` = the synthetic note path (`<folder>/notebook`, also a
/// stable id), `title`, `updatedAt` = the sidecar mtime. Strokes are NOT loaded
/// here — they are hydrated lazily by the editor from the sidecar, so the list
/// stays cheap (one directory listing). Home tiles that show a stroke count will
/// therefore read 0 until the note is opened; the count is no longer cached.
final noteListProvider = AsyncNotifierProvider<NoteListNotifier, List<Note>>( final noteListProvider = AsyncNotifierProvider<NoteListNotifier, List<Note>>(
NoteListNotifier.new, NoteListNotifier.new,
); );
class NoteListNotifier extends AsyncNotifier<List<Note>> { class NoteListNotifier extends AsyncNotifier<List<Note>> {
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future); Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
@override @override
Future<List<Note>> build() async { Future<List<Note>> build() async {
final db = await _db; return _scan();
return db.getAllNotes();
} }
/// Reloads notes from the database and publishes the result to [state] so Future<List<Note>> _scan() async {
/// the UI rebuilds. Used by pull-to-refresh. final vault = await _vault;
final notes = await vault.scanNotes();
return notes.map(_toNote).toList();
}
/// Adapt a scanned [VaultNote] into the [Note] shape the home tiles render.
/// `id` is the synthetic note path so opening it re-keys the right sidecar.
Note _toNote(VaultNote n) => Note(
id: n.notePath,
title: n.title,
createdAt: n.modified,
updatedAt: n.modified,
);
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
/// after a note is created or edited.
Future<void> loadNotes() async { Future<void> loadNotes() async {
state = const AsyncLoading(); state = const AsyncLoading();
state = await AsyncValue.guard(() async { state = await AsyncValue.guard(_scan);
final db = await _db;
return db.getAllNotes();
});
} }
/// Create an empty standalone notebook folder with [title] and return the
/// adapted [Note] (whose `id` is the synthetic note path). The home screen
/// opens the editor on it; persistence flows through the sidecar.
Future<Note> createNote({String title = 'Untitled'}) async { Future<Note> createNote({String title = 'Untitled'}) async {
final db = await _db; final vault = await _vault;
final notePath = await vault.createEmptyNotebook(title);
final now = DateTime.now(); final now = DateTime.now();
final note = Note( final note = Note(
id: _uuid.v4(), id: notePath,
title: title, title: title,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
); );
await db.insertNote(note);
state = AsyncData([note, ...state.value ?? []]); state = AsyncData([note, ...state.value ?? []]);
return note; return note;
} }
Future<void> updateNote(Note note) async { /// Delete a note by removing its notebook folder (the sidecar travels with
final db = await _db; /// it). [id] is the synthetic note path `<folder>/notebook`.
await db.updateNote(note);
final current = state.value ?? [];
state = AsyncData(current.map((n) => n.id == note.id ? note : n).toList());
}
Future<void> deleteNote(String id) async { Future<void> deleteNote(String id) async {
final db = await _db; final folder = Directory(File(id).parent.path);
await db.deleteNote(id); if (await folder.exists()) {
await folder.delete(recursive: true);
}
final current = state.value ?? []; final current = state.value ?? [];
state = AsyncData(current.where((n) => n.id != id).toList()); state = AsyncData(current.where((n) => n.id != id).toList());
} }
} }
final noteProvider = FutureProvider.family<Note?, String>((ref, id) async {
final db = await ref.watch(databaseServiceProvider.future);
return db.getNoteById(id);
});

View File

@@ -2,7 +2,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/document.dart'; import '../models/document.dart';
import '../models/note.dart'; import '../models/note.dart';
import 'note_provider.dart'; import '../services/database_service.dart';
/// SQLite handle for the FTS/OCR search index. SQLite is now demoted to a
/// rebuildable search cache (the vault sidecars are the source of truth); search
/// is its sole remaining read path until the Phase 6 index rebuild lands.
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
return DatabaseService.getInstance();
});
final searchQueryProvider = StateProvider<String>((ref) => ''); final searchQueryProvider = StateProvider<String>((ref) => '');

View File

@@ -119,7 +119,7 @@ class HomeScreen extends ConsumerWidget {
), ),
child: Center( child: Center(
child: Text( child: Text(
'No ink notes yet — tap + to create one', l.noNotesYetHint,
style: Theme.of(context).textTheme.bodyMedium style: Theme.of(context).textTheme.bodyMedium
?.copyWith( ?.copyWith(
color: Theme.of( color: Theme.of(
@@ -180,8 +180,18 @@ class HomeScreen extends ConsumerWidget {
); );
} }
/// "Create notebook": prompt a title (defaulting to Untitled), create the
/// standalone notebook FOLDER + `notebook.badnote.json` via
/// `VaultService.createEmptyNotebook`, then open the editor on the new note.
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async { Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
final note = await ref.read(noteListProvider.notifier).createNote(); final title = await _promptNotebookTitle(context);
if (title == null) return; // cancelled
final l = context.mounted ? AppLocalizations.of(context) : null;
final resolved = title.trim().isEmpty
? (l?.untitledNote ?? 'Untitled')
: title.trim();
final note =
await ref.read(noteListProvider.notifier).createNote(title: resolved);
if (context.mounted) { if (context.mounted) {
Navigator.of( Navigator.of(
context, context,
@@ -189,6 +199,35 @@ class HomeScreen extends ConsumerWidget {
} }
} }
/// Ask for a notebook title. Returns the entered string (possibly empty →
/// caller defaults it), or null if the user cancelled.
Future<String?> _promptNotebookTitle(BuildContext context) {
final l = AppLocalizations.of(context);
final controller = TextEditingController(text: l.untitledNote);
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.newNotebookTitle),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(hintText: l.notebookTitleHint),
onSubmitted: (v) => Navigator.of(ctx).pop(v),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l.cancel),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(controller.text),
child: Text(l.create),
),
],
),
);
}
/// Single top-level "Import file" action (sibling of "Create notebook"): /// Single top-level "Import file" action (sibling of "Create notebook"):
/// pick a pdf/docx/pptx/ppt, copy it into a new vault notebook folder, then /// pick a pdf/docx/pptx/ppt, copy it into a new vault notebook folder, then
/// open the IN-VAULT copy in the right editor (routed by extension). /// open the IN-VAULT copy in the right editor (routed by extension).
@@ -386,8 +425,10 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Stroke count is no longer cached in the vault scan (strokes
// load lazily in the editor), so the tile shows only the date.
Text( Text(
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr', dateStr,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
if (note.tags.isNotEmpty) if (note.tags.isNotEmpty)

View File

@@ -4,6 +4,9 @@ import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../storage/badnote_sidecar.dart';
import '../storage/sidecar_store.dart';
/// Suffix appended to a source-file path to form its sidecar path. Kept in sync /// Suffix appended to a source-file path to form its sidecar path. Kept in sync
/// with [SidecarRepository.kSidecarSuffix]; duplicated here to avoid a layering /// with [SidecarRepository.kSidecarSuffix]; duplicated here to avoid a layering
/// dependency from the service onto the editor. /// dependency from the service onto the editor.
@@ -41,6 +44,40 @@ class VaultNotebook {
final bool hasSidecar; final bool hasSidecar;
} }
/// Basename (without the sidecar suffix) of a standalone notebook's synthetic
/// note "source". Opening `SidecarRepository.open('<folder>/notebook', …)`
/// therefore writes `<folder>/notebook.badnote.json`.
const String kNotebookBaseName = 'notebook';
/// Full sidecar filename for a standalone (free-ink) notebook folder.
const String kNotebookSidecarName = '$kNotebookBaseName$kVaultSidecarSuffix';
/// A standalone (non-file-backed) free-ink notebook discovered by scanning the
/// vault: a folder holding a `notebook.badnote.json` and NO importable source
/// file. This is the file-based replacement for the old SQLite `notes` table.
class VaultNote {
const VaultNote({
required this.folderPath,
required this.notePath,
required this.title,
required this.modified,
});
/// Absolute path to the notebook folder.
final String folderPath;
/// Synthetic note "source" path `<folder>/notebook`. Pass this to
/// `SidecarRepository.open(notePath, docType: 'notebook')`; it keys the
/// `<folder>/notebook.badnote.json` sidecar. Doubles as the note's stable id.
final String notePath;
/// Display title (from the sidecar's `title`, falling back to the folder name).
final String title;
/// Last-modified time of the sidecar (used for recency sorting).
final DateTime modified;
}
/// Records the user-picked vault root folder (an Obsidian-style vault) and /// Records the user-picked vault root folder (an Obsidian-style vault) and
/// gates app startup behind a valid choice. /// gates app startup behind a valid choice.
/// ///
@@ -160,6 +197,98 @@ class VaultService {
return notebooks; return notebooks;
} }
/// Create an empty (free-ink) standalone notebook FOLDER under the vault root
/// named from [title], write an initial `notebook.badnote.json` carrying that
/// title (so the scan sees it immediately), and return the synthetic note
/// path `<folder>/notebook`.
///
/// Pass the returned path to `SidecarRepository.open(path, docType:
/// 'notebook')`, which keys the folder's `notebook.badnote.json` — there is
/// NO fake source file. Throws [StateError] if no valid vault root is set.
Future<String> createEmptyNotebook(String title) async {
final root = vaultRoot;
if (root == null || root.isEmpty) {
throw StateError('No vault root is set; cannot create a notebook.');
}
final trimmed = title.trim();
final baseName = _sanitizeFolderName(trimmed);
final folder = await _uniqueNotebookFolder(root, baseName);
await folder.create(recursive: true);
final notePath = p.join(folder.path, kNotebookBaseName);
final now = DateTime.now().toUtc();
final sidecar = BadnoteSidecar(
docType: 'notebook',
title: trimmed.isEmpty ? null : trimmed,
createdAt: now,
updatedAt: now,
);
await SidecarStore.writeAtomic(
File('$notePath$kVaultSidecarSuffix'),
sidecar,
);
return notePath;
}
/// Scan the vault root for standalone (free-ink) notebook folders: direct
/// subfolders (excluding hidden `.` folders) that contain a
/// `notebook.badnote.json` and NO importable source file. Returns them sorted
/// by sidecar mtime, most-recent first. Missing / empty vault → empty list.
///
/// File-backed document folders (which DO hold an importable source file) are
/// surfaced by [scanNotebooks] instead, so the two scans never overlap.
Future<List<VaultNote>> scanNotes() async {
final root = vaultRoot;
if (root == null || root.isEmpty) return const [];
final dir = Directory(root);
if (!await dir.exists()) return const [];
final notes = <VaultNote>[];
await for (final entity in dir.list(followLinks: false)) {
if (entity is! Directory) continue;
final folderName = p.basename(entity.path);
if (folderName.startsWith('.')) continue;
final note = await _readNoteFolder(entity);
if (note != null) notes.add(note);
}
notes.sort((a, b) => b.modified.compareTo(a.modified));
return notes;
}
/// Inspect a folder, returning a [VaultNote] iff it holds a
/// `notebook.badnote.json` and NO importable source file, else null.
Future<VaultNote?> _readNoteFolder(Directory folder) async {
File? noteSidecar;
var hasSource = false;
await for (final entity in folder.list(followLinks: false)) {
if (entity is! File) continue;
final name = p.basename(entity.path);
if (name == kNotebookSidecarName) {
noteSidecar = entity;
continue;
}
if (name.endsWith(kVaultSidecarSuffix)) continue;
final ext = p.extension(name).replaceFirst('.', '').toLowerCase();
if (importableExtensions.contains(ext)) hasSource = true;
}
if (noteSidecar == null || hasSource) return null;
final notePath = p.join(folder.path, kNotebookBaseName);
final loaded = await SidecarStore.read(noteSidecar);
final stat = await noteSidecar.stat();
final title = (loaded?.title?.trim().isNotEmpty ?? false)
? loaded!.title!.trim()
: p.basename(folder.path);
return VaultNote(
folderPath: folder.path,
notePath: notePath,
title: title,
modified: stat.modified,
);
}
/// Inspect a single notebook folder, returning a [VaultNotebook] when it /// Inspect a single notebook folder, returning a [VaultNotebook] when it
/// holds an importable source file, else null. Picks the first importable /// holds an importable source file, else null. Picks the first importable
/// file (prefers a `.pdf` so a DOCX→PDF-converted notebook opens as its PDF). /// file (prefers a `.pdf` so a DOCX→PDF-converted notebook opens as its PDF).

View File

@@ -212,6 +212,7 @@ class BadnoteSidecar {
this.version = kBadnoteSidecarVersion, this.version = kBadnoteSidecarVersion,
this.sourceFile, this.sourceFile,
this.docType, this.docType,
this.title,
this.pageCount, this.pageCount,
this.rotation = 0, this.rotation = 0,
this.createdAt, this.createdAt,
@@ -234,6 +235,10 @@ class BadnoteSidecar {
/// `pdf` / `pptx` / `notebook` etc. /// `pdf` / `pptx` / `notebook` etc.
final String? docType; 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? pageCount;
final int rotation; final int rotation;
final DateTime? createdAt; final DateTime? createdAt;
@@ -252,6 +257,7 @@ class BadnoteSidecar {
'badnoteSidecarVersion': version, 'badnoteSidecarVersion': version,
if (sourceFile != null) 'sourceFile': sourceFile, if (sourceFile != null) 'sourceFile': sourceFile,
if (docType != null) 'docType': docType, if (docType != null) 'docType': docType,
if (title != null) 'title': title,
if (pageCount != null) 'pageCount': pageCount, if (pageCount != null) 'pageCount': pageCount,
'rotation': rotation, 'rotation': rotation,
if (createdAt != null) 'createdAt': createdAt!.toIso8601String(), if (createdAt != null) 'createdAt': createdAt!.toIso8601String(),
@@ -293,6 +299,7 @@ class BadnoteSidecar {
kBadnoteSidecarVersion, kBadnoteSidecarVersion,
sourceFile: json['sourceFile'] as String?, sourceFile: json['sourceFile'] as String?,
docType: json['docType'] as String?, docType: json['docType'] as String?,
title: json['title'] as String?,
pageCount: (json['pageCount'] as num?)?.toInt(), pageCount: (json['pageCount'] as num?)?.toInt(),
rotation: (json['rotation'] as num?)?.toInt() ?? 0, rotation: (json['rotation'] as num?)?.toInt() ?? 0,
createdAt: json['createdAt'] == null createdAt: json['createdAt'] == null

View File

@@ -200,4 +200,58 @@ void main() {
expect(sl.scratchpad.strokes.single.id, 's'); expect(sl.scratchpad.strokes.single.id, 's');
after.dispose(); after.dispose();
}); });
group('Phase 4 standalone notebook (notebook.badnote.json)', () {
late String notePath;
setUp(() {
// The synthetic note "source" is <folder>/notebook → sidecar is
// <folder>/notebook.badnote.json (no real source file on disk).
notePath = '${tmpDir.path}/notebook';
});
test('a note\'s strokes (page 0) + title round-trip through the sidecar',
() async {
final repo =
await SidecarRepository.open(notePath, debounce: _fast, docType: 'notebook');
expect(repo.sidecarFile.path, '$notePath.badnote.json');
repo.scheduleTitleSave('My Note');
repo.scheduleStrokeSave(0, [
_stroke('p', tool: EditorTool.pen),
_stroke('h', tool: EditorTool.highlighter),
]);
await repo.flush();
repo.dispose();
// Re-open the same standalone notebook sidecar.
final reopened =
await SidecarRepository.open(notePath, debounce: _fast, docType: 'notebook');
expect(reopened.loadedTitle, 'My Note');
expect(reopened.sidecar.docType, 'notebook');
final page0 = reopened.loadedStrokes[0]!;
expect(page0.map((s) => s.id), ['p', 'h']);
expect(page0.first.tool, EditorTool.pen);
expect(page0.last.tool, EditorTool.highlighter);
expect(page0.first.color, 0xFF112233);
reopened.dispose();
});
test('editing the title persists; unchanged title is a no-op', () async {
final repo =
await SidecarRepository.open(notePath, debounce: _fast, docType: 'notebook');
repo.scheduleTitleSave('First');
await repo.flush();
// Same title again: no write needed, but flush stays safe.
repo.scheduleTitleSave('First');
repo.scheduleTitleSave('Second');
await repo.flush();
repo.dispose();
final reopened =
await SidecarRepository.open(notePath, debounce: _fast, docType: 'notebook');
expect(reopened.loadedTitle, 'Second');
reopened.dispose();
});
});
} }

View File

@@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:badnote/services/vault_service.dart'; import 'package:badnote/services/vault_service.dart';
import 'package:badnote/storage/sidecar_store.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -213,4 +214,129 @@ void main() {
} }
}); });
}); });
group('createEmptyNotebook + scanNotes (Phase 4 standalone notebooks)', () {
test('writes notebook.badnote.json with the title and docType notebook',
() async {
final vault = await makeService();
await vault.setVaultRoot(tempDir.path);
final sep = Platform.pathSeparator;
final notePath = await vault.createEmptyNotebook('My Algebra Notes');
// Synthetic note path is <folder>/notebook, folder named from the title.
final expectedFolder = '${tempDir.path}${sep}My Algebra Notes';
expect(notePath, '$expectedFolder${sep}notebook');
// The sidecar exists at <folder>/notebook.badnote.json and carries the
// title + docType, but NO fake source file was created.
final sidecarFile = File('$notePath.badnote.json');
expect(sidecarFile.existsSync(), isTrue);
final loaded =
await SidecarStore.read(sidecarFile);
expect(loaded, isNotNull);
expect(loaded!.title, 'My Algebra Notes');
expect(loaded.docType, 'notebook');
// The folder holds only the sidecar (and possibly its .bak/.tmp), never
// an importable source file.
final files = Directory(expectedFolder)
.listSync()
.whereType<File>()
.map((f) => f.uri.pathSegments.last)
.toList();
expect(files, contains('notebook.badnote.json'));
expect(
files.any((n) =>
n.endsWith('.pdf') ||
n.endsWith('.pptx') ||
n.endsWith('.docx') ||
n.endsWith('.ppt')),
isFalse,
);
});
test('de-duplicates the notebook folder name on title collision', () async {
final vault = await makeService();
await vault.setVaultRoot(tempDir.path);
final sep = Platform.pathSeparator;
final a = await vault.createEmptyNotebook('Journal');
final b = await vault.createEmptyNotebook('Journal');
expect(a, '${tempDir.path}${sep}Journal${sep}notebook');
expect(b, '${tempDir.path}${sep}Journal 2${sep}notebook');
});
test('blank title falls back to an Untitled folder, null sidecar title',
() async {
final vault = await makeService();
await vault.setVaultRoot(tempDir.path);
final sep = Platform.pathSeparator;
final notePath = await vault.createEmptyNotebook(' ');
expect(notePath, '${tempDir.path}${sep}Untitled${sep}notebook');
final loaded = await SidecarStore.read(File('$notePath.badnote.json'));
expect(loaded!.title, isNull);
});
test('scanNotes lists note folders; scanNotebooks excludes them', () async {
final vault = await makeService();
await vault.setVaultRoot(tempDir.path);
final sep = Platform.pathSeparator;
// A standalone note.
final notePath = await vault.createEmptyNotebook('Ideas');
// A file-backed document notebook.
final docFolder = '${tempDir.path}${sep}Lecture';
final doc = File('$docFolder${sep}Lecture.pdf');
await doc.create(recursive: true);
await doc.writeAsString('pdf');
final notes = await vault.scanNotes();
expect(notes.length, 1, reason: 'only the standalone note is a note');
expect(notes.first.title, 'Ideas');
expect(notes.first.notePath, notePath);
expect(notes.first.folderPath, '${tempDir.path}${sep}Ideas');
// The document notebook is NOT a note...
expect(notes.any((n) => n.folderPath == docFolder), isFalse);
// ...and the standalone note is NOT a document.
final notebooks = await vault.scanNotebooks();
expect(notebooks.length, 1);
expect(notebooks.first.filename, 'Lecture.pdf');
expect(
notebooks.any((nb) => nb.folderPath == '${tempDir.path}${sep}Ideas'),
isFalse,
);
});
test('note title round-trips through the sidecar (title persists)',
() async {
final vault = await makeService();
await vault.setVaultRoot(tempDir.path);
final notePath = await vault.createEmptyNotebook('Round Trip');
final notes = await vault.scanNotes();
expect(notes.single.title, 'Round Trip');
// Re-open the sidecar directly: the title is still there.
final loaded = await SidecarStore.read(File('$notePath.badnote.json'));
expect(loaded!.title, 'Round Trip');
});
test('a note folder whose sidecar title is missing falls back to folder',
() async {
final vault = await makeService();
await vault.setVaultRoot(tempDir.path);
final sep = Platform.pathSeparator;
// Hand-write a note sidecar with no title field.
final folder = '${tempDir.path}${sep}Loose Note';
final sidecar = File('$folder${sep}notebook.badnote.json');
await sidecar.create(recursive: true);
await sidecar.writeAsString('{"docType":"notebook"}');
final notes = await vault.scanNotes();
expect(notes.single.title, 'Loose Note');
});
});
} }