Files
BadNote/lib/services/vault_service.dart
Akiba So 2b1c6ba7e0
All checks were successful
CI / Windows build (push) Successful in 8m42s
feat: OneNote-style notebooks, text fonts, and page navigation
Add notebook.json containers with multi-member pages, fix PDF text
editing (size/bold/drag/double-tap), index SidecarText in search, and
share keyboard page shortcuts plus a PDF scrubber.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:27:35 +08:00

544 lines
20 KiB
Dart

import 'dart:io';
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/notebook_manifest.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.
const String kVaultSidecarSuffix = '.badnote.json';
/// A notebook discovered by scanning the vault: one folder holding a source
/// file (and, optionally, its sidecar). This is the file-backed source of truth
/// for the home screen list (the SQLite `documents` table is no longer read).
class VaultNotebook {
const VaultNotebook({
required this.folderPath,
required this.sourceFilePath,
required this.filename,
required this.docType,
required this.modified,
this.hasSidecar = false,
});
/// Absolute path to the notebook folder.
final String folderPath;
/// Absolute path to the annotatable source file inside the folder.
final String sourceFilePath;
/// Source filename including extension, e.g. `Calculus Lecture 3.pdf`.
final String filename;
/// Lowercased extension without the dot: `pdf` / `pptx` / `ppt` / `docx`.
final String docType;
/// Last-modified time of the source file (used for recency sorting).
final DateTime modified;
/// Whether a `<file>.badnote.json` sidecar exists next to the source file.
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;
}
/// OneNote-style multi-document notebook: a vault folder with [kNotebookManifestName].
class VaultContainer {
const VaultContainer({
required this.folderPath,
required this.title,
required this.modified,
required this.memberCount,
});
final String folderPath;
final String title;
final DateTime modified;
final int memberCount;
}
/// Records the user-picked vault root folder (an Obsidian-style vault) and
/// gates app startup behind a valid choice.
///
/// The vault root is the single folder under which all notebooks will live.
/// Phase 0 only persists the path and validates it exists; no data is moved
/// into the vault yet (later phases do that).
///
/// Persistence is SharedPreferences-backed under [vaultRootKey]. The service is
/// easily mockable: inject a [SharedPreferences] (e.g. from
/// `SharedPreferences.setMockInitialValues`) via the constructor for tests.
class VaultService {
/// SharedPreferences key under which the vault root path is stored.
static const String vaultRootKey = 'vaultRoot';
/// SharedPreferences key gating the one-time SQLite→sidecar migration
/// (Phase 5). Set true once the migration completes so it never re-runs.
static const String vaultMigrationDoneKey = 'vaultMigrationDone';
final SharedPreferences _prefs;
VaultService._(this._prefs);
static VaultService? _instance;
/// Singleton accessor, mirroring [DatabaseService.getInstance]. Lazily reads
/// the shared [SharedPreferences] instance.
static Future<VaultService> getInstance() async {
if (_instance != null) return _instance!;
final prefs = await SharedPreferences.getInstance();
final service = VaultService._(prefs);
_instance = service;
return service;
}
/// Test-only constructor: inject a (typically mock) [SharedPreferences] so
/// the vault root can be exercised without platform channels.
@visibleForTesting
VaultService.forTest(SharedPreferences prefs) : _prefs = prefs;
/// Test-only: drop the cached singleton so the next [getInstance] rebuilds.
@visibleForTesting
static void resetForTest() {
_instance = null;
}
/// The currently stored vault root path, or null if none has been chosen.
String? get vaultRoot => _prefs.getString(vaultRootKey);
/// Persist [path] as the vault root.
Future<void> setVaultRoot(String path) async {
await _prefs.setString(vaultRootKey, path);
}
/// Forget the stored vault root (e.g. to re-prompt the user).
Future<void> clearVaultRoot() async {
await _prefs.remove(vaultRootKey);
}
/// True once the one-time SQLite→sidecar migration (Phase 5) has completed.
/// When false, startup runs the migrator before opening the home screen.
bool get vaultMigrationDone => _prefs.getBool(vaultMigrationDoneKey) ?? false;
/// Mark the one-time SQLite→sidecar migration as done so it never re-runs.
Future<void> setVaultMigrationDone() async {
await _prefs.setBool(vaultMigrationDoneKey, true);
}
/// True iff a vault root is set AND that directory currently exists.
///
/// Returns false when no path is stored or when the stored path no longer
/// resolves to a directory (external drive unplugged, folder deleted) — the
/// caller then re-prompts rather than silently scattering data elsewhere.
Future<bool> vaultRootValid() async {
final path = vaultRoot;
if (path == null || path.isEmpty) return false;
return Directory(path).exists();
}
/// Source-file extensions BadNote can import as notebooks.
static const Set<String> importableExtensions = {'pdf', 'docx', 'pptx', 'ppt'};
/// Create a notebook FOLDER under the vault root, COPY the source file at
/// [sourceFilePath] into it, and return the path of the in-vault copy.
///
/// The folder name is the sanitized source basename (without extension),
/// de-duplicated with a numeric suffix on collision (`Lecture`, `Lecture 2`,
/// …). The sidecar (`<file>.badnote.json`) will live next to the copy — the
/// [SidecarRepository] keys off the returned path, so nothing else is needed.
///
/// Throws [StateError] if no valid vault root is set.
Future<String> createNotebook(String sourceFilePath) async {
final root = vaultRoot;
if (root == null || root.isEmpty) {
throw StateError('No vault root is set; cannot create a notebook.');
}
final source = File(sourceFilePath);
final filename = p.basename(sourceFilePath);
final baseName = _sanitizeFolderName(p.basenameWithoutExtension(filename));
final folder = await _uniqueNotebookFolder(root, baseName);
await folder.create(recursive: true);
final destPath = p.join(folder.path, filename);
await source.copy(destPath);
return destPath;
}
/// Scan the vault root for notebook folders. A notebook is a direct
/// subfolder (excluding the hidden `.badnote` metadata folder) that contains
/// at least one importable source file. Returns the notebooks sorted by
/// source-file mtime, most-recent first. An empty / missing vault yields an
/// empty list (never throws).
/// Scan vault folders that hold a [kNotebookManifestName] container.
Future<List<VaultContainer>> scanContainers() async {
final root = vaultRoot;
if (root == null || root.isEmpty) return const [];
final dir = Directory(root);
if (!await dir.exists()) return const [];
final out = <VaultContainer>[];
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 c = await _readContainerFolder(entity);
if (c != null) out.add(c);
}
out.sort((a, b) => b.modified.compareTo(a.modified));
return out;
}
Future<VaultContainer?> _readContainerFolder(Directory folder) async {
final manifest = await NotebookManifest.read(folder.path);
if (manifest == null) return null;
final file = NotebookManifest.fileIn(folder.path);
final stat = await file.stat();
final title = manifest.title.trim().isNotEmpty
? manifest.title.trim()
: p.basename(folder.path);
return VaultContainer(
folderPath: folder.path,
title: title,
modified: stat.modified,
memberCount: manifest.members.length,
);
}
/// Create an OneNote-style notebook container with one blank ink page.
Future<VaultContainer> createNotebookContainer(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 pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
final pageRel = p.join('pages', pageId, kNotebookBaseName);
final pageDir = Directory(p.join(folder.path, 'pages', pageId));
await pageDir.create(recursive: true);
final notePath = p.join(folder.path, pageRel);
final now = DateTime.now().toUtc();
final pageTitle = trimmed.isEmpty ? 'Untitled' : trimmed;
await SidecarStore.writeAtomic(
File('$notePath$kVaultSidecarSuffix'),
BadnoteSidecar(
docType: 'notebook',
title: pageTitle,
createdAt: now,
updatedAt: now,
),
);
final manifest = NotebookManifest(
title: pageTitle,
members: [
NotebookMember(
id: pageId,
kind: NotebookMemberKind.note,
relativePath: pageRel.replaceAll('\\', '/'),
title: pageTitle,
),
],
);
await NotebookManifest.write(folder.path, manifest);
return VaultContainer(
folderPath: folder.path,
title: pageTitle,
modified: now,
memberCount: 1,
);
}
/// Append a blank ink page to an existing container. Returns the new member.
Future<NotebookMember> addBlankPageToContainer(
String folderPath, {
String title = 'Untitled page',
}) async {
final manifest = await NotebookManifest.read(folderPath);
if (manifest == null) {
throw StateError('Not a notebook container: $folderPath');
}
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
final pageRel = 'pages/$pageId/$kNotebookBaseName';
final pageDir = Directory(p.join(folderPath, 'pages', pageId));
await pageDir.create(recursive: true);
final notePath = p.join(folderPath, 'pages', pageId, kNotebookBaseName);
final now = DateTime.now().toUtc();
await SidecarStore.writeAtomic(
File('$notePath$kVaultSidecarSuffix'),
BadnoteSidecar(
docType: 'notebook',
title: title,
createdAt: now,
updatedAt: now,
),
);
final member = NotebookMember(
id: pageId,
kind: NotebookMemberKind.note,
relativePath: pageRel,
title: title,
);
await NotebookManifest.write(
folderPath,
manifest.copyWith(members: [...manifest.members, member]),
);
return member;
}
/// Copy [sourceAbsolutePath] into the container and register it as a member.
Future<NotebookMember> importFileIntoContainer(
String folderPath,
String sourceAbsolutePath,
) async {
final manifest = await NotebookManifest.read(folderPath);
if (manifest == null) {
throw StateError('Not a notebook container: $folderPath');
}
final basename = p.basename(sourceAbsolutePath);
final ext = p.extension(basename).replaceFirst('.', '').toLowerCase();
final kind = notebookMemberKindFromExt(ext);
if (kind == null || kind == NotebookMemberKind.note) {
throw StateError('Unsupported import type: $ext');
}
final destRel = basename;
var destPath = p.join(folderPath, destRel);
var n = 2;
while (await File(destPath).exists()) {
final stem = p.basenameWithoutExtension(basename);
destPath = p.join(folderPath, '$stem $n.$ext');
n++;
}
await File(sourceAbsolutePath).copy(destPath);
final member = NotebookMember(
id: 'doc-${DateTime.now().millisecondsSinceEpoch}',
kind: kind,
relativePath: p.basename(destPath),
title: p.basenameWithoutExtension(destPath),
);
await NotebookManifest.write(
folderPath,
manifest.copyWith(members: [...manifest.members, member]),
);
return member;
}
/// Absolute path for a member inside [folderPath].
String memberAbsolutePath(String folderPath, NotebookMember member) =>
p.normalize(p.join(folderPath, member.relativePath));
Future<List<VaultNotebook>> scanNotebooks() async {
final root = vaultRoot;
if (root == null || root.isEmpty) return const [];
final dir = Directory(root);
if (!await dir.exists()) return const [];
final notebooks = <VaultNotebook>[];
await for (final entity in dir.list(followLinks: false)) {
if (entity is! Directory) continue;
final folderName = p.basename(entity.path);
if (folderName.startsWith('.')) continue;
// Container folders are listed by [scanContainers], not here.
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
final notebook = await _readNotebookFolder(entity);
if (notebook != null) notebooks.add(notebook);
}
notebooks.sort((a, b) => b.modified.compareTo(a.modified));
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;
if (await NotebookManifest.fileIn(entity.path).exists()) 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).
Future<VaultNotebook?> _readNotebookFolder(Directory folder) async {
File? chosen;
String? chosenExt;
await for (final entity in folder.list(followLinks: false)) {
if (entity is! File) continue;
final name = p.basename(entity.path);
if (name.endsWith(kVaultSidecarSuffix)) continue;
final ext = p.extension(name).replaceFirst('.', '').toLowerCase();
if (!importableExtensions.contains(ext)) continue;
// Prefer a PDF artifact when present (DOCX-converted notebooks keep both).
if (chosen == null || (ext == 'pdf' && chosenExt != 'pdf')) {
chosen = entity;
chosenExt = ext;
}
}
if (chosen == null || chosenExt == null) return null;
final stat = await chosen.stat();
final sidecar = File('${chosen.path}$kVaultSidecarSuffix');
return VaultNotebook(
folderPath: folder.path,
sourceFilePath: chosen.path,
filename: p.basename(chosen.path),
docType: chosenExt,
modified: stat.modified,
hasSidecar: await sidecar.exists(),
);
}
/// Find an unused notebook folder under [root] for [baseName], appending a
/// ` 2`, ` 3`, … suffix on collision.
Future<Directory> _uniqueNotebookFolder(String root, String baseName) async {
final safeBase = baseName.isEmpty ? 'Untitled' : baseName;
var candidate = Directory(p.join(root, safeBase));
var n = 2;
while (await candidate.exists()) {
candidate = Directory(p.join(root, '$safeBase $n'));
n++;
}
return candidate;
}
/// Sanitize a basename into a safe folder name: strip characters illegal on
/// Windows/POSIX (`\ / : * ? " < > |`) and control chars, collapse
/// whitespace, and trim trailing dots/spaces (illegal on Windows).
static String _sanitizeFolderName(String name) {
final cleaned = name
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim()
.replaceAll(RegExp(r'[. ]+$'), '');
return cleaned;
}
}