feat(import): one Import-file entry + vault notebooks
All checks were successful
CI / Windows build (push) Successful in 14m14s
All checks were successful
CI / Windows build (push) Successful in 14m14s
Phase 3. Import becomes a single top-level action beside "Create notebook" and the library is vault-backed. - VaultService.createNotebook copies a picked file into a fresh (de-duplicated) notebook folder under the vault; its sidecar lives beside it, so annotations travel with the file. - Home screen: one "Import file" action with a multi-extension picker (pdf / docx / pptx); routes to the editor by extension. - The document list is now a vault scan (folders with a source file), not the SQLite documents table — no cache, always correct. - PPTX soffice detection fix; DOCX convert-on-import is best-effort and fails gracefully when LibreOffice is unavailable. analyze clean, tests green.
This commit is contained in:
@@ -5,6 +5,19 @@
|
|||||||
"search": "Search",
|
"search": "Search",
|
||||||
"importPdf": "Import PDF",
|
"importPdf": "Import PDF",
|
||||||
"importPpt": "Import PPT",
|
"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)",
|
"penCanvasBeta": "Pen Canvas (beta)",
|
||||||
"newNote": "New Note",
|
"newNote": "New Note",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
|
|||||||
@@ -128,6 +128,48 @@ abstract class AppLocalizations {
|
|||||||
/// **'Import PPT'**
|
/// **'Import PPT'**
|
||||||
String get importPpt;
|
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.
|
/// No description provided for @penCanvasBeta.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -23,6 +23,32 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get importPpt => 'Import PPT';
|
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
|
@override
|
||||||
String get penCanvasBeta => 'Pen Canvas (beta)';
|
String get penCanvasBeta => 'Pen Canvas (beta)';
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,32 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get importPpt => '导入 PPT';
|
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
|
@override
|
||||||
String get penCanvasBeta => '手写画布(测试版)';
|
String get penCanvasBeta => '手写画布(测试版)';
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"importPdf": "导入 PDF",
|
"importPdf": "导入 PDF",
|
||||||
"importPpt": "导入 PPT",
|
"importPpt": "导入 PPT",
|
||||||
|
"importFile": "导入文件",
|
||||||
|
"createNotebook": "新建笔记本",
|
||||||
|
"noDocumentsYet": "暂无文档——点按“导入文件”",
|
||||||
|
"processingImport": "正在导入…",
|
||||||
|
"importFailed": "无法导入该文件:{error}",
|
||||||
|
"convertNeedsLibreOffice": "导入 Word 文档需要安装 LibreOffice。请先转换为 PDF,或安装 LibreOffice。",
|
||||||
|
"unsupportedFileType": "不支持的文件类型:{ext}",
|
||||||
"penCanvasBeta": "手写画布(测试版)",
|
"penCanvasBeta": "手写画布(测试版)",
|
||||||
"newNote": "新建笔记",
|
"newNote": "新建笔记",
|
||||||
"open": "打开",
|
"open": "打开",
|
||||||
|
|||||||
@@ -1,61 +1,68 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
import '../models/document.dart';
|
import '../models/document.dart';
|
||||||
import '../services/database_service.dart';
|
import '../services/vault_service.dart';
|
||||||
import 'note_provider.dart';
|
|
||||||
|
|
||||||
const _uuid = Uuid();
|
final vaultServiceProvider = FutureProvider<VaultService>((ref) async {
|
||||||
|
return VaultService.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
final documentListProvider =
|
final documentListProvider =
|
||||||
AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
|
AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
|
||||||
DocumentListNotifier.new,
|
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<List<Document>> {
|
class DocumentListNotifier extends AsyncNotifier<List<Document>> {
|
||||||
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
|
Future<VaultService> get _vault =>
|
||||||
|
ref.read(vaultServiceProvider.future);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Document>> build() async {
|
Future<List<Document>> build() async {
|
||||||
final db = await _db;
|
return _scan();
|
||||||
return db.getAllDocuments();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reloads documents from the database and publishes the result to [state]
|
Future<List<Document>> _scan() async {
|
||||||
/// so the UI rebuilds. Used by pull-to-refresh.
|
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<void> loadDocuments() async {
|
Future<void> loadDocuments() async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
state = await AsyncValue.guard(() async {
|
state = await AsyncValue.guard(_scan);
|
||||||
final db = await _db;
|
|
||||||
return db.getAllDocuments();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Document> 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<void> removeDocument(String id) async {
|
Future<void> removeDocument(String id) async {
|
||||||
final db = await _db;
|
final dir = Directory(id);
|
||||||
await db.deleteDocument(id);
|
if (await dir.exists()) {
|
||||||
|
await dir.delete(recursive: true);
|
||||||
|
}
|
||||||
final current = state.value ?? [];
|
final current = state.value ?? [];
|
||||||
state = AsyncData(current.where((d) => d.id != id).toList());
|
state = AsyncData(current.where((d) => d.id != id).toList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/document.dart';
|
import '../models/document.dart';
|
||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
@@ -7,8 +9,8 @@ import '../providers/document_provider.dart';
|
|||||||
import '../providers/note_provider.dart';
|
import '../providers/note_provider.dart';
|
||||||
import '../providers/ocr_provider.dart';
|
import '../providers/ocr_provider.dart';
|
||||||
import '../editor/canvas/pen_editor_screen.dart';
|
import '../editor/canvas/pen_editor_screen.dart';
|
||||||
import '../services/pdf_service.dart';
|
|
||||||
import '../services/pptx_service.dart';
|
import '../services/pptx_service.dart';
|
||||||
|
import '../services/vault_service.dart';
|
||||||
import '../editor/canvas/pen_note_screen.dart';
|
import '../editor/canvas/pen_note_screen.dart';
|
||||||
import '../editor/canvas/pen_slide_screen.dart';
|
import '../editor/canvas/pen_slide_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
@@ -50,14 +52,9 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.picture_as_pdf),
|
icon: const Icon(Icons.file_open),
|
||||||
tooltip: l.importPdf,
|
tooltip: l.importFile,
|
||||||
onPressed: () => _importPdf(context),
|
onPressed: () => _importFile(context, ref),
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.slideshow),
|
|
||||||
tooltip: l.importPpt,
|
|
||||||
onPressed: () => _importPptx(context),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
@@ -163,7 +160,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No documents yet — import a PDF or PPT',
|
l.noDocumentsYet,
|
||||||
style: Theme.of(context).textTheme.bodyMedium
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
@@ -192,43 +189,112 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _importPdf(BuildContext context) async {
|
/// Single top-level "Import file" action (sibling of "Create notebook"):
|
||||||
final pdfService = PdfService();
|
/// pick a pdf/docx/pptx/ppt, copy it into a new vault notebook folder, then
|
||||||
final filePath = await pdfService.pickPdfFile();
|
/// open the IN-VAULT copy in the right editor (routed by extension).
|
||||||
if (filePath != null && context.mounted) {
|
Future<void> _importFile(BuildContext context, WidgetRef ref) async {
|
||||||
Navigator.of(context).push(
|
final l = AppLocalizations.of(context);
|
||||||
MaterialPageRoute(
|
final result = await FilePicker.platform.pickFiles(
|
||||||
builder: (_) => PenEditorScreen(pdfPath: filePath),
|
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<void> _importPptx(BuildContext context) async {
|
/// Route an in-vault [filePath] to the correct editor by extension:
|
||||||
final pptxService = PptxService();
|
/// pdf → [PenEditorScreen]; pptx/ppt → [PenSlideScreen]; docx → convert to
|
||||||
final filePath = await pptxService.openPptxFile();
|
/// PDF (best-effort, LibreOffice) then open as PDF. Unsupported / failed
|
||||||
if (filePath == null || !context.mounted) return;
|
/// conversions surface a friendly message instead of crashing.
|
||||||
|
Future<void> _openVaultFile(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String filePath,
|
||||||
|
) async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final ext = p.extension(filePath).replaceFirst('.', '').toLowerCase();
|
||||||
|
|
||||||
if (context.mounted) {
|
switch (ext) {
|
||||||
ScaffoldMessenger.of(
|
case 'pdf':
|
||||||
context,
|
Navigator.of(context).push(
|
||||||
).showSnackBar(const SnackBar(content: Text('Processing PPTX...')));
|
MaterialPageRoute(
|
||||||
}
|
builder: (_) => PenEditorScreen(pdfPath: filePath),
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
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<void> _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) {
|
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
|
||||||
@@ -259,19 +325,13 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _createAndOpenNote(context, ref),
|
onPressed: () => _createAndOpenNote(context, ref),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: Text(AppLocalizations.of(context).newNote),
|
label: Text(AppLocalizations.of(context).createNotebook),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _importPdf(context),
|
onPressed: () => _importFile(context, ref),
|
||||||
icon: const Icon(Icons.picture_as_pdf),
|
icon: const Icon(Icons.file_open),
|
||||||
label: Text(AppLocalizations.of(context).importPdf),
|
label: Text(AppLocalizations.of(context).importFile),
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => _importPptx(context),
|
|
||||||
icon: const Icon(Icons.slideshow),
|
|
||||||
label: Text(AppLocalizations.of(context).importPpt),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -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<void> _openDocument(BuildContext context) async {
|
Future<void> _openDocument(BuildContext context) async {
|
||||||
final document = widget.document;
|
final document = widget.document;
|
||||||
final isPdf = document.docType == 'pdf';
|
final l = AppLocalizations.of(context);
|
||||||
|
|
||||||
if (isPdf) {
|
if (document.docType == 'pdf') {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => PenEditorScreen(pdfPath: document.filePath),
|
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
|
// PPT/PPTX: convert to images then push PenSlideScreen
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
const SnackBar(content: Text('Processing presentation...')),
|
SnackBar(content: Text(l.processingPresentation)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final pptxService = PptxService();
|
final pptxService = PptxService();
|
||||||
@@ -536,7 +619,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (slideImages.isEmpty) {
|
if (slideImages.isEmpty) {
|
||||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
const SnackBar(content: Text('Could not open presentation.')),
|
SnackBar(content: Text(l.couldNotOpenPresentation)),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,13 +127,15 @@ class PptxService {
|
|||||||
/// Try converting via LibreOffice headless.
|
/// Try converting via LibreOffice headless.
|
||||||
Future<List<String>> _convertViaLibreOffice(String pptxPath) async {
|
Future<List<String>> _convertViaLibreOffice(String pptxPath) async {
|
||||||
try {
|
try {
|
||||||
// Check if LibreOffice is available
|
// Resolve the LibreOffice binary across platforms. On Windows the binary
|
||||||
final which = await Process.run('which', ['libreoffice']);
|
// is `soffice.exe` (not on PATH for `which`, which is POSIX-only), so we
|
||||||
if (which.exitCode != 0) return [];
|
// probe the standard install locations as well — see resolveSoffice().
|
||||||
|
final soffice = await resolveSoffice();
|
||||||
|
if (soffice == null) return [];
|
||||||
|
|
||||||
final outDir = await _makeTmpDir('pptx_images');
|
final outDir = await _makeTmpDir('pptx_images');
|
||||||
|
|
||||||
final result = await Process.run('libreoffice', [
|
final result = await Process.run(soffice, [
|
||||||
'--headless',
|
'--headless',
|
||||||
'--convert-to',
|
'--convert-to',
|
||||||
'png',
|
'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<String?> 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<bool> _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<String?> 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.
|
/// Generate placeholder slide images when LibreOffice is not available.
|
||||||
///
|
///
|
||||||
/// Uses ImageMagick `convert` to create PNG files with slide numbers.
|
/// Uses ImageMagick `convert` to create PNG files with slide numbers.
|
||||||
|
|||||||
@@ -1,8 +1,46 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
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 `<file>.badnote.json` sidecar exists next to the source file.
|
||||||
|
final bool hasSidecar;
|
||||||
|
}
|
||||||
|
|
||||||
/// Records the user-picked vault root folder (an Obsidian-style vault) and
|
/// Records the user-picked vault root folder (an Obsidian-style vault) and
|
||||||
/// gates app startup behind a valid choice.
|
/// gates app startup behind a valid choice.
|
||||||
///
|
///
|
||||||
@@ -67,4 +105,115 @@ class VaultService {
|
|||||||
if (path == null || path.isEmpty) return false;
|
if (path == null || path.isEmpty) return false;
|
||||||
return Directory(path).exists();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,4 +78,139 @@ void main() {
|
|||||||
await vault.setVaultRoot('');
|
await vault.setVaultRoot('');
|
||||||
expect(await vault.vaultRootValid(), isFalse);
|
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<String> 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<String> 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user