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.
349 lines
13 KiB
Dart
349 lines
13 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/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;
|
|
}
|
|
|
|
/// 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';
|
|
|
|
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 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).
|
|
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; // skip .badnote etc.
|
|
|
|
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;
|
|
|
|
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;
|
|
}
|
|
}
|