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

@@ -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).