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

@@ -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 <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: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 <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');
});
});
}