From f4f0853eae6d6fbf3ac9cdd420439430afcefce3 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Wed, 24 Jun 2026 22:48:18 +0800 Subject: [PATCH] feat(storage): notes are vault sidecar notebooks 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. --- lib/editor/canvas/pen_note_screen.dart | 133 +++++++++++++----- .../persistence/sidecar_repository.dart | 12 ++ lib/l10n/app_en.arb | 5 + lib/l10n/app_localizations.dart | 30 ++++ lib/l10n/app_localizations_en.dart | 15 ++ lib/l10n/app_localizations_zh.dart | 15 ++ lib/l10n/app_zh.arb | 5 + lib/providers/note_provider.dart | 81 ++++++----- lib/providers/search_provider.dart | 9 +- lib/screens/home_screen.dart | 47 ++++++- lib/services/vault_service.dart | 129 +++++++++++++++++ lib/storage/badnote_sidecar.dart | 7 + test/sidecar_repository_test.dart | 54 +++++++ test/vault_service_test.dart | 126 +++++++++++++++++ 14 files changed, 598 insertions(+), 70 deletions(-) diff --git a/lib/editor/canvas/pen_note_screen.dart b/lib/editor/canvas/pen_note_screen.dart index 72a7919..f888d33 100644 --- a/lib/editor/canvas/pen_note_screen.dart +++ b/lib/editor/canvas/pen_note_screen.dart @@ -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 { bool _dirty = false; bool _needsCenter = true; - String? _noteId; + /// The note's synthetic source path `/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 { 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 _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 _initPenConfig() async { final controller = await PenConfigController.load(); if (!mounted) { @@ -136,6 +189,13 @@ class _PenNoteScreenState extends ConsumerState { @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 { // ── 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 _save() async { if (!_dirty) return; final notifier = ref.read(noteListProvider.notifier); @@ -206,40 +269,46 @@ class _PenNoteScreenState extends ConsumerState { final title = _titleController.text.trim().isEmpty ? 'Untitled' : _titleController.text.trim(); - final inkStrokes = [ - 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 = [ + 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 _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 = [ + 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) { diff --git a/lib/editor/persistence/sidecar_repository.dart b/lib/editor/persistence/sidecar_repository.dart index 66b2632..9998d77 100644 --- a/lib/editor/persistence/sidecar_repository.dart +++ b/lib/editor/persistence/sidecar_repository.dart @@ -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 strokes) { final next = Map>.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>? strokes, Map>? highlights, List? 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, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7d38868..0639124 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7,6 +7,11 @@ "importPpt": "Import PPT", "importFile": "Import file", "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", "processingImport": "Importing…", "importFailed": "Couldn't import that file: {error}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5ffbe72..7a3d3e1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -140,6 +140,36 @@ abstract class AppLocalizations { /// **'Create notebook'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dddb910..b725fc4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -29,6 +29,21 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get noDocumentsYet => 'No documents yet — tap Import file'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8ef0354..0ea29c6 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -29,6 +29,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get createNotebook => '新建笔记本'; + @override + String get newNotebookTitle => '新建笔记本'; + + @override + String get notebookTitleHint => '笔记本标题'; + + @override + String get create => '创建'; + + @override + String get untitledNote => '未命名'; + + @override + String get noNotesYetHint => '还没有手写笔记——点按 + 新建'; + @override String get noDocumentsYet => '暂无文档——点按“导入文件”'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 84d778f..b98dd35 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -7,6 +7,11 @@ "importPpt": "导入 PPT", "importFile": "导入文件", "createNotebook": "新建笔记本", + "newNotebookTitle": "新建笔记本", + "notebookTitleHint": "笔记本标题", + "create": "创建", + "untitledNote": "未命名", + "noNotesYetHint": "还没有手写笔记——点按 + 新建", "noDocumentsYet": "暂无文档——点按“导入文件”", "processingImport": "正在导入…", "importFailed": "无法导入该文件:{error}", diff --git a/lib/providers/note_provider.dart b/lib/providers/note_provider.dart index d9659cc..196a4f1 100644 --- a/lib/providers/note_provider.dart +++ b/lib/providers/note_provider.dart @@ -1,68 +1,81 @@ +import 'dart:io'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:uuid/uuid.dart'; import '../models/note.dart'; -import '../services/database_service.dart'; - -const _uuid = Uuid(); - -final databaseServiceProvider = FutureProvider((ref) async { - return DatabaseService.getInstance(); -}); +import '../services/vault_service.dart'; +import 'document_provider.dart' show vaultServiceProvider; +/// 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 (`/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.new, ); class NoteListNotifier extends AsyncNotifier> { - Future get _db => ref.read(databaseServiceProvider.future); + Future get _vault => ref.read(vaultServiceProvider.future); @override Future> build() async { - final db = await _db; - return db.getAllNotes(); + return _scan(); } - /// Reloads notes from the database and publishes the result to [state] so - /// the UI rebuilds. Used by pull-to-refresh. + Future> _scan() async { + 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 loadNotes() async { state = const AsyncLoading(); - state = await AsyncValue.guard(() async { - final db = await _db; - return db.getAllNotes(); - }); + state = await AsyncValue.guard(_scan); } + /// 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 createNote({String title = 'Untitled'}) async { - final db = await _db; + final vault = await _vault; + final notePath = await vault.createEmptyNotebook(title); final now = DateTime.now(); final note = Note( - id: _uuid.v4(), + id: notePath, title: title, createdAt: now, updatedAt: now, ); - await db.insertNote(note); state = AsyncData([note, ...state.value ?? []]); return note; } - Future updateNote(Note note) async { - final db = await _db; - await db.updateNote(note); - final current = state.value ?? []; - state = AsyncData(current.map((n) => n.id == note.id ? note : n).toList()); - } - + /// Delete a note by removing its notebook folder (the sidecar travels with + /// it). [id] is the synthetic note path `/notebook`. Future deleteNote(String id) async { - final db = await _db; - await db.deleteNote(id); + final folder = Directory(File(id).parent.path); + if (await folder.exists()) { + await folder.delete(recursive: true); + } final current = state.value ?? []; state = AsyncData(current.where((n) => n.id != id).toList()); } } - -final noteProvider = FutureProvider.family((ref, id) async { - final db = await ref.watch(databaseServiceProvider.future); - return db.getNoteById(id); -}); diff --git a/lib/providers/search_provider.dart b/lib/providers/search_provider.dart index 2e3c88a..e654bd3 100644 --- a/lib/providers/search_provider.dart +++ b/lib/providers/search_provider.dart @@ -2,7 +2,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/document.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((ref) async { + return DatabaseService.getInstance(); +}); final searchQueryProvider = StateProvider((ref) => ''); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index dee5c8f..dc5680d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -119,7 +119,7 @@ class HomeScreen extends ConsumerWidget { ), child: Center( child: Text( - 'No ink notes yet — tap + to create one', + l.noNotesYetHint, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( 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 _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) { Navigator.of( 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 _promptNotebookTitle(BuildContext context) { + final l = AppLocalizations.of(context); + final controller = TextEditingController(text: l.untitledNote); + return showDialog( + 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"): /// 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). @@ -386,8 +425,10 @@ class _NoteTileState extends ConsumerState<_NoteTile> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, 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( - '${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr', + dateStr, style: Theme.of(context).textTheme.bodySmall, ), if (note.tags.isNotEmpty) diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index 8bf9cfd..0684a97 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -4,6 +4,9 @@ import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as p; 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 /// with [SidecarRepository.kSidecarSuffix]; duplicated here to avoid a layering /// dependency from the service onto the editor. @@ -41,6 +44,40 @@ class VaultNotebook { final bool hasSidecar; } +/// Basename (without the sidecar suffix) of a standalone notebook's synthetic +/// note "source". Opening `SidecarRepository.open('/notebook', …)` +/// therefore writes `/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 `/notebook`. Pass this to + /// `SidecarRepository.open(notePath, docType: 'notebook')`; it keys the + /// `/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 /// gates app startup behind a valid choice. /// @@ -160,6 +197,98 @@ class VaultService { 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 `/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 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> scanNotes() async { + final root = vaultRoot; + if (root == null || root.isEmpty) return const []; + final dir = Directory(root); + if (!await dir.exists()) return const []; + + final notes = []; + 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 _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 /// 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). diff --git a/lib/storage/badnote_sidecar.dart b/lib/storage/badnote_sidecar.dart index be1141d..06ef8ff 100644 --- a/lib/storage/badnote_sidecar.dart +++ b/lib/storage/badnote_sidecar.dart @@ -212,6 +212,7 @@ class BadnoteSidecar { this.version = kBadnoteSidecarVersion, this.sourceFile, this.docType, + this.title, this.pageCount, this.rotation = 0, this.createdAt, @@ -234,6 +235,10 @@ class BadnoteSidecar { /// `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; @@ -252,6 +257,7 @@ class BadnoteSidecar { '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(), @@ -293,6 +299,7 @@ class BadnoteSidecar { 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 diff --git a/test/sidecar_repository_test.dart b/test/sidecar_repository_test.dart index be7c344..0505378 100644 --- a/test/sidecar_repository_test.dart +++ b/test/sidecar_repository_test.dart @@ -200,4 +200,58 @@ void main() { expect(sl.scratchpad.strokes.single.id, 's'); after.dispose(); }); + + group('Phase 4 standalone notebook (notebook.badnote.json)', () { + late String notePath; + + setUp(() { + // The synthetic note "source" is /notebook → sidecar is + // /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(); + }); + }); } diff --git a/test/vault_service_test.dart b/test/vault_service_test.dart index 85bb877..0196a94 100644 --- a/test/vault_service_test.dart +++ b/test/vault_service_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:badnote/services/vault_service.dart'; +import 'package:badnote/storage/sidecar_store.dart'; void main() { 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 /notebook, folder named from the title. + final expectedFolder = '${tempDir.path}${sep}My Algebra Notes'; + expect(notePath, '$expectedFolder${sep}notebook'); + + // The sidecar exists at /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() + .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'); + }); + }); }