diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0ce2959..7d38868 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5,6 +5,19 @@ "search": "Search", "importPdf": "Import PDF", "importPpt": "Import PPT", + "importFile": "Import file", + "createNotebook": "Create notebook", + "noDocumentsYet": "No documents yet — tap Import file", + "processingImport": "Importing…", + "importFailed": "Couldn't import that file: {error}", + "@importFailed": { + "placeholders": { "error": { "type": "String" } } + }, + "convertNeedsLibreOffice": "Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.", + "unsupportedFileType": "Unsupported file type: {ext}", + "@unsupportedFileType": { + "placeholders": { "ext": { "type": "String" } } + }, "penCanvasBeta": "Pen Canvas (beta)", "newNote": "New Note", "open": "Open", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 56e5739..5ffbe72 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -128,6 +128,48 @@ abstract class AppLocalizations { /// **'Import PPT'** String get importPpt; + /// No description provided for @importFile. + /// + /// In en, this message translates to: + /// **'Import file'** + String get importFile; + + /// No description provided for @createNotebook. + /// + /// In en, this message translates to: + /// **'Create notebook'** + String get createNotebook; + + /// No description provided for @noDocumentsYet. + /// + /// In en, this message translates to: + /// **'No documents yet — tap Import file'** + String get noDocumentsYet; + + /// No description provided for @processingImport. + /// + /// In en, this message translates to: + /// **'Importing…'** + String get processingImport; + + /// No description provided for @importFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t import that file: {error}'** + String importFailed(String error); + + /// No description provided for @convertNeedsLibreOffice. + /// + /// In en, this message translates to: + /// **'Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.'** + String get convertNeedsLibreOffice; + + /// No description provided for @unsupportedFileType. + /// + /// In en, this message translates to: + /// **'Unsupported file type: {ext}'** + String unsupportedFileType(String ext); + /// No description provided for @penCanvasBeta. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 32e89dd..dddb910 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -23,6 +23,32 @@ class AppLocalizationsEn extends AppLocalizations { @override String get importPpt => 'Import PPT'; + @override + String get importFile => 'Import file'; + + @override + String get createNotebook => 'Create notebook'; + + @override + String get noDocumentsYet => 'No documents yet — tap Import file'; + + @override + String get processingImport => 'Importing…'; + + @override + String importFailed(String error) { + return 'Couldn\'t import that file: $error'; + } + + @override + String get convertNeedsLibreOffice => + 'Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.'; + + @override + String unsupportedFileType(String ext) { + return 'Unsupported file type: $ext'; + } + @override String get penCanvasBeta => 'Pen Canvas (beta)'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index af50612..8ef0354 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -23,6 +23,32 @@ class AppLocalizationsZh extends AppLocalizations { @override String get importPpt => '导入 PPT'; + @override + String get importFile => '导入文件'; + + @override + String get createNotebook => '新建笔记本'; + + @override + String get noDocumentsYet => '暂无文档——点按“导入文件”'; + + @override + String get processingImport => '正在导入…'; + + @override + String importFailed(String error) { + return '无法导入该文件:$error'; + } + + @override + String get convertNeedsLibreOffice => + '导入 Word 文档需要安装 LibreOffice。请先转换为 PDF,或安装 LibreOffice。'; + + @override + String unsupportedFileType(String ext) { + return '不支持的文件类型:$ext'; + } + @override String get penCanvasBeta => '手写画布(测试版)'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f303d16..84d778f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -5,6 +5,13 @@ "search": "搜索", "importPdf": "导入 PDF", "importPpt": "导入 PPT", + "importFile": "导入文件", + "createNotebook": "新建笔记本", + "noDocumentsYet": "暂无文档——点按“导入文件”", + "processingImport": "正在导入…", + "importFailed": "无法导入该文件:{error}", + "convertNeedsLibreOffice": "导入 Word 文档需要安装 LibreOffice。请先转换为 PDF,或安装 LibreOffice。", + "unsupportedFileType": "不支持的文件类型:{ext}", "penCanvasBeta": "手写画布(测试版)", "newNote": "新建笔记", "open": "打开", diff --git a/lib/providers/document_provider.dart b/lib/providers/document_provider.dart index 8685253..8d0bad3 100644 --- a/lib/providers/document_provider.dart +++ b/lib/providers/document_provider.dart @@ -1,61 +1,68 @@ +import 'dart:io'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:uuid/uuid.dart'; import '../models/document.dart'; -import '../services/database_service.dart'; -import 'note_provider.dart'; +import '../services/vault_service.dart'; -const _uuid = Uuid(); +final vaultServiceProvider = FutureProvider((ref) async { + return VaultService.getInstance(); +}); final documentListProvider = AsyncNotifierProvider>( DocumentListNotifier.new, ); +/// The home-screen document list is now sourced from a VAULT SCAN (folders +/// under the vault root containing a source file + optional sidecar), NOT the +/// SQLite `documents` table. The sidecar that travels with the file is the +/// source of truth; there is no SQLite cache for this list (the scan is cheap — +/// one directory listing — and always correct). class DocumentListNotifier extends AsyncNotifier> { - Future get _db => ref.read(databaseServiceProvider.future); + Future get _vault => + ref.read(vaultServiceProvider.future); @override Future> build() async { - final db = await _db; - return db.getAllDocuments(); + return _scan(); } - /// Reloads documents from the database and publishes the result to [state] - /// so the UI rebuilds. Used by pull-to-refresh. + Future> _scan() async { + final vault = await _vault; + final notebooks = await vault.scanNotebooks(); + return notebooks.map(_toDocument).toList(); + } + + /// Adapt a scanned [VaultNotebook] into the [Document] shape the home-screen + /// tiles already render. The notebook folder path doubles as a stable id. + Document _toDocument(VaultNotebook nb) { + return Document( + id: nb.folderPath, + filename: nb.filename, + docType: nb.docType, + filePath: nb.sourceFilePath, + pageCount: 0, + createdAt: nb.modified, + updatedAt: nb.modified, + ); + } + + /// Re-scan the vault and publish the result. Used by pull-to-refresh and + /// after an import. Future loadDocuments() async { state = const AsyncLoading(); - state = await AsyncValue.guard(() async { - final db = await _db; - return db.getAllDocuments(); - }); - } - - Future addDocument({ - required String filename, - required String docType, - required String filePath, - int pageCount = 0, - }) async { - final db = await _db; - final now = DateTime.now(); - final document = Document( - id: _uuid.v4(), - filename: filename, - docType: docType, - filePath: filePath, - pageCount: pageCount, - createdAt: now, - updatedAt: now, - ); - await db.insertDocument(document); - state = AsyncData([document, ...state.value ?? []]); - return document; + state = await AsyncValue.guard(_scan); } + /// Remove a notebook by deleting its folder (source file + sidecar travel + /// together, so removing the folder removes the whole notebook). [id] is the + /// notebook folder path produced by [_toDocument]. Future removeDocument(String id) async { - final db = await _db; - await db.deleteDocument(id); + final dir = Directory(id); + if (await dir.exists()) { + await dir.delete(recursive: true); + } final current = state.value ?? []; state = AsyncData(current.where((d) => d.id != id).toList()); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b5f9086..dee5c8f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,5 +1,7 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; import '../l10n/app_localizations.dart'; import '../models/document.dart'; import '../models/note.dart'; @@ -7,8 +9,8 @@ import '../providers/document_provider.dart'; import '../providers/note_provider.dart'; import '../providers/ocr_provider.dart'; import '../editor/canvas/pen_editor_screen.dart'; -import '../services/pdf_service.dart'; import '../services/pptx_service.dart'; +import '../services/vault_service.dart'; import '../editor/canvas/pen_note_screen.dart'; import '../editor/canvas/pen_slide_screen.dart'; import 'search_screen.dart'; @@ -50,14 +52,9 @@ class HomeScreen extends ConsumerWidget { }, ), IconButton( - icon: const Icon(Icons.picture_as_pdf), - tooltip: l.importPdf, - onPressed: () => _importPdf(context), - ), - IconButton( - icon: const Icon(Icons.slideshow), - tooltip: l.importPpt, - onPressed: () => _importPptx(context), + icon: const Icon(Icons.file_open), + tooltip: l.importFile, + onPressed: () => _importFile(context, ref), ), IconButton( icon: const Icon(Icons.search), @@ -163,7 +160,7 @@ class HomeScreen extends ConsumerWidget { ), child: Center( child: Text( - 'No documents yet — import a PDF or PPT', + l.noDocumentsYet, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( color: Theme.of( @@ -192,43 +189,112 @@ class HomeScreen extends ConsumerWidget { } } - Future _importPdf(BuildContext context) async { - final pdfService = PdfService(); - final filePath = await pdfService.pickPdfFile(); - if (filePath != null && context.mounted) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PenEditorScreen(pdfPath: filePath), - ), - ); + /// Single top-level "Import file" action (sibling of "Create notebook"): + /// pick a pdf/docx/pptx/ppt, copy it into a new vault notebook folder, then + /// open the IN-VAULT copy in the right editor (routed by extension). + Future _importFile(BuildContext context, WidgetRef ref) async { + final l = AppLocalizations.of(context); + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: VaultService.importableExtensions.toList(), + ); + final picked = result?.files; + if (picked == null || picked.isEmpty) return; + final pickedPath = picked.first.path; + if (pickedPath == null) return; + + final messenger = context.mounted ? ScaffoldMessenger.of(context) : null; + messenger?.showSnackBar(SnackBar(content: Text(l.processingImport))); + + try { + final vault = await ref.read(vaultServiceProvider.future); + final vaultPath = await vault.createNotebook(pickedPath); + // Refresh the documents list so the new notebook shows on return. + await ref.read(documentListProvider.notifier).loadDocuments(); + if (!context.mounted) return; + await _openVaultFile(context, ref, vaultPath); + } catch (e) { + messenger?.showSnackBar(SnackBar(content: Text(l.importFailed('$e')))); } } - Future _importPptx(BuildContext context) async { - final pptxService = PptxService(); - final filePath = await pptxService.openPptxFile(); - if (filePath == null || !context.mounted) return; + /// Route an in-vault [filePath] to the correct editor by extension: + /// pdf → [PenEditorScreen]; pptx/ppt → [PenSlideScreen]; docx → convert to + /// PDF (best-effort, LibreOffice) then open as PDF. Unsupported / failed + /// conversions surface a friendly message instead of crashing. + Future _openVaultFile( + BuildContext context, + WidgetRef ref, + String filePath, + ) async { + final l = AppLocalizations.of(context); + final ext = p.extension(filePath).replaceFirst('.', '').toLowerCase(); - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Processing PPTX...'))); - } - - final slideImages = await pptxService.convertToImages(filePath); - final extractedText = await pptxService.extractText(filePath); - - if (context.mounted) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PenSlideScreen( - filePath: filePath, - slideImagePaths: slideImages, - extractedText: extractedText.isEmpty ? null : extractedText, + switch (ext) { + case 'pdf': + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PenEditorScreen(pdfPath: filePath), ), - ), + ); + case 'pptx': + case 'ppt': + await _openPresentation(context, filePath); + case 'docx': + final pptxService = PptxService(); + final pdfPath = await pptxService.convertToPdf(filePath); + if (!context.mounted) return; + if (pdfPath == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.convertNeedsLibreOffice)), + ); + return; + } + // The converted PDF lives next to the docx in the notebook folder, so + // it becomes the annotatable artifact; re-scan picks it up. + await ref.read(documentListProvider.notifier).loadDocuments(); + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PenEditorScreen(pdfPath: pdfPath), + ), + ); + default: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.unsupportedFileType(ext))), + ); + } + } + + Future _openPresentation( + BuildContext context, + String filePath, + ) async { + final l = AppLocalizations.of(context); + final pptxService = PptxService(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.processingPresentation)), ); } + final slideImages = await pptxService.convertToImages(filePath); + final extractedText = await pptxService.extractText(filePath); + if (!context.mounted) return; + if (slideImages.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.couldNotOpenPresentation)), + ); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PenSlideScreen( + filePath: filePath, + slideImagePaths: slideImages, + extractedText: extractedText.isEmpty ? null : extractedText, + ), + ), + ); } Widget _buildEmptyState(BuildContext context, WidgetRef ref) { @@ -259,19 +325,13 @@ class HomeScreen extends ConsumerWidget { FilledButton.icon( onPressed: () => _createAndOpenNote(context, ref), icon: const Icon(Icons.add), - label: Text(AppLocalizations.of(context).newNote), + label: Text(AppLocalizations.of(context).createNotebook), ), const SizedBox(height: 12), OutlinedButton.icon( - onPressed: () => _importPdf(context), - icon: const Icon(Icons.picture_as_pdf), - label: Text(AppLocalizations.of(context).importPdf), - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () => _importPptx(context), - icon: const Icon(Icons.slideshow), - label: Text(AppLocalizations.of(context).importPpt), + onPressed: () => _importFile(context, ref), + icon: const Icon(Icons.file_open), + label: Text(AppLocalizations.of(context).importFile), ), ], ), @@ -512,22 +572,45 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { ); } - // [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen + // Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen, + // docx → best-effort convert-to-PDF then open as PDF. Future _openDocument(BuildContext context) async { final document = widget.document; - final isPdf = document.docType == 'pdf'; + final l = AppLocalizations.of(context); - if (isPdf) { + if (document.docType == 'pdf') { Navigator.of(context).push( MaterialPageRoute( builder: (_) => PenEditorScreen(pdfPath: document.filePath), ), ); - } else { + return; + } + + if (document.docType == 'docx') { + final pdfPath = await PptxService().convertToPdf(document.filePath); + if (!mounted) return; + if (pdfPath == null) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar(content: Text(l.convertNeedsLibreOffice)), + ); + return; + } + await ref.read(documentListProvider.notifier).loadDocuments(); + if (!mounted) return; + Navigator.of(this.context).push( + MaterialPageRoute( + builder: (_) => PenEditorScreen(pdfPath: pdfPath), + ), + ); + return; + } + + { // PPT/PPTX: convert to images then push PenSlideScreen if (mounted) { ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Processing presentation...')), + SnackBar(content: Text(l.processingPresentation)), ); } final pptxService = PptxService(); @@ -536,7 +619,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { if (!mounted) return; if (slideImages.isEmpty) { ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Could not open presentation.')), + SnackBar(content: Text(l.couldNotOpenPresentation)), ); return; } diff --git a/lib/services/pptx_service.dart b/lib/services/pptx_service.dart index 9a3a4cf..26edc3c 100644 --- a/lib/services/pptx_service.dart +++ b/lib/services/pptx_service.dart @@ -127,13 +127,15 @@ class PptxService { /// Try converting via LibreOffice headless. Future> _convertViaLibreOffice(String pptxPath) async { try { - // Check if LibreOffice is available - final which = await Process.run('which', ['libreoffice']); - if (which.exitCode != 0) return []; + // Resolve the LibreOffice binary across platforms. On Windows the binary + // is `soffice.exe` (not on PATH for `which`, which is POSIX-only), so we + // probe the standard install locations as well — see resolveSoffice(). + final soffice = await resolveSoffice(); + if (soffice == null) return []; final outDir = await _makeTmpDir('pptx_images'); - final result = await Process.run('libreoffice', [ + final result = await Process.run(soffice, [ '--headless', '--convert-to', 'png', @@ -174,6 +176,73 @@ class PptxService { } } + /// Resolve the LibreOffice CLI binary for the current platform, or null when + /// it cannot be found. + /// + /// Order: + /// 1. Windows: `soffice.exe` at the standard install paths + /// (`C:\Program Files\LibreOffice\program\soffice.exe`, and the 32-bit + /// `Program Files (x86)` variant). The POSIX `which` can't find these. + /// 2. POSIX: `which libreoffice`, then `which soffice` (macOS/some distros). + /// 3. Otherwise null → callers fall back gracefully. + static Future resolveSoffice() async { + if (Platform.isWindows) { + const candidates = [ + r'C:\Program Files\LibreOffice\program\soffice.exe', + r'C:\Program Files (x86)\LibreOffice\program\soffice.exe', + ]; + for (final c in candidates) { + if (await File(c).exists()) return c; + } + // Last resort: maybe soffice is on PATH (e.g. a portable install). + if (await _whichOk('soffice')) return 'soffice'; + return null; + } + if (await _whichOk('libreoffice')) return 'libreoffice'; + if (await _whichOk('soffice')) return 'soffice'; + return null; + } + + static Future _whichOk(String cmd) async { + try { + final r = await Process.run('which', [cmd]); + return r.exitCode == 0; + } catch (_) { + return false; + } + } + + /// Convert an arbitrary office document (e.g. DOCX) to PDF via LibreOffice + /// headless, writing the PDF NEXT TO [sourcePath] (same folder, same + /// basename + `.pdf`). Returns the PDF path on success, or null when + /// LibreOffice is unavailable or the conversion fails — callers MUST handle + /// null and surface a friendly message rather than crash. + Future convertToPdf(String sourcePath) async { + final soffice = await resolveSoffice(); + if (soffice == null) return null; + + final outDir = p.dirname(sourcePath); + try { + final result = await Process.run(soffice, [ + '--headless', + '--convert-to', + 'pdf', + '--outdir', + outDir, + sourcePath, + ]); + if (result.exitCode != 0) return null; + final pdfPath = p.join( + outDir, + '${p.basenameWithoutExtension(sourcePath)}.pdf', + ); + if (await File(pdfPath).exists()) return pdfPath; + return null; + } catch (_) { + return null; + } + } + /// Generate placeholder slide images when LibreOffice is not available. /// /// Uses ImageMagick `convert` to create PNG files with slide numbers. diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index e7f0c62..8bf9cfd 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -1,8 +1,46 @@ import 'dart:io'; import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.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; +} + /// Records the user-picked vault root folder (an Obsidian-style vault) and /// gates app startup behind a valid choice. /// @@ -67,4 +105,115 @@ class VaultService { 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; + } + + /// 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; + } } diff --git a/test/vault_service_test.dart b/test/vault_service_test.dart index bcbd7f5..85bb877 100644 --- a/test/vault_service_test.dart +++ b/test/vault_service_test.dart @@ -78,4 +78,139 @@ void main() { await vault.setVaultRoot(''); expect(await vault.vaultRootValid(), isFalse); }); + + group('createNotebook', () { + late Directory srcDir; + + setUp(() async { + srcDir = await Directory.systemTemp.createTemp('vault_src'); + }); + + tearDown(() async { + if (await srcDir.exists()) await srcDir.delete(recursive: true); + }); + + Future makeSource(String name, [String content = 'pdf-bytes']) async { + final f = File('${srcDir.path}${Platform.pathSeparator}$name'); + await f.writeAsString(content); + return f.path; + } + + test('creates folder, copies file, returns the in-vault path', () async { + final vault = await makeService(); + await vault.setVaultRoot(tempDir.path); + final src = await makeSource('Calculus Lecture 3.pdf', 'hello'); + + final vaultPath = await vault.createNotebook(src); + + // Returned path is inside the vault, in a folder named from the file. + final expectedFolder = + '${tempDir.path}${Platform.pathSeparator}Calculus Lecture 3'; + expect(Directory(expectedFolder).existsSync(), isTrue); + expect( + vaultPath, + '$expectedFolder${Platform.pathSeparator}Calculus Lecture 3.pdf', + ); + // The file was copied with its contents intact. + expect(File(vaultPath).existsSync(), isTrue); + expect(File(vaultPath).readAsStringSync(), 'hello'); + // The original is untouched (copy, not move). + expect(File(src).existsSync(), isTrue); + }); + + test('de-duplicates the folder name on collision', () async { + final vault = await makeService(); + await vault.setVaultRoot(tempDir.path); + + final a = await makeSource('Notes.pdf', 'A'); + final firstPath = await vault.createNotebook(a); + expect( + firstPath, + '${tempDir.path}${Platform.pathSeparator}Notes${Platform.pathSeparator}Notes.pdf', + ); + + // Re-import a file with the same basename → suffixed folder. + final b = await makeSource('Notes.pdf', 'B'); + final secondPath = await vault.createNotebook(b); + expect( + secondPath, + '${tempDir.path}${Platform.pathSeparator}Notes 2${Platform.pathSeparator}Notes.pdf', + ); + expect(File(secondPath).readAsStringSync(), 'B'); + // First copy still intact. + expect(File(firstPath).readAsStringSync(), 'A'); + }); + + test('throws when no vault root is set', () async { + final vault = await makeService(); + final src = await makeSource('x.pdf'); + expect(() => vault.createNotebook(src), throwsStateError); + }); + }); + + group('scanNotebooks', () { + Future writeFile(String path, [String content = 'x']) async { + final f = File(path); + await f.create(recursive: true); + await f.writeAsString(content); + return f.path; + } + + test('empty / missing vault yields an empty list', () async { + final vault = await makeService(); + // No root set. + expect(await vault.scanNotebooks(), isEmpty); + // Root set but empty. + await vault.setVaultRoot(tempDir.path); + expect(await vault.scanNotebooks(), isEmpty); + }); + + test('lists notebook folders that contain a source file', () async { + final vault = await makeService(); + await vault.setVaultRoot(tempDir.path); + final sep = Platform.pathSeparator; + + // A PDF notebook with a sidecar. + await writeFile('${tempDir.path}${sep}Lecture${sep}Lecture.pdf'); + await writeFile( + '${tempDir.path}${sep}Lecture${sep}Lecture.pdf.badnote.json', + '{}', + ); + // A PPTX notebook without a sidecar. + await writeFile('${tempDir.path}${sep}Deck${sep}Deck.pptx'); + // A hidden vault-metadata folder is skipped. + await writeFile('${tempDir.path}$sep.badnote${sep}vault.json', '{}'); + // A folder with no importable file is skipped. + await writeFile('${tempDir.path}${sep}Empty${sep}readme.txt'); + + final notebooks = await vault.scanNotebooks(); + final byName = {for (final n in notebooks) n.filename: n}; + + expect(byName.keys, containsAll(['Lecture.pdf', 'Deck.pptx'])); + expect(notebooks.length, 2); + + expect(byName['Lecture.pdf']!.docType, 'pdf'); + expect(byName['Lecture.pdf']!.hasSidecar, isTrue); + expect(byName['Deck.pptx']!.docType, 'pptx'); + expect(byName['Deck.pptx']!.hasSidecar, isFalse); + }); + + test('a created notebook is discoverable by the scan', () async { + final vault = await makeService(); + await vault.setVaultRoot(tempDir.path); + final srcDir = await Directory.systemTemp.createTemp('vault_scan_src'); + try { + final src = '${srcDir.path}${Platform.pathSeparator}Doc.pdf'; + await File(src).writeAsString('bytes'); + await vault.createNotebook(src); + + final notebooks = await vault.scanNotebooks(); + expect(notebooks.length, 1); + expect(notebooks.first.filename, 'Doc.pdf'); + expect(notebooks.first.docType, 'pdf'); + } finally { + await srcDir.delete(recursive: true); + } + }); + }); }