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 `.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('/notebook', …)` /// therefore writes `/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 `/notebook`. Pass this to /// `SidecarRepository.open(notePath, docType: 'notebook')`; it keys the /// `/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 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 setVaultRoot(String path) async { await _prefs.setString(vaultRootKey, path); } /// Forget the stored vault root (e.g. to re-prompt the user). Future 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 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 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 (`.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 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> scanNotebooks() async { final root = vaultRoot; if (root == null || root.isEmpty) return const []; final dir = Directory(root); if (!await dir.exists()) return const []; final notebooks = []; 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 `/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 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> scanNotes() async { final root = vaultRoot; if (root == null || root.isEmpty) return const []; final dir = Directory(root); if (!await dir.exists()) return const []; final notes = []; 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 _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 _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 _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; } }