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:
@@ -127,13 +127,15 @@ class PptxService {
|
||||
/// Try converting via LibreOffice headless.
|
||||
Future<List<String>> _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<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.
|
||||
///
|
||||
/// Uses ImageMagick `convert` to create PNG files with slide numbers.
|
||||
|
||||
@@ -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 `<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
|
||||
/// 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user