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:
@@ -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('<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
|
||||
/// 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 `<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
|
||||
/// 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).
|
||||
|
||||
Reference in New Issue
Block a user