feat(storage): notes are vault sidecar notebooks
All checks were successful
CI / Windows build (push) Successful in 12m55s
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user