diff --git a/.github/workflows/linux-packaging.yml b/.github/workflows/linux-packaging.yml index a1ee1775..c34ce4b3 100644 --- a/.github/workflows/linux-packaging.yml +++ b/.github/workflows/linux-packaging.yml @@ -1,12 +1,11 @@ name: Linux packaging check on: + workflow_dispatch: pull_request: paths: - - 'app/linux/**' - - 'app/pubspec.yaml' - - 'app/pubspec.lock' - - 'app/integration_test/desktop_package_*.dart' + - 'app/**' + - 'packages/**' - 'tools/run_packaged_smoke.py' - 'tools/check_packaged_reader.*' - 'tools/install_linux_build_dependencies.sh' @@ -45,6 +44,8 @@ jobs: cache: true - name: Install locked dependencies run: flutter pub get --enforce-lockfile + - name: Validate watched folders on Linux + run: flutter test test/folders - name: Compile Linux release run: flutter build linux --release - name: Package Linux downloads diff --git a/.github/workflows/windows-packaging.yml b/.github/workflows/windows-packaging.yml index db92cf79..39177410 100644 --- a/.github/workflows/windows-packaging.yml +++ b/.github/workflows/windows-packaging.yml @@ -41,6 +41,8 @@ jobs: flutter-version: '3.41.2' cache: true - run: flutter pub get --enforce-lockfile + - name: Validate watched folders on Windows + run: flutter test test/folders - name: Prepare preview version and endpoints run: | python ../tools/release_gate.py client --endpoints diff --git a/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt b/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt index c818f7ab..c4a0299f 100644 --- a/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt +++ b/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt @@ -1,5 +1,186 @@ package com.papyrus.reader +import android.app.Activity +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.DocumentsContract import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.util.ArrayDeque +import java.util.UUID +import java.util.concurrent.Executors -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + private val folderRequest = 7142 + private var folderResult: MethodChannel.Result? = null + private val worker = Executors.newSingleThreadExecutor() + private val main = Handler(Looper.getMainLooper()) + private val cursors = mutableMapOf() + private val grants by lazy { getSharedPreferences("papyrus-folder-grants", MODE_PRIVATE) } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "papyrus/folders").setMethodCallHandler { call, result -> + if (call.method == "pick") { + if (folderResult != null) { + result.error("busy", "A folder picker is already open.", null) + } else { + folderResult = result + try { + startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + }, folderRequest) + } catch (error: Exception) { + folderResult = null + result.error("unavailable", "The system folder picker is unavailable.", null) + } + } + return@setMethodCallHandler + } + worker.execute { + try { + val key = call.argument("grant") + val tree = key?.let { grants.getString(it, null)?.let(Uri::parse) } + fun requireTree(): Uri = tree ?: throw SecurityException("Reconnect this folder to allow access.") + val value: Any? = when (call.method) { + "restore" -> { + val uri = requireTree() + contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission } + } + "start" -> { + val uri = requireTree() + val cursor = UUID.randomUUID().toString() + cursors[cursor] = FolderCursor(uri, call.argument("recursive") == true) + cursor + } + "next" -> cursors[call.argument("cursor")]?.next() ?: emptyList() + "close" -> { cursors.remove(call.argument("cursor"))?.close(); null } + "stat" -> { + val uri = DocumentsContract.buildDocumentUriUsingTree(requireTree(), call.argument("key")!!) + contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + if (!cursor.moveToFirst()) throw java.io.FileNotFoundException("Source file unavailable offline.") + metadata(cursor) + } ?: throw java.io.FileNotFoundException("Source file unavailable offline.") + } + "read" -> { + val uri = DocumentsContract.buildDocumentUriUsingTree(requireTree(), call.argument("key")!!) + contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw java.io.FileNotFoundException("Source file unavailable offline.") + } + "release" -> { + if (key != null && !grants.edit().remove(key).commit()) { + throw java.io.IOException("Could not release folder permission.") + } + if (tree != null && grants.all.values.none { it == tree.toString() }) { + try { + contentResolver.releasePersistableUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: SecurityException) { } + } + null + } + else -> throw IllegalArgumentException("Unknown folder operation.") + } + main.post { result.success(value) } + } catch (error: Exception) { + val code = if (error is SecurityException) "access" else "unavailable" + main.post { result.error(code, error.message ?: "Could not access this folder.", null) } + } + } + } + } + + @Deprecated("Activity result bridge for Flutter folder access") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != folderRequest) { + super.onActivityResult(requestCode, resultCode, data) + return + } + val result = folderResult + folderResult = null + val uri = data?.data + if (resultCode != Activity.RESULT_OK || uri == null) { + result?.success(null) + return + } + try { + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val document = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri)) + val name = contentResolver.query(document, arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), null, null, null) + ?.use { if (it.moveToFirst()) it.getString(0) else null } ?: "Book folder" + val key = UUID.randomUUID().toString() + if (!grants.edit().putString(key, uri.toString()).commit()) throw java.io.IOException("Could not save folder permission.") + result?.success(mapOf("key" to key, "identity" to uri.toString(), "name" to name, "location" to name, "persistent" to true)) + } catch (error: Exception) { + if (grants.all.values.none { it == uri.toString() }) { + try { + contentResolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: SecurityException) { } + } + result?.error("access", "Could not keep access to this folder. Select a permitted subfolder.", null) + } + } + + override fun onDestroy() { + worker.execute { + cursors.values.forEach { it.close() } + cursors.clear() + } + worker.shutdown() + super.onDestroy() + } + + private val projection = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED + ) + + private fun metadata(cursor: Cursor): Map = mapOf( + "key" to cursor.getString(0), "name" to cursor.getString(1), + "size" to (if (cursor.isNull(3)) -1L else cursor.getLong(3)), + "modified" to (if (cursor.isNull(4)) 0L else cursor.getLong(4)) + ) + + private inner class FolderCursor(private val tree: Uri, private val recursive: Boolean) { + private val directories = ArrayDeque().apply { add(DocumentsContract.getTreeDocumentId(tree)) } + private var current: Cursor? = null + + fun next(): List> { + val batch = mutableListOf>() + while (batch.size < 128) { + if (current == null) { + if (directories.isEmpty()) break + val directory = directories.removeFirst() + val uri = DocumentsContract.buildChildDocumentsUriUsingTree(tree, directory) + try { + current = contentResolver.query(uri, projection, null, null, null) + ?: throw java.io.FileNotFoundException("Folder unavailable. Reconnect the drive or folder.") + } catch (error: Exception) { + if (directory == DocumentsContract.getTreeDocumentId(tree)) throw error + batch.add(mapOf("key" to directory, "name" to directory.substringAfterLast('/'), + "size" to 0L, "modified" to 0L, "error" to (error.message ?: "Could not read this subfolder."))) + continue + } + } + val cursor = current!! + if (!cursor.moveToNext()) { cursor.close(); current = null; continue } + val name = cursor.getString(1) + if (name.startsWith(".") || name.startsWith("~") || Regex("\\.(tmp|part|partial|crdownload|download)$", RegexOption.IGNORE_CASE).containsMatchIn(name)) continue + if (cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR) { + if (recursive) directories.add(cursor.getString(0)) + } else { + batch.add(metadata(cursor)) + } + } + return batch + } + + fun close() { current?.close(); current = null; directories.clear() } + } +} diff --git a/app/integration_test/powersync_books_integration_test.dart b/app/integration_test/powersync_books_integration_test.dart index b09a653f..572c27a5 100644 --- a/app/integration_test/powersync_books_integration_test.dart +++ b/app/integration_test/powersync_books_integration_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -6,14 +7,133 @@ import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_repository.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/auth/token_store.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/folders/directory_access.dart'; +import 'package:papyrus/folders/folder_import_target.dart'; +import 'package:papyrus/folders/folder_repository.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/services/book_import_service_stub.dart'; import 'package:path/path.dart' as path; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; +import 'desktop_package_fixtures.dart' show createEpub; + const runIntegration = bool.fromEnvironment('RUN_POWERSYNC_INTEGRATION'); +/// Keeps OS grant acceptance separate from the live import and sync contract. +class IntegrationFolderAccess implements DirectoryAccess { + IntegrationFolderAccess(this.bytes); + final Uint8List bytes; + static const grant = FolderGrant(key: 'fixture', identity: 'fixture', name: 'Fixture', location: 'Test books'); + FolderFile get file => FolderFile(key: 'book.epub', name: 'book.epub', size: bytes.length, modified: 1); + @override + bool get supportsWatching => true; + @override + Future pick() async => grant; + @override + Future reconnect(FolderGrant grant) async => true; + @override + Stream enumerate(FolderGrant grant, {required bool recursive}) => Stream.value(file); + @override + Future stat(FolderGrant grant, FolderFile file) async => this.file; + @override + Future read(FolderGrant grant, FolderFile file) async => bytes; + @override + Stream changes(FolderGrant grant) => const Stream.empty(); + @override + Future release(FolderGrant grant) async {} +} + +Future verifyFolderImport( + PapyrusPowerSyncService first, + PapyrusPowerSyncService second, + AuthRepository repository, + Directory root, +) async { + SharedPreferences.setMockInitialValues({}); + final preferences = await SharedPreferences.getInstance(); + final auth = AuthProvider(preferences, repository: repository, bootstrapOnCreate: false); + await auth.bootstrap(); + final queue = MediaUploadQueue(preferences); + await queue.activateScope(MediaStorageScope(profileKey: 'official', userId: auth.user!.userId)); + final store = DataStore(); + await store.attachBookRepository(first); + final importer = BookImportService(); + final source = File(path.join(root.path, 'source.epub')); + final original = createEpub(); + await source.writeAsBytes(original); + final access = IntegrationFolderAccess(original); + final folders = WatchedFolders( + access: access, + libraryReady: () => true, + captureTarget: () => LibraryFolderImportTarget( + store: store, + queue: queue, + importer: importer, + auth: auth, + database: first, + ), + openRepository: (_) => FolderRepository.open('integration', databasePath: path.join(root.path, 'folders.db')), + ); + String? importedId; + + try { + await first.setOnline(false); + await folders.activate('integration'); + await folders.connect(IntegrationFolderAccess.grant); + await folders.waitUntilIdle(); + expect(folders.folders.single.added, 1); + final imported = store.books.singleWhere((book) => book.fileHash != null); + importedId = imported.id; + expect(await importer.getBookFile(imported.id), original); + expect(queue.pendingTasks.where((task) => task.bookId == imported.id), isNotEmpty); + expect(await second.getById(imported.id), isNull); + await folders.activate(null); + await folders.activate('integration'); + await folders.waitUntilIdle(); + expect(store.books.where((book) => book.fileHash == imported.fileHash), hasLength(1)); + await first.setOnline(true); + await waitForBook(second, imported.id); + await queue.processPending( + dataStore: store, + readBookFile: importer.getBookFile, + readPendingCover: importer.getPendingCoverFile, + uploadMedia: repository.uploadMedia, + ); + expect(queue.pendingTasks, isEmpty); + final deadline = DateTime.now().add(const Duration(seconds: 20)); + + while ((await second.getById(imported.id))?.fileMediaId == null && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 200)); + } + + expect((await second.getById(imported.id))?.fileMediaId, isNotNull); + expect(await source.readAsBytes(), original); + } finally { + await folders.activate(null); + folders.dispose(); + + if (importedId != null) { + await first.delete(importedId); + await importer.deleteBookFile(importedId); + await importer.deletePendingCoverFile(queue.activeScope!, importedId); + } + + await store.disposeBookRepository(); + store.dispose(); + queue.dispose(); + auth.dispose(); + importer.dispose(); + } +} + class MemoryRefreshTokenStorage implements RefreshTokenStorage { String? value; @@ -119,11 +239,14 @@ void main() { await first.setOnline(true); await waitForBook(second, book.id, title: 'Offline edit'); expect((await second.getById(book.id))?.title, 'Offline edit'); + await verifyFolderImport(first, second, firstAuth, root); await second.delete(book.id); await waitForBook(first, book.id, present: false); } finally { await Future.wait([first.close(), second.close(), other.close()]); await root.delete(recursive: true); + await firstAuth.deleteAccount(); + await otherAuth.deleteAccount(); } }, skip: !runIntegration); } diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig index ec97fc6f..4508409b 100644 --- a/app/ios/Flutter/Debug.xcconfig +++ b/app/ios/Flutter/Debug.xcconfig @@ -1,2 +1,9 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" + +// Flutter 3.41 invokes lipo with multiple architectures, which Xcode 27 rejects. +// Debug simulators run on the host architecture. Device and release builds keep their defaults. +PAPYRUS_SIMULATOR_ARCH_arm64 = arm64 +PAPYRUS_SIMULATOR_ARCH_arm64e = arm64 +PAPYRUS_SIMULATOR_ARCH_x86_64 = x86_64 +ARCHS[sdk=iphonesimulator*] = $(PAPYRUS_SIMULATOR_ARCH_$(NATIVE_ARCH_ACTUAL)) diff --git a/app/ios/Podfile b/app/ios/Podfile index d502ffb6..c484d597 100644 --- a/app/ios/Podfile +++ b/app/ios/Podfile @@ -39,5 +39,13 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + + target.build_configurations.each do |config| + minimum = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + + if minimum.nil? || Gem::Version.new(minimum) < Gem::Version.new('15.0') + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + end + end end end diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 4205c0db..cf352068 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -37,6 +37,8 @@ PODS: - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS + - flutter_timezone (0.0.1): + - Flutter - flutter_web_auth_2 (5.0.0): - Flutter - integration_test (0.0.1): @@ -44,6 +46,8 @@ PODS: - mobile_scanner (7.0.0): - Flutter - FlutterMacOS + - package_info_plus (0.4.5): + - Flutter - pdfium_flutter (0.1.5): - Flutter - FlutterMacOS @@ -70,9 +74,11 @@ DEPENDENCIES: - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - pdfium_flutter (from `.symlinks/plugins/pdfium_flutter/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) @@ -94,12 +100,16 @@ EXTERNAL SOURCES: :path: Flutter flutter_secure_storage_darwin: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + flutter_timezone: + :path: ".symlinks/plugins/flutter_timezone/ios" flutter_web_auth_2: :path: ".symlinks/plugins/flutter_web_auth_2/ios" integration_test: :path: ".symlinks/plugins/integration_test/ios" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/darwin" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" pdfium_flutter: :path: ".symlinks/plugins/pdfium_flutter/darwin" shared_preferences_foundation: @@ -115,11 +125,13 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 flutter_web_auth_2: 646fc9df97a01c59e5eea99b237da2c6360f8439 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 pdfium_flutter: e5a6e881af0b182d5b0465ebad23a8a20caa28d4 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb @@ -129,6 +141,6 @@ SPEC CHECKSUMS: UnrarKit: 62f535c7a34ec52d2514b9b148f33dcfa9a9dc39 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b -PODFILE CHECKSUM: 3bc1835650444b3631a015d054c4006b53a55fb7 +PODFILE CHECKSUM: 55f34781c0ed3a6f45e28cd590bf911bdb7aff5d COCOAPODS: 1.17.0 diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift index c30b367e..79a628ff 100644 --- a/app/ios/Runner/AppDelegate.swift +++ b/app/ios/Runner/AppDelegate.swift @@ -1,8 +1,11 @@ import Flutter import UIKit +import UniformTypeIdentifiers @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + private var folderAccess: PapyrusFolderAccess? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -12,5 +15,210 @@ import UIKit func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "PapyrusFolders") { + folderAccess = PapyrusFolderAccess(messenger: registrar.messenger(), presenter: window?.rootViewController) + } } } + +// Read-only access to user-selected directories. Bookmarks never leave this device. +private final class PapyrusFolderAccess: NSObject { + private let queue = DispatchQueue(label: "papyrus.folder-access") + private let defaults = UserDefaults.standard + private var cursors: [String: FolderCursor] = [:] + private var pickerResult: FlutterResult? + private weak var presenter: UIViewController? + + init(messenger: FlutterBinaryMessenger, presenter: UIViewController?) { + super.init() + self.presenter = presenter + let channel = FlutterMethodChannel(name: "papyrus/folders", binaryMessenger: messenger) + channel.setMethodCallHandler { [self] call, result in + if call.method == "pick" { + pick(result) + return + } + let arguments = call.arguments as? [String: Any] ?? [:] + queue.async { [self] in + do { + let value = try perform(call.method, arguments) + DispatchQueue.main.async { result(value) } + } catch { + let failure = error as NSError + let access = failure.domain == "PapyrusFolderAccess" || failure.code == NSFileReadNoPermissionError + DispatchQueue.main.async { + result(FlutterError(code: access ? "access" : "unavailable", + message: access ? "Reconnect this folder to allow access." : "Source file unavailable offline or folder disconnected. \(error.localizedDescription)", details: nil)) + } + } + } + } + } + + private func bookmarkKey(_ key: String) -> String { "papyrus.folder-grant.\(key)" } + + private func resolve(_ key: String) throws -> URL { + guard let data = defaults.data(forKey: bookmarkKey(key)) else { + throw NSError(domain: "PapyrusFolderAccess", code: 1) + } + var stale = false + let url = try URL(resolvingBookmarkData: data, options: [.withoutUI], bookmarkDataIsStale: &stale) + guard !stale else { throw NSError(domain: "PapyrusFolderAccess", code: 1) } + return url + } + + private func access(_ key: String, _ operation: (URL) throws -> T) throws -> T { + let url = try resolve(key) + guard url.startAccessingSecurityScopedResource() else { throw NSError(domain: "PapyrusFolderAccess", code: 1) } + defer { url.stopAccessingSecurityScopedResource() } + return try operation(url) + } + + private func source(_ root: URL, _ key: String) throws -> URL { + let url = root.appendingPathComponent(key).standardizedFileURL + let resolved = url.resolvingSymlinksInPath() + let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey]) + guard resolved.path.hasPrefix(directoryPrefix(root.resolvingSymlinksInPath())), + values.isSymbolicLink != true, values.isRegularFile == true else { + throw NSError(domain: "PapyrusFolderAccess", code: 1) + } + return url + } + + private func directoryPrefix(_ root: URL) -> String { + root.path.hasSuffix("/") ? root.path : root.path + "/" + } + + private func metadata(_ url: URL, root: URL) throws -> [String: Any] { + let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + return ["key": String(url.path.dropFirst(directoryPrefix(root).count)), "name": url.lastPathComponent, + "size": values.fileSize ?? -1, "modified": Int64((values.contentModificationDate?.timeIntervalSince1970 ?? 0) * 1000)] + } + + private func perform(_ method: String, _ arguments: [String: Any]) throws -> Any? { + let key = arguments["grant"] as? String ?? "" + switch method { + case "restore": return try access(key) { FileManager.default.fileExists(atPath: $0.path) } + case "start": + let root = try resolve(key) + guard root.startAccessingSecurityScopedResource() else { throw NSError(domain: "PapyrusFolderAccess", code: 1) } + let failures = FolderFailures() + let prefix = directoryPrefix(root) + guard FileManager.default.fileExists(atPath: root.path), + let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey, .isRegularFileKey], + options: [.skipsHiddenFiles], errorHandler: { url, error in + failures.entries.append(["key": String(url.path.dropFirst(prefix.count)), + "name": url.lastPathComponent, "size": 0, "modified": 0, "error": error.localizedDescription]) + return true + }) else { + root.stopAccessingSecurityScopedResource() + throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError) + } + let cursor = UUID().uuidString + cursors[cursor] = FolderCursor(root: root, enumerator: enumerator, + recursive: arguments["recursive"] as? Bool ?? true, failures: failures) + return cursor + case "next": + guard let cursor = cursors[arguments["cursor"] as? String ?? ""] else { return [] } + var batch: [[String: Any]] = [] + while batch.count < 128, let url = cursor.enumerator.nextObject() as? URL { + do { + let values = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey, .isRegularFileKey]) + let name = url.lastPathComponent + if name.hasPrefix(".") || name.hasPrefix("~") || values.isSymbolicLink == true || + name.range(of: "\\.(tmp|part|partial|crdownload|download)$", options: [.regularExpression, .caseInsensitive]) != nil { + cursor.enumerator.skipDescendants() + continue + } + if values.isDirectory == true { + if !cursor.recursive { cursor.enumerator.skipDescendants() } + } else if values.isRegularFile == true { + batch.append(try metadata(url, root: cursor.root)) + } + } catch { + batch.append(["key": String(url.path.dropFirst(directoryPrefix(cursor.root).count)), "name": url.lastPathComponent, + "size": 0, "modified": 0, "error": error.localizedDescription]) + } + } + batch.append(contentsOf: cursor.failures.entries) + cursor.failures.entries.removeAll() + return batch + case "close": cursors.removeValue(forKey: arguments["cursor"] as? String ?? "")?.close(); return nil + case "stat": return try access(key) { root in try metadata(source(root, arguments["key"] as? String ?? ""), root: root) } + case "read": + return try access(key) { root in + let url = try source(root, arguments["key"] as? String ?? "") + var coordinationError: NSError? + var readResult: Result? + NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { coordinated in + readResult = Result { try Data(contentsOf: coordinated) } + } + if let error = coordinationError { throw error } + guard let readResult = readResult else { throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadUnknownError) } + return FlutterStandardTypedData(bytes: try readResult.get()) + } + case "release": defaults.removeObject(forKey: bookmarkKey(key)); return nil + default: throw NSError(domain: "PapyrusFolderAccess", code: 2) + } + } + + private func selected(_ url: URL?, result: @escaping FlutterResult) { + guard let url = url else { result(nil); return } + do { + guard url.startAccessingSecurityScopedResource() else { throw NSError(domain: "PapyrusFolderAccess", code: 1) } + defer { url.stopAccessingSecurityScopedResource() } + let data = try url.bookmarkData(options: [.minimalBookmark], includingResourceValuesForKeys: nil, relativeTo: nil) + let key = UUID().uuidString + defaults.set(data, forKey: bookmarkKey(key)) + result(["key": key, "identity": url.standardizedFileURL.path, "name": url.lastPathComponent, + "location": url.path, "persistent": true]) + } catch { + result(FlutterError(code: "access", message: "Could not save access to this folder.", details: nil)) + } + } + + private func pick(_ result: @escaping FlutterResult) { + let activePresenter = presenter ?? UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene }.flatMap { $0.windows } + .first { $0.isKeyWindow }?.rootViewController + guard pickerResult == nil, let presenter = activePresenter else { + result(FlutterError(code: "busy", message: "A folder picker is already open.", details: nil)); return + } + pickerResult = result + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder]) + picker.delegate = self + presenter.present(picker, animated: true) + } + + private final class FolderFailures { + var entries: [[String: Any]] = [] + } + + private final class FolderCursor { + let root: URL + let enumerator: FileManager.DirectoryEnumerator + let recursive: Bool + let failures: FolderFailures + private var closed = false + init(root: URL, enumerator: FileManager.DirectoryEnumerator, recursive: Bool, failures: FolderFailures) { + self.root = root; self.enumerator = enumerator; self.recursive = recursive + self.failures = failures + } + func close() { + if !closed { root.stopAccessingSecurityScopedResource(); closed = true } + } + deinit { close() } + } +} + +extension PapyrusFolderAccess: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let result = pickerResult else { return } + pickerResult = nil + selected(urls.first, result: result) + } + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + pickerResult?(nil) + pickerResult = nil + } +} diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index dbbb98af..8611fda3 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -1,3 +1,4 @@ +import 'package:papyrus/pages/watched_folders_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -135,7 +136,15 @@ class AppRouter { GoRoute( name: 'BOOKS', path: 'books', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LibraryPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const LibraryPage(), + ), + ), + GoRoute( + path: 'folders', + pageBuilder: (context, state) => + NoTransitionPage(key: state.pageKey, child: const WatchedFoldersPage()), ), GoRoute( name: 'CATALOGS', diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index be536cfd..aad57c0f 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -58,6 +58,8 @@ class DataStore extends ChangeNotifier { final List _bookShelfRelations = []; final List _bookTagRelations = []; + Future Function(Book book)? beforeBookDelete; + bool _isLoaded = false; Object? _libraryLoadError; bool _disposed = false; @@ -342,12 +344,22 @@ class DataStore extends ChangeNotifier { } void deleteBook(String id) { - final repository = requireBookRepository(); - _reportWrite(repository.delete(id)); + requireBookRepository(); + _reportWrite(deleteBookAndWait(id)); } Future deleteBookAndWait(String id) async { final repository = requireBookRepository(); + final book = await repository.getById(id); + + if (!isBookRepositoryCurrent(repository)) { + throw StateError('The selected library changed.'); + } + + if (book != null) { + await beforeBookDelete?.call(book); + } + await deleteBookFromRepositoryAndWait(repository, id); } diff --git a/app/lib/folders/directory_access.dart b/app/lib/folders/directory_access.dart new file mode 100644 index 00000000..ebdc1133 --- /dev/null +++ b/app/lib/folders/directory_access.dart @@ -0,0 +1,78 @@ +import 'dart:typed_data'; + +class FolderAccessException implements Exception { + const FolderAccessException(this.code, this.message); + final String code; + final String message; + + @override + String toString() => message; +} + +class FolderGrant { + const FolderGrant({ + required this.key, + required this.identity, + required this.name, + required this.location, + this.persistent = true, + }); + final String key; + final String identity; + final String name; + final String location; + final bool persistent; + + Map toJson() => { + 'key': key, + 'identity': identity, + 'name': name, + 'location': location, + 'persistent': persistent, + }; + + factory FolderGrant.fromJson(Map json) => FolderGrant( + key: json['key'] as String, + identity: json['identity'] as String, + name: json['name'] as String, + location: json['location'] as String, + persistent: json['persistent'] as bool? ?? true, + ); +} + +class FolderFile { + const FolderFile({required this.key, required this.name, required this.size, required this.modified, this.error}); + final String key; + final String name; + + /// Providers may omit the size. A negative value means it is unknown. + final int size; + final int modified; + final String? error; + + String get stamp => '$size:$modified'; + + factory FolderFile.fromJson(Map json) => FolderFile( + key: json['key'] as String, + name: json['name'] as String, + size: (json['size'] as num?)?.toInt() ?? -1, + modified: (json['modified'] as num?)?.toInt() ?? 0, + error: json['error'] as String?, + ); +} + +abstract interface class DirectoryAccess { + bool get supportsWatching; + Future pick(); + Future reconnect(FolderGrant grant); + Stream enumerate(FolderGrant grant, {required bool recursive}); + Future stat(FolderGrant grant, FolderFile file); + Future read(FolderGrant grant, FolderFile file); + Stream changes(FolderGrant grant); + Future release(FolderGrant grant); +} + +bool includeFolderEntry(String name) => + !name.startsWith('.') && + !name.startsWith('~') && + !RegExp(r'\.(tmp|part|partial|crdownload|download)$', caseSensitive: false).hasMatch(name); diff --git a/app/lib/folders/directory_access_factory.dart b/app/lib/folders/directory_access_factory.dart new file mode 100644 index 00000000..335b79cc --- /dev/null +++ b/app/lib/folders/directory_access_factory.dart @@ -0,0 +1 @@ +export 'directory_access_native.dart' if (dart.library.js_interop) 'directory_access_web.dart'; diff --git a/app/lib/folders/directory_access_native.dart b/app/lib/folders/directory_access_native.dart new file mode 100644 index 00000000..aafe8b88 --- /dev/null +++ b/app/lib/folders/directory_access_native.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:papyrus/folders/directory_access.dart'; +import 'package:path/path.dart' as path; + +DirectoryAccess createDirectoryAccess() => NativeDirectoryAccess(); + +class NativeDirectoryAccess implements DirectoryAccess { + NativeDirectoryAccess({TargetPlatform? platform}) : _platform = platform ?? defaultTargetPlatform; + + final TargetPlatform _platform; + static const _channel = MethodChannel('papyrus/folders'); + bool get _usesGrants => const [TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS].contains(_platform); + + @override + bool get supportsWatching => true; + + Future _invoke(String method, [Map? arguments]) async { + try { + final operation = _channel.invokeMethod(method, arguments); + return await (method == 'pick' ? operation : operation.timeout(const Duration(seconds: 30))); + } on TimeoutException { + throw const FolderAccessException('unavailable', 'Source file unavailable offline or still being downloaded.'); + } on PlatformException catch (error) { + throw FolderAccessException(error.code, error.message ?? 'Could not access this folder.'); + } on FileSystemException catch (error) { + throw FolderAccessException('unavailable', error.message); + } + } + + @override + Future pick() async { + if (_usesGrants) { + final selected = await _invoke('pick'); + return selected == null ? null : FolderGrant.fromJson(Map.from(selected)); + } + + final selected = await FilePicker.platform.getDirectoryPath(dialogTitle: 'Choose a book folder'); + + if (selected == null) { + return null; + } + + final location = await Directory(selected).resolveSymbolicLinks(); + return FolderGrant( + key: location, + identity: _platform == TargetPlatform.windows ? location.toLowerCase() : location, + name: path.basename(location), + location: location, + ); + } + + @override + Future reconnect(FolderGrant grant) async { + if (_usesGrants) { + return await _invoke('restore', {'grant': grant.key}) ?? false; + } + + return Directory(grant.key).exists(); + } + + @override + Stream enumerate(FolderGrant grant, {required bool recursive}) async* { + if (_usesGrants) { + final cursor = await _invoke('start', {'grant': grant.key, 'recursive': recursive}); + + try { + while (true) { + final batch = await _invoke('next', {'cursor': cursor}) ?? []; + + if (batch.isEmpty) { + break; + } + + for (final entry in batch) { + yield FolderFile.fromJson(Map.from(entry as Map)); + } + } + } finally { + await _invoke('close', {'cursor': cursor}); + } + + return; + } + + Stream walk(Directory directory) async* { + await for (final entry in directory.list(followLinks: false)) { + if (!includeFolderEntry(path.basename(entry.path))) { + continue; + } + + if (entry is Directory && recursive) { + try { + await for (final file in walk(entry)) { + yield file; + } + } on FileSystemException { + yield FolderFile( + key: path.relative(entry.path, from: grant.key), + name: path.basename(entry.path), + size: 0, + modified: 0, + error: 'Could not read this subfolder.', + ); + } + } else if (entry is File) { + final relative = path.relative(entry.path, from: grant.key); + + try { + final info = await entry.stat(); + yield FolderFile( + key: relative, + name: path.basename(entry.path), + size: info.size, + modified: info.modified.millisecondsSinceEpoch, + ); + } on FileSystemException { + yield FolderFile( + key: relative, + name: path.basename(entry.path), + size: 0, + modified: 0, + error: 'Could not read this file.', + ); + } + } + } + } + + try { + await for (final file in walk(Directory(grant.key))) { + yield file; + } + } on FileSystemException catch (error) { + final denied = _platform == TargetPlatform.windows + ? error.osError?.errorCode == 5 + : const [1, 13].contains(error.osError?.errorCode); + throw FolderAccessException( + denied ? 'access' : 'unavailable', + denied + ? 'Allow Papyrus to read this folder, then reconnect it.' + : 'The folder is unavailable. Reconnect the drive or folder.', + ); + } + } + + Future _file(FolderGrant grant, FolderFile file) async { + final location = path.normalize(path.join(grant.key, file.key)); + + if (!path.isWithin(grant.key, location) || + await FileSystemEntity.type(location, followLinks: false) != FileSystemEntityType.file) { + throw const FolderAccessException('unavailable', 'The source file moved or is no longer a regular file.'); + } + + final resolved = await File(location).resolveSymbolicLinks(); + + if (!path.isWithin(grant.key, resolved)) { + throw const FolderAccessException('access', 'The file is outside the selected folder.'); + } + + return File(resolved); + } + + @override + Future stat(FolderGrant grant, FolderFile file) async { + if (_usesGrants) { + final result = await _invoke('stat', {'grant': grant.key, 'key': file.key}); + return FolderFile.fromJson(Map.from(result!)); + } + + final info = await (await _file(grant, file)).stat(); + return FolderFile(key: file.key, name: file.name, size: info.size, modified: info.modified.millisecondsSinceEpoch); + } + + @override + Future read(FolderGrant grant, FolderFile file) async { + if (_usesGrants) { + return (await _invoke('read', {'grant': grant.key, 'key': file.key}))!; + } + + return (await _file(grant, file)).readAsBytes(); + } + + @override + Stream changes(FolderGrant grant) async* { + if (_platform == TargetPlatform.macOS) { + final location = await _invoke('watch', {'grant': grant.key}); + + try { + yield* Directory(location!).watch(recursive: true).map((_) {}); + } finally { + await _invoke('unwatch', {'grant': grant.key}); + } + } else if (!_usesGrants) { + yield* Directory(grant.key).watch(recursive: _platform != TargetPlatform.linux).map((_) {}); + } + } + + @override + Future release(FolderGrant grant) async { + if (_usesGrants) { + await _invoke('release', {'grant': grant.key}); + } + } +} diff --git a/app/lib/folders/directory_access_web.dart b/app/lib/folders/directory_access_web.dart new file mode 100644 index 00000000..d8fb622c --- /dev/null +++ b/app/lib/folders/directory_access_web.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:papyrus/folders/directory_access.dart'; + +@JS('papyrusFolders.call') +external JSPromise _call(JSString method, JSString arguments); + +@JS('papyrusFolders.canWatch') +external JSBoolean get _canWatch; + +DirectoryAccess createDirectoryAccess() => WebDirectoryAccess(); + +class WebDirectoryAccess implements DirectoryAccess { + @override + bool get supportsWatching => _canWatch.toDart; + + Future _invoke(String method, [Map arguments = const {}]) async { + final response = await _call(method.toJS, jsonEncode(arguments).toJS).toDart; + + if (response.isA()) { + return (response as JSArrayBuffer).toDart.asUint8List(); + } + + final result = jsonDecode((response as JSString).toDart) as Map; + + if (result['error'] != null) { + throw FolderAccessException(result['code'] as String, result['error'] as String); + } + + return result['value']; + } + + @override + Future pick() async { + final result = await _invoke('pick'); + return result == null ? null : FolderGrant.fromJson(result as Map); + } + + @override + Future reconnect(FolderGrant grant) async => await _invoke('restore', {'grant': grant.key}) as bool; + + @override + Stream enumerate(FolderGrant grant, {required bool recursive}) async* { + final cursor = await _invoke('start', {'grant': grant.key, 'recursive': recursive}); + + try { + while (true) { + final batch = await _invoke('next', {'cursor': cursor}) as List; + + if (batch.isEmpty) { + break; + } + + for (final entry in batch) { + yield FolderFile.fromJson(entry as Map); + } + } + } finally { + await _invoke('close', {'cursor': cursor}); + } + } + + @override + Future stat(FolderGrant grant, FolderFile file) async => + FolderFile.fromJson(await _invoke('stat', {'grant': grant.key, 'key': file.key}) as Map); + + @override + Future read(FolderGrant grant, FolderFile file) async => + await _invoke('read', {'grant': grant.key, 'key': file.key}) as Uint8List; + + @override + Stream changes(FolderGrant grant) => const Stream.empty(); + + @override + Future release(FolderGrant grant) async => _invoke('release', {'grant': grant.key}); +} diff --git a/app/lib/folders/folder_fingerprint.dart b/app/lib/folders/folder_fingerprint.dart new file mode 100644 index 00000000..e4fdce10 --- /dev/null +++ b/app/lib/folders/folder_fingerprint.dart @@ -0,0 +1 @@ +export 'folder_fingerprint_native.dart' if (dart.library.js_interop) 'folder_fingerprint_web.dart'; diff --git a/app/lib/folders/folder_fingerprint_native.dart b/app/lib/folders/folder_fingerprint_native.dart new file mode 100644 index 00000000..c22e6927 --- /dev/null +++ b/app/lib/folders/folder_fingerprint_native.dart @@ -0,0 +1,5 @@ +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +Future fingerprint(Uint8List bytes) => compute(_hash, bytes); +String _hash(Uint8List bytes) => sha256.convert(bytes).toString(); diff --git a/app/lib/folders/folder_fingerprint_web.dart b/app/lib/folders/folder_fingerprint_web.dart new file mode 100644 index 00000000..14153f80 --- /dev/null +++ b/app/lib/folders/folder_fingerprint_web.dart @@ -0,0 +1,9 @@ +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +Future fingerprint(Uint8List bytes) async { + final digest = await web.window.crypto.subtle.digest('SHA-256'.toJS, bytes.toJS).toDart; + return (digest as JSArrayBuffer).toDart.asUint8List().map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); +} diff --git a/app/lib/folders/folder_import_target.dart b/app/lib/folders/folder_import_target.dart new file mode 100644 index 00000000..cbd11494 --- /dev/null +++ b/app/lib/folders/folder_import_target.dart @@ -0,0 +1,156 @@ +import 'dart:typed_data'; + +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/models/shelf.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/book_shelf_relation.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/services/book_import_session.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; + +/// A captured library destination, also used to recover a partially committed job. +abstract interface class FolderImportTarget { + bool get isCurrent; + List get books; + Future getBook(String id); + Future readStored(String id); + Future process(Uint8List bytes, String filename, String id); + Future finish(BookImportResult result, String filename, String? shelfId); + Future ensureCopy(Book book, Uint8List bytes, String extension, String? shelfId); + Future deleteStaged(String id); +} + +class LibraryFolderImportTarget implements FolderImportTarget { + LibraryFolderImportTarget({ + required this.store, + required this.queue, + required this.importer, + required AuthProvider auth, + required PapyrusPowerSyncService database, + }) : session = BookImportSession.capture( + dataStore: store, + queue: queue, + importService: importer, + authProvider: auth, + powerSyncService: database, + retainCommittedBook: true, + ), + repository = store.requireBookRepository(), + shelves = store.libraryRepository?.shelves, + memberships = store.libraryRepository?.bookShelves, + scope = queue.activeScope; + + final DataStore store; + final MediaUploadQueue queue; + final BookImportService importer; + final BookImportSession session; + final BookRepository repository; + final EntityRepository? shelves; + final EntityRepository? memberships; + final MediaStorageScope? scope; + + @override + bool get isCurrent => session.isCurrent(); + + @override + List get books => store.books; + + void _check() { + if (!isCurrent) { + throw StateError('The selected library changed.'); + } + } + + @override + Future getBook(String id) async => await repository.getById(id); + + @override + Future readStored(String id) => importer.getBookFile(id); + + @override + Future process(Uint8List bytes, String filename, String id) => + importer.importBookWithId(bytes, filename, id); + + @override + Future finish(BookImportResult result, String filename, String? shelfId) async { + _check(); + final existing = await getBook(result.bookId); + _check(); + + if (existing == null) { + await session.commit(result, filename); + } else if (scope != null) { + // Metadata may have committed before the media queue persisted. Keep edits + // and reading positions intact while repairing only the missing outbox work. + if (existing.fileMediaId == null && !queue.pendingTasks.any((task) => task.id == '${existing.id}:book_file')) { + await queue.enqueueBookFile( + book: existing, + filename: filename, + contentType: _contentType(result.fileExtension), + ); + } + + _check(); + + if (existing.coverMediaId == null && + result.coverImage != null && + !queue.pendingTasks.any((task) => task.id == '${existing.id}:cover_image')) { + await importer.storePendingCoverFile(scope!, existing.id, result.coverImage!); + _check(); + await queue.enqueueCover( + book: existing, + filename: '${existing.id}-cover', + contentType: result.coverMimeType ?? 'image/jpeg', + ); + } + } + + await _shelf(result.bookId, shelfId); + } + + Future _shelf(String bookId, String? shelfId) async { + _check(); + + if (shelfId == null || memberships == null || await shelves?.getById(shelfId) == null) { + return; + } + + _check(); + final relation = BookShelfRelation(bookId: bookId, shelfId: shelfId, addedAt: DateTime.now().toUtc()); + + if (await memberships!.getById('$bookId:$shelfId') == null) { + _check(); + await memberships!.upsert(relation); + } + } + + @override + Future ensureCopy(Book book, Uint8List bytes, String extension, String? shelfId) async { + _check(); + + if (!await importer.hasBookFile(book.id)) { + _check(); + await importer.storeBookFile(book.id, extension, bytes); + } + + await _shelf(book.id, shelfId); + } + + @override + Future deleteStaged(String id) => importer.deleteBookFile(id); +} + +String _contentType(String extension) => switch (extension) { + 'epub' => 'application/epub+zip', + 'pdf' => 'application/pdf', + 'txt' => 'text/plain', + 'cbz' => 'application/vnd.comicbook+zip', + 'cbr' => 'application/vnd.comicbook-rar', + _ => 'application/octet-stream', +}; diff --git a/app/lib/folders/folder_models.dart b/app/lib/folders/folder_models.dart new file mode 100644 index 00000000..aac685d8 --- /dev/null +++ b/app/lib/folders/folder_models.dart @@ -0,0 +1,110 @@ +import 'package:papyrus/folders/directory_access.dart'; + +enum FolderStatus { scanning, importing, upToDate, paused, accessNeeded, unavailable, attention } + +class WatchedFolder { + WatchedFolder({ + required this.id, + required this.grant, + this.customName, + this.recursive = true, + this.shelfId, + this.enabled = true, + this.status = FolderStatus.upToDate, + this.lastScan, + this.added = 0, + this.duplicates = 0, + this.unsupported = 0, + this.error, + }); + + final String id; + FolderGrant grant; + String? customName; + + bool recursive; + String? shelfId; + bool enabled; + FolderStatus status; + DateTime? lastScan; + int added; + int duplicates; + int unsupported; + String? error; + FolderImportProgress? progress; + + String get name => customName ?? grant.name; + + void restore(WatchedFolder saved) { + grant = saved.grant; + customName = saved.customName; + recursive = saved.recursive; + shelfId = saved.shelfId; + enabled = saved.enabled; + status = saved.status; + lastScan = saved.lastScan; + added = saved.added; + duplicates = saved.duplicates; + unsupported = saved.unsupported; + error = saved.error; + } + + String get statusLabel => switch (status) { + FolderStatus.scanning => 'Scanning', + FolderStatus.importing => 'Importing', + FolderStatus.upToDate => 'Up to date', + FolderStatus.paused => 'Paused', + FolderStatus.accessNeeded => 'Access needed', + FolderStatus.unavailable => 'Folder unavailable', + FolderStatus.attention => 'Needs attention', + }; + + Map toJson() => { + 'id': id, + 'grant': grant.toJson(), + 'customName': customName, + 'recursive': recursive, + 'shelf': shelfId, + 'enabled': enabled, + 'status': status.name, + 'lastScan': lastScan?.toIso8601String(), + 'added': added, + 'duplicates': duplicates, + 'unsupported': unsupported, + 'error': error, + }; + + factory WatchedFolder.fromJson(Map json) => WatchedFolder( + id: json['id'] as String, + grant: FolderGrant.fromJson(Map.from(json['grant'] as Map)), + customName: json['customName'] as String?, + recursive: json['recursive'] as bool, + shelfId: json['shelf'] as String?, + enabled: json['enabled'] as bool, + status: FolderStatus.values.byName(json['status'] as String), + lastScan: DateTime.tryParse(json['lastScan'] as String? ?? ''), + added: json['added'] as int? ?? 0, + duplicates: json['duplicates'] as int? ?? 0, + unsupported: json['unsupported'] as int? ?? 0, + error: json['error'] as String?, + ); +} + +class FolderPreview { + int supported = 0; + int unsupported = 0; + int bytes = 0; + int unknownSizes = 0; + int inaccessible = 0; +} + +class FolderImportProgress { + FolderImportProgress({required this.total}); + + final int total; + int processed = 0; + + int get remaining => total - processed; + double get fraction => total == 0 ? 0 : processed / total; + String get label => '$processed/$total processed ($remaining left)'; +} diff --git a/app/lib/folders/folder_repository.dart b/app/lib/folders/folder_repository.dart new file mode 100644 index 00000000..1edef313 --- /dev/null +++ b/app/lib/folders/folder_repository.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:papyrus/folders/directory_access.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:powersync/powersync.dart'; + +/// This database is never connected to a server or cleared with the sync cache. +class FolderRepository { + FolderRepository(this.database); + final PowerSyncDatabase database; + static const leaseDuration = Duration(minutes: 2); + + static const schema = Schema([ + Table.localOnly('folders', [Column.text('json')]), + Table.localOnly('files', [Column.text('folder_id'), Column.text('json')]), + Table.localOnly('jobs', [Column.text('folder_id'), Column.text('json')]), + Table.localOnly('exclusions', [Column.text('book_id')]), + Table.localOnly('lease', [Column.text('owner'), Column.integer('expires')]), + Table.localOnly('retired_grants', [Column.text('json')]), + Table.localOnly('folder_books', [Column.text('folder_id'), Column.text('book_id')]), + ]); + + static Future open(String scope, {String? databasePath}) async { + final name = 'papyrus-folders-${sha256.convert(utf8.encode(scope))}.db'; + final location = databasePath ?? (kIsWeb ? name : path.join((await getApplicationSupportDirectory()).path, name)); + final database = PowerSyncDatabase(schema: schema, path: location); + await database.initialize(); + await database.execute(''' + INSERT OR REPLACE INTO folder_books (id, folder_id, book_id) + SELECT folder_id || ':' || json_extract(json, '\$.bookId'), folder_id, json_extract(json, '\$.bookId') + FROM files WHERE json_extract(json, '\$.bookId') IS NOT NULL + '''); + return FolderRepository(database); + } + + Future>> bookIds() async { + final result = >{}; + + for (final row in await database.getAll('SELECT folder_id, book_id FROM folder_books')) { + (result[row['folder_id'] as String] ??= {}).add(row['book_id'] as String); + } + + return result; + } + + Future> folders() async => [ + for (final row in await database.getAll('SELECT json FROM folders ORDER BY id')) + WatchedFolder.fromJson(jsonDecode(row['json'] as String) as Map), + ]; + + Future saveFolder(WatchedFolder folder) => save('folders', folder.id, folder.toJson()); + + Future replaceGrant(WatchedFolder folder, FolderGrant previous) => + database.writeTransaction((transaction) async { + await transaction.execute('INSERT OR REPLACE INTO folders (id, json) VALUES (?, ?)', [ + folder.id, + jsonEncode(folder.toJson()), + ]); + + if (previous.key != folder.grant.key) { + await transaction.execute('INSERT OR REPLACE INTO retired_grants (id, json) VALUES (?, ?)', [ + previous.key, + jsonEncode(previous.toJson()), + ]); + } + }); + + Future> retiredGrants() async => [ + for (final row in await database.getAll('SELECT json FROM retired_grants')) + FolderGrant.fromJson(jsonDecode(row['json'] as String) as Map), + ]; + + Future forgetGrant(String key) async => database.execute('DELETE FROM retired_grants WHERE id = ?', [key]); + + Future?> get(String table, String id) async { + _validateTable(table); + final row = await database.getOptional('SELECT json FROM $table WHERE id = ?', [id]); + return row == null ? null : jsonDecode(row['json'] as String) as Map; + } + + Future save(String table, String id, Map value, {String? folderId}) async { + _validateTable(table); + + if (table == 'folders') { + await database.execute('INSERT OR REPLACE INTO folders (id, json) VALUES (?, ?)', [id, jsonEncode(value)]); + } else { + await database.writeTransaction((transaction) async { + await transaction.execute('INSERT OR REPLACE INTO $table (id, folder_id, json) VALUES (?, ?, ?)', [ + id, + folderId, + jsonEncode(value), + ]); + + final bookId = value['bookId']; + + if (table == 'files' && bookId != null) { + await transaction.execute('INSERT OR REPLACE INTO folder_books (id, folder_id, book_id) VALUES (?, ?, ?)', [ + '$folderId:$bookId', + folderId, + bookId, + ]); + } + }); + } + } + + Future>> records(String table, {String? folderId}) async { + _validateTable(table); + final rows = await database.getAll( + 'SELECT id, json FROM $table${folderId == null ? '' : ' WHERE folder_id = ?'}', + folderId == null ? [] : [folderId], + ); + return [ + for (final row in rows) {...jsonDecode(row['json'] as String) as Map, 'recordId': row['id']}, + ]; + } + + Future delete(String table, String id) async { + _validateTable(table); + await database.execute('DELETE FROM $table WHERE id = ?', [id]); + } + + Future exclude(String hash, String bookId) async { + await database.execute('INSERT OR REPLACE INTO exclusions (id, book_id) VALUES (?, ?)', [hash, bookId]); + } + + Future isExcluded(String hash) async => + await database.getOptional('SELECT id FROM exclusions WHERE id = ?', [hash]) != null; + + Future allow(String hash) async => database.execute('DELETE FROM exclusions WHERE id = ?', [hash]); + + Future acquire(String owner, DateTime now) => database.writeTransaction((transaction) async { + final lease = await transaction.getOptional('SELECT owner, expires FROM lease WHERE id = ?', ['scan']); + + if (lease != null && lease['owner'] != owner && (lease['expires'] as int) > now.millisecondsSinceEpoch) { + return false; + } + + await transaction.execute('INSERT OR REPLACE INTO lease (id, owner, expires) VALUES (?, ?, ?)', [ + 'scan', + owner, + now.add(leaseDuration).millisecondsSinceEpoch, + ]); + return true; + }); + + Future release(String owner) async => + database.execute('DELETE FROM lease WHERE id = ? AND owner = ?', ['scan', owner]); + + Future removeFolder(String id) => database.writeTransaction((transaction) async { + final row = await transaction.getOptional('SELECT json FROM folders WHERE id = ?', [id]); + + if (row != null) { + final folder = WatchedFolder.fromJson(jsonDecode(row['json'] as String) as Map); + await transaction.execute('INSERT OR REPLACE INTO retired_grants (id, json) VALUES (?, ?)', [ + folder.grant.key, + jsonEncode(folder.grant.toJson()), + ]); + } + + for (final table in ['files', 'jobs', 'folder_books']) { + await transaction.execute('DELETE FROM $table WHERE folder_id = ?', [id]); + } + + await transaction.execute('DELETE FROM folders WHERE id = ?', [id]); + }); + + Future clear() => database.writeTransaction((transaction) async { + for (final table in ['folders', 'files', 'jobs', 'exclusions', 'folder_books']) { + await transaction.execute('DELETE FROM $table'); + } + }); + + Future close() => database.close(); + + void _validateTable(String table) { + if (!const ['folders', 'files', 'jobs'].contains(table)) { + throw ArgumentError.value(table, 'table'); + } + } +} diff --git a/app/lib/folders/watched_folders.dart b/app/lib/folders/watched_folders.dart new file mode 100644 index 00000000..b272d6ed --- /dev/null +++ b/app/lib/folders/watched_folders.dart @@ -0,0 +1,1044 @@ +import 'dart:async'; + +import 'package:papyrus/folders/folder_fingerprint.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:papyrus/folders/directory_access.dart'; +import 'package:papyrus/folders/folder_import_target.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/folders/folder_repository.dart'; +import 'package:papyrus/widgets/add_book/book_import_controller.dart'; +import 'package:uuid/uuid.dart'; + +class _ScanCancelled implements Exception {} + +enum _FileResult { processed, deferred } + +class _FolderWork { + _FolderWork(this.recordId, {this.job}); + + final String recordId; + final Map? job; + FolderFile? file; +} + +/// Owns scheduling and durable work independently of the Library page lifetime. +class WatchedFolders extends ChangeNotifier with WidgetsBindingObserver { + WatchedFolders({ + required this.access, + required this.captureTarget, + required this.libraryReady, + this.deleteStagedFile, + Future Function(String)? openRepository, + DateTime Function()? now, + this.stabilityDelay = const Duration(seconds: 2), + this.pollInterval = const Duration(seconds: 60), + }) : _openRepository = openRepository ?? FolderRepository.open, + _now = now ?? DateTime.now { + WidgetsBinding.instance.addObserver(this); + } + + final DirectoryAccess access; + final FolderImportTarget Function() captureTarget; + final bool Function() libraryReady; + final Future Function(String)? deleteStagedFile; + final Future Function(String) _openRepository; + final DateTime Function() _now; + final Duration stabilityDelay; + final Duration pollInterval; + final String _owner = const Uuid().v4(); + FolderRepository? _repository; + final List _folders = []; + final Map> _bookIds = {}; + final Map _knownHashes = {}; + final Map _pendingHashes = {}; + + Set bookIds(String folderId) => _bookIds[folderId] ?? const {}; + final Map> _events = {}; + final Map _debounces = {}; + final Set _requested = {}; + Future? _running; + Future _edits = Future.value(); + int _pendingEdits = 0; + bool _scanAll = false; + Future _scopeChange = Future.value(); + Timer? _timer; + int _generation = 0; + bool _disposed = false; + bool _foreground = true; + bool _suspended = false; + bool _hasLease = false; + DateTime? _leaseExpires; + Timer? _heartbeat; + Future? _renewing; + bool get _leaseValid => _hasLease && _leaseExpires != null && _now().isBefore(_leaseExpires!); + String? _scope; + String? error; + + List get folders => List.unmodifiable(_folders); + bool get ready => _repository != null && libraryReady() && !_suspended; + String? get scope => _scope; + bool get supportsWatching => access.supportsWatching; + bool get _desktop => + !kIsWeb && + const [TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux].contains(defaultTargetPlatform); + List get extensions => kIsWeb ? bookImportWebExtensions : bookImportNativeExtensions; + bool _supported(FolderFile file) => extensions.contains(file.name.split('.').last.toLowerCase()); + + Future activate(String? scope) { + if (_disposed) { + return Future.value(); + } + + if (_scope == scope && (_repository != null || scope == null)) { + return _scopeChange; + } + + _timer?.cancel(); + _timer = scope == null ? null : Timer.periodic(pollInterval, (_) => schedule()); + _scope = scope; + _generation++; + _requested.clear(); + final generation = _generation; + _scopeChange = _scopeChange.catchError((Object _) {}).then((_) async { + await _running; + await _edits.catchError((Object _) {}); + await _cancelEvents(); + await _repository?.close(); + _repository = null; + _folders.clear(); + _bookIds.clear(); + + if (generation != _generation || _disposed) { + return; + } + + if (scope != null) { + try { + final repository = await _openRepository(scope); + + if (generation != _generation || _disposed) { + await repository.close(); + return; + } + + _repository = repository; + _suspended = false; + _folders.addAll(await repository.folders()); + + _bookIds.addAll(await repository.bookIds()); + error = null; + _listen(); + } catch (failure) { + error = 'Could not open watched folders: $failure'; + } + } + + _notify(); + schedule(); + }); + return _scopeChange; + } + + void invalidate() { + _generation++; + _requested.clear(); + } + + Future suspend() async { + _suspended = true; + invalidate(); + await _running; + await _edits.catchError((Object _) {}); + } + + void resume() { + _suspended = false; + schedule(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (_desktop) { + return; + } + + _foreground = state == AppLifecycleState.resumed; + + if (_foreground) { + schedule(); + } else { + invalidate(); + } + } + + void schedule([String? folderId]) { + if (_disposed || !ready || !_foreground || _pendingEdits > 0) { + return; + } + + _scanAll = _scanAll || folderId == null; + _requested.addAll( + folderId == null ? _folders.where((folder) => folder.enabled).map((folder) => folder.id) : [folderId], + ); + + if (_running != null || (_requested.isEmpty && !_scanAll)) { + return; + } + + _running = _drain() + .catchError((Object failure, StackTrace stack) { + if (failure is! _ScanCancelled) { + error = 'Folder scan could not finish: $failure'; + } + }) + .whenComplete(() { + _running = null; + _notify(); + }); + } + + Future scanNow(String id) async { + schedule(id); + await _running; + } + + Future waitUntilIdle() async => _running; + + Future _drain() async { + final repository = _repository!; + final generation = _generation; + + if (!await _acquireLease(repository)) { + _requested.clear(); + error = 'Another Papyrus window is checking these folders.'; + return; + } + + try { + await _releaseRetiredGrants(repository); + await _refreshFolders(repository); + + if (_scanAll) { + _requested.addAll(_folders.where((folder) => folder.enabled).map((folder) => folder.id)); + _scanAll = false; + } + + while (_requested.isNotEmpty && generation == _generation && !_disposed && ready && _foreground) { + final id = _requested.first; + _requested.remove(id); + final folder = _folders.where((folder) => folder.id == id).firstOrNull; + + if (folder != null && folder.enabled) { + await _scan(repository, folder, generation); + } + } + } finally { + await _releaseLease(repository); + } + } + + Future _acquireLease(FolderRepository repository) async { + final started = _now(); + + if (!await repository.acquire(_owner, started)) { + return false; + } + + _hasLease = true; + _leaseExpires = started.add(FolderRepository.leaseDuration); + _heartbeat = Timer.periodic(const Duration(seconds: 20), (_) { + if (_renewing != null) { + return; + } + + _renewing = (() async { + try { + final started = _now(); + _hasLease = await repository.acquire(_owner, started); + _leaseExpires = started.add(FolderRepository.leaseDuration); + } catch (_) { + _hasLease = false; + } + })().whenComplete(() => _renewing = null); + }); + return true; + } + + Future _releaseLease(FolderRepository repository) async { + _heartbeat?.cancel(); + await _renewing; + _hasLease = false; + await repository.release(_owner); + } + + void _checkEditLease() { + if (!_leaseValid) { + throw StateError('Folder access changed in another window. Try again.'); + } + } + + Future _refreshFolders(FolderRepository repository) async { + final saved = await repository.folders(); + final identities = saved.map((folder) => folder.id).toSet(); + + for (final current in _folders.toList()) { + final restored = saved.where((folder) => folder.id == current.id).firstOrNull; + + if (restored == null || restored.grant.key != current.grant.key) { + await _events.remove(current.id)?.cancel(); + } + + if (restored != null) { + current.restore(restored); + } + } + + _folders.removeWhere((folder) => !identities.contains(folder.id)); + + for (final folder in saved) { + if (!_folders.any((current) => current.id == folder.id)) { + _folders.add(folder); + } + } + + _bookIds.clear(); + _bookIds.addAll(await repository.bookIds()); + + _listen(); + } + + Future _releaseRetiredGrants(FolderRepository repository) async { + for (final grant in await repository.retiredGrants()) { + try { + await access.release(grant); + await repository.forgetGrant(grant.key); + } catch (_) { + // A committed replacement must not lose its new grant when cleanup fails. + // The old grant remains journaled for another scan or restart. + } + } + } + + Future _edit(Future Function(FolderRepository) action) { + final repository = _repository; + final scope = _scope; + invalidate(); + _pendingEdits++; + final operation = _edits.catchError((Object _) {}).then((_) async { + await _running; + + if (repository == null || repository != _repository || scope != _scope || _disposed) { + throw StateError('The selected library changed.'); + } + + if (!await _acquireLease(repository)) { + throw StateError('Another Papyrus window is checking these folders. Try again when it finishes.'); + } + + try { + await _refreshFolders(repository); + _checkEditLease(); + await action(repository); + } finally { + await _releaseLease(repository); + } + }); + + _edits = operation.whenComplete(() { + _pendingEdits--; + _notify(); + schedule(); + }); + return _edits; + } + + void _check(FolderImportTarget target, WatchedFolder folder, int generation) { + if (_disposed || + !_leaseValid || + generation != _generation || + !target.isCurrent || + !folder.enabled || + !_foreground || + _suspended) { + throw _ScanCancelled(); + } + } + + Future preview(FolderGrant grant, bool recursive, {bool Function()? cancelled}) async { + final result = FolderPreview(); + + await for (final file in access.enumerate(grant, recursive: recursive)) { + if (_disposed || cancelled?.call() == true) { + break; + } + + if (file.error != null) { + result.inaccessible++; + } else if (_supported(file)) { + result.supported++; + + if (file.size < 0) { + result.unknownSizes++; + } else { + result.bytes += file.size; + } + } else { + result.unsupported++; + } + } + + return result; + } + + Future connect(FolderGrant grant, {String? name, bool recursive = true, String? shelfId}) async { + if (!ready) { + throw StateError('Wait for the selected library to finish loading.'); + } + + await _edit((repository) async { + if (_folders.any((folder) => folder.grant.identity == grant.identity)) { + throw StateError('This folder is already connected to this library.'); + } + + final folder = WatchedFolder( + id: const Uuid().v4(), + grant: grant, + customName: _folderName(name ?? grant.name), + recursive: recursive, + shelfId: shelfId, + ); + await repository.saveFolder(folder); + _folders.add(folder); + _listen(); + }); + } + + Future update( + WatchedFolder folder, { + String? name, + bool? enabled, + bool? recursive, + String? shelfId, + bool changeShelf = false, + }) => _edit((repository) async { + _requireFolder(folder); + final updated = WatchedFolder.fromJson(folder.toJson()); + + if (name != null) { + updated.customName = _folderName(name); + } + + if (enabled != null) { + updated.enabled = enabled; + updated.status = enabled ? FolderStatus.upToDate : FolderStatus.paused; + } + + if (changeShelf) { + updated.shelfId = shelfId; + } + + if (recursive != null) { + updated.recursive = recursive; + } + + await repository.saveFolder(updated); + folder.restore(updated); + }); + + String _folderName(String value) { + final name = value.trim(); + + if (name.isEmpty) { + throw ArgumentError('Enter a folder name.'); + } + + return name; + } + + void _requireFolder(WatchedFolder folder) { + if (!_folders.contains(folder)) { + throw StateError('This folder connection was removed in another window.'); + } + } + + Future reconnect(WatchedFolder folder) async { + if (!await access.reconnect(folder.grant)) { + throw const FolderAccessException('access', 'Access was not restored. Select the folder again.'); + } + + await _events.remove(folder.id)?.cancel(); + await update(folder, enabled: true); + _listen(); + } + + Future replaceGrant(WatchedFolder folder, FolderGrant grant) => _edit((repository) async { + _requireFolder(folder); + if (_folders.any((other) => other.id != folder.id && other.grant.identity == grant.identity)) { + throw StateError('This folder is already connected to this library.'); + } + + await _events.remove(folder.id)?.cancel(); + final old = folder.grant; + final updated = WatchedFolder.fromJson(folder.toJson()); + updated.grant = grant; + updated.enabled = true; + _checkEditLease(); + await repository.replaceGrant(updated, old); + folder.restore(updated); + await _releaseRetiredGrants(repository); + _listen(); + }); + + Future stop(WatchedFolder folder) => _edit((repository) async { + _requireFolder(folder); + final target = captureTarget(); + + for (final job in await repository.records('jobs', folderId: folder.id)) { + final bookId = job['bookId'] as String; + + if (await target.getBook(bookId) == null) { + await target.deleteStaged(bookId); + } + } + + _checkEditLease(); + await repository.removeFolder(folder.id); + _folders.remove(folder); + _bookIds.remove(folder.id); + await _events.remove(folder.id)?.cancel(); + await _releaseRetiredGrants(repository); + _notify(); + }); + + Future>> issues(String id) async => [ + for (final record in await _repository!.records('files', folderId: id)) + if (const ['changed', 'failed', 'excluded'].contains(record['state'])) record, + for (final job in await _repository!.records('jobs', folderId: id)) + if (job['detached'] == true && job['state'] == 'failed') job, + ]; + + Future review(WatchedFolder folder, Map record, {required bool import}) => + _edit((repository) async { + _requireFolder(folder); + final id = record['recordId'] as String; + final next = Map.from(record)..remove('recordId'); + + if (import) { + final hash = record['hash'] as String?; + + if (hash != null) { + await repository.allow(hash); + } + + next['state'] = 'requested'; + } else { + next['state'] = 'ignored'; + } + + await repository.save('files', id, next, folderId: folder.id); + }); + + Future retry(WatchedFolder folder) => _edit((repository) async { + _requireFolder(folder); + final target = captureTarget(); + + for (final job in await _repository!.records('jobs', folderId: folder.id)) { + final bookId = job['bookId'] as String; + final bytes = await target.readStored(bookId); + + if (bytes != null && await fingerprint(bytes) != job['hash'] && await target.getBook(bookId) == null) { + await target.deleteStaged(bookId); + } + + job['state'] = 'pending'; + job.remove('error'); + await _repository!.save('jobs', job['recordId'] as String, job, folderId: folder.id); + } + + for (final file in await _repository!.records('files', folderId: folder.id)) { + if (file['state'] == 'failed') { + file['state'] = 'retry'; + await _repository!.save('files', file['recordId'] as String, file, folderId: folder.id); + } + } + }); + + Future recordDeletion(String bookId, String? hash) async { + final repository = _repository; + + if (repository == null) { + return; + } + + if (hash != null) { + await _edit((repository) async { + await repository.exclude(hash, bookId); + + for (final job in await repository.records('jobs')) { + if (job['bookId'] == bookId) { + await repository.delete('jobs', job['recordId'] as String); + } + } + }); + } + } + + Future clear() async { + await suspend(); + await _cancelEvents(); + + if (deleteStagedFile != null && _repository != null) { + for (final job in await _repository!.records('jobs')) { + await deleteStagedFile!(job['bookId'] as String); + } + } + + for (final folder in _folders) { + await access.release(folder.grant); + } + + if (_repository != null) { + await _releaseRetiredGrants(_repository!); + } + + await _repository?.clear(); + _folders.clear(); + _bookIds.clear(); + _notify(); + } + + Future _scan(FolderRepository repository, WatchedFolder folder, int generation) async { + final target = captureTarget(); + folder.status = FolderStatus.scanning; + folder.progress = null; + folder.error = null; + folder.unsupported = 0; + error = null; + _notify(); + + try { + _knownHashes.clear(); + _pendingHashes.clear(); + + for (final record in await repository.records('files')) { + if (record['state'] == 'done' && record['hash'] != null && record['bookId'] != null) { + _knownHashes[record['hash'] as String] = record['bookId'] as String; + } + } + + for (final job in await repository.records('jobs')) { + if (job['hash'] != null && job['bookId'] != null) { + _knownHashes[job['hash'] as String] = job['bookId'] as String; + _pendingHashes[job['hash'] as String] = job['bookId'] as String; + } + } + + for (final book in target.books) { + if (book.fileHash != null) { + _knownHashes[book.fileHash!] = book.id; + } + } + + final work = {}; + + for (final job in await repository.records('jobs', folderId: folder.id)) { + if (job['state'] != 'failed') { + final id = job['recordId'] as String; + work[id] = _FolderWork(id, job: job); + } + } + + Object? enumerationFailure; + + try { + await for (final file in access.enumerate(folder.grant, recursive: folder.recursive)) { + _check(target, folder, generation); + + if (file.error == null && !_supported(file)) { + folder.unsupported++; + continue; + } + + final recordId = '${folder.id}:${file.key}'; + final previous = await repository.get('files', recordId); + + if (_unchanged(file, previous) && !work.containsKey(recordId)) { + // Reconcile library deletions without counting unchanged files as imports. + await _consider(repository, folder, target, generation, file, recordId, previous); + continue; + } + + (work[recordId] ??= _FolderWork(recordId)).file = file; + } + } on _ScanCancelled { + rethrow; + } catch (failure) { + // Staged jobs can still recover when the source folder is unavailable. + enumerationFailure = failure; + } + + _check(target, folder, generation); + final progress = FolderImportProgress(total: work.length); + + if (work.isNotEmpty) { + folder.progress = progress; + folder.status = FolderStatus.importing; + _notify(); + } + + var hasDeferredFiles = false; + + for (final item in work.values) { + _check(target, folder, generation); + + if (item.job != null) { + await _recover(repository, folder, target, generation, item.job!); + } + + final file = item.file; + + if (file != null) { + final previous = await repository.get('files', item.recordId); + + try { + final result = await _consider(repository, folder, target, generation, file, item.recordId, previous); + + if (result == _FileResult.deferred) { + hasDeferredFiles = true; + } + } on _ScanCancelled { + rethrow; + } catch (failure) { + await repository.save('files', item.recordId, { + ...?previous, + 'key': file.key, + 'name': file.name, + 'stamp': file.stamp, + 'state': 'failed', + 'error': '$failure', + }, folderId: folder.id); + + if (_outOfSpace(failure)) { + rethrow; + } + } + } + + _check(target, folder, generation); + progress.processed++; + _notify(); + } + + if (enumerationFailure != null) { + throw enumerationFailure; + } + + _check(target, folder, generation); + final pendingIssues = await issues(folder.id); + folder.status = pendingIssues.any((issue) => issue['state'] != 'excluded') + ? FolderStatus.attention + : FolderStatus.upToDate; + folder.lastScan = DateTime.now(); + if (!folder.grant.persistent && !hasDeferredFiles) { + folder.enabled = false; + folder.status = FolderStatus.paused; + folder.error = 'Select this folder again to import later additions.'; + } + } on _ScanCancelled { + folder.status = folder.enabled ? FolderStatus.upToDate : FolderStatus.paused; + } on FolderAccessException catch (failure) { + folder.status = failure.code == 'access' ? FolderStatus.accessNeeded : FolderStatus.unavailable; + folder.error = failure.message; + } catch (failure) { + folder.status = FolderStatus.attention; + folder.error = '$failure'; + } finally { + folder.progress = null; + + if (_leaseValid) { + await repository.saveFolder(folder); + } + + _notify(); + } + } + + Future<_FileResult> _consider( + FolderRepository repository, + WatchedFolder folder, + FolderImportTarget target, + int generation, + FolderFile file, + String recordId, + Map? previous, + ) async { + _check(target, folder, generation); + + if (file.error != null) { + throw FolderAccessException('unavailable', file.error!); + } + + final previousId = previous?['bookId'] as String?; + final previousHash = previous?['hash'] as String?; + final requested = previous?['state'] == 'requested'; + + final previouslyCommitted = + const ['done', 'changed', 'ignored', 'excluded'].contains(previous?['state']) || + (previousId != null && await target.getBook(previousId) != null); + + if (!requested && + previouslyCommitted && + previousId != null && + previousHash != null && + await target.getBook(previousId) == null) { + await repository.exclude(previousHash, previousId); + await repository.save('files', recordId, {...previous!, 'state': 'excluded'}, folderId: folder.id); + + if (previous['stamp'] == file.stamp) { + return _FileResult.processed; + } + } + + if (_unchanged(file, previous)) { + return _FileResult.processed; + } + + final now = _now().millisecondsSinceEpoch; + + if (file.modified > 0 && file.modified <= now && now - file.modified < stabilityDelay.inMilliseconds) { + return _FileResult.deferred; + } + + final needsObservation = file.modified == 0 || file.modified > now; + + if (needsObservation && (previous?['stamp'] != file.stamp || previous?['seen'] == null)) { + await repository.save('files', recordId, {...?previous, 'stamp': file.stamp, 'seen': now}, folderId: folder.id); + return _FileResult.deferred; + } + + if (needsObservation && now - (previous!['seen'] as int) < stabilityDelay.inMilliseconds) { + return _FileResult.deferred; + } + + final start = await access.stat(folder.grant, file); + final bytes = await access.read(folder.grant, file); + final end = await access.stat(folder.grant, file); + _check(target, folder, generation); + + if (start.stamp != end.stamp || (end.size >= 0 && bytes.length != end.size)) { + return _FileResult.deferred; + } + + final hash = await fingerprint(bytes); + _check(target, folder, generation); + + final record = { + 'key': file.key, + 'name': file.name, + 'stamp': end.stamp, + 'hash': hash, + 'seen': now, + }; + + if (await repository.isExcluded(hash)) { + await repository.save('files', recordId, { + ...record, + 'state': 'excluded', + 'bookId': previousId, + }, folderId: folder.id); + return _FileResult.processed; + } + + if (!requested && previousHash == hash && const ['done', 'ignored', 'changed'].contains(previous?['state'])) { + await repository.save('files', recordId, {...previous!, ...record}, folderId: folder.id); + return _FileResult.processed; + } + + if (!requested && previouslyCommitted && previousId != null && previousHash != null && previousHash != hash) { + await repository.save('files', recordId, { + ...record, + 'state': 'changed', + 'bookId': previousId, + 'previousHash': previousHash, + }, folderId: folder.id); + return _FileResult.processed; + } + + final knownId = _knownHashes[hash]; + final existing = knownId == null ? null : await target.getBook(knownId); + + if (existing != null) { + _check(target, folder, generation); + await target.ensureCopy(existing, bytes, file.name.split('.').last.toLowerCase(), folder.shelfId); + await repository.save('files', recordId, { + ...record, + 'state': 'done', + 'bookId': existing.id, + }, folderId: folder.id); + (_bookIds[folder.id] ??= {}).add(existing.id); + folder.duplicates++; + return _FileResult.processed; + } + + final jobId = recordId; + final saved = await repository.get('jobs', jobId); + + if (saved != null && saved['hash'] != hash) { + // A source can change while an earlier import is waiting for its outbox. + // Keep that recovery evidence without reusing or overwriting its book. + final retainedId = '$jobId:retained:${saved['bookId']}'; + await repository.save('jobs', retainedId, { + ...saved, + 'recordId': retainedId, + 'detached': true, + }, folderId: folder.id); + } + + final job = { + ...record, + 'recordId': jobId, + 'bookId': saved?['hash'] == hash ? saved!['bookId'] : _pendingHashes[hash] ?? const Uuid().v4(), + 'state': 'pending', + }; + await repository.save('jobs', jobId, job, folderId: folder.id); + _knownHashes[hash] = job['bookId'] as String; + _pendingHashes[hash] = job['bookId'] as String; + await _recover(repository, folder, target, generation, job, bytes: bytes); + return _FileResult.processed; + } + + Future _recover( + FolderRepository repository, + WatchedFolder folder, + FolderImportTarget target, + int generation, + Map job, { + Uint8List? bytes, + }) async { + final id = job['recordId'] as String; + final bookId = job['bookId'] as String; + _check(target, folder, generation); + + try { + bytes ??= await target.readStored(bookId); + + if (bytes == null) { + return; + } + + if (await fingerprint(bytes) != job['hash']) { + throw StateError('The staged file is incomplete. Retry this file from its source.'); + } + + if (await repository.isExcluded(job['hash'] as String)) { + if (await target.getBook(bookId) == null) { + await target.deleteStaged(bookId); + } + + await repository.delete('jobs', id); + return; + } + + _check(target, folder, generation); + final result = await target.process(bytes, job['name'] as String, bookId); + _check(target, folder, generation); + job['state'] = 'prepared'; + await repository.save('jobs', id, job, folderId: folder.id); + _check(target, folder, generation); + await target.finish(result, job['name'] as String, folder.shelfId); + _check(target, folder, generation); + if (job['detached'] != true) { + await repository.save('files', id, {...job, 'state': 'done'}, folderId: folder.id); + } + + await repository.delete('jobs', id); + (_bookIds[folder.id] ??= {}).add(bookId); + _knownHashes[job['hash'] as String] = bookId; + folder.added++; + } on _ScanCancelled { + rethrow; + } catch (failure) { + job['state'] = 'failed'; + job['error'] = '$failure'; + await repository.save('jobs', id, job, folderId: folder.id); + if (job['detached'] != true) { + await repository.save('files', id, job, folderId: folder.id); + } + + if (_outOfSpace(failure)) { + throw StateError('Not enough local storage. Free up space, then retry failed imports.'); + } + } + } + + bool _unchanged(FolderFile file, Map? previous) => + file.error == null && + file.modified > 0 && + previous?['stamp'] == file.stamp && + const ['done', 'ignored', 'changed', 'failed', 'excluded'].contains(previous?['state']); + + bool _outOfSpace(Object failure) => RegExp( + r'no space|disk full|quotaexceeded|not enough (local )?storage|ENOSPC', + caseSensitive: false, + ).hasMatch('$failure'); + + void _listen() { + for (final folder in _folders) { + if (_events.containsKey(folder.id) || !folder.grant.persistent) { + continue; + } + + _events[folder.id] = access + .changes(folder.grant) + .listen( + (_) { + _debounces.remove(folder.id)?.cancel(); + _debounces[folder.id] = Timer(stabilityDelay, () => schedule(folder.id)); + }, + onError: (Object _) { + // Periodic reconciliation continues when OS event delivery is unavailable. + }, + ); + } + } + + Future _cancelEvents() async { + for (final timer in _debounces.values) { + timer.cancel(); + } + + _debounces.clear(); + + for (final subscription in _events.values) { + await subscription.cancel(); + } + + _events.clear(); + } + + void _notify() { + if (!_disposed) { + notifyListeners(); + } + } + + @override + void dispose() { + _disposed = true; + invalidate(); + _timer?.cancel(); + WidgetsBinding.instance.removeObserver(this); + unawaited(_close()); + super.dispose(); + } + + Future _close() async { + await _scopeChange; + await _running; + await _edits.catchError((Object _) {}); + await _cancelEvents(); + await _repository?.close(); + _repository = null; + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index 3a758d7d..c5767b29 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,3 +1,6 @@ +import 'package:papyrus/folders/directory_access_factory.dart'; +import 'package:papyrus/folders/folder_import_target.dart'; +import 'package:papyrus/folders/watched_folders.dart'; import 'package:papyrus/services/reading_device_identity.dart'; import 'dart:async'; import 'dart:convert'; @@ -214,6 +217,8 @@ class _PapyrusState extends State { late final SyncSettingsProvider _syncSettingsProvider; late final MediaUploadQueue _mediaUploadQueue; late final BookImportService _bookImportService; + late final WatchedFolders _watchedFolders; + bool _folderLibraryReady = false; late final OpdsCatalogs _opdsCatalogs; late final OpdsDownloads _opdsDownloads; late final OpdsHttpClient _opdsHttpClient; @@ -285,6 +290,21 @@ class _PapyrusState extends State { ), ); + _watchedFolders = WatchedFolders( + access: createDirectoryAccess(), + deleteStagedFile: _bookImportService.deleteBookFile, + libraryReady: () => _dataStore.isLoaded && !_switchingSyncProfile && !_authProvider.isDeletingGuestAccount, + captureTarget: () => LibraryFolderImportTarget( + store: _dataStore, + queue: _mediaUploadQueue, + importer: _bookImportService, + auth: _authProvider, + database: _powerSyncService, + ), + ); + _dataStore.beforeBookDelete = (book) => _watchedFolders.recordDeletion(book.id, book.fileHash); + _dataStore.addListener(_handleFolderLibraryReady); + _bookStorageStatusController = BookStorageStatusController( authProvider: _authProvider, powerSyncService: _powerSyncService, @@ -326,6 +346,8 @@ class _PapyrusState extends State { @override void dispose() { + _dataStore.removeListener(_handleFolderLibraryReady); + _watchedFolders.dispose(); _authProvider.removeListener(_syncPowerSyncAuthState); _syncSettingsProvider.removeListener(_handleSyncSettingsChanged); unawaited(_disposeDataServices()); @@ -365,7 +387,18 @@ class _PapyrusState extends State { await _powerSyncService.close(); } + void _handleFolderLibraryReady() { + final ready = _dataStore.isLoaded; + + if (ready && !_folderLibraryReady) { + _watchedFolders.schedule(); + } + + _folderLibraryReady = ready; + } + void _syncPowerSyncAuthState() { + _watchedFolders.invalidate(); _updateOpdsScope(); if (_authStateOperation != null) { @@ -412,6 +445,7 @@ class _PapyrusState extends State { _authProvider.user?.userId == userId && !_authProvider.isOfflineMode) { _opdsCatalogs.setScope(scope.persistenceKey); + unawaited(_watchedFolders.activate(scope.persistenceKey)); } await _refreshMediaUsage(); @@ -428,11 +462,14 @@ class _PapyrusState extends State { _activeProfileKey == profileKey && _syncSettingsProvider.activeProfileKey == profileKey) { _opdsCatalogs.setScope(MediaStorageScope.localGuest.persistenceKey); + unawaited(_watchedFolders.activate(MediaStorageScope.localGuest.persistenceKey)); } return; } + await _watchedFolders.suspend(); + unawaited(_watchedFolders.activate(null)); await _mediaUploadQueue.activateScope(null); if (!_authProvider.isBootstrapping && _powerSyncService.mode != null) { @@ -464,6 +501,8 @@ class _PapyrusState extends State { Future _switchActiveSyncProfile(String nextProfileKey, PapyrusApiConfig nextConfig) async { _switchingSyncProfile = true; + await _watchedFolders.suspend(); + unawaited(_watchedFolders.activate(null)); try { await _mediaUploadQueue.waitUntilIdle(); @@ -578,6 +617,7 @@ class _PapyrusState extends State { // Core data store - single source of truth ChangeNotifierProvider.value(value: _dataStore), ChangeNotifierProvider.value(value: _mediaUploadQueue), + ChangeNotifierProvider.value(value: _watchedFolders), ChangeNotifierProvider.value(value: _syncSettingsProvider), ChangeNotifierProvider.value(value: _bookStorageStatusController), Provider.value(value: _powerSyncService), @@ -589,6 +629,7 @@ class _PapyrusState extends State { files: _bookImportService, catalogs: _opdsCatalogs, downloads: _opdsDownloads, + folders: _watchedFolders, ), ), ChangeNotifierProvider.value(value: _opdsCatalogs), diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index 62d1cd68..eb6e75c4 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -364,9 +364,14 @@ class MediaUploadQueue extends ChangeNotifier { final scope = _requireActiveScope(); await _withMutation(() async { + if (_activeScope != scope) { + throw StateError('Authenticated media upload scope changed before enqueue'); + } + + final nextTasks = [..._tasks.where((existing) => existing.id != task.id), task]; + await _saveTasks(scope, nextTasks); _advanceTaskVersion(task.id); - _tasks = [..._tasks.where((existing) => existing.id != task.id), task]; - await _save(scope); + _tasks = nextTasks; notifyListeners(); }); @@ -502,10 +507,20 @@ class MediaUploadQueue extends ChangeNotifier { Future _save(MediaStorageScope scope) => _saveTasks(scope, _tasks); Future _saveTasks(MediaStorageScope scope, List tasks) async { - final saved = await _prefs.setString(_storageKey(scope), jsonEncode(tasks.map((task) => task.toJson()).toList())); + try { + final saved = await _prefs.setString(_storageKey(scope), jsonEncode(tasks.map((task) => task.toJson()).toList())); + + if (!saved) { + throw StateError('Could not persist media upload queue'); + } + } catch (error, stackTrace) { + // Preferences update their memory cache before the platform write finishes. + // A failed write must not appear durable when this profile is reopened. + try { + await _prefs.reload(); + } catch (_) {} - if (!saved) { - throw StateError('Could not persist media upload queue'); + Error.throwWithStackTrace(error, stackTrace); } } diff --git a/app/lib/models/library_filters.dart b/app/lib/models/library_filters.dart index 7d4eeed6..ab554006 100644 --- a/app/lib/models/library_filters.dart +++ b/app/lib/models/library_filters.dart @@ -63,6 +63,7 @@ class LibraryFilters { final Set formats; final Set topicIds; final Set shelfIds; + final Set folderIds; final Set publishers; final Set seriesNames; final Set statuses; @@ -80,6 +81,7 @@ class LibraryFilters { Set formats = const {}, Set topicIds = const {}, Set shelfIds = const {}, + Set folderIds = const {}, Set publishers = const {}, Set seriesNames = const {}, Set statuses = const {}, @@ -95,6 +97,7 @@ class LibraryFilters { formats = Set.unmodifiable(formats), topicIds = Set.unmodifiable(topicIds), shelfIds = Set.unmodifiable(shelfIds), + folderIds = Set.unmodifiable(folderIds), publishers = Set.unmodifiable(publishers), seriesNames = Set.unmodifiable(seriesNames), statuses = Set.unmodifiable(statuses), @@ -123,6 +126,10 @@ class LibraryFilters { count++; } + if (folderIds.isNotEmpty) { + count++; + } + if (publishers.isNotEmpty) { count++; } @@ -170,6 +177,7 @@ class LibraryFilters { Set? formats, Set? topicIds, Set? shelfIds, + Set? folderIds, Set? publishers, Set? seriesNames, Set? statuses, @@ -187,6 +195,7 @@ class LibraryFilters { formats: formats ?? this.formats, topicIds: topicIds ?? this.topicIds, shelfIds: shelfIds ?? this.shelfIds, + folderIds: folderIds ?? this.folderIds, publishers: publishers ?? this.publishers, seriesNames: seriesNames ?? this.seriesNames, statuses: statuses ?? this.statuses, @@ -216,6 +225,7 @@ class LibraryFilters { setEquals(other.formats, formats) && setEquals(other.topicIds, topicIds) && setEquals(other.shelfIds, shelfIds) && + setEquals(other.folderIds, folderIds) && setEquals(other.publishers, publishers) && setEquals(other.seriesNames, seriesNames) && setEquals(other.statuses, statuses) && @@ -235,6 +245,7 @@ class LibraryFilters { Object.hashAllUnordered(formats), Object.hashAllUnordered(topicIds), Object.hashAllUnordered(shelfIds), + Object.hashAllUnordered(folderIds), Object.hashAllUnordered(publishers), Object.hashAllUnordered(seriesNames), Object.hashAllUnordered(statuses), diff --git a/app/lib/pages/library_page.dart b/app/lib/pages/library_page.dart index 21d7627d..482f5bed 100644 --- a/app/lib/pages/library_page.dart +++ b/app/lib/pages/library_page.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/folders/watched_folders.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/library_filter_options.dart'; import 'package:papyrus/models/shelf.dart'; @@ -134,6 +135,8 @@ class _LibraryPageState extends State { @override Widget build(BuildContext context) { + context.watch(); + final libraryProvider = context.watch(); final downloadsProvider = widget.isShelfView ? null : context.watch(); final dataStore = context.watch(); @@ -189,6 +192,14 @@ class _LibraryPageState extends State { return shelfBooks; } + Map> _folderBookIds() { + final folders = context.read(); + + return { + for (final folder in folders?.folders ?? []) folder.id: folders!.bookIds(folder.id), + }; + } + LibraryFilterOptions _filterOptions(DataStore dataStore, List sourceBooks) { return widget.isShelfView ? LibraryFilterOptions.fromDataStore(dataStore, books: sourceBooks) @@ -196,7 +207,7 @@ class _LibraryPageState extends State { } List _getFilteredBooks(LibraryProvider provider, DataStore dataStore, List sourceBooks) { - final books = provider.filterBooks(sourceBooks, dataStore: dataStore); + final books = provider.filterBooks(sourceBooks, dataStore: dataStore, folderBookIds: _folderBookIds()); return provider.sortBooks(books); } @@ -528,15 +539,22 @@ class _LibraryPageState extends State { final dataStore = context.read(); final sourceBooks = _sourceBooks(dataStore); + final folders = context.read(); + final scope = folders?.scope; + final filters = await LibraryAdvancedFilterSheet.show( context, libraryProvider: libraryProvider, dataStore: dataStore, sourceBooks: sourceBooks, filterOptions: _filterOptions(dataStore, sourceBooks), + folderOptions: [ + for (final folder in folders?.folders ?? []) LibraryFilterOption(value: folder.id, label: folder.name), + ], + folderBookIds: _folderBookIds(), ); - if (filters != null && mounted) { + if (filters != null && mounted && scope == folders?.scope) { libraryProvider.applyFilters(filters); } } @@ -1136,7 +1154,14 @@ class _LibraryPageState extends State { ? books.where((book) => items.downloadingBookIds.contains(book.id)).toList() : books; - final candidatePlaceholders = showDownloadingOnly ? items.downloadingOrphanJobs : items.orphanJobs; + final List candidatePlaceholders; + + if (libraryProvider.filters.folderIds.isNotEmpty) { + candidatePlaceholders = const []; + } else { + candidatePlaceholders = showDownloadingOnly ? items.downloadingOrphanJobs : items.orphanJobs; + } + final normalizedQuery = libraryProvider.searchQuery.trim().toLowerCase(); final visiblePlaceholderJobs = normalizedQuery.isEmpty diff --git a/app/lib/pages/watched_folders_page.dart b/app/lib/pages/watched_folders_page.dart new file mode 100644 index 00000000..14ff4416 --- /dev/null +++ b/app/lib/pages/watched_folders_page.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/folders/directory_access.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/providers/library_provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/add_book/connect_folder_sheet.dart'; +import 'package:papyrus/widgets/folders/folder_details_sheet.dart'; +import 'package:papyrus/widgets/folders/folder_table.dart'; +import 'package:papyrus/widgets/folders/edit_folder_sheet.dart'; +import 'package:papyrus/widgets/folders/folder_issues_sheet.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; +import 'package:papyrus/widgets/folders/folder_sheet.dart'; +import 'package:papyrus/widgets/shared/app_drawer.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:papyrus/widgets/shared/empty_state.dart'; +import 'package:provider/provider.dart'; + +class WatchedFoldersPage extends StatefulWidget { + const WatchedFoldersPage({super.key}); + + @override + State createState() => _WatchedFoldersPageState(); +} + +class _WatchedFoldersPageState extends State { + bool _picking = false; + String? _actionError; + String? _errorScope; + + Future _run(Future Function() action) async { + final folders = context.read(); + final scope = folders.scope; + setState(() { + _actionError = null; + _errorScope = scope; + }); + + try { + await action(); + } catch (error) { + if (mounted && folders.scope == scope) { + setState(() => _actionError = '$error'); + } + } + } + + Future _pick({WatchedFolder? replacing}) async { + if (replacing == null) { + setState(() => _picking = true); + + try { + await connectBookFolder(context); + } finally { + if (mounted) { + setState(() => _picking = false); + } + } + + return; + } + + final folders = context.read(); + final scope = folders.scope; + setState(() => _picking = true); + FolderGrant? grant; + var connected = false; + + try { + grant = await folders.access.pick(); + + if (grant == null || !mounted || scope != folders.scope) { + return; + } + + await folders.replaceGrant(replacing, grant); + connected = true; + } finally { + if (grant != null && !connected) { + await folders.access.release(grant); + } + + if (mounted) { + setState(() => _picking = false); + } + } + } + + @override + Widget build(BuildContext context) { + final folders = context.watch(); + final error = _errorScope == folders.scope ? _actionError ?? folders.error : folders.error; + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.desktopSmall; + final padding = libraryPageHorizontalPadding(context); + final empty = folders.folders.isEmpty; + final label = folders.supportsWatching ? 'Connect folder' : 'Import folder'; + final onConnect = folders.ready && !_picking ? () => _run(() => _pick()) : null; + + return Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (desktop) + Padding( + padding: EdgeInsets.fromLTRB(padding, Spacing.lg, padding, Spacing.md), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: TouchTargets.desktopRecommended), + child: LibraryToolbar( + search: Text('Folders', style: Theme.of(context).textTheme.headlineSmall), + actions: [if (!empty) LibraryAddButton(label: label, onPressed: onConnect)], + ), + ), + ) + else + LibraryMobileToolbar( + search: Text('Folders', style: Theme.of(context).textTheme.titleLarge), + onMenuPressed: () => openAppDrawer(context, Scaffold.maybeOf(context)), + ), + if (error != null) + Padding( + padding: EdgeInsets.symmetric(horizontal: padding, vertical: Spacing.sm), + child: Semantics( + liveRegion: true, + child: Text(error, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + ), + if (!folders.ready) + const Expanded( + child: Center(child: AppCircularProgressIndicator(semanticsLabel: 'Loading folders')), + ) + else if (empty) + Expanded( + child: EmptyState( + icon: Icons.folder_outlined, + title: 'Books from your folders', + subtitle: folders.supportsWatching + ? 'Connect a folder to add its books and discover new ones while Papyrus is open.' + : 'Import books from a folder on this device.', + action: EmptyStateAction(label: label, icon: Icons.add, onPressed: onConnect), + ), + ) + else ...[ + Expanded( + child: FolderTable( + folders: folders.folders, + busy: _picking || !folders.ready, + padding: EdgeInsets.fromLTRB(padding, 0, padding, desktop ? Spacing.lg : Spacing.fabClearance), + onAction: (folder, action) => _run(() => _action(folder, action)), + ), + ), + ], + ], + ), + ), + floatingActionButton: !desktop && !empty && folders.ready + ? FloatingActionButton(onPressed: onConnect, tooltip: label, child: const Icon(Icons.add)) + : null, + ); + } + + Future _action(WatchedFolder folder, String action) async { + final folders = context.read(); + + switch (action) { + case 'details': + final next = await showFolderSheet(context, builder: (_) => FolderDetailsSheet(folder: folder)); + + if (next != null && mounted && folders.folders.contains(folder)) { + await _action(folder, next); + } + case 'edit': + await showFolderSheet(context, builder: (_) => EditFolderSheet(folder: folder)); + case 'books': + final library = context.read(); + library.applyFilters(library.filters.copyWith(folderIds: {folder.id})); + context.go('/library/books'); + case 'scan': + await folders.scanNow(folder.id); + case 'pause': + await folders.update(folder, enabled: !folder.enabled); + case 'retry': + await folders.retry(folder); + case 'reconnect': + if (!folder.grant.persistent) { + await _pick(replacing: folder); + return; + } + + try { + await folders.reconnect(folder); + } on FolderAccessException { + if (mounted) { + await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (dialogContext) => AlertDialog( + title: const Text('Select this folder again'), + content: const Text( + 'Access could not be restored. Select the source folder to reconnect it. Your imported books are safe.', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _run(() => _pick(replacing: folder)); + }, + child: const Text('Choose folder'), + ), + ], + ), + ); + } + } + case 'select': + await _pick(replacing: folder); + case 'review': + await showFolderSheet( + context, + builder: (_) => FolderIssuesSheet(folder: folder, folders: folders), + ); + case 'stop': + final confirmed = await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (context) => AlertDialog( + title: const Text('Stop watching this folder?'), + content: const Text('Imported books stay in your library. Pending imports from this folder will stop.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Stop watching')), + ], + ), + ); + + if (confirmed == true && mounted && folders.folders.contains(folder)) { + await folders.stop(folder); + } + } + } +} diff --git a/app/lib/providers/library_provider.dart b/app/lib/providers/library_provider.dart index 037f7500..6aa3c60b 100644 --- a/app/lib/providers/library_provider.dart +++ b/app/lib/providers/library_provider.dart @@ -170,11 +170,24 @@ class LibraryProvider extends ChangeNotifier { notifyListeners(); } - List filterBooks(List books, {required DataStore dataStore, LibraryFilters? filters}) { + List filterBooks( + List books, { + required DataStore dataStore, + LibraryFilters? filters, + Map> folderBookIds = const {}, + }) { final searchQuery = _searchQuery.trim().toLowerCase(); final appliedFilters = filters ?? _filters; + final folderMatches = { + for (final id in appliedFilters.folderIds) ...?folderBookIds[id], + }; + return books.where((book) { + if (appliedFilters.folderIds.isNotEmpty && !folderMatches.contains(book.id)) { + return false; + } + if (searchQuery.isNotEmpty && !book.title.toLowerCase().contains(searchQuery) && !book.allAuthors.toLowerCase().contains(searchQuery)) { diff --git a/app/lib/services/book_import_commit_service.dart b/app/lib/services/book_import_commit_service.dart index d6c4e5a1..aab8f88e 100644 --- a/app/lib/services/book_import_commit_service.dart +++ b/app/lib/services/book_import_commit_service.dart @@ -57,6 +57,7 @@ class BookImportCommitService { required DateTime addedAt, required String localFilePath, MediaStorageScope? accountScope, + bool retainCommittedBook = false, }) async { final book = Book( id: result.bookId, @@ -112,6 +113,12 @@ class BookImportCommitService { return book; } catch (error, stackTrace) { + if (metadataAdded && retainCommittedBook && _isLibraryContextCurrent()) { + // Journaled imports repair their outbox separately without removing a + // book that is already available locally. + Error.throwWithStackTrace(error, stackTrace); + } + if (metadataAdded) { await _bestEffort(() => _deleteBook(book.id)); } diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index 882d2ce6..e4c0b786 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -147,7 +147,15 @@ class BookImportService { /// Supports EPUB and PDF files. /// Throws [UnsupportedError] when called on non-web platforms. /// Throws [ArgumentError] for unsupported file formats. - Future importBook(Uint8List bytes, String filename) async { + Future importBook(Uint8List bytes, String filename) => + importBookWithId(bytes, filename, const Uuid().v4()); + + /// Reuses a journaled identity when recovering an interrupted folder import. + Future importBookWithId(Uint8List bytes, String filename, String bookId) async { + if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(bookId)) { + throw ArgumentError.value(bookId, 'bookId', 'Invalid import identity'); + } + if (!kIsWeb) { throw UnsupportedError('BookImportService is only supported on web.'); } @@ -178,7 +186,6 @@ class BookImportService { } } - final bookId = const Uuid().v4(); final completer = Completer(); final worker = _getWorker(); _pending['process:$bookId'] = completer; diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index a7baafd9..b2b48754 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import 'package:papyrus/media/cover_storage_bucket.dart'; import 'package:papyrus/media/local_cover_image_provider.dart'; import 'package:papyrus/media/media_storage_scope.dart'; @@ -21,32 +21,43 @@ export 'package:papyrus/services/book_import_result.dart'; class BookImportService { static final RegExp _safeFilePart = RegExp(r'^[a-zA-Z0-9_.-]+$'); - final _metadataService = FileMetadataService(); static final Map> _coverOperations = {}; /// Imports a book file: extracts metadata and stores the file locally. /// /// Supports all formats handled by [FileMetadataService]: /// EPUB, PDF, MOBI, AZW3, CBZ, CBR, TXT. - Future importBook(Uint8List bytes, String filename) async { + Future importBook(Uint8List bytes, String filename) => + importBookWithId(bytes, filename, const Uuid().v4()); + + /// Reuses a journaled identity when recovering an interrupted folder import. + Future importBookWithId(Uint8List bytes, String filename, String bookId) async { + if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(bookId)) { + throw ArgumentError.value(bookId, 'bookId', 'Invalid import identity'); + } + final ext = p.extension(filename).toLowerCase().replaceFirst('.', ''); if (ext.isEmpty) { throw ArgumentError('Filename has no extension: $filename'); } - final bookId = const Uuid().v4(); - // Extract metadata - final metadata = await _metadataService.extractMetadata(bytes, filename); - - // Compute SHA-256 hash - final fileHash = sha256.convert(bytes).toString(); + final (metadata, fileHash) = await compute(_extractImportMetadata, (bytes, filename)); // Store the file final booksDir = await _getBooksDirectory(); final file = File(p.join(booksDir.path, '$bookId.$ext')); - await file.writeAsBytes(bytes); + final temporary = File('${file.path}.tmp'); + + try { + await temporary.writeAsBytes(bytes, flush: true); + await temporary.rename(file.path); + } finally { + if (await temporary.exists()) { + await temporary.delete(); + } + } return BookImportResult( bookId: bookId, @@ -74,9 +85,12 @@ class BookImportService { final files = booksDir.listSync(); for (final entity in files) { - if (entity is File && p.basenameWithoutExtension(entity.path) == bookId) { + final name = p.basename(entity.path); + + if (entity is File && + (p.basenameWithoutExtension(entity.path) == bookId || + (name.startsWith('$bookId.') && name.endsWith('.tmp')))) { await entity.delete(); - return; } } } @@ -375,3 +389,8 @@ class BookImportService { return directory; } } + +Future<(FileMetadataResult, String)> _extractImportMetadata((Uint8List, String) input) async { + final metadata = await FileMetadataService().extractMetadata(input.$1, input.$2); + return (metadata, sha256.convert(input.$1).toString()); +} diff --git a/app/lib/services/book_import_session.dart b/app/lib/services/book_import_session.dart index 75c9c527..5e27bb02 100644 --- a/app/lib/services/book_import_session.dart +++ b/app/lib/services/book_import_session.dart @@ -40,6 +40,7 @@ class BookImportSession { required BookImportService importService, required AuthProvider authProvider, required PapyrusPowerSyncService powerSyncService, + bool retainCommittedBook = false, }) { final repository = dataStore.requireBookRepository(); final authenticated = authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; @@ -84,6 +85,7 @@ class BookImportSession { addedAt: DateTime.now(), localFilePath: kIsWeb ? 'opfs://books/${result.bookId}.${result.fileExtension}' : result.bookId, accountScope: scope, + retainCommittedBook: retainCommittedBook, ), ); } diff --git a/app/lib/services/guest_account_deletion_service.dart b/app/lib/services/guest_account_deletion_service.dart index d393dcca..c0afb35f 100644 --- a/app/lib/services/guest_account_deletion_service.dart +++ b/app/lib/services/guest_account_deletion_service.dart @@ -1,3 +1,4 @@ +import 'package:papyrus/folders/watched_folders.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/opds/opds_catalogs.dart'; import 'package:papyrus/opds/opds_downloads.dart'; @@ -13,6 +14,7 @@ class GuestAccountDeletionService { required this.files, required this.catalogs, required this.downloads, + this.folders, }); final AuthProvider auth; @@ -20,8 +22,10 @@ class GuestAccountDeletionService { final BookImportService files; final OpdsCatalogs catalogs; final OpdsDownloads downloads; + final WatchedFolders? folders; Future delete() => auth.deleteGuestAccount(() async { + await folders?.suspend(); await downloads.cancelAndWait(); await library.clearGuestLibrary( @@ -32,6 +36,7 @@ class GuestAccountDeletionService { await files.clearCoverFiles(MediaStorageScope.localGuest); await catalogs.clearScope(MediaStorageScope.localGuest.persistenceKey); + await folders?.clear(); }, ); }); diff --git a/app/lib/widgets/add_book/add_book_choice_sheet.dart b/app/lib/widgets/add_book/add_book_choice_sheet.dart index e64dcbec..ac3a7378 100644 --- a/app/lib/widgets/add_book/add_book_choice_sheet.dart +++ b/app/lib/widgets/add_book/add_book_choice_sheet.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'package:papyrus/widgets/add_book/connect_folder_sheet.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:provider/provider.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -75,6 +79,17 @@ class _AddBookChoiceSheetState extends State { Navigator.of(context).pop(choice); } + void _connectFolder() { + if (_isSelecting) { + return; + } + + _isSelecting = true; + final completed = ModalRoute.of(context)?.completed; + Navigator.of(context).pop(); + unawaited(connectBookFolder(widget.callerContext, afterDismissal: completed)); + } + @override Widget build(BuildContext context) { return AppBottomSheet( @@ -90,6 +105,15 @@ class _AddBookChoiceSheetState extends State { onTap: () => _select(_AddBookChoice.importDigital), ), const SizedBox(height: Spacing.sm), + _ChoiceOption( + icon: Icons.create_new_folder_outlined, + title: context.watch()?.supportsWatching == false ? 'Import folder' : 'Connect folder', + subtitle: context.watch()?.supportsWatching == false + ? 'Add books from a device folder' + : 'Automatically add books from a device folder', + onTap: _connectFolder, + ), + const SizedBox(height: Spacing.sm), _ChoiceOption( icon: Icons.menu_book, title: 'Add physical book', diff --git a/app/lib/widgets/add_book/connect_folder_sheet.dart b/app/lib/widgets/add_book/connect_folder_sheet.dart new file mode 100644 index 00000000..166f4cf5 --- /dev/null +++ b/app/lib/widgets/add_book/connect_folder_sheet.dart @@ -0,0 +1,317 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/folders/directory_access.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/folders/folder_sheet.dart'; +import 'package:papyrus/widgets/folders/folder_location_field.dart'; +import 'package:papyrus/widgets/folders/folder_subfolders_switch.dart'; +import 'package:papyrus/widgets/library/acquisition_status_text.dart' show formatBytes; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_actions.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:provider/provider.dart'; + +/// Start picking synchronously with the user's tap, even while the choice sheet closes. +Future connectBookFolder(BuildContext context, {Future? afterDismissal}) async { + final folders = context.read(); + final scope = folders.scope; + FolderGrant? grant; + var connected = false; + + try { + if (!folders.ready) { + throw StateError('Wait for your library to finish loading.'); + } + + grant = await folders.access.pick(); + await afterDismissal; + + if (grant == null || !context.mounted || scope != folders.scope) { + return; + } + + connected = + await showFolderSheet( + context, + builder: (_) => ConnectFolderSheet(grant: grant!, folders: folders, scope: scope), + ) ?? + false; + } catch (error) { + await afterDismissal; + + if (context.mounted && scope == folders.scope) { + await showFolderSheet( + context, + builder: (context) => FolderSheet( + title: 'Could not connect folder', + body: Text('$error'), + footer: BottomSheetActions( + primary: OutlinedButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), + ), + ), + ); + } + } finally { + if (grant != null && !connected) { + await folders.access.release(grant); + } + } +} + +class ConnectFolderSheet extends StatefulWidget { + const ConnectFolderSheet({super.key, required this.grant, required this.folders, required this.scope}); + final FolderGrant grant; + final WatchedFolders folders; + final String? scope; + + @override + State createState() => _ConnectFolderSheetState(); +} + +class _ConnectFolderSheetState extends State { + late final _name = TextEditingController(text: widget.grant.name); + bool _recursive = true; + String? _shelf; + FolderPreview? _preview; + String? _error; + bool _saving = false; + bool _scanning = false; + int _revision = 0; + + @override + void initState() { + super.initState(); + _scan(); + } + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + Future _scan() async { + final revision = ++_revision; + setState(() { + _scanning = true; + _error = null; + }); + + try { + final preview = await widget.folders.preview( + widget.grant, + _recursive, + cancelled: () => !mounted || revision != _revision || widget.scope != widget.folders.scope, + ); + + if (mounted && revision == _revision) { + setState(() { + _preview = preview; + _scanning = false; + }); + } + } catch (error) { + if (mounted && revision == _revision) { + setState(() { + _error = '$error'; + _scanning = false; + }); + } + } + } + + Future _save() async { + setState(() => _saving = true); + + try { + if (widget.scope != widget.folders.scope) { + throw StateError('The selected library changed. Choose this folder again.'); + } + + await widget.folders.connect(widget.grant, name: _name.text, recursive: _recursive, shelfId: _shelf); + + if (mounted) { + Navigator.pop(context, true); + } + } catch (error) { + if (mounted) { + setState(() { + _saving = false; + _error = '$error'; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final preview = _preview; + + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final secondaryStyle = textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant); + final problemCount = (preview?.unsupported ?? 0) + (preview?.inaccessible ?? 0); + final canImport = !_saving && !_scanning && preview != null && _error == null && _name.text.trim().isNotEmpty; + + return FolderSheet( + title: widget.grant.persistent ? 'Connect folder' : 'Import folder', + canClose: !_saving, + body: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FolderLocationField(grant: widget.grant), + const SizedBox(height: Spacing.md), + TextField( + controller: _name, + enabled: !_saving, + decoration: InputDecoration( + labelText: 'Name', + errorText: _name.text.trim().isEmpty ? 'Enter a folder name' : null, + ), + textCapitalization: TextCapitalization.sentences, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: Spacing.md), + FolderShelfPicker(value: _shelf, onChanged: _saving ? null : (value) => setState(() => _shelf = value)), + FolderSubfoldersSwitch( + value: _recursive, + onChanged: _saving + ? null + : (value) { + setState(() => _recursive = value); + _scan(); + }, + ), + const Divider(height: Spacing.lg), + SizedBox( + height: Spacing.xs, + child: _scanning + ? const AppLinearProgressIndicator(semanticsLabel: 'Checking folder', minHeight: Spacing.xs) + : null, + ), + Wrap( + spacing: Spacing.xl, + runSpacing: Spacing.sm, + children: [ + _PreviewValue(label: 'Books found', value: preview?.supported.toString() ?? '—'), + _PreviewValue(label: 'Total size', value: _previewSize(preview)), + ], + ), + if (preview != null) ...[ + if (preview.unsupported > 0 || preview.inaccessible > 0) + ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: Spacing.sm), + title: Text('$problemCount ${problemCount == 1 ? 'file' : 'files'} cannot be imported'), + children: [ + if (preview.unsupported > 0) _PreviewProblem(label: 'Unsupported format', count: preview.unsupported), + if (preview.inaccessible > 0) _PreviewProblem(label: 'Could not access', count: preview.inaccessible), + ], + ), + if (!widget.grant.persistent) ...[ + const SizedBox(height: Spacing.md), + Text('Select this folder again to import new books.', style: secondaryStyle), + ], + ], + if (_error != null) ...[ + const SizedBox(height: Spacing.md), + Semantics( + liveRegion: true, + child: Text(_error!, style: TextStyle(color: colors.error)), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton(onPressed: _saving ? null : _scan, child: const Text('Check folder again')), + ), + ], + ], + ), + footer: BottomSheetFormActions( + onCancel: _saving ? null : () => Navigator.pop(context, false), + onSave: canImport ? _save : null, + saveLabel: _saving ? 'Importing…' : (widget.grant.persistent ? 'Import and watch' : 'Import books'), + ), + ); + } +} + +String _previewSize(FolderPreview? preview) { + if (preview == null) { + return '—'; + } + + if (preview.unknownSizes > 0) { + return preview.bytes == 0 ? 'Unknown' : 'At least ${formatBytes(preview.bytes)}'; + } + + return formatBytes(preview.bytes); +} + +class _PreviewValue extends StatelessWidget { + const _PreviewValue({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.xs), + Text(value, style: theme.textTheme.titleMedium), + ], + ); + } +} + +class _PreviewProblem extends StatelessWidget { + const _PreviewProblem({required this.label, required this.count}); + + final String label; + final int count; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.xs), + child: Row( + children: [ + Expanded(child: Text(label)), + const SizedBox(width: Spacing.md), + Text('$count'), + ], + ), + ); +} + +class FolderShelfPicker extends StatelessWidget { + const FolderShelfPicker({super.key, required this.value, required this.onChanged}); + final String? value; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final shelves = context.watch().shelves; + + return DropdownButtonFormField( + initialValue: shelves.any((shelf) => shelf.id == value) ? value : '', + isExpanded: true, + decoration: const InputDecoration(labelText: 'Destination shelf'), + items: [ + const DropdownMenuItem(value: '', child: Text('No shelf')), + for (final shelf in shelves) + DropdownMenuItem( + value: shelf.id, + child: Text(shelf.name, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: onChanged == null ? null : (value) => onChanged!(value == '' ? null : value), + ); + } +} diff --git a/app/lib/widgets/folders/edit_folder_sheet.dart b/app/lib/widgets/folders/edit_folder_sheet.dart new file mode 100644 index 00000000..4755170b --- /dev/null +++ b/app/lib/widgets/folders/edit_folder_sheet.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/add_book/connect_folder_sheet.dart'; +import 'package:papyrus/widgets/folders/folder_sheet.dart'; +import 'package:papyrus/widgets/folders/folder_location_field.dart'; +import 'package:papyrus/widgets/folders/folder_subfolders_switch.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; +import 'package:provider/provider.dart'; + +class EditFolderSheet extends StatefulWidget { + const EditFolderSheet({super.key, required this.folder}); + + final WatchedFolder folder; + + @override + State createState() => _EditFolderSheetState(); +} + +class _EditFolderSheetState extends State { + late final String? _scope = context.read().scope; + late bool _recursive = widget.folder.recursive; + late String? _shelf = widget.folder.shelfId; + late final _name = TextEditingController(text: widget.folder.name); + bool _saving = false; + String? _error; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + Future _save() async { + final folders = context.read(); + + if (_scope != folders.scope || !folders.folders.contains(widget.folder)) { + return; + } + + setState(() { + _saving = true; + _error = null; + }); + + try { + await folders.update(widget.folder, name: _name.text, recursive: _recursive, shelfId: _shelf, changeShelf: true); + + if (mounted) { + Navigator.pop(context); + } + } catch (error) { + if (mounted) { + setState(() { + _saving = false; + _error = '$error'; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final folders = context.watch(); + final store = context.watch(); + final active = _scope == folders.scope && folders.folders.contains(widget.folder); + final enabled = active && folders.ready && !_saving; + final changed = + _name.text.trim() != widget.folder.name || + _recursive != widget.folder.recursive || + _shelf != widget.folder.shelfId; + final shelfMissing = _shelf != null && store.getShelf(_shelf!) == null; + final theme = Theme.of(context); + + return FolderSheet( + title: 'Edit folder', + canClose: !_saving, + body: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!active) + const Text('This folder is no longer available in the selected library.') + else ...[ + FolderLocationField(grant: widget.folder.grant), + const SizedBox(height: Spacing.md), + TextField( + controller: _name, + enabled: enabled, + decoration: InputDecoration( + labelText: 'Name', + errorText: _name.text.trim().isEmpty ? 'Enter a folder name' : null, + ), + textCapitalization: TextCapitalization.sentences, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: Spacing.md), + FolderShelfPicker(value: _shelf, onChanged: enabled ? (value) => setState(() => _shelf = value) : null), + if (shelfMissing) ...[ + const SizedBox(height: Spacing.sm), + Text( + 'This shelf was deleted. Choose another shelf or No shelf.', + style: TextStyle(color: theme.colorScheme.error), + ), + const SizedBox(height: Spacing.sm), + ], + FolderSubfoldersSwitch( + value: _recursive, + onChanged: enabled ? (value) => setState(() => _recursive = value) : null, + ), + ], + if (_error != null) ...[ + const SizedBox(height: Spacing.md), + Semantics( + liveRegion: true, + child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + ), + ], + ], + ), + footer: BottomSheetFormActions( + onCancel: _saving ? null : () => Navigator.pop(context), + onSave: enabled && changed && _name.text.trim().isNotEmpty ? _save : null, + saveLabel: _saving ? 'Saving…' : 'Save changes', + ), + ); + } +} diff --git a/app/lib/widgets/folders/folder_details_sheet.dart b/app/lib/widgets/folders/folder_details_sheet.dart new file mode 100644 index 00000000..56871212 --- /dev/null +++ b/app/lib/widgets/folders/folder_details_sheet.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/folders/folder_identity.dart'; +import 'package:papyrus/widgets/folders/edit_folder_sheet.dart'; +import 'package:papyrus/widgets/folders/folder_sheet.dart'; +import 'package:papyrus/widgets/folders/folder_status.dart'; +import 'package:papyrus/widgets/folders/folder_progress_bar.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_actions.dart'; +import 'package:provider/provider.dart'; + +class FolderDetailsSheet extends StatefulWidget { + const FolderDetailsSheet({super.key, required this.folder}); + + final WatchedFolder folder; + + @override + State createState() => _FolderDetailsSheetState(); +} + +class _FolderDetailsSheetState extends State { + late final String? _scope = context.read().scope; + bool _busy = false; + bool _scanning = false; + String? _error; + + Future _pause() async { + final folders = context.read(); + + if (_scope != folders.scope || !folders.folders.contains(widget.folder)) { + return; + } + + setState(() { + _busy = true; + _error = null; + }); + + try { + await folders.update(widget.folder, enabled: !widget.folder.enabled); + } catch (error) { + if (mounted) { + setState(() => _error = '$error'); + } + } finally { + if (mounted) { + setState(() => _busy = false); + } + } + } + + Future _action(String action) async { + final folders = context.read(); + + if (_scope != folders.scope || !folders.folders.contains(widget.folder)) { + return; + } + + switch (action) { + case 'edit': + await showFolderSheet(context, builder: (_) => EditFolderSheet(folder: widget.folder)); + case 'pause': + await _pause(); + default: + Navigator.pop(context, action); + } + } + + Future _scan() async { + final folders = context.read(); + + if (_scanning || _scope != folders.scope || !folders.folders.contains(widget.folder)) { + return; + } + + setState(() { + _scanning = true; + _error = null; + }); + + try { + await folders.scanNow(widget.folder.id); + } catch (error) { + if (mounted && _scope == folders.scope) { + setState(() => _error = '$error'); + } + } finally { + if (mounted) { + setState(() => _scanning = false); + } + } + } + + @override + Widget build(BuildContext context) { + final folders = context.watch(); + final store = context.watch(); + final uploads = context.watch(); + final guest = context.watch().isOfflineMode; + final folder = widget.folder; + final active = _scope == folders.scope && folders.folders.contains(folder); + final enabled = active && folders.ready && !_busy; + final working = folder.status == FolderStatus.scanning || folder.status == FolderStatus.importing; + final ids = folders.bookIds(folder.id); + final count = ids.where((id) => store.getBook(id) != null).length; + final pending = uploads.pendingTasks + .where((task) => ids.contains(task.bookId)) + .map((task) => task.bookId) + .toSet() + .length; + final shelf = folder.shelfId == null ? null : store.getShelf(folder.shelfId!); + final shelfMissing = folder.shelfId != null && shelf == null; + final theme = Theme.of(context); + final secondary = theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant); + + final recovery = switch (folder.status) { + _ when !folder.grant.persistent => ('Select again', 'select'), + FolderStatus.accessNeeded || FolderStatus.unavailable => ('Reconnect', 'reconnect'), + _ when shelfMissing => ('Choose shelf', 'edit'), + FolderStatus.attention => ('Review files', 'review'), + _ => null, + }; + final buttonStyle = ButtonStyle( + shape: theme.outlinedButtonTheme.style?.shape, + minimumSize: const WidgetStatePropertyAll(Size(0, TouchTargets.mobileRecommended)), + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm)), + ); + + return FolderSheet( + title: folder.name, + canClose: !_busy, + contentPadding: const EdgeInsets.all(Spacing.md), + header: Row( + children: [ + Expanded(child: FolderIdentity(folder: folder)), + const SizedBox(width: Spacing.sm), + PopupMenuButton( + tooltip: '${folder.name} options', + enabled: enabled, + popUpAnimationStyle: AppMotion.animationStyle(context), + onSelected: _action, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'edit', child: Text('Edit folder')), + if (folder.grant.persistent) + PopupMenuItem(value: 'pause', child: Text(folder.enabled ? 'Pause watching' : 'Resume watching')), + const PopupMenuItem(value: 'review', child: Text('Review files')), + if (folder.status == FolderStatus.attention) + const PopupMenuItem(value: 'retry', child: Text('Retry failed imports')), + const PopupMenuDivider(), + PopupMenuItem( + value: 'stop', + child: Text('Stop watching', style: TextStyle(color: theme.colorScheme.error)), + ), + ], + ), + ], + ), + body: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!active) + const Text('This folder is no longer available in the selected library.') + else ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + decoration: ShapeDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + shape: theme.cardTheme.shape ?? const RoundedRectangleBorder(), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: Spacing.md, + runSpacing: Spacing.xs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FolderStatusLabel(folder: folder, shelfMissing: shelfMissing), + if (recovery != null) + TextButton( + onPressed: enabled ? () => _action(recovery.$2) : null, + child: Text(recovery.$1), + ), + ], + ), + if (folder.error != null) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Text(folder.error!, style: secondary), + ), + ], + ), + ), + FolderProgressBar(folder: folder), + const SizedBox(height: Spacing.md), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + FilledButton.icon( + style: buttonStyle, + onPressed: enabled ? () => Navigator.pop(context, 'books') : null, + icon: const Icon(Icons.menu_book_outlined, size: IconSizes.small), + label: Text('View $count ${count == 1 ? 'book' : 'books'}'), + ), + if (folder.grant.persistent) + OutlinedButton.icon( + style: buttonStyle, + onPressed: enabled && !working && !_scanning && folder.enabled ? _scan : null, + icon: const Icon(Icons.refresh, size: IconSizes.small), + label: const Text('Scan now'), + ), + ], + ), + const SizedBox(height: Spacing.md), + const Divider(height: 1), + const SizedBox(height: Spacing.md), + Wrap( + spacing: Spacing.xl, + runSpacing: Spacing.sm, + children: [ + _FolderProperty( + label: 'Destination shelf', + value: shelfMissing ? 'Unavailable' : shelf?.name ?? 'No shelf', + ), + _FolderProperty(label: 'Subfolders', value: folder.recursive ? 'Included' : 'Excluded'), + _FolderProperty( + label: 'Last successful check', + value: folderLastChecked(folder, includeLabel: false), + ), + ], + ), + if (!guest && pending > 0) ...[ + const SizedBox(height: Spacing.sm), + Text('$pending waiting to upload', style: secondary), + ], + ], + if (_error != null) ...[ + const SizedBox(height: Spacing.md), + Semantics( + liveRegion: true, + child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + ), + ], + ], + ), + footer: BottomSheetActions( + primary: OutlinedButton(onPressed: _busy ? null : () => Navigator.pop(context), child: const Text('Close')), + ), + ); + } +} + +class _FolderProperty extends StatelessWidget { + const _FolderProperty({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return IntrinsicWidth( + child: Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.xs), + SelectableText(value, style: theme.textTheme.bodyMedium), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/folders/folder_identity.dart b/app/lib/widgets/folders/folder_identity.dart new file mode 100644 index 00000000..4b53bcc9 --- /dev/null +++ b/app/lib/widgets/folders/folder_identity.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +class FolderIdentity extends StatelessWidget { + const FolderIdentity({super.key, required this.folder, this.titleStyle}); + + final WatchedFolder folder; + final TextStyle? titleStyle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Row( + children: [ + Container( + width: TouchTargets.desktopRecommended, + height: TouchTargets.desktopRecommended, + decoration: ShapeDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.1), + shape: theme.cardTheme.shape ?? const RoundedRectangleBorder(), + ), + child: Icon(Icons.folder_outlined, color: theme.colorScheme.primary), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(folder.name, style: titleStyle, maxLines: 2, overflow: TextOverflow.ellipsis), + if (folder.grant.location != folder.grant.name) ...[ + const SizedBox(height: Spacing.xs), + Tooltip( + message: folder.grant.location, + child: Text( + folder.grant.location, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ], + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/widgets/folders/folder_issues_sheet.dart b/app/lib/widgets/folders/folder_issues_sheet.dart new file mode 100644 index 00000000..0cd9a435 --- /dev/null +++ b/app/lib/widgets/folders/folder_issues_sheet.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/folders/folder_sheet.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:provider/provider.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_actions.dart'; + +class FolderIssuesSheet extends StatefulWidget { + const FolderIssuesSheet({super.key, required this.folder, required this.folders}); + final WatchedFolder folder; + final WatchedFolders folders; + + @override + State createState() => _FolderIssuesSheetState(); +} + +class _FolderIssuesSheetState extends State { + late final String? _scope = widget.folders.scope; + late Future>> _issues = widget.folders.issues(widget.folder.id); + String? _error; + bool _busy = false; + + Future _review(Map record, bool import) async { + if (_scope != widget.folders.scope) { + return; + } + + setState(() { + _busy = true; + _error = null; + }); + + try { + if (record['state'] == 'failed') { + await widget.folders.retry(widget.folder); + } else { + await widget.folders.review(widget.folder, record, import: import); + } + + if (mounted) { + setState(() => _issues = widget.folders.issues(widget.folder.id)); + } + } catch (error) { + if (mounted) { + setState(() => _error = '$error'); + } + } finally { + if (mounted) { + setState(() => _busy = false); + } + } + } + + @override + Widget build(BuildContext context) { + final folders = context.watch(); + final store = context.watch(); + final active = _scope == folders.scope && folders.folders.contains(widget.folder); + final theme = Theme.of(context); + + return FolderSheet( + title: 'Review files', + canClose: !_busy, + body: !active + ? const Text('This folder is no longer available in the selected library.') + : FutureBuilder>>( + future: _issues, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text('${snapshot.error}'); + } + + final records = snapshot.data; + + if (records == null) { + return const Center(child: AppCircularProgressIndicator(semanticsLabel: 'Loading files')); + } + + if (records.isEmpty) { + return const Text('No files need review.'); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_error != null) + Semantics( + liveRegion: true, + child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + ), + for (final record in records) ...[ + Text(record['name'] as String? ?? 'Source file', style: theme.textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + if (record['state'] == 'changed') ...[ + Text( + 'In your library: ${store.getBook(record['bookId'] as String? ?? '')?.title ?? 'Previously imported book'}', + ), + const SizedBox(height: Spacing.xs), + const Text( + 'The source file has changed. Keeping the existing book preserves its reading progress and annotations.', + ), + ] else + Text( + record['state'] == 'failed' + ? '${record['error']}' + : 'Removed from your library. It will not be imported automatically.', + ), + const SizedBox(height: Spacing.sm), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + if (record['state'] == 'changed') + TextButton( + onPressed: _busy ? null : () => _review(record, false), + child: const Text('Keep existing'), + ), + OutlinedButton( + onPressed: _busy ? null : () => _review(record, true), + child: Text(switch (record['state']) { + 'changed' => 'Import separately', + 'failed' => 'Retry', + _ => 'Reimport', + }), + ), + ], + ), + const SizedBox(height: Spacing.md), + const Divider(), + const SizedBox(height: Spacing.md), + ], + ], + ); + }, + ), + footer: BottomSheetActions( + primary: OutlinedButton(onPressed: _busy ? null : () => Navigator.pop(context), child: const Text('Close')), + ), + ); + } +} diff --git a/app/lib/widgets/folders/folder_location_field.dart b/app/lib/widgets/folders/folder_location_field.dart new file mode 100644 index 00000000..2108dff6 --- /dev/null +++ b/app/lib/widgets/folders/folder_location_field.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/folders/directory_access.dart'; + +class FolderLocationField extends StatelessWidget { + const FolderLocationField({super.key, required this.grant}); + + final FolderGrant grant; + + @override + Widget build(BuildContext context) => InputDecorator( + decoration: InputDecoration( + labelText: grant.location == grant.name ? 'Selected folder' : 'Folder path', + prefixIcon: const Icon(Icons.folder_outlined), + ), + child: SelectableText(grant.location, style: Theme.of(context).textTheme.bodyLarge), + ); +} diff --git a/app/lib/widgets/folders/folder_progress_bar.dart b/app/lib/widgets/folders/folder_progress_bar.dart new file mode 100644 index 00000000..a0690d9f --- /dev/null +++ b/app/lib/widgets/folders/folder_progress_bar.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; + +class FolderProgressBar extends StatelessWidget { + const FolderProgressBar({super.key, required this.folder}); + + final WatchedFolder folder; + + @override + Widget build(BuildContext context) { + final importing = folder.status == FolderStatus.importing; + final working = importing || folder.status == FolderStatus.scanning; + + return SizedBox( + height: Spacing.xs, + child: working + ? AppLinearProgressIndicator( + value: importing ? folder.progress?.fraction ?? 0 : null, + minHeight: Spacing.xs, + semanticsLabel: + '${folder.statusLabel} ${folder.name}${folder.progress == null ? '' : ': ${folder.progress!.label}'}', + ) + : null, + ); + } +} diff --git a/app/lib/widgets/folders/folder_sheet.dart b/app/lib/widgets/folders/folder_sheet.dart new file mode 100644 index 00000000..a10b8a26 --- /dev/null +++ b/app/lib/widgets/folders/folder_sheet.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; + +Future showFolderSheet(BuildContext context, {required WidgetBuilder builder}) { + final reduceAnimations = AppMotion.disabled(context); + + return showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: false, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), + sheetAnimationStyle: AppMotion.animationStyle(context), + builder: (context) => AppMotionScope(reduceAnimations: reduceAnimations, child: builder(context)), + ); +} + +/// Keeps asynchronous folder operations from dismissing their owning sheet. +class FolderSheet extends StatelessWidget { + const FolderSheet({ + super.key, + required this.title, + required this.body, + required this.footer, + this.header, + this.contentPadding = const EdgeInsets.all(Spacing.lg), + this.canClose = true, + }); + + final String title; + final Widget body; + final Widget footer; + final Widget? header; + final EdgeInsetsGeometry contentPadding; + final bool canClose; + + @override + Widget build(BuildContext context) => PopScope( + canPop: canClose, + child: AppBottomSheet( + title: title, + header: header, + canClose: canClose, + body: body, + footer: footer, + contentPadding: contentPadding, + ), + ); +} diff --git a/app/lib/widgets/folders/folder_status.dart b/app/lib/widgets/folders/folder_status.dart new file mode 100644 index 00000000..a45a1eb1 --- /dev/null +++ b/app/lib/widgets/folders/folder_status.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +String folderLastChecked(WatchedFolder folder, {bool includeLabel = true}) => folder.lastScan == null + ? 'Not checked yet' + : '${includeLabel ? 'Checked ' : ''}${DateFormat.yMMMd().add_Hm().format(folder.lastScan!.toLocal())}'; + +class FolderStatusLabel extends StatelessWidget { + const FolderStatusLabel({super.key, required this.folder, this.shelfMissing = false}); + + final WatchedFolder folder; + final bool shelfMissing; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final style = theme.textTheme.bodyMedium!; + final lineHeight = MediaQuery.textScalerOf(context).scale(style.fontSize!) * (style.height ?? 1.4); + final progress = folder.progress; + final needsAttention = + shelfMissing || + const [FolderStatus.accessNeeded, FolderStatus.unavailable, FolderStatus.attention].contains(folder.status); + final color = needsAttention ? colors.error : colors.onSurfaceVariant; + final icon = switch (folder.status) { + _ when shelfMissing => Icons.warning_amber_outlined, + FolderStatus.scanning || FolderStatus.importing => Icons.sync, + FolderStatus.upToDate => Icons.check, + FolderStatus.paused when !folder.grant.persistent => Icons.upload_file_outlined, + FolderStatus.paused => Icons.pause, + FolderStatus.accessNeeded => Icons.lock_outline, + FolderStatus.unavailable => Icons.folder_off_outlined, + FolderStatus.attention => Icons.warning_amber_outlined, + }; + final label = switch (folder.status) { + FolderStatus.importing when progress != null => + '${progress.processed}/${progress.total}\n${progress.remaining} left', + _ when shelfMissing => 'Shelf needs attention', + FolderStatus.paused when !folder.grant.persistent => 'Manual import', + _ => folder.statusLabel, + }; + + return SizedBox( + height: lineHeight * 2, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: IconSizes.small, color: color), + const SizedBox(width: Spacing.xs), + Flexible( + child: Text( + label, + style: style.copyWith(color: color), + maxLines: 2, + overflow: TextOverflow.ellipsis, + semanticsLabel: folder.status == FolderStatus.importing && progress != null + ? 'Importing books: ${progress.label}' + : null, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/widgets/folders/folder_subfolders_switch.dart b/app/lib/widgets/folders/folder_subfolders_switch.dart new file mode 100644 index 00000000..d9958187 --- /dev/null +++ b/app/lib/widgets/folders/folder_subfolders_switch.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class FolderSubfoldersSwitch extends StatelessWidget { + const FolderSubfoldersSwitch({super.key, required this.value, required this.onChanged}); + + final bool value; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) => Theme( + data: Theme.of(context).copyWith( + splashFactory: NoSplash.splashFactory, + highlightColor: Colors.transparent, + hoverColor: Colors.transparent, + ), + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Include subfolders'), + value: value, + onChanged: onChanged, + overlayColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.focused) ? null : Colors.transparent, + ), + ), + ); +} diff --git a/app/lib/widgets/folders/folder_table.dart b/app/lib/widgets/folders/folder_table.dart new file mode 100644 index 00000000..a6a88e98 --- /dev/null +++ b/app/lib/widgets/folders/folder_table.dart @@ -0,0 +1,295 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/folders/folder_identity.dart'; +import 'package:papyrus/widgets/folders/folder_status.dart'; +import 'package:papyrus/widgets/folders/folder_progress_bar.dart'; +import 'package:provider/provider.dart'; + +class FolderTable extends StatelessWidget { + const FolderTable({ + super.key, + required this.folders, + required this.busy, + required this.padding, + required this.onAction, + }); + + final List folders; + final bool busy; + final EdgeInsets padding; + final void Function(WatchedFolder folder, String action) onAction; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth - padding.horizontal >= 920 && MediaQuery.textScalerOf(context).scale(1) <= 1.3; + final theme = Theme.of(context); + + return ListView.builder( + padding: padding, + itemCount: folders.length + 2, + itemBuilder: (context, index) { + if (index == 0) { + return Column( + children: [ + const Divider(height: 1), + if (wide) + Padding( + padding: const EdgeInsets.all(Spacing.md), + child: DefaultTextStyle( + style: theme.textTheme.labelSmall!.copyWith(color: theme.colorScheme.onSurfaceVariant), + child: const _FolderColumns( + identity: Text('FOLDER'), + status: Text('STATUS'), + books: Text('BOOKS'), + shelf: Text('DESTINATION SHELF'), + checked: Text('LAST CHECKED'), + action: SizedBox.shrink(), + ), + ), + ), + ], + ); + } + + if (index == folders.length + 1) { + return const Divider(height: 1); + } + + final folder = folders[index - 1]; + + return _FolderRow( + key: ValueKey(folder.id), + folder: folder, + busy: busy, + wide: wide, + showDivider: wide || index > 1, + onAction: (action) => onAction(folder, action), + ); + }, + ); + }, + ); +} + +class _FolderColumns extends StatelessWidget { + const _FolderColumns({ + required this.identity, + required this.status, + required this.books, + required this.shelf, + required this.checked, + required this.action, + }); + + final Widget identity; + final Widget status; + final Widget books; + final Widget shelf; + final Widget checked; + final Widget action; + + @override + Widget build(BuildContext context) => Row( + children: [ + Expanded(flex: 4, child: identity), + const SizedBox(width: Spacing.md), + SizedBox(width: MediaQuery.textScalerOf(context).scale(180), child: status), + const SizedBox(width: Spacing.md), + SizedBox(width: 64, child: books), + const SizedBox(width: Spacing.md), + Expanded(flex: 2, child: shelf), + const SizedBox(width: Spacing.md), + SizedBox(width: 136, child: checked), + SizedBox(width: 128, child: action), + ], + ); +} + +class _FolderRow extends StatelessWidget { + const _FolderRow({ + super.key, + required this.folder, + required this.busy, + required this.wide, + required this.showDivider, + required this.onAction, + }); + + final WatchedFolder folder; + final bool busy; + final bool wide; + final bool showDivider; + final ValueChanged onAction; + + @override + Widget build(BuildContext context) { + final folders = context.watch(); + final store = context.watch(); + final count = folders.bookIds(folder.id).where((id) => store.getBook(id) != null).length; + final shelf = folder.shelfId == null ? null : store.getShelf(folder.shelfId!); + final shelfMissing = folder.shelfId != null && shelf == null; + final shelfName = shelfMissing ? 'Unavailable' : shelf?.name ?? 'No shelf'; + final theme = Theme.of(context); + final action = switch (folder.status) { + _ when !folder.grant.persistent => ('Select again', 'select'), + FolderStatus.accessNeeded => ('Reconnect', 'reconnect'), + _ when shelfMissing => ('Choose shelf', 'edit'), + FolderStatus.paused => ('Resume', 'pause'), + FolderStatus.attention => ('Review', 'review'), + _ => null, + }; + final identity = FolderIdentity(folder: folder, titleStyle: theme.textTheme.titleMedium); + final status = FolderStatusLabel(folder: folder, shelfMissing: shelfMissing); + final books = Text( + '$count', + style: theme.textTheme.bodyMedium, + semanticsLabel: '$count books in library', + ); + final actionStyle = theme.textTheme.labelLarge!; + final actionHeight = math.max( + TouchTargets.mobileRecommended, + MediaQuery.textScalerOf(context).scale(actionStyle.fontSize!) * (actionStyle.height ?? 1.4) * 2, + ); + final actionButton = SizedBox( + height: actionHeight, + child: action == null + ? null + : TextButton( + onPressed: busy ? null : () => onAction(action.$2), + style: TextButton.styleFrom( + minimumSize: const Size(0, TouchTargets.mobileRecommended), + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(action.$1, maxLines: 2, overflow: TextOverflow.ellipsis)), + const SizedBox(width: Spacing.xs), + const Icon(Icons.chevron_right, size: IconSizes.small), + ], + ), + ), + ); + + return Column( + children: [ + if (showDivider) const Divider(height: 1), + Material( + type: MaterialType.transparency, + child: InkWell( + onTap: busy ? null : () => onAction('details'), + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: wide + ? _FolderColumns( + identity: identity, + status: status, + books: books, + shelf: Tooltip( + message: shelfName, + child: Text(shelfName, maxLines: 2, overflow: TextOverflow.ellipsis), + ), + checked: Text( + folderLastChecked(folder, includeLabel: false), + style: theme.textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + action: Align(alignment: Alignment.centerRight, child: actionButton), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + identity, + const SizedBox(height: Spacing.md), + _FolderSummary( + status: status, + books: books, + shelf: Text(shelfName), + checked: Text(folderLastChecked(folder, includeLabel: false)), + ), + Align(alignment: Alignment.centerRight, child: actionButton), + ], + ), + ), + ), + ), + FolderProgressBar(folder: folder), + ], + ); + } +} + +class _FolderSummary extends StatelessWidget { + const _FolderSummary({required this.status, required this.books, required this.shelf, required this.checked}); + + final Widget status; + final Widget books; + final Widget shelf; + final Widget checked; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final theme = Theme.of(context); + final textScale = MediaQuery.textScalerOf(context); + final columns = switch (constraints.maxWidth) { + final width when width >= textScale.scale(680) => 4, + final width when width >= textScale.scale(300) => 2, + _ => 1, + }; + final fields = [('Status', status), ('Books', books), ('Destination shelf', shelf), ('Last checked', checked)]; + final valueHeight = + textScale.scale(theme.textTheme.bodyMedium!.fontSize!) * (theme.textTheme.bodyMedium!.height ?? 1.4) * 2; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var start = 0; start < fields.length; start += columns) ...[ + if (start > 0) const SizedBox(height: Spacing.sm), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var index = start; index < start + columns; index++) ...[ + if (index > start) const SizedBox(width: Spacing.md), + Expanded( + flex: columns == 2 && index.isEven ? 3 : 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fields[index].$1, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: Spacing.xs), + SizedBox( + height: valueHeight, + child: Align( + alignment: Alignment.centerLeft, + heightFactor: 1, + child: DefaultTextStyle( + style: theme.textTheme.bodyMedium!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + child: fields[index].$2, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ], + ], + ); + }, + ); +} diff --git a/app/lib/widgets/library/library_advanced_filter_sheet.dart b/app/lib/widgets/library/library_advanced_filter_sheet.dart index 8219be7f..4b4fcb47 100644 --- a/app/lib/widgets/library/library_advanced_filter_sheet.dart +++ b/app/lib/widgets/library/library_advanced_filter_sheet.dart @@ -18,6 +18,8 @@ class LibraryAdvancedFilterSheet extends StatefulWidget { final ScrollController? scrollController; final List? sourceBooks; final LibraryFilterOptions? filterOptions; + final List> folderOptions; + final Map> folderBookIds; const LibraryAdvancedFilterSheet({ super.key, @@ -26,6 +28,8 @@ class LibraryAdvancedFilterSheet extends StatefulWidget { this.scrollController, this.sourceBooks, this.filterOptions, + this.folderOptions = const [], + this.folderBookIds = const {}, }); static Future show( @@ -34,6 +38,8 @@ class LibraryAdvancedFilterSheet extends StatefulWidget { required DataStore dataStore, List? sourceBooks, LibraryFilterOptions? filterOptions, + List> folderOptions = const [], + Map> folderBookIds = const {}, }) { return showModalBottomSheet( sheetAnimationStyle: AppMotion.animationStyle(context), @@ -56,6 +62,8 @@ class LibraryAdvancedFilterSheet extends StatefulWidget { dataStore: dataStore, sourceBooks: sourceBooks, filterOptions: filterOptions, + folderOptions: folderOptions, + folderBookIds: folderBookIds, ), ), ); @@ -82,6 +90,8 @@ class LibraryAdvancedFilterSheet extends StatefulWidget { scrollController: scrollController, sourceBooks: sourceBooks, filterOptions: filterOptions, + folderOptions: folderOptions, + folderBookIds: folderBookIds, ), ), ); @@ -106,7 +116,14 @@ class _LibraryAdvancedFilterSheetState extends State : LibraryFilterOptions.fromDataStore(widget.dataStore, books: _sourceBooks)); int get _matchingBookCount { - return widget.libraryProvider.filterBooks(_sourceBooks, dataStore: widget.dataStore, filters: _draft).length; + return widget.libraryProvider + .filterBooks( + _sourceBooks, + dataStore: widget.dataStore, + filters: _draft, + folderBookIds: widget.folderBookIds, + ) + .length; } void _updateDraft(LibraryFilters filters) { @@ -213,6 +230,7 @@ class _LibraryAdvancedFilterSheetState extends State ); final shelfOptions = _withSelectedOptions(_options.shelves, _draft.shelfIds, _shelfLabel); + final folderOptions = _withSelectedOptions(widget.folderOptions, _draft.folderIds, (_) => 'Unavailable folder'); final topicOptions = _withSelectedOptions(_options.topics, _draft.topicIds, _topicLabel); final readingStatusOptions = _withSelectedOptions( @@ -231,7 +249,7 @@ class _LibraryAdvancedFilterSheetState extends State publisherOptions.isNotEmpty || seriesOptions.isNotEmpty; - final hasOrganizationOptions = shelfOptions.isNotEmpty || topicOptions.isNotEmpty; + final hasOrganizationOptions = shelfOptions.isNotEmpty || topicOptions.isNotEmpty || folderOptions.isNotEmpty; final hasRatingOptions = availableRatings.isNotEmpty || showUnrated; return AppBottomSheet( @@ -296,6 +314,13 @@ class _LibraryAdvancedFilterSheetState extends State selectedValues: _draft.shelfIds, onChanged: (values) => _updateDraft(_draft.copyWith(shelfIds: values)), ), + if (folderOptions.isNotEmpty) + _SearchableFacet( + label: 'Source folders', + options: folderOptions, + selectedValues: _draft.folderIds, + onChanged: (values) => _updateDraft(_draft.copyWith(folderIds: values)), + ), if (topicOptions.isNotEmpty) _SearchableFacet( label: 'Topics', diff --git a/app/lib/widgets/library/library_drawer.dart b/app/lib/widgets/library/library_drawer.dart index d82b875e..882fba42 100644 --- a/app/lib/widgets/library/library_drawer.dart +++ b/app/lib/widgets/library/library_drawer.dart @@ -61,6 +61,15 @@ class LibraryDrawer extends StatelessWidget { context.go('/library/shelves'); }, ), + _DrawerNavItem( + icon: Icons.folder_outlined, + label: 'Folders', + isSelected: currentPath.startsWith('/library/folders'), + onTap: () { + Navigator.of(context).pop(); + context.go('/library/folders'); + }, + ), _DrawerNavItem( icon: Icons.public, label: 'Catalogs', diff --git a/app/lib/widgets/profile/storage_sync_settings_section.dart b/app/lib/widgets/profile/storage_sync_settings_section.dart index 68735e8c..6e5a2e0f 100644 --- a/app/lib/widgets/profile/storage_sync_settings_section.dart +++ b/app/lib/widgets/profile/storage_sync_settings_section.dart @@ -1,3 +1,4 @@ +import 'package:papyrus/folders/watched_folders.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'dart:async'; @@ -532,17 +533,23 @@ class StorageSyncSettingsSection extends StatelessWidget { try { final files = context.read(); + final folders = context.read(); + final database = context.read(); + await folders?.suspend(); - await context.read().clearGuestLibrary( + await database.clearGuestLibrary( deleteFiles: (books) async { for (final book in books) { await files.deleteBookFile(book.id); } await files.clearCoverFiles(MediaStorageScope.localGuest); + await folders?.clear(); }, ); + folders?.resume(); + messenger.showSnackBar( snackBarAnimationStyle: snackBarAnimationStyle, const SnackBar(content: Text('Local library cleared.')), @@ -575,7 +582,14 @@ class StorageSyncSettingsSection extends StatelessWidget { final scope = context.read().activeScope; final powerSyncService = context.read(); final importService = context.read(); - await powerSyncService.clearAuthenticatedCache(); + final folders = context.read(); + await folders?.suspend(); + + try { + await powerSyncService.clearAuthenticatedCache(); + } finally { + folders?.resume(); + } if (scope != null) { await importService.clearCoverFiles(scope); diff --git a/app/lib/widgets/shell/adaptive_app_shell.dart b/app/lib/widgets/shell/adaptive_app_shell.dart index 6a6ec26e..6c360f50 100644 --- a/app/lib/widgets/shell/adaptive_app_shell.dart +++ b/app/lib/widgets/shell/adaptive_app_shell.dart @@ -65,6 +65,12 @@ class AdaptiveAppShell extends StatelessWidget { count: dataStore.shelves.length, selectedIcon: Icons.shelves, ), + const AppShellNavItem( + path: '/library/folders', + label: 'Folders', + icon: Icons.folder_outlined, + selectedIcon: Icons.folder, + ), AppShellNavItem( path: '/library/catalogs', label: 'Catalogs', diff --git a/app/macos/Podfile b/app/macos/Podfile index 167132a2..d648080b 100644 --- a/app/macos/Podfile +++ b/app/macos/Podfile @@ -38,5 +38,13 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) + + target.build_configurations.each do |config| + minimum = config.build_settings['MACOSX_DEPLOYMENT_TARGET'] + + if minimum.nil? || Gem::Version.new(minimum) < Gem::Version.new('12.0') + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '12.0' + end + end end end diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj index a5325c3e..41461cce 100644 --- a/app/macos/Runner.xcodeproj/project.pbxproj +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -461,7 +461,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -543,7 +543,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -593,7 +593,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements index dddb8a30..8fcca880 100644 --- a/app/macos/Runner/DebugProfile.entitlements +++ b/app/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,11 @@ com.apple.security.network.server + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + com.apple.security.files.bookmarks.app-scope + diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift index 3cc05eb2..fe37877f 100644 --- a/app/macos/Runner/MainFlutterWindow.swift +++ b/app/macos/Runner/MainFlutterWindow.swift @@ -2,6 +2,7 @@ import Cocoa import FlutterMacOS class MainFlutterWindow: NSWindow { + private var folderAccess: PapyrusFolderAccess? override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame @@ -9,7 +10,199 @@ class MainFlutterWindow: NSWindow { self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) + folderAccess = PapyrusFolderAccess(messenger: flutterViewController.engine.binaryMessenger) super.awakeFromNib() } } + +// Read-only access to user-selected directories. Bookmarks never leave this device. +private final class PapyrusFolderAccess: NSObject { + private let queue = DispatchQueue(label: "papyrus.folder-access") + private let defaults = UserDefaults.standard + private var cursors: [String: FolderCursor] = [:] + private var watched: [String: URL] = [:] + + init(messenger: FlutterBinaryMessenger) { + super.init() + let channel = FlutterMethodChannel(name: "papyrus/folders", binaryMessenger: messenger) + channel.setMethodCallHandler { [self] call, result in + if call.method == "pick" { + pick(result) + return + } + let arguments = call.arguments as? [String: Any] ?? [:] + queue.async { [self] in + do { + let value = try perform(call.method, arguments) + DispatchQueue.main.async { result(value) } + } catch { + let failure = error as NSError + let access = failure.domain == "PapyrusFolderAccess" || failure.code == NSFileReadNoPermissionError + DispatchQueue.main.async { + result(FlutterError(code: access ? "access" : "unavailable", + message: access ? "Reconnect this folder to allow access." : "Source file unavailable offline or folder disconnected. \(error.localizedDescription)", details: nil)) + } + } + } + } + } + + private func bookmarkKey(_ key: String) -> String { "papyrus.folder-grant.\(key)" } + + private func resolve(_ key: String) throws -> URL { + guard let data = defaults.data(forKey: bookmarkKey(key)) else { + throw NSError(domain: "PapyrusFolderAccess", code: 1) + } + var stale = false + let url = try URL(resolvingBookmarkData: data, options: [.withSecurityScope, .withoutUI], bookmarkDataIsStale: &stale) + guard !stale else { throw NSError(domain: "PapyrusFolderAccess", code: 1) } + return url + } + + private func access(_ key: String, _ operation: (URL) throws -> T) throws -> T { + let url = try resolve(key) + guard url.startAccessingSecurityScopedResource() else { throw NSError(domain: "PapyrusFolderAccess", code: 1) } + defer { url.stopAccessingSecurityScopedResource() } + return try operation(url) + } + + private func source(_ root: URL, _ key: String) throws -> URL { + let url = root.appendingPathComponent(key).standardizedFileURL + let resolved = url.resolvingSymlinksInPath() + let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey]) + guard resolved.path.hasPrefix(directoryPrefix(root.resolvingSymlinksInPath())), + values.isSymbolicLink != true, values.isRegularFile == true else { + throw NSError(domain: "PapyrusFolderAccess", code: 1) + } + return url + } + + private func directoryPrefix(_ root: URL) -> String { + root.path.hasSuffix("/") ? root.path : root.path + "/" + } + + private func metadata(_ url: URL, root: URL) throws -> [String: Any] { + let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + return ["key": String(url.path.dropFirst(directoryPrefix(root).count)), "name": url.lastPathComponent, + "size": values.fileSize ?? -1, "modified": Int64((values.contentModificationDate?.timeIntervalSince1970 ?? 0) * 1000)] + } + + private func perform(_ method: String, _ arguments: [String: Any]) throws -> Any? { + let key = arguments["grant"] as? String ?? "" + switch method { + case "watch": + let root = try resolve(key) + guard root.startAccessingSecurityScopedResource() else { throw NSError(domain: "PapyrusFolderAccess", code: 1) } + watched.removeValue(forKey: key)?.stopAccessingSecurityScopedResource() + watched[key] = root + return root.path + case "unwatch": watched.removeValue(forKey: key)?.stopAccessingSecurityScopedResource(); return nil + case "restore": return try access(key) { FileManager.default.fileExists(atPath: $0.path) } + case "start": + let root = try resolve(key) + guard root.startAccessingSecurityScopedResource() else { throw NSError(domain: "PapyrusFolderAccess", code: 1) } + let failures = FolderFailures() + let prefix = directoryPrefix(root) + guard FileManager.default.fileExists(atPath: root.path), + let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey, .isRegularFileKey], + options: [.skipsHiddenFiles], errorHandler: { url, error in + failures.entries.append(["key": String(url.path.dropFirst(prefix.count)), + "name": url.lastPathComponent, "size": 0, "modified": 0, "error": error.localizedDescription]) + return true + }) else { + root.stopAccessingSecurityScopedResource() + throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError) + } + let cursor = UUID().uuidString + cursors[cursor] = FolderCursor(root: root, enumerator: enumerator, + recursive: arguments["recursive"] as? Bool ?? true, failures: failures) + return cursor + case "next": + guard let cursor = cursors[arguments["cursor"] as? String ?? ""] else { return [] } + var batch: [[String: Any]] = [] + while batch.count < 128, let url = cursor.enumerator.nextObject() as? URL { + do { + let values = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey, .isRegularFileKey]) + let name = url.lastPathComponent + if name.hasPrefix(".") || name.hasPrefix("~") || values.isSymbolicLink == true || + name.range(of: "\\.(tmp|part|partial|crdownload|download)$", options: [.regularExpression, .caseInsensitive]) != nil { + cursor.enumerator.skipDescendants() + continue + } + if values.isDirectory == true { + if !cursor.recursive { cursor.enumerator.skipDescendants() } + } else if values.isRegularFile == true { + batch.append(try metadata(url, root: cursor.root)) + } + } catch { + batch.append(["key": String(url.path.dropFirst(directoryPrefix(cursor.root).count)), "name": url.lastPathComponent, + "size": 0, "modified": 0, "error": error.localizedDescription]) + } + } + batch.append(contentsOf: cursor.failures.entries) + cursor.failures.entries.removeAll() + return batch + case "close": cursors.removeValue(forKey: arguments["cursor"] as? String ?? "")?.close(); return nil + case "stat": return try access(key) { root in try metadata(source(root, arguments["key"] as? String ?? ""), root: root) } + case "read": + return try access(key) { root in + let url = try source(root, arguments["key"] as? String ?? "") + var coordinationError: NSError? + var readResult: Result? + NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { coordinated in + readResult = Result { try Data(contentsOf: coordinated) } + } + if let error = coordinationError { throw error } + guard let readResult = readResult else { throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadUnknownError) } + return FlutterStandardTypedData(bytes: try readResult.get()) + } + case "release": defaults.removeObject(forKey: bookmarkKey(key)); return nil + default: throw NSError(domain: "PapyrusFolderAccess", code: 2) + } + } + + private func selected(_ url: URL?, result: @escaping FlutterResult) { + guard let url = url else { result(nil); return } + do { + guard url.startAccessingSecurityScopedResource() else { throw NSError(domain: "PapyrusFolderAccess", code: 1) } + defer { url.stopAccessingSecurityScopedResource() } + let data = try url.bookmarkData(options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess], includingResourceValuesForKeys: nil, relativeTo: nil) + let key = UUID().uuidString + defaults.set(data, forKey: bookmarkKey(key)) + result(["key": key, "identity": url.standardizedFileURL.path, "name": url.lastPathComponent, + "location": url.path, "persistent": true]) + } catch { + result(FlutterError(code: "access", message: "Could not save access to this folder.", details: nil)) + } + } + + private func pick(_ result: @escaping FlutterResult) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "Choose folder" + panel.begin { [self] response in selected(response == .OK ? panel.url : nil, result: result) } + } + + private final class FolderFailures { + var entries: [[String: Any]] = [] + } + + private final class FolderCursor { + let root: URL + let enumerator: FileManager.DirectoryEnumerator + let recursive: Bool + let failures: FolderFailures + private var closed = false + init(root: URL, enumerator: FileManager.DirectoryEnumerator, recursive: Bool, failures: FolderFailures) { + self.root = root; self.enumerator = enumerator; self.recursive = recursive + self.failures = failures + } + func close() { + if !closed { root.stopAccessingSecurityScopedResource(); closed = true } + } + deinit { close() } + } +} diff --git a/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements index 852fa1a4..089f7c39 100644 --- a/app/macos/Runner/Release.entitlements +++ b/app/macos/Runner/Release.entitlements @@ -4,5 +4,11 @@ com.apple.security.app-sandbox + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + com.apple.security.files.bookmarks.app-scope + diff --git a/app/test/data/book_repository_data_store_test.dart b/app/test/data/book_repository_data_store_test.dart index 8d3a2d50..613377e5 100644 --- a/app/test/data/book_repository_data_store_test.dart +++ b/app/test/data/book_repository_data_store_test.dart @@ -17,6 +17,7 @@ class FakeBookRepository implements BookRepository { final List deletes = []; Completer? upsertGate; Completer? deleteGate; + Completer? readGate; Object? upsertError; Object? deleteError; @@ -33,6 +34,7 @@ class FakeBookRepository implements BookRepository { @override Future getById(String id) async { + await readGate?.future; return upserts.where((book) => book.id == id).firstOrNull; } @@ -60,6 +62,28 @@ void main() { SharedPreferences.setMockInitialValues({}); }); + test('deletion cannot record folder exclusions in a switched library', () async { + final first = FakeBookRepository(); + final second = FakeBookRepository(); + first.upserts.add(_book('one', 'First')); + first.readGate = Completer(); + final store = DataStore(bookRepository: first); + var exclusions = 0; + store.beforeBookDelete = (_) async => exclusions++; + final deleting = store.deleteBookAndWait('one'); + await store.attachBookRepository(second); + final expectation = expectLater(deleting, throwsStateError); + first.readGate!.complete(); + await expectation; + expect(exclusions, 0); + expect(first.deletes, isEmpty); + expect(second.deletes, isEmpty); + await store.disposeBookRepository(); + await first.controller.close(); + await second.controller.close(); + store.dispose(); + }); + test('repository stream is the source of the DataStore book snapshot', () async { final repository = FakeBookRepository(); final store = DataStore(); diff --git a/app/test/folders/connect_folder_flow_test.dart b/app/test/folders/connect_folder_flow_test.dart new file mode 100644 index 00000000..ecb07c48 --- /dev/null +++ b/app/test/folders/connect_folder_flow_test.dart @@ -0,0 +1,268 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/folders/directory_access.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/add_book/add_book_choice_sheet.dart'; +import 'package:papyrus/widgets/add_book/connect_folder_sheet.dart'; +import 'package:provider/provider.dart'; + +import 'watched_folders_test.dart' show MemoryAccess, grant; + +class _Access extends MemoryAccess { + int picks = 0; + int releases = 0; + Object? pickFailure; + + @override + Future pick() async { + picks++; + + if (pickFailure != null) { + throw pickFailure!; + } + + return grant; + } + + @override + Future release(FolderGrant grant) async { + releases++; + } +} + +class _Folders extends WatchedFolders { + _Folders(DirectoryAccess access) + : super(access: access, captureTarget: () => throw UnimplementedError(), libraryReady: () => true); + int connections = 0; + String? connectedName; + Future Function(bool recursive)? previewResult; + + @override + Future preview(FolderGrant grant, bool recursive, {bool Function()? cancelled}) => + previewResult?.call(recursive) ?? super.preview(grant, recursive, cancelled: cancelled); + + @override + bool get ready => true; + + @override + String get scope => 'guest'; + + @override + Future connect(FolderGrant grant, {String? name, bool recursive = true, String? shelfId}) async { + connections++; + connectedName = name; + } +} + +class _Auth extends ChangeNotifier implements AuthProvider { + @override + bool get isOfflineMode => true; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + for (final size in [const Size(390, 844), const Size(1280, 900)]) { + testWidgets('connect folder opens picker directly and stays in Library at $size', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + final access = _Access()..put('sample.epub', [1, 2, 3]); + final folders = _Folders(access); + final store = DataStore(); + final auth = _Auth(); + addTearDown(folders.dispose); + addTearDown(store.dispose); + addTearDown(auth.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: folders), + ChangeNotifierProvider.value(value: store), + ChangeNotifierProvider.value(value: auth), + ], + child: MaterialApp( + theme: AppTheme.light, + home: Builder( + builder: (context) => Scaffold( + appBar: AppBar(title: const Text('Library')), + body: FilledButton(onPressed: () => AddBookChoiceSheet.show(context), child: const Text('Add book')), + ), + ), + ), + ), + ); + await tester.tap(find.text('Add book')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Connect folder')); + expect(access.picks, 1, reason: 'The original tap invokes the picker without a navigation or another tap.'); + await tester.pumpAndSettle(); + expect(find.byType(ConnectFolderSheet), findsOneWidget); + expect(find.text('Books found'), findsOneWidget); + expect(find.text('Total size'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.text('Folder path'), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Cancel'), findsOneWidget); + expect(find.text('Library'), findsOneWidget); + expect(find.text('Watched folders'), findsNothing); + expect(folders.connections, 0, reason: 'Preview does not create a connection.'); + expect(tester.widget(find.byType(TextField)).controller!.text, 'Books'); + expect(find.text('/Books'), findsOneWidget); + await tester.enterText(find.byType(TextField), ' '); + await tester.pumpAndSettle(); + expect(tester.widget(find.widgetWithText(FilledButton, 'Import and watch')).onPressed, isNull); + await tester.enterText(find.byType(TextField), 'Reading inbox'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Import and watch')); + await tester.pumpAndSettle(); + expect(folders.connections, 1); + expect(find.byType(SnackBar), findsNothing); + expect(folders.connectedName, 'Reading inbox'); + expect(find.byType(ConnectFolderSheet), findsNothing); + expect(find.text('Library'), findsOneWidget); + expect(access.releases, 0); + }); + } + + for (final size in [const Size(390, 844), const Size(1280, 900)]) { + testWidgets('changing recursion retains preview geometry and ignores stale scans at $size', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + final folders = _Folders(_Access()); + final store = DataStore(); + addTearDown(folders.dispose); + addTearDown(store.dispose); + final scans = >[]; + folders.previewResult = (_) async => FolderPreview()..unsupported = 16; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: folders), + ChangeNotifierProvider.value(value: store), + ], + child: MaterialApp( + theme: AppTheme.dark, + home: Builder( + builder: (context) => Scaffold( + body: FilledButton(onPressed: () => connectBookFolder(context), child: const Text('Connect')), + ), + ), + ), + ), + ); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + final sheet = find.byType(ConnectFolderSheet); + final toggle = find.byType(SwitchListTile); + final name = find.byType(TextField); + final import = find.widgetWithText(FilledButton, 'Import and watch'); + final sheetBounds = tester.getRect(sheet); + final nameBounds = tester.getRect(name); + final toggleBounds = tester.getRect(toggle); + folders.previewResult = (_) { + final scan = Completer(); + scans.add(scan); + return scan.future; + }; + + for (var index = 0; index < 2; index++) { + await tester.tap(toggle); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.getRect(sheet), sheetBounds); + expect(tester.getRect(name), nameBounds); + expect(tester.getRect(toggle), toggleBounds); + expect(find.text('16 files cannot be imported'), findsOneWidget); + expect(find.text('—'), findsNothing); + expect(tester.widget(import).onPressed, isNull); + } + + scans.first.complete(FolderPreview()..supported = 999); + await tester.pump(); + expect(find.text('999'), findsNothing); + expect(tester.widget(import).onPressed, isNull); + scans.last.complete( + FolderPreview() + ..unsupported = 16 + ..supported = 2, + ); + await tester.pumpAndSettle(); + expect(find.text('2'), findsOneWidget); + expect(tester.widget(import).onPressed, isNotNull); + expect(tester.getRect(sheet), sheetBounds); + expect(tester.getRect(name), nameBounds); + expect(tester.getRect(toggle), toggleBounds); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('picker errors stay in a sheet without a snackbar', (tester) async { + final access = _Access()..pickFailure = const FolderAccessException('access', 'Folder access denied'); + final folders = _Folders(access); + addTearDown(folders.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: folders, + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: FilledButton(onPressed: () => connectBookFolder(context), child: const Text('Connect')), + ), + ), + ), + ), + ); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + expect(find.text('Could not connect folder'), findsOneWidget); + expect(find.text('Folder access denied'), findsOneWidget); + expect(find.byType(SnackBar), findsNothing); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(folders.connections, 0); + }); + + testWidgets('canceling the preview releases access and creates no connection', (tester) async { + final access = _Access(); + final folders = _Folders(access); + final store = DataStore(); + final auth = _Auth(); + addTearDown(folders.dispose); + addTearDown(store.dispose); + addTearDown(auth.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: folders), + ChangeNotifierProvider.value(value: store), + ChangeNotifierProvider.value(value: auth), + ], + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: FilledButton(onPressed: () => connectBookFolder(context), child: const Text('Connect')), + ), + ), + ), + ), + ); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(folders.connections, 0); + expect(access.releases, 1); + }); +} diff --git a/app/test/folders/directory_access_native_test.dart b/app/test/folders/directory_access_native_test.dart new file mode 100644 index 00000000..4957041b --- /dev/null +++ b/app/test/folders/directory_access_native_test.dart @@ -0,0 +1,119 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/folders/directory_access.dart'; +import 'package:papyrus/folders/directory_access_native.dart'; +import 'package:path/path.dart' as path; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory root; + late FolderGrant grant; + final access = NativeDirectoryAccess(platform: Platform.isWindows ? TargetPlatform.windows : TargetPlatform.linux); + + setUp(() async { + root = await Directory.systemTemp.createTemp('papyrus-folder-access-'); + // macOS temp locations may contain a parent symlink, unlike picker grants. + root = Directory(await root.resolveSymbolicLinks()); + grant = FolderGrant(key: root.path, identity: root.path, name: 'Books', location: root.path); + }); + + tearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + test( + 'filesystem scans recurse only when requested and omit hidden and temporary entries', + () async { + await File('${root.path}/book.epub').writeAsBytes([1]); + await Directory('${root.path}/nested').create(); + await File('${root.path}/nested/later.pdf').writeAsBytes([2, 3]); + await Directory('${root.path}/.hidden').create(); + await File('${root.path}/.hidden/hidden.epub').writeAsBytes([4]); + await File('${root.path}/unfinished.pdf.part').writeAsBytes([5]); + + final recursive = await access.enumerate(grant, recursive: true).toList(); + expect(recursive.map((file) => file.key), unorderedEquals(['book.epub', path.join('nested', 'later.pdf')])); + final shallow = await access.enumerate(grant, recursive: false).toList(); + expect(shallow.single.key, 'book.epub'); + expect(await access.read(grant, shallow.single), [1]); + expect((await access.stat(grant, shallow.single)).size, 1); + expect(await File('${root.path}/book.epub').readAsBytes(), [1]); + }, + ); + + test( + 'filesystem scans omit linked files and directories', + () async { + await File('${root.path}/book.epub').writeAsBytes([1]); + await Directory('${root.path}/nested').create(); + await Link('${root.path}/linked.epub').create('${root.path}/book.epub'); + await Link('${root.path}/linked-folder').create('${root.path}/nested'); + final files = await access.enumerate(grant, recursive: true).toList(); + expect(files.map((file) => file.key), ['book.epub']); + }, + skip: Platform.isWindows ? 'Symlink creation requires a separately configured Windows host.' : false, + ); + + test('reads reject traversal outside the selected directory', () async { + await expectLater( + access.read(grant, const FolderFile(key: '../outside.epub', name: 'outside.epub', size: 1, modified: 1)), + throwsA(isA()), + ); + }); + + test('missing folders report unavailability without creating a replacement directory', () async { + await root.delete(); + await expectLater( + access.enumerate(grant, recursive: true).toList(), + throwsA(isA().having((error) => error.code, 'code', 'unavailable')), + ); + expect(await access.reconnect(grant), isFalse); + expect(await root.exists(), isFalse); + }); + + test( + 'reads reject sources replaced by symlinks', + () async { + await File('${root.path}/book.epub').writeAsBytes([1]); + final file = (await access.enumerate(grant, recursive: false).toList()).single; + await File('${root.path}/book.epub').delete(); + await Link('${root.path}/book.epub').create('${root.path}/missing.epub'); + await expectLater(access.read(grant, file), throwsA(isA())); + }, + skip: Platform.isWindows ? 'Symlink creation requires a separately configured Windows host.' : false, + ); + + for (final platform in [TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS]) { + test('$platform uses grant operations and closes an enumeration cancelled mid-batch', () async { + const channel = MethodChannel('papyrus/folders'); + final calls = []; + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return switch (call.method) { + 'start' => 'cursor', + 'next' => [ + {'key': 'provider-document', 'name': 'book.epub', 'size': -1, 'modified': 1}, + {'key': 'another-document', 'name': 'later.pdf', 'size': 2, 'modified': 1}, + ], + 'read' => Uint8List.fromList([1]), + 'restore' => true, + _ => null, + }; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final adapter = NativeDirectoryAccess(platform: platform); + final files = await adapter.enumerate(grant, recursive: true).take(1).toList(); + expect(files.single.size, -1); + expect(calls.map((call) => call.method), ['start', 'next', 'close']); + expect(calls.first.arguments, {'grant': grant.key, 'recursive': true}); + expect(await adapter.read(grant, files.single), [1]); + expect(await adapter.reconnect(grant), isTrue); + await adapter.release(grant); + expect(calls.last.method, 'release'); + }); + } +} diff --git a/app/test/folders/watched_folders_page_test.dart b/app/test/folders/watched_folders_page_test.dart new file mode 100644 index 00000000..91d3ebbe --- /dev/null +++ b/app/test/folders/watched_folders_page_test.dart @@ -0,0 +1,786 @@ +import 'package:papyrus/widgets/folders/folder_identity.dart'; +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/folders/directory_access.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/pages/library_page.dart'; +import 'package:papyrus/pages/watched_folders_page.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/library_provider.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/folders/folder_details_sheet.dart'; +import 'package:papyrus/widgets/folders/edit_folder_sheet.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; +import 'package:papyrus/widgets/library/book_grid.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/test_helpers.dart'; +import 'watched_folders_test.dart' show MemoryAccess, grant; + +class _Folders extends WatchedFolders { + _Folders() : super(access: MemoryAccess(), captureTarget: () => throw UnimplementedError(), libraryReady: () => true); + final items = []; + int edits = 0; + Object? updateFailure; + int scans = 0; + Completer? scanPending; + Object? scanFailure; + String profile = 'guest'; + + @override + bool get ready => true; + + @override + String get scope => profile; + + @override + List get folders => items; + + @override + Set bookIds(String folderId) => {'book-1'}; + + @override + Future update( + WatchedFolder folder, { + String? name, + bool? enabled, + bool? recursive, + String? shelfId, + bool changeShelf = false, + }) async { + if (updateFailure != null) { + throw updateFailure!; + } + + edits++; + folder.customName = name?.trim() ?? folder.customName; + folder.recursive = recursive ?? folder.recursive; + folder.enabled = enabled ?? folder.enabled; + folder.status = folder.enabled ? FolderStatus.upToDate : FolderStatus.paused; + + if (changeShelf) { + folder.shelfId = shelfId; + } + + notifyListeners(); + } + + @override + Future scanNow(String id) async { + scans++; + await scanPending?.future; + + if (scanFailure != null) { + throw scanFailure!; + } + } + + void switchProfile() { + profile = 'another'; + items.clear(); + notifyListeners(); + } +} + +class _Auth extends ChangeNotifier implements AuthProvider { + @override + PapyrusUser? get user => null; + @override + bool get isOfflineMode => true; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const _capture = bool.fromEnvironment('FOLDER_DESIGN_CAPTURE'); +final _reviewBoundary = GlobalKey(); + +ThemeData _captureTheme(ThemeData theme) { + if (!_capture) { + return theme; + } + + // Resolve the platform font for component styles that otherwise use Ahem in tests. + ButtonStyle? font(ButtonStyle? style) => style?.copyWith( + textStyle: WidgetStateProperty.resolveWith( + (states) => style.textStyle?.resolve(states)?.copyWith(fontFamily: 'Roboto'), + ), + ); + + return theme.copyWith( + textButtonTheme: TextButtonThemeData(style: font(theme.textButtonTheme.style)), + outlinedButtonTheme: OutlinedButtonThemeData(style: font(theme.outlinedButtonTheme.style)), + elevatedButtonTheme: ElevatedButtonThemeData(style: font(theme.elevatedButtonTheme.style)), + chipTheme: theme.chipTheme.copyWith(labelStyle: theme.chipTheme.labelStyle?.copyWith(fontFamily: 'Roboto')), + ); +} + +Future _captureReview(WidgetTester tester, String name) async { + if (!_capture) { + return; + } + + await tester.runAsync(() async { + final boundary = _reviewBoundary.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + final directory = Directory('build/folder-review')..createSync(recursive: true); + await File('${directory.path}/$name.png').writeAsBytes(data!.buffer.asUint8List()); + image.dispose(); + }); +} + +void main() { + setUpAll(() async { + if (_capture) { + final flutterRoot = Platform.environment['FLUTTER_ROOT']!; + + for (final (family, file) in [('Roboto', 'Roboto-Regular.ttf'), ('MaterialIcons', 'MaterialIcons-Regular.otf')]) { + final font = File('$flutterRoot/bin/cache/artifacts/material_fonts/$file'); + await (FontLoader(family)..addFont(Future.value(ByteData.sublistView(font.readAsBytesSync())))).load(); + } + } + }); + late _Folders folders; + late DataStore store; + late _Auth auth; + late MediaUploadQueue uploads; + late LibraryProvider library; + late GoRouter router; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + folders = _Folders(); + store = createTestDataStore(); + auth = _Auth(); + uploads = MediaUploadQueue(await SharedPreferences.getInstance()); + library = LibraryProvider(); + router = GoRouter( + initialLocation: '/library/folders', + routes: [ + GoRoute(path: '/library/folders', builder: (_, _) => const WatchedFoldersPage()), + GoRoute( + path: '/library/books', + builder: (_, state) => const LibraryPage(), + ), + ], + ); + }); + + tearDown(() { + router.dispose(); + folders.dispose(); + store.dispose(); + auth.dispose(); + uploads.dispose(); + library.dispose(); + }); + + Future mount( + WidgetTester tester, { + Size size = const Size(1200, 900), + ThemeData? theme, + double textScale = 1, + }) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: folders), + ChangeNotifierProvider.value(value: store), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: uploads), + ChangeNotifierProvider.value(value: library), + ], + child: MaterialApp.router( + theme: _captureTheme(theme ?? AppTheme.light), + routerConfig: router, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(textScale)), + child: RepaintBoundary(key: _reviewBoundary, child: child!), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + for (final size in [const Size(390, 844), const Size(1200, 900)]) { + testWidgets('one empty-state action and stable list actions at $size', (tester) async { + await mount(tester, size: size); + expect(find.text('Connect folder'), findsOneWidget); + expect(find.byType(FloatingActionButton), findsNothing); + final toolbar = find.byType(size.width < 840 ? LibraryMobileToolbar : LibraryToolbar); + final toolbarPosition = tester.getTopLeft(toolbar); + folders.items.add(WatchedFolder(id: 'folder', grant: grant)); + folders.notifyListeners(); + await tester.pumpAndSettle(); + expect(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books')), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.byType(Card), findsNothing); + expect(find.text('Account upload'), findsNothing); + expect(tester.getTopLeft(toolbar), toolbarPosition); + expect(find.byType(FloatingActionButton), size.width < 840 ? findsOneWidget : findsNothing); + expect(tester.takeException(), isNull); + }); + } + + for (final size in [const Size(390, 844), const Size(820, 900), const Size(1200, 800)]) { + testWidgets('folder table aligns rows and opens compact details at $size', (tester) async { + folders.items.addAll([ + WatchedFolder( + id: 'folder', + customName: 'Reading inbox', + grant: const FolderGrant( + key: 'root', + identity: 'root', + name: 'Books', + location: '/Users/reader/Documents/Books', + ), + lastScan: DateTime(2026, 10, 31, 9, 30), + ), + WatchedFolder( + id: 'archive', + grant: const FolderGrant(key: 'archive', identity: 'archive', name: 'Archive', location: '/Archive'), + status: FolderStatus.accessNeeded, + ), + ]); + await mount(tester, size: size, theme: AppTheme.dark); + expect(find.byType(Card), findsNothing); + expect(find.text('Reconnect'), findsOneWidget); + expect(find.text('1'), findsNWidgets(2)); + expect(find.text('BOOKS'), size.width >= 840 ? findsOneWidget : findsNothing); + expect(find.text('LIBRARY'), findsNothing); + expect(find.text('DESTINATION SHELF'), size.width >= 840 ? findsOneWidget : findsNothing); + expect(find.text('LAST CHECKED'), size.width >= 840 ? findsOneWidget : findsNothing); + expect(find.text('FOLDER'), size.width >= 840 ? findsOneWidget : findsNothing); + expect(find.text('2 connected folders'), findsNothing); + + if (size.width >= 840) { + expect(tester.getTopLeft(find.text('Reading inbox')).dx, tester.getTopLeft(find.text('Archive')).dx); + expect(tester.getTopLeft(find.text('1').first).dx, tester.getTopLeft(find.text('1').last).dx); + } + + if (size.width < 840) { + final status = tester.getTopLeft(find.text('Status').first); + final books = tester.getTopLeft(find.text('Books').first); + final shelf = tester.getTopLeft(find.text('Destination shelf').first); + final checked = tester.getTopLeft(find.text('Last checked').first); + expect(status.dy, books.dy); + expect(shelf.dy, checked.dy); + + if (size.width < 600) { + expect(status.dx, shelf.dx); + expect(books.dx, checked.dx); + expect(shelf.dy, greaterThan(status.dy)); + } else { + expect(status.dy, shelf.dy); + } + } + + await _captureReview(tester, 'folders-${size.width.toInt()}'); + await tester.tap(find.text('Reading inbox')); + await tester.pumpAndSettle(); + await _captureReview(tester, 'details-${size.width.toInt()}'); + expect(find.text('Stop watching'), findsNothing); + expect(find.text('Connection'), findsNothing); + expect( + find.descendant(of: find.byType(FolderDetailsSheet), matching: find.text('Destination shelf')), + findsOneWidget, + ); + expect(find.text('Close').hitTestable(), findsOneWidget); + expect(tester.getSize(find.byType(FolderDetailsSheet)).height, lessThan(size.height * .85)); + final viewBooks = find.descendant(of: find.byType(FolderDetailsSheet), matching: find.text('View 1 book')); + await tester.ensureVisible(viewBooks); + await tester.pumpAndSettle(); + await tester.tap(viewBooks); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsNothing); + await tester.tap(find.byTooltip('Advanced filters, 1 active')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Source folders')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Source folders')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Source folders')); + await tester.pumpAndSettle(); + await _captureReview(tester, 'filters-${size.width.toInt()}'); + expect(tester.takeException(), isNull); + }); + } + + for (final (size, scale, theme) in [ + (const Size(390, 844), 1.0, AppTheme.dark), + (const Size(1200, 800), 1.0, AppTheme.dark), + (const Size(320, 700), 2.0, AppTheme.eink), + ]) { + testWidgets('connect sheet groups source settings and preview at $size', (tester) async { + final access = folders.access as MemoryAccess; + final browser = size.width == 390; + + if (browser) { + access.selected = const FolderGrant(key: 'root', identity: 'root', name: 'Books', location: 'Books'); + } + + access.put('sample.epub', [1, 2, 3]); + await mount(tester, size: size, theme: theme, textScale: scale); + await tester.ensureVisible(find.text('Connect folder')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Connect folder')); + await tester.pumpAndSettle(); + expect(find.text(browser ? 'Selected folder' : 'Folder path'), findsOneWidget); + expect(find.widgetWithText(SelectableText, browser ? 'Books' : '/Books'), findsOneWidget); + expect(find.text('Name'), findsOneWidget); + expect(find.text('Books found'), findsOneWidget); + expect(find.text('Total size'), findsOneWidget); + expect(find.text('Import and watch').hitTestable(), findsOneWidget); + await _captureReview(tester, 'connect-${size.width.toInt()}'); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(folders.items, isEmpty); + }); + } + + testWidgets('browser folders show the name once in rows and details without a path substitute', (tester) async { + final folder = WatchedFolder( + id: 'folder', + grant: const FolderGrant(key: 'root', identity: 'root', name: 'Books', location: 'Books'), + ); + folders.items.add(folder); + await mount(tester); + expect(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books')), findsOneWidget); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byType(FolderDetailsSheet), + matching: find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books')), + ), + findsOneWidget, + ); + await _captureReview(tester, 'details-browser'); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + await folders.update(folder, name: 'Reading inbox'); + await tester.pumpAndSettle(); + expect(find.text('Reading inbox'), findsOneWidget); + expect(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books')), findsNothing); + }); + + for (final size in [const Size(390, 844), const Size(1024, 800)]) { + testWidgets('progress reserves row and sheet space and displays remaining books at $size', (tester) async { + final folder = WatchedFolder(id: 'folder', grant: grant); + folders.items.addAll([ + folder, + WatchedFolder( + id: 'second', + grant: const FolderGrant(key: 'second', identity: 'second', name: 'Archive', location: '/Archive'), + ), + ]); + await mount(tester, size: size, theme: AppTheme.dark); + final nextRow = find.byKey(const ValueKey('second')); + final position = tester.getTopLeft(nextRow); + folder.status = FolderStatus.scanning; + folders.notifyListeners(); + await tester.pump(); + expect(tester.getTopLeft(nextRow), position); + expect(tester.widget(find.byType(LinearProgressIndicator)).value, isNull); + folder.status = FolderStatus.importing; + folder.progress = FolderImportProgress(total: 10000)..processed = 2500; + folders.notifyListeners(); + await tester.pump(); + expect(tester.getTopLeft(nextRow), position); + expect(find.text('2500/10000\n7500 left'), findsOneWidget); + expect(tester.widget(find.byType(LinearProgressIndicator)).value, .25); + await _captureReview(tester, 'import-progress-${size.width.toInt()}'); + folder.status = FolderStatus.upToDate; + folder.progress = null; + folders.notifyListeners(); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(nextRow), position); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + final details = find.byType(FolderDetailsSheet); + final sheetBounds = tester.getRect(details); + final viewBooks = find.descendant(of: details, matching: find.text('View 1 book')); + final buttonPosition = tester.getTopLeft(viewBooks); + folder.status = FolderStatus.importing; + folder.progress = FolderImportProgress(total: 9)..processed = 3; + folders.notifyListeners(); + await tester.pump(); + expect(tester.getRect(details), sheetBounds); + expect(tester.getTopLeft(viewBooks), buttonPosition); + expect( + tester + .widget( + find.descendant(of: details, matching: find.byType(LinearProgressIndicator)), + ) + .value, + closeTo(1 / 3, .001), + ); + await _captureReview(tester, 'import-details-${size.width.toInt()}'); + expect(tester.takeException(), isNull); + }); + } + + for (final (size, scale) in [ + (const Size(320, 900), 2.0), + (const Size(390, 844), 1.0), + (const Size(820, 900), 1.0), + (const Size(1200, 900), 1.3), + ]) { + testWidgets('folder row height stays stable across all statuses at $size and scale $scale', (tester) async { + final folder = WatchedFolder(id: 'folder', grant: grant, status: FolderStatus.paused); + folders.items.add(folder); + await mount(tester, size: size, textScale: scale, theme: AppTheme.dark); + final row = find.byKey(const ValueKey('folder')); + final bounds = tester.getRect(row); + + for (final status in FolderStatus.values) { + for (final shelfMissing in [false, true]) { + folder.status = status; + folder.shelfId = shelfMissing ? 'deleted-shelf' : null; + folder.lastScan = DateTime(2026, 11, 1, 23, 59); + folder.progress = status == FolderStatus.importing + ? (FolderImportProgress(total: 10000)..processed = 2500) + : null; + folders.notifyListeners(); + await tester.pump(); + expect(tester.getRect(row), bounds, reason: '$status, missing shelf: $shelfMissing'); + expect(tester.takeException(), isNull); + } + } + + folder.status = FolderStatus.paused; + folder.shelfId = null; + folder.lastScan = null; + folder.progress = null; + folders.notifyListeners(); + await tester.pumpAndSettle(); + expect(tester.getRect(row), bounds); + }); + } + + testWidgets('folder rows open details from keyboard focus', (tester) async { + folders.items.add(WatchedFolder(id: 'folder', grant: grant)); + await mount(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(find.byType(FolderDetailsSheet), findsOneWidget); + expect(find.text('Connection'), findsNothing); + }); + + testWidgets('folder action errors are inline and disappear when switching profiles', (tester) async { + folders.items.add(WatchedFolder(id: 'folder', grant: grant, enabled: false, status: FolderStatus.paused)); + folders.updateFailure = StateError('Could not resume folder'); + await mount(tester); + await tester.tap(find.text('Resume')); + await tester.pumpAndSettle(); + expect(find.textContaining('Could not resume folder'), findsOneWidget); + expect(find.byType(SnackBar), findsNothing); + folders.switchProfile(); + await tester.pumpAndSettle(); + expect(find.textContaining('Could not resume folder'), findsNothing); + }); + + testWidgets('contextual resume updates the row and scanning is available in details', (tester) async { + final folder = WatchedFolder(id: 'folder', grant: grant, enabled: false, status: FolderStatus.paused); + folders.items.add(folder); + await mount(tester); + await tester.tap(find.text('Resume')); + await tester.pumpAndSettle(); + expect(folder.enabled, isTrue); + expect(folders.edits, 1); + expect(find.byType(FolderDetailsSheet), findsNothing); + expect(find.text('Up to date'), findsOneWidget); + expect(find.text('Details'), findsNothing); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Scan now')); + await tester.pumpAndSettle(); + expect(folders.scans, 1); + expect(find.byType(FolderDetailsSheet), findsOneWidget); + expect(find.text('Close').hitTestable(), findsOneWidget); + }); + + testWidgets('scan keeps details open, shows errors, and allows closing while work continues', (tester) async { + folders.items.add(WatchedFolder(id: 'folder', grant: grant)); + folders.scanPending = Completer(); + await mount(tester); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + final detailsState = tester.state(find.byType(FolderDetailsSheet)); + await tester.tap(find.text('Scan now')); + await tester.pump(); + expect(tester.state(find.byType(FolderDetailsSheet)), same(detailsState)); + expect(tester.widget(find.widgetWithText(OutlinedButton, 'Scan now')).onPressed, isNull); + expect(find.text('Close').hitTestable(), findsOneWidget); + folders.scanPending!.complete(); + await tester.pumpAndSettle(); + expect(tester.state(find.byType(FolderDetailsSheet)), same(detailsState)); + folders.scanFailure = StateError('Scan failed'); + await tester.tap(find.text('Scan now')); + await tester.pumpAndSettle(); + expect(find.textContaining('Scan failed'), findsOneWidget); + expect(tester.widget(find.widgetWithText(OutlinedButton, 'Scan now')).onPressed, isNotNull); + folders.scanFailure = null; + folders.scanPending = Completer(); + await tester.tap(find.text('Scan now')); + await tester.pump(); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.byType(FolderDetailsSheet), findsNothing); + folders.scanPending!.complete(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('management has no draft and editing is explicit and cancelable', (tester) async { + final folder = WatchedFolder(id: 'folder', grant: grant); + folders.items.add(folder); + await mount(tester); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + final detailsState = tester.state(find.byType(FolderDetailsSheet)); + expect(find.byType(Dialog), findsNothing); + expect(find.byType(SwitchListTile), findsNothing); + expect(find.text('Save changes'), findsNothing); + await tester.tap(find.byTooltip('Books options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pause watching')); + await tester.pumpAndSettle(); + expect(folder.enabled, isFalse); + expect(find.text('Paused'), findsNWidgets(2)); + await tester.tap(find.byTooltip('Books options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit folder')); + await tester.pumpAndSettle(); + expect(find.byType(EditFolderSheet), findsOneWidget); + expect(find.byType(FolderDetailsSheet), findsOneWidget); + expect(find.text('Pause watching'), findsNothing); + expect(tester.widget(find.widgetWithText(FilledButton, 'Save changes')).onPressed, isNull); + await tester.tap(find.byType(SwitchListTile)); + await tester.pumpAndSettle(); + expect(folder.recursive, isTrue); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(folder.recursive, isTrue); + expect(find.byType(FolderDetailsSheet), findsOneWidget); + await tester.tap(find.byTooltip('Books options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit folder')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(SwitchListTile)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save changes')); + await tester.pumpAndSettle(); + expect(folder.recursive, isFalse); + expect(folder.enabled, isFalse); + expect(find.text('1'), findsOneWidget); + expect(find.byType(EditFolderSheet), findsNothing); + expect(tester.state(find.byType(FolderDetailsSheet)), same(detailsState)); + expect(find.text('Excluded'), findsOneWidget); + }); + + testWidgets('renaming is cancelable and uses the saved name in rows and source filters', (tester) async { + final folder = WatchedFolder(id: 'folder', grant: grant); + folders.items.add(folder); + await mount(tester); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Books options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit folder')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Draft'); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(folder.name, 'Books'); + expect(find.byType(FolderDetailsSheet), findsOneWidget); + await tester.tap(find.byTooltip('Books options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit folder')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), ' Reading inbox '); + await tester.pumpAndSettle(); + await _captureReview(tester, 'edit-name'); + await tester.tap(find.text('Save changes')); + await tester.pumpAndSettle(); + final details = find.byType(FolderDetailsSheet); + expect(details, findsOneWidget); + expect(find.descendant(of: details, matching: find.text('Reading inbox')), findsOneWidget); + expect(find.descendant(of: details, matching: find.text('/Books')), findsOneWidget); + expect(folder.grant.identity, grant.identity); + await tester.tap(find.text('View 1 book')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Advanced filters, 1 active')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Source folders')); + await tester.tap(find.text('Source folders')); + await tester.pumpAndSettle(); + expect(find.text('Reading inbox'), findsOneWidget); + expect(library.filters.folderIds, {'folder'}); + }); + + testWidgets('view books uses advanced source folder filters and reset restores the library', (tester) async { + folders.items.add(WatchedFolder(id: 'folder', grant: grant)); + await mount(tester); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('View 1 book')); + await tester.pumpAndSettle(); + expect(library.filters.folderIds, {'folder'}); + expect(library.activeFilterCount, 1); + expect(find.byType(InputChip), findsNothing); + expect(tester.widget(find.byType(BookGrid)).books.map((book) => book.id), ['book-1']); + await tester.tap(find.byTooltip('Advanced filters, 1 active')); + await tester.pumpAndSettle(); + final facet = find.text('Source folders'); + await tester.ensureVisible(facet); + await tester.pumpAndSettle(); + await tester.tap(facet); + await tester.pumpAndSettle(); + expect(find.text('Books'), findsOneWidget); + await tester.tap(find.text('Reset')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Show ${store.books.length} books')); + await tester.pumpAndSettle(); + expect(library.filters.folderIds, isEmpty); + expect(tester.widget(find.byType(BookGrid)).books.length, store.books.length); + expect(tester.takeException(), isNull); + }); + + testWidgets('source folder selection is a draft until applied and combines with other filters', (tester) async { + folders.items.add(WatchedFolder(id: 'folder', grant: grant)); + await mount(tester); + router.go('/library/books'); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Advanced filters')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Source folders')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Source folders')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Books')); + await tester.tap(find.text('Books')); + await tester.pumpAndSettle(); + expect(library.filters.folderIds, isEmpty); + expect(find.text('Show 1 book'), findsOneWidget); + await tester.tap(find.text('Show 1 book')); + await tester.pumpAndSettle(); + expect(library.filters.folderIds, {'folder'}); + library.applyFilters(library.filters.copyWith(authors: {'missing author'})); + await tester.pumpAndSettle(); + expect(library.activeFilterCount, 2); + expect(find.byType(BookGrid), findsNothing); + library.clearFilters(); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(BookGrid)).books.length, store.books.length); + }); + + testWidgets('details keep removal explanation in the confirmation only', (tester) async { + folders.items.add(WatchedFolder(id: 'folder', grant: grant)); + await mount(tester); + await tester.tap(find.text('Books')); + await tester.pumpAndSettle(); + expect(find.text('Imported books stay in your library.'), findsNothing); + expect(find.text('Stop watching…'), findsNothing); + expect(find.text('/Books'), findsNWidgets(2)); + expect(find.text('Subfolders'), findsOneWidget); + await tester.tap(find.byTooltip('Books options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Stop watching')); + await tester.pumpAndSettle(); + expect(find.text('Stop watching this folder?'), findsOneWidget); + expect( + find.text('Imported books stay in your library. Pending imports from this folder will stop.'), + findsOneWidget, + ); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(folders.items, hasLength(1)); + }); + + testWidgets('profile changes invalidate open details', (tester) async { + folders.items.add(WatchedFolder(id: 'folder', grant: grant)); + await mount(tester); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + folders.switchProfile(); + await tester.pumpAndSettle(); + expect(find.text('This folder is no longer available in the selected library.'), findsOneWidget); + expect(find.text('Edit folder'), findsNothing); + expect(find.text('Close').hitTestable(), findsOneWidget); + expect(folders.edits, 0); + }); + + for (final (name, theme) in [('light', AppTheme.light), ('dark', AppTheme.dark), ('e-ink', AppTheme.eink)]) { + testWidgets('large text retains reachable management and edit actions in $name', (tester) async { + folders.items.add(WatchedFolder(id: 'folder', grant: grant, status: FolderStatus.accessNeeded)); + await mount(tester, size: const Size(320, 700), theme: theme, textScale: 2); + expect(find.text('Reconnect'), findsOneWidget); + expect(find.text('/Books'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + expect(find.text('Close').hitTestable(), findsOneWidget); + await _captureReview(tester, 'large-text-$name'); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Books options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit folder')); + await tester.pumpAndSettle(); + expect(find.text('Save changes').hitTestable(), findsOneWidget); + expect(find.text('Cancel').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('profile changes invalidate a pending preference edit', (tester) async { + final folder = WatchedFolder(id: 'folder', grant: grant); + folders.items.add(folder); + await mount(tester); + await tester.tap(find.descendant(of: find.byType(FolderIdentity), matching: find.text('Books'))); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Books options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit folder')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(SwitchListTile)); + await tester.pumpAndSettle(); + folders.switchProfile(); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byType(EditFolderSheet), + matching: find.text('This folder is no longer available in the selected library.'), + ), + findsOneWidget, + ); + expect(tester.widget(find.widgetWithText(FilledButton, 'Save changes')).onPressed, isNull); + expect(folder.recursive, isTrue); + expect(folders.edits, 0); + }); +} diff --git a/app/test/folders/watched_folders_test.dart b/app/test/folders/watched_folders_test.dart new file mode 100644 index 00000000..9730aae9 --- /dev/null +++ b/app/test/folders/watched_folders_test.dart @@ -0,0 +1,827 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/folders/directory_access.dart'; +import 'package:papyrus/folders/folder_import_target.dart'; +import 'package:papyrus/folders/folder_models.dart'; +import 'package:papyrus/folders/folder_repository.dart'; +import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/services/book_import_result.dart'; + +const grant = FolderGrant(key: 'root', identity: 'root', name: 'Books', location: '/Books'); + +class MemoryAccess implements DirectoryAccess { + FolderGrant selected = grant; + final files = {}; + final revisions = {}; + int reads = 0; + int enumerated = 0; + String? failure; + int? reportedSize; + int? reportedModified; + bool failRelease = false; + final released = []; + void Function()? afterRead; + void Function()? afterEnumerate; + + void put(String name, List bytes) { + files[name] = Uint8List.fromList(bytes); + revisions[name] = (revisions[name] ?? 100) + 1; + } + + @override + bool get supportsWatching => true; + + @override + Future pick() async => selected; + + @override + Future reconnect(FolderGrant grant) async => failure == null; + + FolderFile info(String name) => FolderFile( + key: name, + name: name.split('/').last, + size: reportedSize ?? files[name]!.length, + modified: reportedModified ?? revisions[name] ?? 1, + ); + + @override + Stream enumerate(FolderGrant grant, {required bool recursive}) async* { + if (failure != null) { + throw FolderAccessException(failure!, 'Folder cannot be accessed.'); + } + + for (final name in files.keys.toList()) { + if (recursive || !name.contains('/')) { + enumerated++; + afterEnumerate?.call(); + yield info(name); + } + } + } + + @override + Future stat(FolderGrant grant, FolderFile file) async => info(file.key); + + @override + Future read(FolderGrant grant, FolderFile file) async { + reads++; + final bytes = Uint8List.fromList(files[file.key]!); + afterRead?.call(); + return bytes; + } + + @override + Stream changes(FolderGrant grant) => const Stream.empty(); + + @override + Future release(FolderGrant grant) async { + if (failRelease) { + throw StateError('Grant storage unavailable'); + } + + released.add(grant.key); + } +} + +class MemoryTarget implements FolderImportTarget { + final saved = {}; + final copies = {}; + final memberships = {}; + final identities = []; + bool failAfterPersist = false; + Completer? processing; + Completer? started; + bool current = true; + + @override + bool get isCurrent => current; + + @override + List get books => saved.values.toList(); + + @override + Future getBook(String id) async => saved[id]; + + @override + Future readStored(String id) async => copies[id]; + + @override + Future process(Uint8List bytes, String filename, String id) async { + identities.add(id); + copies[id] = bytes; + started?.complete(); + started = null; + await processing?.future; + return BookImportResult( + bookId: id, + title: filename, + author: 'Author', + fileSize: bytes.length, + fileHash: sha256.convert(bytes).toString(), + fileExtension: filename.split('.').last, + ); + } + + @override + Future finish(BookImportResult result, String filename, String? shelfId) async { + saved.putIfAbsent( + result.bookId, + () => Book( + id: result.bookId, + title: result.title, + author: result.author, + fileHash: result.fileHash, + addedAt: DateTime(2026), + ), + ); + + if (failAfterPersist) { + throw StateError('Queue persistence failed'); + } + + if (shelfId != null) { + memberships[result.bookId] = shelfId; + } + } + + @override + Future ensureCopy(Book book, Uint8List bytes, String extension, String? shelfId) async { + copies.putIfAbsent(book.id, () => bytes); + + if (shelfId != null) { + memberships[book.id] = shelfId; + } + } + + @override + Future deleteStaged(String id) async => copies.remove(id); +} + +class _FolderRepository extends FolderRepository { + _FolderRepository(super.database); + + String? rejectedName; + bool rejectReplacement = false; + + @override + Future replaceGrant(WatchedFolder folder, FolderGrant previous) async { + if (rejectReplacement) { + throw StateError('Folder could not be saved'); + } + + await super.replaceGrant(folder, previous); + } + + @override + Future saveFolder(WatchedFolder folder) async { + if (rejectedName != null && folder.name == rejectedName) { + throw StateError('Folder could not be saved'); + } + + await super.saveFolder(folder); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory temporary; + late WatchedFolders folders; + late MemoryAccess access; + late MemoryTarget target; + late _FolderRepository repository; + var ready = true; + var clockOffset = Duration.zero; + + setUp(() async { + temporary = await Directory.systemTemp.createTemp('papyrus-folder-test-'); + access = MemoryAccess(); + target = MemoryTarget(); + ready = true; + clockOffset = Duration.zero; + folders = WatchedFolders( + access: access, + captureTarget: () => target, + libraryReady: () => ready, + stabilityDelay: Duration.zero, + pollInterval: const Duration(days: 1), + now: () => DateTime.now().add(clockOffset), + openRepository: (scope) async { + repository = _FolderRepository( + (await FolderRepository.open(scope, databasePath: '${temporary.path}/$scope.db')).database, + ); + return repository; + }, + ); + await folders.activate('guest'); + await folders.waitUntilIdle(); + }); + + tearDown(() async { + await folders.activate(null); + folders.dispose(); + await temporary.delete(recursive: true); + }); + + Future connect({String? shelf}) async { + await folders.connect(grant, shelfId: shelf); + await folders.waitUntilIdle(); + return folders.folders.single; + } + + test('import progress has a fixed candidate total and reads one book at a time', () async { + access.put('existing.epub', [1]); + final folder = await connect(); + access.put('new.epub', [2]); + access.put('another.pdf', [3]); + access.put('duplicate.epub', [2]); + access.put('cover.jpg', [4]); + final observed = <(int, int)>[]; + folders.addListener(() { + final progress = folder.progress; + + if (progress != null) { + expect(progress.fraction, inInclusiveRange(0, 1)); + observed.add((progress.processed, progress.total)); + } + }); + target.processing = Completer(); + final started = target.started = Completer(); + final scan = folders.scanNow(folder.id); + await started.future; + expect(folder.status, FolderStatus.importing); + expect(folder.progress!.total, 3); + expect(folder.progress!.processed, 0); + expect(folder.progress!.remaining, 3); + expect(access.reads, 2, reason: 'Only the original book and the current candidate have been read.'); + target.processing!.complete(); + await scan; + expect(observed.toSet(), {(0, 3), (1, 3), (2, 3), (3, 3)}); + expect(folder.progress, isNull); + expect(target.books, hasLength(3)); + expect(folder.duplicates, 1); + observed.clear(); + await folders.scanNow(folder.id); + expect(observed, isEmpty, reason: 'Unchanged books do not create another import batch.'); + }); + + test('failed imports and recovered jobs advance progress without double counting source files', () async { + final folder = await connect(); + access.put('first.epub', [1]); + access.put('second.epub', [2]); + target.failAfterPersist = true; + final observed = <(int, int)>[]; + folders.addListener(() { + final progress = folder.progress; + + if (progress != null) { + observed.add((progress.processed, progress.total)); + } + }); + await folders.scanNow(folder.id); + expect(observed.toSet(), {(0, 2), (1, 2), (2, 2)}); + expect(folder.status, FolderStatus.attention); + expect(folder.progress, isNull); + observed.clear(); + target.failAfterPersist = false; + await folders.retry(folder); + await folders.waitUntilIdle(); + expect(observed.toSet(), {(0, 2), (1, 2), (2, 2)}); + expect(await repository.records('jobs'), isEmpty); + expect(target.books, hasLength(2)); + }); + + test('custom names persist independently of source access and survive reconnecting', () async { + await folders.connect(grant, name: ' Reading inbox '); + await folders.waitUntilIdle(); + final folder = folders.folders.single; + expect(folder.name, 'Reading inbox'); + expect(folder.grant.location, '/Books'); + await folders.update(folder, name: 'Research'); + await folders.waitUntilIdle(); + await folders.activate(null); + await folders.activate('guest'); + await folders.waitUntilIdle(); + final restored = folders.folders.single; + expect(restored.name, 'Research'); + expect(restored.grant.identity, grant.identity); + await folders.replaceGrant( + restored, + const FolderGrant(key: 'new', identity: 'new', name: 'Moved books', location: '/Volumes/Drive/Books'), + ); + await folders.waitUntilIdle(); + expect(restored.name, 'Research'); + expect((await repository.folders()).single.name, 'Research'); + expect(restored.grant.location, '/Volumes/Drive/Books'); + }); + + test('a failed name save leaves the prior name intact and can be retried', () async { + final folder = await connect(); + repository.rejectedName = 'Research'; + await expectLater(folders.update(folder, name: 'Research'), throwsStateError); + expect(folder.name, 'Books'); + expect((await repository.folders()).single.name, 'Books'); + repository.rejectedName = null; + await folders.update(folder, name: 'Research'); + expect(folder.name, 'Research'); + expect((await repository.folders()).single.name, 'Research'); + }); + + test('blank names are rejected without changing the saved connection', () async { + await expectLater(folders.connect(grant, name: ' '), throwsArgumentError); + expect(await repository.folders(), isEmpty); + final folder = await connect(); + await expectLater(folders.update(folder, name: ' '), throwsArgumentError); + expect(folder.name, 'Books'); + expect((await repository.folders()).single.name, 'Books'); + }); + + test('preview is read only and imports supported files lazily', () async { + access.put('book.epub', [1, 2, 3]); + access.put('nested/book.pdf', [4, 5]); + access.put('cover.jpg', [6]); + final preview = await folders.preview(grant, true); + expect(preview.supported, 2); + expect(preview.unsupported, 1); + expect(preview.bytes, 5); + expect(await repository.folders(), isEmpty); + expect(access.reads, 0); + await connect(shelf: 'reading'); + expect(target.books, hasLength(2)); + expect(target.memberships.values, everyElement('reading')); + expect(access.reads, 2); + }); + + test('changed recursion persists and applies only to future discoveries', () async { + access.put('book.epub', [1]); + final folder = await connect(); + await folders.update(folder, recursive: false); + await folders.waitUntilIdle(); + access.put('nested/new.pdf', [2]); + await folders.scanNow(folder.id); + expect(target.books, hasLength(1)); + await folders.activate(null); + await folders.activate('guest'); + await folders.waitUntilIdle(); + expect(folders.folders.single.recursive, isFalse); + await folders.update(folders.folders.single, recursive: true); + await folders.waitUntilIdle(); + expect(target.books, hasLength(2)); + }); + + test('rescans and reopening do not reparse unchanged books', () async { + access.put('book.epub', [1]); + final folder = await connect(); + await folders.scanNow(folder.id); + expect(target.identities, hasLength(1)); + expect(access.reads, 1); + await folders.activate(null); + await folders.activate('guest'); + await folders.waitUntilIdle(); + expect(target.books, hasLength(1)); + expect(target.identities, hasLength(1)); + }); + + test('new files import and source deletion retains copies', () async { + access.put('book.epub', [1]); + final folder = await connect(); + access.files.clear(); + access.put('new.pdf', [2]); + await folders.scanNow(folder.id); + expect(target.books, hasLength(2)); + expect(target.copies, hasLength(2)); + }); + + test('identical files and renames associate with one book', () async { + access.put('book.epub', [1]); + access.put('other.epub', [1]); + final folder = await connect(); + expect(target.books, hasLength(1)); + access.files.remove('book.epub'); + access.put('renamed.epub', [1]); + await folders.scanNow(folder.id); + expect(target.identities, hasLength(1)); + expect(target.books, hasLength(1)); + }); + + test('changed content requires review and preserves existing book', () async { + access.put('book.epub', [1]); + final folder = await connect(); + final original = target.books.single; + access.put('book.epub', [2]); + await folders.scanNow(folder.id); + final issue = (await folders.issues(folder.id)).single; + expect(issue['state'], 'changed'); + expect(target.books.single, same(original)); + await folders.review(folder, issue, import: true); + await folders.waitUntilIdle(); + expect(target.books, hasLength(2)); + expect(target.saved[original.id], same(original)); + await folders.activate(null); + await folders.activate('guest'); + await folders.waitUntilIdle(); + expect(folders.bookIds(folder.id), containsAll(target.saved.keys)); + }); + + test('unknown provider size imports and unknown timestamps detect same-size changes', () async { + access.reportedSize = -1; + access.reportedModified = 0; + access.put('book.epub', [1, 2]); + final preview = await folders.preview(grant, true); + expect(preview.bytes, 0); + expect(preview.unknownSizes, 1); + final folder = await connect(); + await folders.scanNow(folder.id); + expect(target.books, hasLength(1)); + final processed = target.identities.length; + await folders.scanNow(folder.id); + expect(target.identities, hasLength(processed)); + access.put('book.epub', [3, 4]); + await folders.scanNow(folder.id); + expect((await folders.issues(folder.id)).single['state'], 'changed'); + expect(target.books, hasLength(1)); + }); + + test('future source timestamps use repeated observation instead of deferring forever', () async { + access.reportedModified = DateTime.now().add(const Duration(days: 1)).millisecondsSinceEpoch; + access.put('book.epub', [1]); + final folder = await connect(); + expect(target.books, isEmpty); + await folders.scanNow(folder.id); + expect(target.books, hasLength(1)); + }); + + for (final condition in ['recent', 'missing timestamp', 'future timestamp', 'changed during read']) { + test('one-time selections finish files deferred for $condition before pausing', () async { + await folders.activate(null); + folders.dispose(); + var now = DateTime.now(); + folders = WatchedFolders( + access: access, + captureTarget: () => target, + libraryReady: () => ready, + now: () => now, + pollInterval: const Duration(days: 1), + openRepository: (scope) => FolderRepository.open(scope, databasePath: '${temporary.path}/$scope.db'), + ); + await folders.activate('guest'); + await folders.waitUntilIdle(); + access.put('book.epub', [1]); + + switch (condition) { + case 'recent': + access.reportedModified = now.millisecondsSinceEpoch; + case 'missing timestamp': + access.reportedModified = 0; + case 'future timestamp': + access.reportedModified = now.add(const Duration(days: 1)).millisecondsSinceEpoch; + case 'changed during read': + access.afterRead = () { + access.afterRead = null; + access.put('book.epub', [2]); + }; + } + + const selection = FolderGrant( + key: 'selection', + identity: 'selection', + name: 'Books', + location: 'Books', + persistent: false, + ); + await folders.connect(selection); + await folders.waitUntilIdle(); + final folder = folders.folders.single; + expect(target.books, isEmpty); + expect(folder.enabled, isTrue, reason: 'Deferred files must remain eligible for automatic scans'); + now = now.add(const Duration(seconds: 3)); + folders.schedule(); + await folders.waitUntilIdle(); + expect(target.books, hasLength(1)); + expect(target.copies.values.single, condition == 'changed during read' ? [2] : [1]); + expect(folder.enabled, isFalse); + expect(folder.status, FolderStatus.paused); + access.put('later.pdf', [3]); + folders.schedule(); + await folders.waitUntilIdle(); + expect(target.books, hasLength(1), reason: 'Completed one-time selections must not watch later additions'); + }); + } + + test('failed grant replacement retains the saved connection', () async { + final folder = await connect(); + repository.rejectReplacement = true; + const replacement = FolderGrant(key: 'new', identity: 'root', name: 'Books', location: '/Books'); + await expectLater(folders.replaceGrant(folder, replacement), throwsStateError); + await folders.waitUntilIdle(); + expect(folder.grant.key, grant.key); + expect((await repository.folders()).single.grant.key, grant.key); + expect(access.released, isEmpty); + }); + + test('grant cleanup failures survive replacement and restart without revoking the new grant', () async { + final folder = await connect(); + access.failRelease = true; + const replacement = FolderGrant(key: 'new', identity: 'root', name: 'Books', location: '/Books'); + await folders.replaceGrant(folder, replacement); + await folders.waitUntilIdle(); + expect(folder.grant.key, replacement.key); + expect((await repository.retiredGrants()).single.key, grant.key); + await folders.activate(null); + access.failRelease = false; + await folders.activate('guest'); + await folders.waitUntilIdle(); + expect(await repository.retiredGrants(), isEmpty); + expect(access.released, [grant.key]); + expect(folders.folders.single.grant.key, replacement.key); + }); + + test('stopping journals failed permission cleanup without losing imported books', () async { + access.put('book.epub', [1]); + final folder = await connect(); + access.failRelease = true; + await folders.stop(folder); + await folders.waitUntilIdle(); + expect(folders.folders, isEmpty); + expect(target.books, hasLength(1)); + expect((await repository.retiredGrants()).single.key, grant.key); + access.failRelease = false; + folders.schedule(); + await folders.waitUntilIdle(); + expect(await repository.retiredGrants(), isEmpty); + }); + + test('keep existing remembers a changed version', () async { + access.put('book.epub', [1]); + final folder = await connect(); + access.put('book.epub', [2]); + await folders.scanNow(folder.id); + await folders.review(folder, (await folders.issues(folder.id)).single, import: false); + await folders.waitUntilIdle(); + await folders.scanNow(folder.id); + expect(await folders.issues(folder.id), isEmpty); + expect(target.books, hasLength(1)); + }); + + test('user deletion suppresses renamed copies until explicit reimport', () async { + access.put('book.epub', [1]); + final folder = await connect(); + final book = target.books.single; + await folders.recordDeletion(book.id, book.fileHash); + target.saved.clear(); + access.put('renamed.epub', [1]); + await folders.scanNow(folder.id); + expect(target.books, isEmpty); + final issue = (await folders.issues(folder.id)).first; + await folders.review(folder, issue, import: true); + await folders.waitUntilIdle(); + expect(target.books, hasLength(1)); + expect(target.books.single.id, isNot(book.id)); + }); + + test('explicit reimport does not reuse a deleted identity referenced by a paused overlapping folder', () async { + access.put('book.epub', [1]); + final first = await connect(); + final original = target.books.single; + const overlap = FolderGrant(key: 'overlap', identity: 'overlap', name: 'Overlap', location: '/Books/overlap'); + await folders.connect(overlap); + await folders.waitUntilIdle(); + final second = folders.folders.singleWhere((folder) => folder.id != first.id); + await folders.update(second, enabled: false); + await folders.waitUntilIdle(); + await folders.recordDeletion(original.id, original.fileHash); + await folders.waitUntilIdle(); + target.saved.clear(); + await folders.scanNow(first.id); + await folders.review(first, (await folders.issues(first.id)).single, import: true); + await folders.waitUntilIdle(); + expect(target.books.single.id, isNot(original.id)); + }); + + test('failed queue after metadata commit retries with the same identity', () async { + access.put('book.epub', [1]); + target.failAfterPersist = true; + final folder = await connect(); + final id = target.books.single.id; + expect((await folders.issues(folder.id)).single['state'], 'failed'); + target.failAfterPersist = false; + await folders.retry(folder); + await folders.waitUntilIdle(); + expect(target.books.single.id, id); + expect(target.identities, everyElement(id)); + expect(await repository.records('jobs'), isEmpty); + expect(await folders.issues(folder.id), isEmpty); + }); + + test('prepared copy recovers after source access is lost and app reopens', () async { + access.put('book.epub', [1]); + target.failAfterPersist = true; + final folder = await connect(); + final job = (await repository.records('jobs')).single; + job['state'] = 'prepared'; + await repository.save('jobs', job['recordId'] as String, job, folderId: folder.id); + access.files.clear(); + access.failure = 'access'; + target.failAfterPersist = false; + await folders.activate(null); + await folders.activate('guest'); + await folders.waitUntilIdle(); + expect(target.books, hasLength(1)); + expect(await repository.records('jobs'), isEmpty); + expect(folders.folders.single.status, FolderStatus.accessNeeded); + expect(folders.folders.single.progress, isNull); + }); + + test('a changed source keeps the original book and its failed upload journal', () async { + access.put('book.epub', [1]); + target.failAfterPersist = true; + final folder = await connect(); + final original = target.books.single; + access.put('book.epub', [2]); + await folders.scanNow(folder.id); + final changed = (await folders.issues(folder.id)).single; + expect(changed['state'], 'changed'); + target.failAfterPersist = false; + await folders.review(folder, changed, import: true); + await folders.waitUntilIdle(); + expect(target.books, hasLength(2)); + expect(target.copies[original.id], [1]); + expect(await repository.records('jobs'), hasLength(1)); + await folders.retry(folder); + await folders.waitUntilIdle(); + expect(await repository.records('jobs'), isEmpty); + expect(target.copies[original.id], [1]); + expect(target.books, hasLength(2)); + }); + + test('profile switch during parsing cannot commit to either library', () async { + access.put('book.epub', [1]); + target.processing = Completer(); + target.started = Completer(); + final started = target.started!.future; + await folders.connect(grant); + await started; + final switching = folders.activate('other'); + target.processing!.complete(); + await switching; + expect(target.books, isEmpty); + expect(folders.folders, isEmpty); + target.processing = null; + await folders.activate('guest'); + await folders.waitUntilIdle(); + expect(target.books, hasLength(1)); + }); + + test('work resumed after lease expiry cannot commit before reacquiring the lease', () async { + access.put('book.epub', [1]); + target.processing = Completer(); + target.started = Completer(); + final started = target.started!.future; + await folders.connect(grant); + await started; + clockOffset = const Duration(minutes: 3); + target.processing!.complete(); + await folders.waitUntilIdle(); + expect(target.books, isEmpty); + expect(await repository.records('jobs'), hasLength(1)); + target.processing = null; + await folders.scanNow(folders.folders.single.id); + expect(target.books, hasLength(1)); + expect(await repository.records('jobs'), isEmpty); + }); + + test('permission loss is recoverable and does not remove imported books', () async { + access.put('book.epub', [1]); + final folder = await connect(); + access.failure = 'access'; + await folders.scanNow(folder.id); + expect(folder.status, FolderStatus.accessNeeded); + expect(target.books, hasLength(1)); + access.failure = null; + await folders.reconnect(folder); + await folders.waitUntilIdle(); + expect(folder.status, FolderStatus.upToDate); + }); + + test('files changing while read are deferred', () async { + access.put('book.epub', [1]); + access.afterRead = () => access.put('book.epub', [2, 3]); + final folder = await connect(); + expect(target.books, isEmpty); + access.afterRead = null; + await folders.scanNow(folder.id); + expect(target.books, hasLength(1)); + }); + + test('paused folders and unavailable libraries do not import', () async { + final folder = await connect(); + await folders.update(folder, enabled: false); + access.put('book.epub', [1]); + await folders.scanNow(folder.id); + expect(target.books, isEmpty); + ready = false; + await folders.update(folder, enabled: true); + await folders.scanNow(folder.id); + expect(target.books, isEmpty); + ready = true; + await folders.scanNow(folder.id); + expect(target.books, hasLength(1)); + }); + + test('stop watching retains books and clear removes connections', () async { + access.put('book.epub', [1]); + final folder = await connect(); + await folders.stop(folder); + expect(target.books, hasLength(1)); + expect(await repository.folders(), isEmpty); + await connect(); + await folders.clear(); + folders.resume(); + expect(await repository.folders(), isEmpty); + expect(target.books, hasLength(1)); + }); + + test('a persisted lease excludes a second window and expires', () async { + final now = DateTime.now(); + expect(await repository.acquire('first', now), isTrue); + expect(await repository.acquire('second', now), isFalse); + await repository.release('second'); + expect(await repository.acquire('second', now), isFalse); + expect(await repository.acquire('second', now.add(const Duration(minutes: 3))), isTrue); + }); + + test('a second window observes pause and removal without recreating the connection', () async { + final folder = await connect(); + final other = WatchedFolders( + access: access, + captureTarget: () => target, + libraryReady: () => true, + stabilityDelay: Duration.zero, + pollInterval: const Duration(days: 1), + openRepository: (scope) => FolderRepository.open(scope, databasePath: '${temporary.path}/$scope.db'), + ); + + try { + await other.activate('guest'); + await other.waitUntilIdle(); + await folders.update(folder, enabled: false); + await folders.waitUntilIdle(); + access.put('later.epub', [1]); + await other.scanNow(folder.id); + expect(target.books, isEmpty); + expect(other.folders.single.enabled, isFalse); + await folders.stop(folder); + await folders.waitUntilIdle(); + await other.scanNow(folder.id); + expect(other.folders, isEmpty); + expect(await repository.folders(), isEmpty); + } finally { + await other.activate(null); + other.dispose(); + } + }); + + test('a duplicate connection is rejected after reopening the shared database', () async { + await connect(); + await expectLater(folders.connect(grant), throwsStateError); + await folders.waitUntilIdle(); + expect(await repository.folders(), hasLength(1)); + }); + + test('large import discovery cancels before reading file contents', () async { + final folder = await connect(); + + for (var index = 0; index < 10000; index++) { + access.put('$index.epub', [1]); + } + + access.afterEnumerate = () { + if (access.enumerated == 10) { + folders.invalidate(); + } + }; + await folders.scanNow(folder.id); + expect(access.enumerated, 10); + expect(access.reads, 0); + expect(folder.progress, isNull); + expect(target.books, isEmpty); + }); + + test('large preview streams metadata without loading file contents and can cancel', () async { + for (var index = 0; index < 10000; index++) { + access.put('$index.epub', [1]); + } + + final preview = await folders.preview(grant, true); + expect(preview.supported, 10000); + expect(access.reads, 0); + access.enumerated = 0; + await folders.preview(grant, true, cancelled: () => access.enumerated >= 10); + expect(access.enumerated, 10); + expect(access.reads, 0); + }); +} diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index d3e2bdcb..ee2b8681 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -10,6 +10,18 @@ import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; import 'package:shared_preferences/shared_preferences.dart'; +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; + +class _FailingPreferences extends InMemorySharedPreferencesStore { + _FailingPreferences() : super.empty(); + bool failWrites = true; + + @override + Future setValue(String valueType, String key, Object value) async { + return failWrites ? false : super.setValue(valueType, key, value); + } +} void main() { setUp(() { @@ -126,6 +138,33 @@ void main() { expect(prefs.getString('media_upload_queue:official--user-1'), isNot(contains(base64Encode('cover'.codeUnits)))); }); + test('failed single-media enqueue stays absent until its retry is persisted', () async { + final platform = _FailingPreferences(); + SharedPreferencesStorePlatform.instance = platform; + final prefs = await SharedPreferences.getInstance(); + final queue = MediaUploadQueue(prefs); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + await queue.activateScope(scope); + final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); + + Future enqueue() => + queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + + await expectLater(enqueue(), throwsStateError); + expect(queue.pendingTasks, isEmpty); + expect(prefs.getString('media_upload_queue:${scope.persistenceKey}'), isNull); + await queue.activateScope(null); + await queue.activateScope(scope); + expect(queue.pendingTasks, isEmpty); + platform.failWrites = false; + await enqueue(); + final restored = MediaUploadQueue(prefs); + await restored.activateScope(scope); + expect(restored.pendingTasks.single.bookId, book.id); + queue.dispose(); + restored.dispose(); + }); + test('restored cover task reads scoped pending file and drains the task', () async { final prefs = await SharedPreferences.getInstance(); final repository = InMemoryBookRepository(); diff --git a/app/test/providers/library_provider_test.dart b/app/test/providers/library_provider_test.dart index 6a97b4ce..030b10d8 100644 --- a/app/test/providers/library_provider_test.dart +++ b/app/test/providers/library_provider_test.dart @@ -36,6 +36,30 @@ void main() { expect(picker.isSelectionMode, isTrue); }); + test('folder facets use a union within the category and intersect with other filters', () { + final store = createTestDataStore(); + addTearDown(store.dispose); + final first = buildTestBook(id: 'first', author: 'Ada'); + final second = buildTestBook(id: 'second', author: 'Ada'); + final third = buildTestBook(id: 'third', author: 'Other'); + final books = [first, second, third]; + final memberships = { + 'one': {'first', 'third'}, + 'two': {'first', 'second'}, + }; + provider.applyFilters(LibraryFilters(folderIds: {'one', 'two'}, authors: {'ada'})); + expect(provider.activeFilterCount, 2); + expect( + provider.filterBooks(books, dataStore: store, folderBookIds: memberships).map((book) => book.id), + ['first', 'second'], + ); + expect(provider.filterBooks(books, dataStore: store), isEmpty); + provider.applyFilters(provider.filters.copyWith(folderIds: {'disconnected'})); + expect(provider.filterBooks(books, dataStore: store, folderBookIds: memberships), isEmpty); + provider.resetQuickFilters(); + expect(provider.filterBooks(books, dataStore: store), books); + }); + group('initial state', () { test('uses the default view, sort, search, and filters', () { expect(provider.viewMode, LibraryViewMode.grid); diff --git a/app/test/services/book_import_commit_service_test.dart b/app/test/services/book_import_commit_service_test.dart index b53c993c..d38478ff 100644 --- a/app/test/services/book_import_commit_service_test.dart +++ b/app/test/services/book_import_commit_service_test.dart @@ -391,6 +391,45 @@ void main() { await repository.dispose(); }); + test('journaled import retains committed metadata and cover when queue persistence fails', () async { + Book? saved; + var coverStored = false; + + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => coverStored = true, + storeGuestCover: (_, _) async => fail('account import'), + deletePendingCover: (_, _) async => coverStored = false, + deleteGuestCover: (_) async => fail('account import'), + addBook: (book) => saved = book, + deleteBook: (_) => saved = null, + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + throw StateError('Queue persistence failed'); + }, + ); + + await expectLater( + service.commit( + result: _result(coverImage: Uint8List.fromList([1])), + sourceFilename: 'book.epub', + addedAt: addedAt, + localFilePath: 'book-1', + accountScope: accountScope, + retainCommittedBook: true, + ), + throwsStateError, + ); + expect(saved?.id, 'book-1'); + expect(coverStored, isTrue); + }); + test('account enqueue waits for metadata upsert to finish', () async { final repository = _GatedDeleteBookRepository(gateUpsert: true); final dataStore = DataStore(bookRepository: repository); diff --git a/app/test/web/folder_access_test.cjs b/app/test/web/folder_access_test.cjs new file mode 100644 index 00000000..b45e02e9 --- /dev/null +++ b/app/test/web/folder_access_test.cjs @@ -0,0 +1,125 @@ +const assert = require('node:assert/strict'); +const {readFileSync} = require('node:fs'); +const {join} = require('node:path'); +const test = require('node:test'); +const {runInNewContext} = require('node:vm'); +const {randomUUID} = require('node:crypto'); +const source = readFileSync(join(__dirname, '../../web/folder_access.js'), 'utf8'); + +function environment(handle, files = []) { + const records = new Map(); + let picks = 0; + const store = { + get: key => ({result: records.get(key)}), + getAll: () => ({result: [...records.values()]}), + put(value, key) { records.set(key, value); return {result: key}; }, + delete(key) { records.delete(key); return {}; }, + }; + const database = { + close() {}, createObjectStore() {}, + transaction() { + const transaction = {objectStore: () => store}; + queueMicrotask(() => transaction.oncomplete()); + return transaction; + }, + }; + const window = {}; + if (handle) window.showDirectoryPicker = async options => { + assert.equal(options.mode, 'read'); + picks++; + return handle; + }; + runInNewContext(source, { + window, DOMException, ArrayBuffer, crypto: {randomUUID}, + indexedDB: { + open() { + const request = {result: database}; + queueMicrotask(() => request.onsuccess()); + return request; + }, + }, + document: { + createElement() { + return {files, click() { queueMicrotask(() => files.length ? this.onchange() : this.oncancel()); }}; + }, + }, + }); + return { + records, get picks() { return picks; }, canWatch: window.papyrusFolders.canWatch, + async call(method, args = {}) { + const response = await window.papyrusFolders.call(method, JSON.stringify(args)); + return typeof response === 'string' ? JSON.parse(response) : response; + }, + }; +} + +function file(name, content = [1], relative = '') { + return {name, size: content.length, lastModified: 1, webkitRelativePath: relative, + async arrayBuffer() { return Uint8Array.from(content).buffer; }}; +} + +function directory(name, entries = []) { + const handle = { + name, kind: 'directory', permission: 'granted', requests: 0, + async queryPermission() { return this.permission; }, + async requestPermission() { this.requests++; return this.permission; }, + async isSameEntry(other) { return this === other; }, + async *entries() { yield* entries; }, + async getDirectoryHandle(name) { return entries.find(entry => entry[0] === name)[1]; }, + async getFileHandle(name) { return entries.find(entry => entry[0] === name)[1]; }, + }; + return handle; +} +const entry = value => [value.name, {kind: 'file', async getFile() { return value; }}]; + +test('picker starts on the original gesture and duplicate selections share identity only', async () => { + const root = directory('Books'); + const browser = environment(root); + const picking = browser.call('pick'); + assert.equal(browser.picks, 1); + const first = (await picking).value; + const second = (await browser.call('pick')).value; + assert.equal(first.identity, second.identity); + assert.notEqual(first.key, second.key); + await browser.call('release', {grant: second.key}); + assert.ok(browser.records.has(first.key)); +}); + +test('scanning streams entries, skips temporary files and continues after an inaccessible subfolder', async () => { + const bad = directory('blocked'); + bad.entries = async function* () { throw new Error('Offline provider'); }; + const nested = directory('Nested', [entry(file('later.pdf', [2]))]); + const root = directory('Books', [entry(file('.hidden.epub')), ['blocked', bad], + ['Nested', nested], entry(file('book.epub')), entry(file('book.epub.part'))]); + const browser = environment(root); + const grant = (await browser.call('pick')).value.key; + const cursor = (await browser.call('start', {grant, recursive: true})).value; + const batch = (await browser.call('next', {cursor})).value; + assert.deepEqual(batch.map(value => value.key), ['blocked', 'Nested/later.pdf', 'book.epub']); + assert.equal(batch[0].error, 'Offline provider'); + assert.equal((await browser.call('read', {grant, key: 'book.epub'})).byteLength, 1); + await browser.call('close', {cursor}); + assert.deepEqual((await browser.call('next', {cursor})).value, []); +}); + +test('restoration requires explicit permission and automatic scans never prompt', async () => { + const root = directory('Books'); + const browser = environment(root); + const grant = (await browser.call('pick')).value.key; + root.permission = 'denied'; + assert.equal((await browser.call('start', {grant, recursive: true})).code, 'access'); + assert.equal(root.requests, 0); + assert.equal((await browser.call('restore', {grant})).value, false); + assert.equal(root.requests, 1); +}); + +test('fallback folder selection retains relative paths and cancellation creates no grant', async () => { + const browser = environment(null, [file('book.epub', [1], 'Books/book.epub'), + file('later.pdf', [2], 'Books/Nested/later.pdf')]); + assert.equal(browser.canWatch, false); + const grant = (await browser.call('pick')).value; + assert.equal(grant.persistent, false); + const cursor = (await browser.call('start', {grant: grant.key, recursive: false})).value; + assert.deepEqual((await browser.call('next', {cursor})).value.map(value => value.key), ['book.epub']); + assert.equal((await environment(null).call('pick')).value, null); +}); diff --git a/app/test/web/offline_service_worker_test.cjs b/app/test/web/offline_service_worker_test.cjs new file mode 100644 index 00000000..0f57f157 --- /dev/null +++ b/app/test/web/offline_service_worker_test.cjs @@ -0,0 +1,96 @@ +const assert = require('node:assert/strict'); +const {readFileSync} = require('node:fs'); +const {join} = require('node:path'); +const test = require('node:test'); +const {runInNewContext} = require('node:vm'); + +const source = readFileSync(join(__dirname, '../../web/papyrus_service_worker.js'), 'utf8') + .replace("const applicationRevision = 'unprepared';", "const applicationRevision = 'test';"); +const origin = 'https://papyrus.test'; +const scope = `${origin}/reader/`; + +function worker({failAsset = false} = {}) { + const handlers = {}; + const stores = new Map([['unrelated-cache', new Map()], ['papyrus-offline-old', new Map()]]); + const requests = []; + let offline = false; + const key = request => new URL(typeof request === 'string' ? request : request.href || request.url, scope).href; + const manifest = {revision: 'test', assets: ['index.html', 'main.dart.js', 'book_worker.js', 'canvaskit/canvaskit.wasm']}; + const fetch = async request => { + requests.push(key(request)); + if (offline) throw new Error('Offline'); + if (key(request).endsWith('offline-manifest.json')) return new Response(JSON.stringify(manifest)); + return new Response('network'); + }; + class ScopedRequest extends Request { + constructor(url, options) { super(new URL(url, scope), options); } + } + runInNewContext(source, { + URL, Set, JSON, Response, Request: ScopedRequest, fetch, + caches: { + async open(name) { + if (!stores.has(name)) stores.set(name, new Map()); + const entries = stores.get(name); + return { + async match(request) { return entries.get(key(request))?.clone(); }, + async put(request, response) { entries.set(key(request), response); }, + async addAll(batch) { + if (failAsset) throw new Error('Download interrupted'); + for (const request of batch) entries.set(key(request), new Response(`cached:${key(request)}`)); + }, + }; + }, + async keys() { return [...stores.keys()]; }, + async delete(name) { return stores.delete(name); }, + }, + self: { + registration: {scope}, location: {origin}, clients: {async claim() {}}, + addEventListener(name, handler) { handlers[name] = handler; }, + }, + }); + return { + stores, requests, + offline() { offline = true; }, + lifecycle(name) { + let operation; + handlers[name]({waitUntil(value) { operation = value; }}); + return operation; + }, + fetch(url, {method = 'GET', mode = 'cors'} = {}) { + let response; + handlers.fetch({request: {url, method, mode}, respondWith(value) { response = value; }}); + return response; + }, + }; +} + +test('cached application, reader and import runtime reopen offline under a subpath', async () => { + const runtime = worker(); + await runtime.lifecycle('install'); + await runtime.lifecycle('activate'); + runtime.offline(); + for (const name of ['main.dart.js', 'book_worker.js', 'canvaskit/canvaskit.wasm']) { + assert.equal(await (await runtime.fetch(scope + name)).text(), `cached:${scope + name}`); + } + const page = await runtime.fetch(scope + 'library/folders', {mode: 'navigate'}); + assert.equal(await page.text(), `cached:${scope}index.html`); + assert.ok(runtime.stores.has('unrelated-cache')); + assert.ok(!runtime.stores.has('papyrus-offline-old')); +}); + +test('API, private media, cross-origin requests and writes are never cached', async () => { + const runtime = worker(); + await runtime.lifecycle('install'); + assert.equal(runtime.fetch(`${origin}/v1/media/private`), undefined); + assert.equal(runtime.fetch(`${origin}/api/books`), undefined); + assert.equal(runtime.fetch('https://api.test/v1/books'), undefined); + assert.equal(runtime.fetch(scope + 'main.dart.js', {method: 'POST'}), undefined); + assert.equal(await (await runtime.fetch(scope + 'user-book.epub')).text(), 'network'); +}); + +test('failed preparation removes the partial cache and leaves the existing release', async () => { + const runtime = worker({failAsset: true}); + await assert.rejects(runtime.lifecycle('install'), /Download interrupted/); + assert.ok(!runtime.stores.has('papyrus-offline-test')); + assert.ok(runtime.stores.has('papyrus-offline-old')); +}); diff --git a/app/web/flutter_bootstrap.js b/app/web/flutter_bootstrap.js index f15f382a..bd8a28fc 100644 --- a/app/web/flutter_bootstrap.js +++ b/app/web/flutter_bootstrap.js @@ -4,7 +4,15 @@ (() => { let started = false; + if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) { + // Development builds have no offline manifest. Release packaging prepares it. + fetch('offline-manifest.json', {cache: 'no-store'}).then(response => { + if (response.ok) return navigator.serviceWorker.register('papyrus_service_worker.js', {updateViaCache: 'none'}); + }).catch(() => {}); + } + _flutter.loader.load({ + config: {canvasKitBaseUrl: 'canvaskit/'}, onEntrypointLoaded: async (engineInitializer) => { // A web hot restart resets Dart types but leaves browser callbacks alive. // Start a new document rather than reusing the old database listeners. diff --git a/app/web/folder_access.js b/app/web/folder_access.js new file mode 100644 index 00000000..f6ce35f0 --- /dev/null +++ b/app/web/folder_access.js @@ -0,0 +1,178 @@ +/* Device folder permissions stay in this origin and never enter library sync. */ +(() => { + 'use strict'; + const transient = new Map(); + const cursors = new Map(); + const canWatch = typeof window.showDirectoryPicker === 'function'; + const included = name => !name.startsWith('.') && !name.startsWith('~') && + !/\.(tmp|part|partial|crdownload|download)$/i.test(name); + + async function database() { + return new Promise((resolve, reject) => { + const request = indexedDB.open('papyrus-folder-grants', 1); + request.onupgradeneeded = () => request.result.createObjectStore('grants'); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async function stored(mode, operation) { + const db = await database(); + try { + return await new Promise((resolve, reject) => { + const transaction = db.transaction('grants', mode); + const request = operation(transaction.objectStore('grants')); + transaction.oncomplete = () => resolve(request.result); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); + } finally { + db.close(); + } + } + + async function grant(key) { + if (transient.has(key)) return transient.get(key); + const value = await stored('readonly', store => store.get(key)); + if (!value) throw new DOMException('Select this folder again to restore access.', 'NotAllowedError'); + if (await value.handle.queryPermission({mode: 'read'}) !== 'granted') { + throw new DOMException('Reconnect this folder to allow access.', 'NotAllowedError'); + } + return value; + } + + function metadata(file, key) { + return {key, name: file.name, size: file.size, modified: file.lastModified}; + } + + async function source(key, relative) { + const value = await grant(key); + if (value.files) { + const file = value.files.get(relative); + if (!file) throw new DOMException('Select the folder again.', 'NotFoundError'); + return file; + } + const parts = relative.split('/'); + if (parts.some(part => !part || part === '.' || part === '..')) throw new Error('Invalid source path.'); + let directory = value.handle; + for (const part of parts.slice(0, -1)) directory = await directory.getDirectoryHandle(part); + return (await directory.getFileHandle(parts.at(-1))).getFile(); + } + + async function* walk(value, recursive) { + if (value.files) { + for (const [key, file] of value.files) { + const parts = key.split('/'); + if (parts.every(included) && (recursive || parts.length === 1)) yield metadata(file, key); + } + return; + } + async function* visit(directory, prefix) { + for await (const [name, handle] of directory.entries()) { + if (!included(name)) continue; + const key = prefix + name; + if (handle.kind === 'directory') { + if (recursive) { + try { + yield* visit(handle, key + '/'); + } catch (error) { + yield {key, name, size: 0, modified: 0, error: error.message}; + } + } + } else { + try { + yield metadata(await handle.getFile(), key); + } catch (error) { + yield {key, name, size: 0, modified: 0, error: error.message}; + } + } + } + } + yield* visit(value.handle, ''); + } + + async function pick() { + if (canWatch) { + const handle = await window.showDirectoryPicker({mode: 'read'}); + const existing = await stored('readonly', store => store.getAll()); + let identity = crypto.randomUUID(); + for (const value of existing) { + try { + if (await handle.isSameEntry(value.handle)) { + identity = value.identity; + break; + } + } catch (_) { + // A disconnected old grant does not prevent choosing a new folder. + } + } + const key = crypto.randomUUID(); + await stored('readwrite', store => store.put({handle, identity}, key)); + return {key, identity, name: handle.name, location: handle.name, persistent: true}; + } + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.webkitdirectory = true; + const files = await new Promise(resolve => { + input.onchange = () => resolve(Array.from(input.files || [])); + input.oncancel = () => resolve([]); + input.click(); + }); + if (!files.length) return null; + const key = crypto.randomUUID(); + const name = files[0].webkitRelativePath.split('/')[0] || 'Selected files'; + const entries = new Map(files.map(file => [file.webkitRelativePath.split('/').slice(1).join('/') || file.name, file])); + transient.set(key, {files: entries}); + return {key, identity: key, name, location: name, persistent: false}; + } + + const actions = { + pick, + async restore({grant: key}) { + if (transient.has(key)) return true; + const value = await stored('readonly', store => store.get(key)); + return !!value && await value.handle.requestPermission({mode: 'read'}) === 'granted'; + }, + async start({grant: key, recursive}) { + const cursor = crypto.randomUUID(); + cursors.set(cursor, walk(await grant(key), recursive)); + return cursor; + }, + async next({cursor}) { + const iterator = cursors.get(cursor); + if (!iterator) return []; + const batch = []; + for (let count = 0; count < 128; count++) { + const item = await iterator.next(); + if (item.done) break; + batch.push(item.value); + } + return batch; + }, + async close({cursor}) { + await cursors.get(cursor)?.return(); + cursors.delete(cursor); + }, + async stat({grant: key, key: relative}) { return metadata(await source(key, relative), relative); }, + async read({grant: key, key: relative}) { return (await source(key, relative)).arrayBuffer(); }, + async release({grant: key}) { + transient.delete(key); + await stored('readwrite', store => store.delete(key)); + }, + }; + + window.papyrusFolders = { + canWatch, + async call(method, argumentsJson) { + try { + const value = await actions[method](JSON.parse(argumentsJson)); + return value instanceof ArrayBuffer ? value : JSON.stringify({value: value ?? null}); + } catch (error) { + if (error.name === 'AbortError' && method === 'pick') return JSON.stringify({value: null}); + const code = error.name === 'NotAllowedError' ? 'access' : 'unavailable'; + return JSON.stringify({code, error: error.message || 'Could not access the source folder.'}); + } + }, + }; +})(); diff --git a/app/web/index.html b/app/web/index.html index 5fd9e8dd..1dd89b2f 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -33,6 +33,7 @@ + diff --git a/app/web/papyrus_service_worker.js b/app/web/papyrus_service_worker.js new file mode 100644 index 00000000..e45bc7c3 --- /dev/null +++ b/app/web/papyrus_service_worker.js @@ -0,0 +1,71 @@ +'use strict'; + +// The release packager stamps this worker and lists only public application assets. +const prefix = 'papyrus-offline-'; +const applicationRevision = 'unprepared'; +let activeCache; +let assets; + +async function metadata() { + if (activeCache && assets) return; + activeCache = prefix + applicationRevision; + const cache = await caches.open(activeCache); + const manifest = await (await cache.match('offline-manifest.json')).json(); + assets = new Set(manifest.assets.map(path => new URL(path, self.registration.scope).pathname)); +} + +self.addEventListener('install', event => { + event.waitUntil((async () => { + const response = await fetch('offline-manifest.json', {cache: 'no-store'}); + if (!response.ok) throw new Error('Offline application manifest is unavailable.'); + const manifest = await response.json(); + if (manifest.revision !== applicationRevision) throw new Error('Application update changed during download.'); + const name = prefix + manifest.revision; + const cache = await caches.open(name); + try { + // Limit concurrent requests for large reader runtimes and fonts. + for (let offset = 0; offset < manifest.assets.length; offset += 8) { + await cache.addAll(manifest.assets.slice(offset, offset + 8).map(url => new Request(url, {cache: 'reload'}))); + } + await cache.put('offline-manifest.json', new Response(JSON.stringify(manifest))); + activeCache = name; + assets = new Set(manifest.assets.map(path => new URL(path, self.registration.scope).pathname)); + } catch (error) { + await caches.delete(name); + throw error; + } + })()); +}); + +self.addEventListener('activate', event => { + event.waitUntil((async () => { + await metadata(); + for (const name of await caches.keys()) { + if (name.startsWith(prefix) && name !== activeCache) await caches.delete(name); + } + await self.clients.claim(); + })()); +}); + +self.addEventListener('fetch', event => { + const request = event.request; + const url = new URL(request.url); + if (request.method !== 'GET' || url.origin !== self.location.origin) return; + // Never cache authenticated endpoints or user media. + if (url.pathname.includes('/v1/') || url.pathname.includes('/api/')) return; + event.respondWith((async () => { + try { + await metadata(); + const cache = await caches.open(activeCache); + if (request.mode === 'navigate') { + return await cache.match(new URL('index.html', self.registration.scope)) || fetch(request); + } + if (assets.has(url.pathname)) { + return await cache.match(new URL(url.pathname, self.location.origin)) || fetch(request); + } + } catch (_) { + // Storage eviction must not prevent the application loading online again. + } + return fetch(request); + })()); +}); diff --git a/tools/install_linux_build_dependencies.sh b/tools/install_linux_build_dependencies.sh index 17e8706f..5ae3c048 100644 --- a/tools/install_linux_build_dependencies.sh +++ b/tools/install_linux_build_dependencies.sh @@ -9,7 +9,7 @@ fi apt_options=(-o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30) sudo apt-get "${apt_options[@]}" update sudo apt-get "${apt_options[@]}" install -y \ - clang cmake ninja-build pkg-config \ + clang lld cmake ninja-build pkg-config \ libgtk-3-dev libwebkit2gtk-4.1-dev libsecret-1-dev \ liblzma-dev libstdc++-12-dev \ python3 squashfs-tools dpkg-dev file imagemagick desktop-file-utils patchelf \ diff --git a/tools/prepare_web_offline.py b/tools/prepare_web_offline.py new file mode 100644 index 00000000..820e951d --- /dev/null +++ b/tools/prepare_web_offline.py @@ -0,0 +1,40 @@ +"""Prepare a Flutter web build for offline startup and local book import.""" + +import argparse +import hashlib +import json +import re +from pathlib import Path + + +def prepare(root: Path) -> dict: + required = ("index.html", "flutter_bootstrap.js", "main.dart.js", "folder_access.js", "book_worker.js", "papyrus_service_worker.js") + + for name in required: + if not (root / name).is_file(): + raise ValueError(f"Missing web application asset: {name}") + + excluded = {"offline-manifest.json", "papyrus_service_worker.js", "flutter_service_worker.js"} + assets = sorted( + path.relative_to(root).as_posix() + for path in root.rglob("*") + if path.is_file() and path.name not in excluded and path.suffix not in {".map", ".symbols"} + ) + worker = root / "papyrus_service_worker.js" + source = re.sub(r"const applicationRevision = '[^']*';", "const applicationRevision = 'unprepared';", worker.read_text()) + checksum = hashlib.sha256(source.encode()) + + for name in assets: + checksum.update(name.encode()) + checksum.update(hashlib.sha256((root / name).read_bytes()).digest()) + + manifest = {"revision": checksum.hexdigest(), "assets": assets} + (root / "offline-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + worker.write_text(source.replace("const applicationRevision = 'unprepared';", f"const applicationRevision = '{manifest['revision']}';")) + return manifest + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", type=Path) + prepare(parser.parse_args().root) diff --git a/tools/release_assets.py b/tools/release_assets.py index 899c3e62..fef2e17f 100644 --- a/tools/release_assets.py +++ b/tools/release_assets.py @@ -11,6 +11,7 @@ from pathlib import Path from release_gate import parse +from prepare_web_offline import prepare as prepare_web_offline TAG = re.compile(r"v([0-9]+\.[0-9]+\.[0-9]+)\+([1-9][0-9]*)") @@ -172,6 +173,7 @@ def main() -> None: if args.command == "stamp-web": (args.root / "release.json").write_text(json.dumps(identity(args.tag, args.revision), indent=2) + "\n") + prepare_web_offline(args.root) elif args.command == "seal": instructions = Path(__file__).resolve().parents[1] / "docs/INSTALL.md" shutil.copyfile(instructions, args.root / names(args.tag)["instructions"]) diff --git a/tools/tests/test_web_offline.py b/tools/tests/test_web_offline.py new file mode 100644 index 00000000..1b84041d --- /dev/null +++ b/tools/tests/test_web_offline.py @@ -0,0 +1,36 @@ +"""Offline web packaging must be complete, deterministic, and versioned.""" + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from prepare_web_offline import prepare + + +class OfflinePackagingTest(unittest.TestCase): + def test_revision_changes_with_assets_and_worker_but_not_repeated_preparation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in ("index.html", "flutter_bootstrap.js", "main.dart.js", "folder_access.js", "book_worker.js"): + (root / name).write_text(name) + worker = root / "papyrus_service_worker.js" + worker.write_text("const applicationRevision = 'unprepared';\n") + (root / "main.dart.js.map").write_text("debug information") + first = prepare(root) + self.assertEqual(prepare(root), first) + self.assertNotIn("main.dart.js.map", first["assets"]) + self.assertNotIn("offline-manifest.json", first["assets"]) + self.assertIn("book_worker.js", first["assets"]) + self.assertIn(first["revision"], worker.read_text()) + (root / "main.dart.js").write_text("updated application") + second = prepare(root) + self.assertNotEqual(first["revision"], second["revision"]) + worker.write_text(worker.read_text() + "// updated caching behavior\n") + self.assertNotEqual(prepare(root)["revision"], second["revision"]) + + def test_partial_build_cannot_be_packaged_for_offline_use(self): + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(ValueError, "Missing web application asset"): + prepare(Path(directory))