diff --git a/README.md b/README.md index 5dd46b7..d73c92d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Many reading applications offer partial solutions but fall short on essential fe | **Goals** | Reading goals (books, pages, time) with streak tracking | | **Sync** | Cross-device synchronization via self-hostable server | | **Storage** | Multiple backends: self-hosted, Google Drive, WebDAV, S3 | +| **Catalogs** | OPDS 1.2 and 2.0 browsing, keyword search, and automatic imports through the backend relay | | **Accessibility** | E-ink optimization, dark/light themes, customizable fonts | ## Supported platforms @@ -101,6 +102,17 @@ cd ../client/app flutter run -d chrome --web-hostname papyrus.localhost --web-port 3000 --dart-define-from-file=.dart_defines ``` +Web hot restart (`R` in the Flutter terminal) performs a full browser refresh at +the current URL. This releases the previous Dart runtime's database listeners +and workers before starting the app again. Browser storage is retained; ordinary +hot reload (`r`) still uses Flutter's normal behavior. After changing +`web/flutter_bootstrap.js`, refresh the browser once to load the new bootstrap. + +The bootstrap uses Flutter's documented +[`onEntrypointLoaded` hook](https://docs.flutter.dev/platform-integration/web/initialization#the-onentrypointloaded-callback), +which runs on cold startup and hot restart. Its regression checks run with +`node --test test/web/flutter_bootstrap_test.cjs` from `app/`. + Authenticated books use `papyrus-account.db` and synchronize through PowerSync. Guest mode uses the separate local-only `papyrus-guest.db`; guest books remain on that device and are not merged into an account. @@ -120,6 +132,14 @@ for the full integration guide. See [PapyrusReader/docs](https://github.com/PapyrusReader/docs). +### OPDS catalogs + +Open **Library → Catalogs → Add catalog**, enter its URL, and optionally enter HTTP Basic credentials. Browse sections, search, open a publication, and download a supported format into your library. Web imports support EPUB; native imports support EPUB, PDF, MOBI, AZW3, TXT, CBZ, and CBR. + +Saved catalogs and secure-storage credentials are local to each server/account, with a separate guest collection. Catalog requests and downloads use the selected Papyrus backend relay, so catalogs do not need browser CORS support. A reachable backend is required, but a Papyrus account is not. Guest imports stay local. The relay accepts public network destinations and sends catalog credentials only to their origin. OAuth, lending, purchases, DRM, offline catalog caching, and download resumption after closing the app are deferred. + +See [OPDS setup, compatibility, and verification](docs/opds-support.md) for details and local smoke-test commands. + ## Technology stack | Layer | Technology | diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index 0e3c826..85bef9c 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -11,6 +11,8 @@ import 'package:papyrus/pages/dashboard_page.dart'; import 'package:papyrus/pages/developer_options_page.dart'; import 'package:papyrus/pages/goals_page.dart'; import 'package:papyrus/pages/library_page.dart'; +import 'package:papyrus/pages/catalogs_page.dart'; +import 'package:papyrus/pages/catalog_book_page.dart'; import 'package:papyrus/pages/forgot_password_page.dart'; import 'package:papyrus/pages/login_page.dart'; import 'package:papyrus/pages/edit_profile_page.dart'; @@ -43,7 +45,11 @@ class AppRouter { required this.preferencesProvider, required this.syncSettingsProvider, required this.acquisitionAvailabilityProvider, - }); + }) { + // Catalog browsing pushes visited pages to preserve their state on Back. + // Reflect those destinations in web URLs for refresh, sharing and history. + GoRouter.optionURLReflectsImperativeAPIs = true; + } late final GoRouter router = GoRouter( debugLogDiagnostics: true, @@ -121,6 +127,28 @@ class AppRouter { path: 'books', pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LibraryPage()), ), + GoRoute( + name: 'CATALOGS', + path: 'catalogs', + pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const CatalogsPage()), + routes: [ + GoRoute( + name: 'CATALOG', + path: ':catalogId', + routes: [catalogBookRoute()], + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: CatalogsPage( + catalogId: state.pathParameters['catalogId'], + feedUri: Uri.tryParse(state.uri.queryParameters['feed'] ?? '')?.hasScheme == true + ? Uri.tryParse(state.uri.queryParameters['feed']!) + : null, + query: state.uri.queryParameters['q'] ?? '', + ), + ), + ), + ], + ), GoRoute( name: 'SHELVES', path: 'shelves', diff --git a/app/lib/main.dart b/app/lib/main.dart index a7169e3..f109a7e 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -14,8 +14,14 @@ import 'package:papyrus/media/media_cache_service.dart'; import 'package:papyrus/media/media_models.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_library.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; +import 'package:papyrus/services/book_import_session.dart'; import 'package:papyrus/platform/book_import_drop_registration.dart'; -import 'package:papyrus/platform/hot_restart_cleanup.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; import 'package:papyrus/powersync/sync_profile_switch_queue.dart'; @@ -38,7 +44,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'config/app_router.dart'; Future main() async { - await runPreviousHotRestartCleanup(); WidgetsFlutterBinding.ensureInitialized(); ensureBookImportDropPluginRegistered(); usePathUrlStrategy(); @@ -163,6 +168,10 @@ class _PapyrusState extends State { late final SyncSettingsProvider _syncSettingsProvider; late final MediaUploadQueue _mediaUploadQueue; late final BookImportService _bookImportService; + late final OpdsCatalogs _opdsCatalogs; + late final OpdsDownloads _opdsDownloads; + late final OpdsHttpClient _opdsHttpClient; + late final OpdsLibrary _opdsLibrary; late final PapyrusPowerSyncService _powerSyncService; late final BookStorageStatusController _bookStorageStatusController; late final PapyrusApiConfig _officialApiConfig; @@ -218,7 +227,22 @@ class _PapyrusState extends State { mediaUploadQueue: _mediaUploadQueue, hasBookFile: _bookImportService.hasBookFile, ); - registerHotRestartCleanup(_disposeDataServices); + _opdsLibrary = OpdsLibrary(widget.prefs, dataStore: _dataStore); + final opdsCache = OpdsResourceCache(widget.prefs); + _opdsCatalogs = OpdsCatalogs(OpdsCatalogStore(widget.prefs), library: _opdsLibrary, cache: opdsCache); + _opdsHttpClient = OpdsHttpClient(apiConfig: () => _syncSettingsProvider.activeApiConfig, cache: opdsCache); + _opdsDownloads = OpdsDownloads( + library: _opdsLibrary, + httpClient: _opdsHttpClient, + captureImport: () => BookImportSession.capture( + dataStore: _dataStore, + queue: _mediaUploadQueue, + importService: _bookImportService, + authProvider: _authProvider, + powerSyncService: _powerSyncService, + ), + ); + _opdsCatalogs.addListener(_opdsDownloads.reset); unawaited(_dataStore.attachBookRepository(_powerSyncService)); _appRouter = AppRouter( authProvider: _authProvider, @@ -237,6 +261,11 @@ class _PapyrusState extends State { _syncSettingsProvider.removeListener(_handleSyncSettingsChanged); unawaited(_disposeDataServices()); _bookStorageStatusController.dispose(); + _opdsCatalogs.removeListener(_opdsDownloads.reset); + _opdsDownloads.dispose(); + _opdsLibrary.dispose(); + _opdsCatalogs.dispose(); + _opdsHttpClient.cache?.dispose(); _bookImportService.dispose(); _acquisitionDownloadsComposition.dispose(); _acquisitionAvailabilityProvider.dispose(); @@ -255,11 +284,13 @@ class _PapyrusState extends State { } Future _disposeDataServices() async { + _opdsDownloads.reset(); await _dataStore.disposeBookRepository(); await _powerSyncService.close(); } void _syncPowerSyncAuthState() { + _updateOpdsScope(); if (_authStateOperation != null) { _authStateUpdateQueued = true; return; @@ -287,10 +318,20 @@ class _PapyrusState extends State { Future _applyPowerSyncAuthState() async { final user = _authProvider.user; + final profileKey = _activeProfileKey; if (user != null && !_authProvider.isOfflineMode) { final userId = user.userId; - await _mediaUploadQueue.activateScope(MediaStorageScope(profileKey: _activeProfileKey, userId: userId)); - await _powerSyncService.activateAuthenticated(userId, profileKey: _activeProfileKey); + final scope = MediaStorageScope(profileKey: profileKey, userId: userId); + await _mediaUploadQueue.activateScope(scope); + await _powerSyncService.activateAuthenticated(userId, profileKey: profileKey); + if (!_switchingSyncProfile && + _activeProfileKey == profileKey && + _syncSettingsProvider.activeProfileKey == profileKey && + _authProvider.isSignedIn && + _authProvider.user?.userId == userId && + !_authProvider.isOfflineMode) { + _opdsCatalogs.setScope(scope.persistenceKey); + } await _refreshMediaUsage(); await _processMediaUploads(); return; @@ -299,6 +340,12 @@ class _PapyrusState extends State { if (_authProvider.isOfflineMode) { await _mediaUploadQueue.activateScope(null); await _powerSyncService.activateGuest(); + if (_authProvider.isOfflineMode && + !_switchingSyncProfile && + _activeProfileKey == profileKey && + _syncSettingsProvider.activeProfileKey == profileKey) { + _opdsCatalogs.setScope(MediaStorageScope.localGuest.persistenceKey); + } return; } @@ -321,6 +368,7 @@ class _PapyrusState extends State { _acquisitionDownloadsComposition.handleServerChanged(); final nextProfileKey = _syncSettingsProvider.activeProfileKey; final nextConfig = _syncSettingsProvider.activeApiConfig; + if (nextProfileKey != _activeProfileKey) _opdsCatalogs.setScope(null); _profileSwitchQueue.request(nextProfileKey, () => _switchActiveSyncProfile(nextProfileKey, nextConfig)); } @@ -340,6 +388,24 @@ class _PapyrusState extends State { } } + void _updateOpdsScope() { + if (_switchingSyncProfile || + _activeProfileKey != _syncSettingsProvider.activeProfileKey || + _authProvider.isBootstrapping) { + _opdsCatalogs.setScope(null); + } else if (_authProvider.isOfflineMode) { + if (_opdsCatalogs.scope != MediaStorageScope.localGuest.persistenceKey) _opdsCatalogs.setScope(null); + } else if (_authProvider.isSignedIn && _authProvider.user != null) { + final expected = MediaStorageScope( + profileKey: _activeProfileKey, + userId: _authProvider.user!.userId, + ).persistenceKey; + if (_opdsCatalogs.scope != expected) _opdsCatalogs.setScope(null); + } else { + _opdsCatalogs.setScope(null); + } + } + Future _refreshMediaUsage() async { if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return; final repository = _authRepository; @@ -404,6 +470,9 @@ class _PapyrusState extends State { ChangeNotifierProvider.value(value: _bookStorageStatusController), Provider.value(value: _powerSyncService), Provider.value(value: _bookImportService), + ChangeNotifierProvider.value(value: _opdsCatalogs), + ChangeNotifierProvider.value(value: _opdsDownloads), + Provider.value(value: _opdsHttpClient), Provider(create: _createBookDownloadService), Provider(create: _createMediaCacheService), StreamProvider.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState), diff --git a/app/lib/opds/opds_browser.dart b/app/lib/opds/opds_browser.dart new file mode 100644 index 0000000..3887d98 --- /dev/null +++ b/app/lib/opds/opds_browser.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_parser.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; +import 'package:papyrus/opds/opds_search.dart'; + +class OpdsBrowser extends ChangeNotifier { + OpdsBrowser({OpdsHttpClient? httpClient}) : httpClient = httpClient ?? OpdsHttpClient() { + this.httpClient.cache?.addListener(_cacheChanged); + } + final OpdsHttpClient httpClient; + OpdsFeed? feed; + String? error; + bool loading = false; + bool isCached = false; + bool authorizationFailed = false; + bool cacheInvalidated = false; + DateTime? fetchedAt; + OpdsCatalog? _catalog; + OpdsCredentials? _credentials; + OpdsCancellation _cancellation = OpdsCancellation(); + OpdsCacheToken? _activeCacheToken; + bool _invalidatingAuthorization = false; + bool _disposed = false; + + void _cacheChanged() { + final token = _activeCacheToken; + if (_disposed || token == null || httpClient.cache!.isCurrent(token)) return; + _activeCacheToken = null; + if (!_invalidatingAuthorization) _cancellation.cancel(); + feed = null; + isCached = false; + fetchedAt = null; + cacheInvalidated = true; + loading = false; + error = 'Catalog data changed. Refresh to load it again.'; + _notify(); + } + + OpdsFeed _parse(OpdsResponse response) => + OpdsParser.parse(response.text, response.uri, contentType: response.headers['content-type']); + + void _checkRequest(OpdsCancellation token, OpdsCacheToken? cacheToken) { + token.check(); + if (_disposed || (cacheToken != null && !httpClient.cache!.isCurrent(cacheToken))) { + throw const OpdsCancelled(); + } + } + + Future _fetch( + OpdsCatalog catalog, + Uri uri, + OpdsCancellation token, + OpdsCredentials? credentials, + T Function(OpdsResponse) parse, { + OpdsCacheToken? cacheToken, + bool preferCached = false, + }) async { + final cache = httpClient.cache; + cacheToken ??= cache?.capture(catalog, uri); + _checkRequest(token, cacheToken); + if (preferCached && cacheToken != null) { + final cached = cache!.read(cacheToken); + if (cached != null) { + try { + return parse(cached.response); + } catch (_) { + unawaited(cache.remove(cacheToken)); + } + } + } + try { + final response = await httpClient.get(catalog, uri, credentials: credentials, cancellation: token); + _checkRequest(token, cacheToken); + final parsed = parse(response); + if (cacheToken != null) await cache!.write(cacheToken, response); + _checkRequest(token, cacheToken); + return parsed; + } on OpdsAuthorizationException { + _checkRequest(token, cacheToken); + if (cacheToken != null) { + // Other browsers cancel immediately; this request still reports its + // authorization error after the synchronous invalidation notification. + late Future invalidating; + _invalidatingAuthorization = true; + try { + invalidating = cache!.invalidateCatalog(catalog); + } finally { + _invalidatingAuthorization = false; + } + await invalidating; + } + rethrow; + } + } + + Future load(OpdsCatalog catalog, Uri uri, {OpdsCredentials? credentials}) async { + _cancellation.cancel(); + final token = _cancellation = OpdsCancellation(); + _catalog = catalog; + _credentials = credentials; + feed = null; + error = null; + isCached = false; + authorizationFailed = false; + cacheInvalidated = false; + fetchedAt = null; + final cache = httpClient.cache; + final cacheToken = cache?.capture(catalog, uri); + _activeCacheToken = cacheToken; + if (cacheToken != null) { + final cached = cache!.read(cacheToken); + if (cached != null) { + try { + feed = _parse(cached.response); + fetchedAt = cached.fetchedAt; + isCached = true; + } catch (_) { + unawaited(cache.remove(cacheToken)); + } + } + } + loading = true; + _notify(); + try { + final loaded = await _fetch(catalog, uri, token, credentials, _parse, cacheToken: cacheToken); + if (token.isCancelled || _disposed) return; + feed = loaded; + isCached = false; + fetchedAt = cacheToken == null ? DateTime.now() : cache!.read(cacheToken)?.fetchedAt ?? DateTime.now(); + } on OpdsCancelled { + return; + } catch (failure) { + if (token.isCancelled || _disposed) return; + if (failure is OpdsAuthorizationException) { + feed = null; + isCached = false; + fetchedAt = null; + authorizationFailed = true; + } + error = opdsErrorMessage(failure); + } finally { + if (!token.isCancelled && !_disposed) { + if (cacheToken != null && !cache!.isCurrent(cacheToken)) { + feed = null; + isCached = false; + fetchedAt = null; + } + loading = false; + _notify(); + } + } + } + + Future search(String query) async { + final catalog = _catalog; + if (catalog == null || query.trim().isEmpty) throw const OpdsException('Enter a search term.'); + final token = _cancellation; + final cacheToken = httpClient.cache?.capture(catalog, catalog.uri); + try { + var link = feed?.searchLink; + link ??= (await _fetch(catalog, catalog.uri, token, _credentials, _parse, preferCached: true)).searchLink; + _checkRequest(token, cacheToken); + if (link == null) throw const OpdsException('This catalog does not advertise keyword search.'); + if (link.type?.split(';').first.trim().toLowerCase() == 'application/opensearchdescription+xml') { + link = await _fetch( + catalog, + link.uri, + token, + _credentials, + (response) => OpdsSearch.fromOpenSearch(response.text, response.uri), + preferCached: true, + ); + } + _checkRequest(token, cacheToken); + return OpdsHttpClient.validateUri(Uri.parse(OpdsSearch.expand(link.template, query.trim()))); + } on OpdsAuthorizationException catch (failure) { + if (!token.isCancelled && !_disposed) { + feed = null; + isCached = false; + fetchedAt = null; + authorizationFailed = true; + error = failure.message; + _notify(); + } + rethrow; + } + } + + void clear() { + _cancellation.cancel(); + _catalog = null; + _credentials = null; + feed = null; + error = null; + loading = false; + isCached = false; + authorizationFailed = false; + cacheInvalidated = false; + fetchedAt = null; + _activeCacheToken = null; + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + httpClient.cache?.removeListener(_cacheChanged); + clear(); + super.dispose(); + } +} + +String opdsErrorMessage(Object error) { + if (error is OpdsException) return error.message; + if (error is FormatException) return 'This response is not a supported OPDS catalog. Check the catalog URL.'; + return 'Could not load this catalog. Check its settings and retry.'; +} diff --git a/app/lib/opds/opds_catalog_store.dart b/app/lib/opds/opds_catalog_store.dart new file mode 100644 index 0000000..7303781 --- /dev/null +++ b/app/lib/opds/opds_catalog_store.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +abstract interface class OpdsSecretStorage { + Future read(String key); + Future write(String key, String value); + Future delete(String key); +} + +class SecureOpdsSecretStorage implements OpdsSecretStorage { + const SecureOpdsSecretStorage(); + static const _storage = FlutterSecureStorage(); + @override + Future read(String key) => _storage.read(key: key); + @override + Future write(String key, String value) => _storage.write(key: key, value: value); + @override + Future delete(String key) => _storage.delete(key: key); +} + +class OpdsCatalogStore { + OpdsCatalogStore(this._prefs, {OpdsSecretStorage? secrets}) : _secrets = secrets ?? const SecureOpdsSecretStorage(); + final SharedPreferences _prefs; + final OpdsSecretStorage _secrets; + Future _pending = Future.value(); + String _key(String scope) => 'papyrus.opds.catalogs.${Uri.encodeComponent(scope)}'; + String _secretKey(String scope, String id) => '${_key(scope)}.${Uri.encodeComponent(id)}.credentials'; + + List load(String scope) { + final raw = _prefs.getString(_key(scope)); + if (raw == null) return []; + try { + return (jsonDecode(raw) as List) + .map((item) => OpdsCatalog.fromJson(Map.from(item as Map))) + .toList(); + } catch (_) { + throw const OpdsException('Saved catalogs could not be read.'); + } + } + + Future credentials(String scope, String id, {String? expectedOrigin}) async { + final raw = await _secrets.read(_secretKey(scope, id)); + if (raw == null) return null; + final data = jsonDecode(raw) as Map; + final catalogs = load(scope); + final index = catalogs.indexWhere((entry) => entry.id == id); + if (index < 0 || + data['origin'] != catalogs[index].uri.origin || + (expectedOrigin != null && data['origin'] != expectedOrigin)) { + return null; + } + return OpdsCredentials(username: data['username'] as String, password: data['password'] as String); + } + + Future _serialize(Future Function() operation) { + final result = _pending.then((_) => operation()); + _pending = result.then((_) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + /// Omitted credentials preserve the existing secret unless the origin changes. + Future save(String scope, OpdsCatalog catalog, {OpdsCredentials? credentials, bool clearCredentials = false}) => + _serialize(() async { + OpdsHttpClient.validateUri(catalog.uri); + if (catalog.name.trim().isEmpty || catalog.id.isEmpty) throw const OpdsException('Enter a catalog name.'); + final catalogKey = _key(scope); + final oldCatalogs = _prefs.getString(catalogKey); + final catalogs = load(scope); + final index = catalogs.indexWhere((entry) => entry.id == catalog.id); + final originChanged = index >= 0 && catalogs[index].uri.origin != catalog.uri.origin; + final key = _secretKey(scope, catalog.id); + final oldSecret = await _secrets.read(key); + try { + if (credentials != null) { + await _secrets.write( + key, + jsonEncode({ + 'origin': catalog.uri.origin, + 'username': credentials.username, + 'password': credentials.password, + }), + ); + } else if (clearCredentials || originChanged) { + await _secrets.delete(key); + } + if (index < 0) { + catalogs.add(catalog); + } else { + catalogs[index] = catalog; + } + if (!await _prefs.setString(catalogKey, jsonEncode(catalogs.map((e) => e.toJson()).toList()))) { + throw const OpdsException('Could not save this catalog.'); + } + } catch (_) { + await _restore(catalogKey, oldCatalogs, key, oldSecret); + rethrow; + } + }); + + Future remove(String scope, String id) => _serialize(() async { + final catalogKey = _key(scope); + final oldCatalogs = _prefs.getString(catalogKey); + final catalogs = load(scope)..removeWhere((entry) => entry.id == id); + final key = _secretKey(scope, id); + final oldSecret = await _secrets.read(key); + try { + await _secrets.delete(key); + if (!await _prefs.setString(catalogKey, jsonEncode(catalogs.map((e) => e.toJson()).toList()))) { + throw const OpdsException('Could not remove this catalog.'); + } + } catch (_) { + await _restore(catalogKey, oldCatalogs, key, oldSecret); + rethrow; + } + }); + + Future _restore(String catalogKey, String? oldCatalogs, String secretKey, String? oldSecret) async { + try { + // SharedPreferences changes its cache before the backend completes. These + // calls restore the cache even if the compensating backend write fails. + if (oldCatalogs == null) { + await _prefs.remove(catalogKey); + } else { + await _prefs.setString(catalogKey, oldCatalogs); + } + } catch (_) { + // Still attempt secret restoration and preserve the original failure. + } + try { + if (oldSecret == null) { + await _secrets.delete(secretKey); + } else { + await _secrets.write(secretKey, oldSecret); + } + } catch (_) { + // Compensation is best effort; origin binding rejects any stale secret. + } + } +} diff --git a/app/lib/opds/opds_catalogs.dart b/app/lib/opds/opds_catalogs.dart new file mode 100644 index 0000000..229f8fd --- /dev/null +++ b/app/lib/opds/opds_catalogs.dart @@ -0,0 +1,87 @@ +import 'package:flutter/foundation.dart'; +import 'package:papyrus/opds/opds_browser.dart'; +import 'package:papyrus/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_library.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; + +class OpdsCatalogs extends ChangeNotifier { + OpdsCatalogs(this.store, {this.library, this.cache}); + final OpdsCatalogStore store; + final OpdsLibrary? library; + final OpdsResourceCache? cache; + String? _scope; + String? get scope => _scope; + int revision = 0; + List catalogs = const []; + String? error; + bool _disposed = false; + OpdsCatalog? find(String id) { + for (final catalog in catalogs) { + if (catalog.id == id) return catalog; + } + return null; + } + + void setScope(String? scope) { + if (_scope == scope) return; + _scope = scope; + library?.setScope(scope); + cache?.setScope(scope); + reload(); + } + + void reload() { + revision++; + error = null; + try { + catalogs = _scope == null ? const [] : List.unmodifiable(store.load(_scope!)); + } catch (failure) { + catalogs = const []; + error = opdsErrorMessage(failure); + } + if (!_disposed) notifyListeners(); + } + + Future credentials(String id) async { + final scope = _scope; + if (scope == null) throw const OpdsException('Wait for the library account to finish loading.'); + final catalog = find(id); + final currentRevision = revision; + if (catalog == null) throw const OpdsCancelled(); + final credentials = await store.credentials(scope, id, expectedOrigin: catalog.uri.origin); + if (_scope != scope || revision != currentRevision || _disposed) throw const OpdsCancelled(); + return credentials; + } + + Future save(OpdsCatalog catalog, {OpdsCredentials? credentials, bool clearCredentials = false}) async { + final scope = _scope; + if (scope == null) throw const OpdsException('Wait for the library account to finish loading.'); + final previous = find(catalog.id); + await store.save(scope, catalog, credentials: credentials, clearCredentials: clearCredentials); + if (previous != null && (previous.uri != catalog.uri || credentials != null || clearCredentials)) { + await cache?.invalidateCatalog(previous, scope: scope); + await library?.forgetCatalog(previous.id, scope: scope); + } + if (_scope == scope && !_disposed) reload(); + } + + Future remove(String id) async { + final scope = _scope; + if (scope == null) return; + final catalog = find(id); + await store.remove(scope, id); + if (catalog != null) { + await cache?.invalidateCatalog(catalog, scope: scope); + await library?.forgetCatalog(id, scope: scope); + } + if (_scope == scope && !_disposed) reload(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/app/lib/opds/opds_downloads.dart b/app/lib/opds/opds_downloads.dart new file mode 100644 index 0000000..fc60b70 --- /dev/null +++ b/app/lib/opds/opds_downloads.dart @@ -0,0 +1,238 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_library.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/services/book_import_result.dart'; +import 'package:papyrus/services/book_import_session.dart'; +import 'package:papyrus/widgets/add_book/book_import_controller.dart'; + +enum OpdsDownloadStatus { downloading, importing, committing, complete, failed, cancelled } + +class OpdsDownloadJob { + OpdsDownloadJob({required this.key, required this.catalog, required this.publication, required this.link}); + final String key; + final OpdsCatalog catalog; + final OpdsPublication publication; + final OpdsLink link; + final cancellation = OpdsCancellation(); + OpdsDownloadStatus status = OpdsDownloadStatus.downloading; + int received = 0; + int? total; + String? error; + bool networkFailure = false; + String? bookId; + bool get isCancellable => status == OpdsDownloadStatus.downloading || status == OpdsDownloadStatus.importing; + bool get isActive => isCancellable || status == OpdsDownloadStatus.committing; + double? get progress => total != null && total! > 0 ? received / total! : null; +} + +class OpdsDownloads extends ChangeNotifier { + OpdsDownloads({required this.captureImport, OpdsHttpClient? httpClient, this.library}) + : httpClient = httpClient ?? OpdsHttpClient() { + library?.addListener(_notify); + } + final OpdsLibrary? library; + final BookImportSession Function() captureImport; + final OpdsHttpClient httpClient; + final Map _jobs = {}; + final Map> _operations = {}; + bool _disposed = false; + List get jobs => List.unmodifiable(_jobs.values); + + String? libraryBookId(OpdsCatalog catalog, OpdsPublication publication, {OpdsLink? link}) => + library?.bookId(catalog, publication, link: link); + + static bool supports(OpdsLink link) => + (kIsWeb ? bookImportWebExtensions : bookImportNativeExtensions).contains(link.supportedExtension); + static String jobKey(OpdsCatalog catalog, OpdsPublication publication, OpdsLink link) => + '${catalog.id}\n${publication.id}\n${link.uri}'; + + Future start(OpdsCatalog catalog, OpdsPublication publication, OpdsLink link, {OpdsCredentials? credentials}) { + final key = jobKey(catalog, publication, link); + if (_operations.containsKey(key)) return _operations[key]!; + if (_disposed) return Future.value(); + final job = OpdsDownloadJob(key: key, catalog: catalog, publication: publication, link: link); + _jobs[key] = job; + final existing = libraryBookId(catalog, publication, link: link); + if (existing != null) { + job.bookId = existing; + job.status = OpdsDownloadStatus.complete; + _notify(); + return Future.value(); + } + final operation = _run(job, credentials); + _operations[key] = operation; + operation.whenComplete(() { + if (identical(_operations[key], operation)) _operations.remove(key); + }); + return operation; + } + + Future _run(OpdsDownloadJob job, OpdsCredentials? credentials) async { + final identity = library?.capture(job.catalog, job.publication, job.link); + BookImportSession? session; + BookImportResult? imported; + var committed = false; + void check() { + job.cancellation.check(); + if (_disposed || !identical(_jobs[job.key], job) || (session != null && !session.isCurrent())) { + throw const OpdsCancelled(); + } + } + + try { + if (!supports(job.link)) { + throw const OpdsException('This format or acquisition method is not supported on this device.'); + } + session = captureImport(); + check(); + _notify(); + final response = await httpClient.get( + job.catalog, + job.link.uri, + credentials: credentials, + cancellation: job.cancellation, + maxBytes: 256 * 1024 * 1024, + onProgress: (received, total) { + job.received = received; + job.total = total; + _notify(); + }, + ); + check(); + final contentType = response.headers['content-type']?.split(';').first.trim().toLowerCase(); + final prefix = utf8 + .decode(response.bytes.take(1024).toList(), allowMalformed: true) + .replaceFirst('\uFEFF', '') + .trimLeft(); + final isHtml = RegExp( + r'^(?:<\?xml[^>]*>\s*)?(?: + value == null || value.trim().isEmpty || ['unknown', 'unknown author'].contains(value.trim().toLowerCase()); + final missingAuthor = missing(result.author); + return BookImportResult( + bookId: result.bookId, + title: missing(result.title) || result.title == 'book' ? publication.title : result.title, + subtitle: result.subtitle, + author: missingAuthor && publication.authors.isNotEmpty ? publication.authors.first : result.author, + coAuthors: missingAuthor && publication.authors.isNotEmpty + ? publication.authors.skip(1).toList() + : result.coAuthors, + publisher: missing(result.publisher) ? publication.publisher : result.publisher, + description: missing(result.description) ? publication.description : result.description, + language: missing(result.language) ? publication.language : result.language, + isbn: missing(result.isbn) ? publication.isbn : result.isbn, + isbn13: result.isbn13, + pageCount: result.pageCount, + coverImage: result.coverImage ?? cover, + coverMimeType: result.coverImage == null ? coverMime : result.coverMimeType, + fileSize: result.fileSize, + fileHash: result.fileHash, + fileExtension: result.fileExtension, + ); +} diff --git a/app/lib/opds/opds_http_client.dart b/app/lib/opds/opds_http_client.dart new file mode 100644 index 0000000..3c04eb4 --- /dev/null +++ b/app/lib/opds/opds_http_client.dart @@ -0,0 +1,223 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; + +class OpdsException implements Exception { + const OpdsException(this.message); + final String message; + @override + String toString() => message; +} + +class OpdsCancelled extends OpdsException { + const OpdsCancelled() : super('Download cancelled.'); +} + +class OpdsAuthorizationException extends OpdsException { + const OpdsAuthorizationException(super.message); +} + +class _OpdsRelayBusy extends OpdsException { + const _OpdsRelayBusy(super.message); +} + +/// Connection failures refer to the selected Papyrus relay server. +class OpdsConnectionException extends OpdsException { + const OpdsConnectionException() + : super( + 'Could not reach the Papyrus catalog server or the download was interrupted. Check your connection and retry.', + ); +} + +class OpdsCancellation { + bool _cancelled = false; + final Set _listeners = {}; + bool get isCancelled => _cancelled; + void check() { + if (_cancelled) throw const OpdsCancelled(); + } + + void cancel() { + if (_cancelled) return; + _cancelled = true; + for (final listener in _listeners.toList()) { + listener(); + } + _listeners.clear(); + } + + void addListener(void Function() listener) { + if (_cancelled) { + listener(); + } else { + _listeners.add(listener); + } + } + + void removeListener(void Function() listener) => _listeners.remove(listener); +} + +class OpdsResponse { + const OpdsResponse({required this.uri, required this.bytes, required this.headers}); + final Uri uri; + final Uint8List bytes; + final Map headers; + String get text => utf8.decode(bytes); +} + +/// Relays every resource through the backend without Papyrus bearer tokens. +class OpdsHttpClient { + OpdsHttpClient({http.Client Function()? clientFactory, PapyrusApiConfig Function()? apiConfig, this.cache}) + : _clientFactory = clientFactory ?? http.Client.new, + _apiConfig = apiConfig ?? PapyrusApiConfig.fromEnvironment; + final http.Client Function() _clientFactory; + final PapyrusApiConfig Function() _apiConfig; + final OpdsResourceCache? cache; + final _waitingRequests = Queue>(); + int _activeRequests = 0; + + Future _acquire(OpdsCancellation? cancellation) async { + cancellation?.check(); + if (_activeRequests < 4) { + _activeRequests++; + return; + } + final pending = Completer(); + _waitingRequests.add(pending); + void cancel() { + if (_waitingRequests.remove(pending)) pending.completeError(const OpdsCancelled()); + } + + cancellation?.addListener(cancel); + try { + await pending.future; + } finally { + cancellation?.removeListener(cancel); + } + } + + void _release() { + if (_waitingRequests.isNotEmpty) { + _waitingRequests.removeFirst().complete(); + } else { + _activeRequests--; + } + } + + static Uri validateUri(Uri uri) { + if (!['http', 'https'].contains(uri.scheme) || uri.host.isEmpty || uri.userInfo.isNotEmpty) { + throw const OpdsException('Enter an HTTP or HTTPS URL without embedded credentials.'); + } + return uri; + } + + Future get( + OpdsCatalog catalog, + Uri uri, { + OpdsCredentials? credentials, + OpdsCancellation? cancellation, + void Function(int received, int? total)? onProgress, + int maxBytes = 8 * 1024 * 1024, + }) async { + validateUri(catalog.uri); + validateUri(uri); + cancellation?.check(); + final relayUri = validateUri(_apiConfig().endpoint('/opds/relay')); + await _acquire(cancellation); + http.Client? client; + try { + cancellation?.check(); + client = _clientFactory(); + cancellation?.addListener(client.close); + late http.StreamedResponse response; + final body = jsonEncode({ + 'url': uri.toString(), + 'catalog_url': catalog.uri.toString(), + 'max_bytes': maxBytes, + if (credentials != null) 'credentials': {'username': credentials.username, 'password': credentials.password}, + }); + for (var attempt = 0; ; attempt++) { + cancellation?.check(); + final request = http.Request('POST', relayUri) + ..headers['content-type'] = 'application/json' + ..body = body; + response = await client.send(request).timeout(const Duration(seconds: 35)); + if (response.statusCode == 200) break; + final error = await _relayError(response); + if (error is! _OpdsRelayBusy || attempt >= 2) throw error; + await Future.delayed(Duration(milliseconds: 500 * (attempt + 1))); + } + final upstreamUrl = response.headers['x-opds-url']; + if (upstreamUrl == null) { + throw const OpdsException( + 'The Papyrus server returned an invalid catalog response. Check its relay configuration.', + ); + } + final finalUri = validateUri(Uri.parse(upstreamUrl)); + final total = response.contentLength; + if (total != null && total > maxBytes) throw const OpdsException('This resource is too large to load.'); + final bytes = BytesBuilder(copy: false); + await for (final chunk in response.stream.timeout(const Duration(seconds: 35))) { + cancellation?.check(); + if (bytes.length + chunk.length > maxBytes) throw const OpdsException('This resource is too large to load.'); + bytes.add(chunk); + onProgress?.call(bytes.length, total); + } + cancellation?.check(); + if (total != null && bytes.length != total) throw const OpdsConnectionException(); + return OpdsResponse(uri: finalUri, bytes: bytes.takeBytes(), headers: response.headers); + } on OpdsException { + rethrow; + } catch (_) { + cancellation?.check(); + throw const OpdsConnectionException(); + } finally { + if (client != null) { + cancellation?.removeListener(client.close); + client.close(); + } + _release(); + } + } + + Future _relayError(http.StreamedResponse response) async { + final bytes = BytesBuilder(copy: false); + await for (final chunk in response.stream.timeout(const Duration(seconds: 35))) { + if (bytes.length + chunk.length > 8192) break; + bytes.add(chunk); + } + try { + final body = jsonDecode(utf8.decode(bytes.takeBytes())); + if (body is Map && body['error'] is Map) { + final error = body['error'] as Map; + if (error['code'] == 'OPDS_RELAY_ERROR' && error['message'] is String) { + if (response.statusCode == 503 && error['details'] is Map && error['details']['retryable'] == true) { + return _OpdsRelayBusy(error['message'] as String); + } + return response.statusCode == 401 || response.statusCode == 403 + ? OpdsAuthorizationException(error['message'] as String) + : OpdsException(error['message'] as String); + } + } + } on FormatException { + // Gateways can return HTML instead of the API error envelope. + } + final message = switch (response.statusCode) { + 401 => 'Check the catalog credentials and the Papyrus server relay configuration.', + 403 => 'The catalog or Papyrus server denied access. Check the catalog settings.', + 404 => 'The catalog resource or server relay was not found. Check the URL and update your Papyrus server.', + 413 => 'This resource is too large to load.', + 429 => 'Too many catalog requests. Please wait and retry.', + _ => 'The catalog relay returned HTTP ${response.statusCode}. Please retry later.', + }; + return response.statusCode == 401 || response.statusCode == 403 + ? OpdsAuthorizationException(message) + : OpdsException(message); + } +} diff --git a/app/lib/opds/opds_json_parser.dart b/app/lib/opds/opds_json_parser.dart new file mode 100644 index 0000000..58fbe1f --- /dev/null +++ b/app/lib/opds/opds_json_parser.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; + +import 'opds_models.dart'; + +class OpdsJsonParser { + static OpdsFeed parse(String body, Uri uri, {String? contentType}) { + final decoded = jsonDecode(body); + if (decoded is! Map || decoded['metadata'] is! Map) { + throw const FormatException('The response is not an OPDS JSON document.'); + } + final metadata = _object(decoded['metadata']); + final title = _text(metadata['title']); + if (title == null) { + throw const FormatException('The OPDS document has no title.'); + } + final links = _links(decoded['links'], uri); + final hasFeedContent = ['publications', 'navigation', 'groups'].any(decoded.containsKey); + final isPublication = + !hasFeedContent && + ((contentType ?? '').toLowerCase().startsWith('application/opds-publication+json') || + links.any((link) => link.isAcquisition)); + if (!hasFeedContent && !isPublication) { + throw const FormatException('The JSON document contains no OPDS collections.'); + } + return OpdsFeed( + uri: uri, + title: title, + links: isPublication ? const [] : links, + navigation: _links(decoded['navigation'], uri), + publications: isPublication + ? [_publication(decoded, uri)] + : _objects(decoded['publications']).map((book) => _publication(book, uri)).toList(), + groups: _objects(decoded['groups']).map((group) => _group(group, uri)).toList(), + facets: _objects(decoded['facets']).map((group) => _group(group, uri)).toList(), + ); + } + + static OpdsGroup _group(Map json, Uri uri) => OpdsGroup( + title: _text(_object(json['metadata'])['title']) ?? 'Untitled group', + links: _links(json['links'], uri), + navigation: _links(json['navigation'], uri), + publications: _objects(json['publications']).map((book) => _publication(book, uri)).toList(), + ); + + static OpdsPublication _publication(Map json, Uri uri) { + final metadata = _object(json['metadata']); + final links = _links(json['links'], uri); + final identifier = _text(metadata['identifier']); + final title = _text(metadata['title']); + if (title == null) throw const FormatException('An OPDS publication has no metadata title.'); + return OpdsPublication( + id: identifier ?? (links.isNotEmpty ? links.first.uri.toString() : title), + title: title, + authors: _names(metadata['author']), + description: _text(metadata['description']), + publisher: _joined(_names(metadata['publisher'])), + language: _joined(_names(metadata['language'])), + isbn: opdsIsbn(identifier), + published: _text(metadata['published']), + subjects: _names(metadata['subject']).toSet().toList(), + numberOfPages: metadata['numberOfPages'] is int && (metadata['numberOfPages'] as int) > 0 + ? metadata['numberOfPages'] as int + : null, + links: links, + images: _links(json['images'], uri), + ); + } + + static List _links(dynamic value, Uri uri) { + final result = []; + for (final json in _objects(value)) { + final href = json['href']; + if (href is! String || href.trim().isEmpty) continue; + final properties = _object(json['properties']); + final indirect = properties['indirectAcquisition']; + final images = json['images']; + final image = images is List && images.isNotEmpty ? images.first : json['image']; + final imageHref = image is String ? image : _object(image)['href']; + result.add( + OpdsLink( + uri: uri.resolve(href), + title: _text(json['title']), + description: _text(json['description']), + imageUri: imageHref is String && imageHref.isNotEmpty ? uri.resolve(imageHref) : null, + type: json['type'] is String ? json['type'] as String : null, + rels: json['rel'] is String + ? (json['rel'] as String).split(RegExp(r'\s+')) + : (json['rel'] is List ? (json['rel'] as List).whereType().toList() : const []), + templated: json['templated'] == true, + indirect: indirect != null && (indirect is! List || indirect.isNotEmpty), + ), + ); + } + return result; + } + + static Map _object(dynamic value) => value is Map ? value : const {}; + + static Iterable> _objects(dynamic value) { + if (value == null) return const []; + if (value is! List) { + throw const FormatException('An OPDS collection must be an array.'); + } + if (value.any((item) => item is! Map)) { + throw const FormatException('An OPDS collection contains an invalid member.'); + } + return value.cast>(); + } + + static String? _text(dynamic value) { + if (value is String) { + final text = opdsPlainText(value); + return text.isEmpty ? null : text; + } + if (value is Map) { + if (value.containsKey('name')) return _text(value['name']); + for (final localized in value.values) { + final text = _text(localized); + if (text != null) return text; + } + } + return null; + } + + static List _names(dynamic value) { + final values = value is List ? value : [value]; + return values.map(_text).whereType().toList(); + } + + static String? _joined(List values) => values.isEmpty ? null : values.join(', '); +} diff --git a/app/lib/opds/opds_library.dart b/app/lib/opds/opds_library.dart new file mode 100644 index 0000000..4bbfa85 --- /dev/null +++ b/app/lib/opds/opds_library.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Captured before import, so its result cannot be attributed to another account. +class OpdsImportIdentity { + const OpdsImportIdentity(this.scope, this.generation, this.parts, this.sample); + final String scope; + final int generation; + final List parts; + final bool sample; +} + +/// Local provenance, separate from catalog metadata and the library's book model. +class OpdsLibrary extends ChangeNotifier { + OpdsLibrary(this._prefs, {required DataStore dataStore}) : _dataStore = dataStore { + _dataStore.addListener(_changed); + } + + final SharedPreferences _prefs; + final DataStore _dataStore; + Map> _imports = {}; + final Map>> _scopedImports = {}; + String? _scope; + int _generation = 0; + bool _disposed = false; + Future _pending = Future.value(); + + String _key(String scope) => 'papyrus.opds.imports.v1.${Uri.encodeComponent(scope)}'; + + void setScope(String? scope) { + if (_scope == scope) return; + _scope = scope; + _generation++; + _imports = scope == null ? {} : _scopedImports.putIfAbsent(scope, () => _read(scope)); + _changed(); + } + + Map> _read(String scope) { + final imports = >{}; + try { + final raw = _prefs.getString(_key(scope)); + if (raw == null) return imports; + final decoded = jsonDecode(raw) as Map; + for (final entry in decoded.entries) { + try { + final parts = jsonDecode(entry.key); + if (parts is! List || parts.length != 4 || parts.any((part) => part is! String)) continue; + final value = Map.from(entry.value as Map); + if (value['book'] is String && value['sample'] is bool) imports[entry.key] = value; + } catch (_) { + // Ignore a damaged record without losing unrelated import identities. + } + } + } catch (_) { + // Broken optional provenance must not prevent opening the library. + } + return imports; + } + + OpdsImportIdentity? capture(OpdsCatalog catalog, OpdsPublication publication, OpdsLink link) { + final scope = _scope; + if (scope == null || publication.id.isEmpty) return null; + return OpdsImportIdentity(scope, _generation, [ + catalog.id, + catalog.uri.toString(), + publication.id, + '${link.uri}', + ], link.hasRel('preview') || link.hasRel('http://opds-spec.org/acquisition/sample')); + } + + String? bookId(OpdsCatalog catalog, OpdsPublication publication, {OpdsLink? link}) { + if (_scope == null || !_dataStore.isLoaded || publication.id.isEmpty) return null; + for (final entry in _imports.entries) { + final parts = (jsonDecode(entry.key) as List).cast(); + if (parts.length != 4 || parts[0] != catalog.id || parts[1] != '${catalog.uri}' || parts[2] != publication.id) { + continue; + } + if (link != null ? parts[3] != '${link.uri}' : entry.value['sample'] == true) continue; + final id = entry.value['book'] as String; + if (_dataStore.getBook(id) != null) return id; + } + return null; + } + + Future record(OpdsImportIdentity identity, String bookId) async { + if (_disposed || identity.scope != _scope || identity.generation != _generation) return; + _imports[jsonEncode(identity.parts)] = {'book': bookId, 'sample': identity.sample}; + _changed(); + await _persist(identity.scope, _imports); + } + + /// Credentials or the source URL changed: identifiers may now mean other books. + Future forgetCatalog(String catalogId, {required String scope}) async { + final imports = _scopedImports.putIfAbsent(scope, () => _read(scope)); + imports.removeWhere((key, _) => (jsonDecode(key) as List).first == catalogId); + if (_scope == scope) _generation++; + _changed(); + await _persist(scope, imports); + } + + Future _persist(String scope, Map> imports) { + final json = jsonEncode(imports); + // Serialize snapshots, including when an older scope's write is still pending. + _pending = _pending.then((_) async { + try { + await _prefs.setString(_key(scope), json); + } catch (_) { + // The imported book remains valid even if optional local metadata cannot save. + } + }); + return _pending; + } + + void _changed() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _dataStore.removeListener(_changed); + super.dispose(); + } +} diff --git a/app/lib/opds/opds_models.dart b/app/lib/opds/opds_models.dart new file mode 100644 index 0000000..c85fd03 --- /dev/null +++ b/app/lib/opds/opds_models.dart @@ -0,0 +1,286 @@ +/// A saved catalog contains no authentication secrets. +class OpdsCatalog { + OpdsCatalog({required this.id, required this.name, required Uri uri}) : uri = uri.replace(userInfo: ''); + + final String id; + final String name; + final Uri uri; + + Map toJson() => {'id': id, 'name': name, 'uri': uri.toString()}; + + factory OpdsCatalog.fromJson(Map json) => + OpdsCatalog(id: json['id'] as String, name: json['name'] as String, uri: Uri.parse(json['uri'] as String)); +} + +class OpdsCredentials { + const OpdsCredentials({required this.username, required this.password}); + + final String username; + final String password; +} + +class OpdsLink { + OpdsLink({ + required this.uri, + this.title, + this.description, + this.imageUri, + this.type, + List rels = const [], + this.templated = false, + this.indirect = false, + }) : rels = List.unmodifiable(rels); + + final Uri uri; + final String? title; + final String? description; + final Uri? imageUri; + final String? type; + final List rels; + final bool templated; + final bool indirect; + + /// Uri escapes braces; leave other escaped bytes intact when expanding. + String get template => uri + .toString() + .replaceAll(RegExp('%7b', caseSensitive: false), '{') + .replaceAll(RegExp('%7d', caseSensitive: false), '}'); + + bool hasRel(String rel) => rels.contains(rel); + + bool get isAcquisition => rels.any( + (rel) => + rel == 'http://opds-spec.org/acquisition' || + rel.startsWith('http://opds-spec.org/acquisition/') || + {'acquisition', 'download', 'preview', 'buy', 'borrow', 'subscribe'}.contains(rel), + ); + + /// Formats that can go straight to the existing local book importer. + String? get supportedExtension { + const directRelations = { + 'http://opds-spec.org/acquisition', + 'http://opds-spec.org/acquisition/open-access', + 'http://opds-spec.org/acquisition/sample', + 'acquisition', + 'download', + 'preview', + }; + if (indirect || templated || !rels.any(directRelations.contains)) return null; + const extensions = { + 'application/epub+zip': 'epub', + 'application/pdf': 'pdf', + 'application/x-mobipocket-ebook': 'mobi', + 'application/vnd.amazon.mobi8-ebook': 'azw3', + 'application/x-cbz': 'cbz', + 'application/vnd.comicbook+zip': 'cbz', + 'application/x-cbr': 'cbr', + 'application/vnd.comicbook-rar': 'cbr', + 'text/plain': 'txt', + }; + final mime = type?.split(';').first.trim().toLowerCase(); + if (extensions.containsKey(mime)) return extensions[mime]; + if (mime != null && mime.isNotEmpty && mime != 'application/octet-stream') { + return null; + } + final extension = uri.path.split('.').last.toLowerCase(); + return extensions.values.contains(extension) ? extension : null; + } +} + +class OpdsPublication { + OpdsPublication({ + required this.id, + required this.title, + List authors = const [], + this.description, + this.publisher, + this.language, + this.isbn, + this.published, + this.rights, + this.numberOfPages, + List subjects = const [], + List links = const [], + List images = const [], + }) : subjects = List.unmodifiable(subjects), + authors = List.unmodifiable(authors), + links = List.unmodifiable(links), + images = List.unmodifiable(images); + + final String id; + final String title; + final List authors; + final String? description; + final String? publisher; + final String? language; + final String? isbn; + final String? published; + final String? rights; + final int? numberOfPages; + final List subjects; + final List links; + final List images; + + /// Prefer a full cover over a thumbnail regardless of feed link ordering. + OpdsLink? get coverLink => + images.where((image) => image.hasRel('http://opds-spec.org/image')).firstOrNull ?? + images.where((image) => !image.hasRel('http://opds-spec.org/image/thumbnail')).firstOrNull ?? + images.firstOrNull; + + OpdsLink? get detailLink { + for (final link in links) { + final type = link.type?.toLowerCase() ?? ''; + if ((link.hasRel('alternate') || link.hasRel('self')) && + (type.startsWith('application/opds-publication+json') || + (type.startsWith('application/atom+xml') && RegExp(r'type\s*=\s*"?entry').hasMatch(type)))) { + return link; + } + } + // Some catalogs represent a book's editions in an acquisition feed rather + // than a standalone entry. Author metadata distinguishes these partial + // publications from category links with decorative thumbnails. + if (authors.isNotEmpty) { + for (final link in links) { + final type = link.type?.toLowerCase() ?? ''; + if (link.hasRel('alternate') && + type.split(';').first.trim() == 'application/atom+xml' && + RegExp(r';\s*kind\s*=\s*"?acquisition"?\s*(?:;|$)').hasMatch(type)) { + return link; + } + } + } + return null; + } +} + +class OpdsGroup { + OpdsGroup({ + required this.title, + List navigation = const [], + List publications = const [], + List links = const [], + }) : navigation = List.unmodifiable(navigation), + publications = List.unmodifiable(publications), + links = List.unmodifiable(links); + + final String title; + final List navigation; + final List publications; + final List links; +} + +class OpdsFeed { + OpdsFeed({ + required this.uri, + required this.title, + List links = const [], + List navigation = const [], + List publications = const [], + List groups = const [], + List facets = const [], + }) : links = List.unmodifiable(links), + navigation = List.unmodifiable(navigation), + publications = List.unmodifiable(publications), + groups = List.unmodifiable(groups), + facets = List.unmodifiable(facets); + + final Uri uri; + final String title; + final List links; + final List navigation; + final List publications; + final List groups; + final List facets; + + OpdsLink? _link(String rel) { + for (final link in links) { + if (link.hasRel(rel)) return link; + } + return null; + } + + OpdsLink? get searchLink { + for (final link in links.where((link) => link.hasRel('search'))) { + final mime = link.type?.split(';').first.trim().toLowerCase(); + if (mime == 'application/opensearchdescription+xml') return link; + if ((mime == null || mime.isEmpty || mime == 'application/opds+json' || mime == 'application/atom+xml') && + RegExp(r'\{[^}]*\b(query|searchTerms)\b').hasMatch(link.template)) { + return link; + } + } + return null; + } + + OpdsLink? get nextLink => _link('next'); + OpdsLink? get previousLink => _link('previous') ?? _link('prev'); +} + +/// Convert catalog markup to readable text, retaining paragraph and line breaks. +String opdsPlainText(String input) { + var text = input.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + final hasMarkup = RegExp(r']*|/?)>').hasMatch(text); + text = text + .replaceAll(RegExp(r''), '') + .replaceAll( + RegExp(r'<(?:[\w.-]+:)?(script|style)\b[^>]*>[\s\S]*?', caseSensitive: false), + '', + ); + if (hasMarkup) { + // Indentation and wrapped source lines are not visual paragraph breaks. + text = text.replaceAll(RegExp(r'\s+'), ' '); + text = text.replaceAllMapped(RegExp(r'<(/?)(?:[\w.-]+:)?([a-zA-Z][\w.-]*)\b[^>]*>', caseSensitive: false), (match) { + final closing = match[1] == '/'; + final tag = match[2]!.toLowerCase(); + if (tag == 'br') return '\n'; + if ({ + 'p', + 'div', + 'section', + 'article', + 'header', + 'footer', + 'aside', + 'blockquote', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'pre', + 'ul', + 'ol', + 'dl', + 'table', + 'hr', + }.contains(tag)) { + return '\n\n'; + } + if ({'li', 'dt', 'dd', 'tr'}.contains(tag)) return closing ? '\n' : ''; + if (tag == 'td' || tag == 'th') return closing ? ' ' : ''; + return ''; + }); + } + text = text.replaceAllMapped(RegExp(r'&(#x[\da-fA-F]+|#\d+|amp|lt|gt|quot|apos|nbsp);'), (match) { + const named = {'amp': '&', 'lt': '<', 'gt': '>', 'quot': '"', 'apos': "'", 'nbsp': ' '}; + final entity = match[1]!; + if (named.containsKey(entity)) return named[entity]!; + final code = entity.startsWith('#x') + ? int.tryParse(entity.substring(2), radix: 16) + : int.tryParse(entity.substring(1)); + return code != null && code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : match[0]!; + }); + return text + .replaceAll(RegExp(r'[^\S\n]+'), ' ') + .replaceAll(RegExp(r' *\n *'), '\n') + .replaceAll(RegExp(r'\n{3,}'), '\n\n') + .trim(); +} + +String? opdsIsbn(String? identifier) { + if (identifier == null) return null; + final normalized = identifier + .replaceFirst(RegExp(r'^(?:urn:)?isbn:', caseSensitive: false), '') + .replaceAll(RegExp(r'[\s-]'), ''); + return RegExp(r'^(?:\d{13}|\d{9}[\dXx])$').hasMatch(normalized) ? normalized.toUpperCase() : null; +} diff --git a/app/lib/opds/opds_parser.dart b/app/lib/opds/opds_parser.dart new file mode 100644 index 0000000..ccede8a --- /dev/null +++ b/app/lib/opds/opds_parser.dart @@ -0,0 +1,15 @@ +import 'opds_json_parser.dart'; +import 'opds_models.dart'; +import 'opds_xml_parser.dart'; + +class OpdsParser { + /// Inspect the document as catalogs commonly use generic HTTP media types. + static OpdsFeed parse(String body, Uri uri, {String? contentType}) { + final text = body.replaceFirst(RegExp(r'^\uFEFF'), '').trimLeft(); + if (text.startsWith('{')) { + return OpdsJsonParser.parse(text, uri, contentType: contentType); + } + if (text.startsWith('<')) return OpdsXmlParser.parse(text, uri); + throw const FormatException('The response is not an OPDS catalog.'); + } +} diff --git a/app/lib/opds/opds_publication_content.dart b/app/lib/opds/opds_publication_content.dart new file mode 100644 index 0000000..02b9b8e --- /dev/null +++ b/app/lib/opds/opds_publication_content.dart @@ -0,0 +1,135 @@ +import 'package:intl/intl.dart'; + +import 'opds_models.dart'; + +/// A display projection. The original catalog description remains untouched. +class OpdsPublicationContent { + OpdsPublicationContent._(this.description, this.information, this.subjects); + + final String? description; + final Map information; + final List subjects; + + factory OpdsPublicationContent.from(OpdsCatalog catalog, OpdsPublication publication) { + final information = {}; + void add(String label, String? value) { + if (value != null && value.trim().isNotEmpty) information[label] = value.trim(); + } + + final formats = publication.links + .where((link) => link.isAcquisition) + .map((link) => link.supportedExtension?.toUpperCase()) + .whereType() + .toSet(); + if (formats.isNotEmpty) add('Formats', formats.join(', ')); + if (publication.numberOfPages != null) add('Pages', '${publication.numberOfPages}'); + add('Publisher', publication.publisher); + add('Published', _date(publication.published)); + add('ISBN', publication.isbn); + add('Language', publication.language); + add('Rights', publication.rights); + final subjects = publication.subjects.where((subject) => subject.trim().isNotEmpty).toSet(); + final original = publication.description?.trim(); + final host = catalog.uri.host.toLowerCase(); + if (original == null || original.isEmpty || !(host == 'gutenberg.org' || host.endsWith('.gutenberg.org'))) { + return OpdsPublicationContent._(original, information, subjects.toList()); + } + + // Gutenberg puts labeled records inside XHTML paragraphs. Only recognize + // its known labels; do not infer metadata from arbitrary catalogs or prose. + final paragraphs = original.split(RegExp(r'\n\s*\n')); + final labeled = RegExp(r'^([^:\n]{1,30}):\s*([\s\S]+)$'); + final known = { + 'Summary', + 'Title', + 'Author', + 'Subject', + 'EBook No.', + 'Downloads', + 'Language', + 'Published', + 'Rights', + 'Publisher', + 'ISBN', + 'Reading Level', + 'LoCC', + 'Category', + }; + final recognized = paragraphs.where((paragraph) => known.contains(labeled.firstMatch(paragraph)?[1])).length; + if (recognized < 2) return OpdsPublicationContent._(original, information, subjects.toList()); + + final summary = []; + final prose = []; + final extracted = {}; + void extract(String label, String value) { + extracted[label] = _combine(extracted[label], value); + } + + for (final paragraph in paragraphs) { + final match = labeled.firstMatch(paragraph); + if (match == null) { + prose.add(paragraph); + continue; + } + final label = match[1]!; + final value = match[2]!.trim(); + switch (label) { + case 'Summary': + summary.add(value); + case 'Title': + if (value != publication.title) extract('Catalog title', value); + case 'Author': + if (!publication.authors.contains(value)) extract('Author', value); + case 'Subject': + subjects.add(value); + case 'EBook No.': + case 'Downloads': + case 'Language': + case 'Published': + case 'Rights': + case 'Publisher': + case 'ISBN': + case 'Reading Level': + case 'LoCC': + case 'Category': + extract(label, value); + default: + prose.add(paragraph); + } + } + for (final entry in extracted.entries) { + final label = switch (entry.key) { + 'EBook No.' => 'Gutenberg ID', + 'Reading Level' => 'Reading level', + 'LoCC' => 'Classification', + _ => entry.key, + }; + final value = label == 'Published' ? _date(entry.value)! : entry.value; + final existing = information[label]; + if (label == 'Language' && existing != null && existing != value) { + information[label] = '$value ($existing)'; + } else { + information[label] = _combine(existing, value); + } + } + // These category labels can also appear among Atom subjects. They now have + // dedicated metadata rows, so avoid repeating them as subject chips. + for (final label in ['Classification', 'Category']) { + subjects.removeAll(information[label]?.split('\n') ?? const []); + } + return OpdsPublicationContent._([...summary, ...prose].join('\n\n'), information, subjects.toList()); + } + + static String _combine(String? existing, String value) => existing == null + ? value + : existing == value || existing.split('\n').contains(value) + ? existing + : '$existing\n$value'; + + static String? _date(String? value) { + if (value == null) return null; + // Partial dates (e.g. just a year) keep their original precision. + final date = RegExp(r'^\d{4}-\d{2}-\d{2}(?:T.*)?$').hasMatch(value) ? DateTime.tryParse(value) : null; + return date == null ? value : DateFormat.yMMMMd().format(date); + } +} diff --git a/app/lib/opds/opds_resource_cache.dart b/app/lib/opds/opds_resource_cache.dart new file mode 100644 index 0000000..ab55e5c --- /dev/null +++ b/app/lib/opds/opds_resource_cache.dart @@ -0,0 +1,203 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// A request snapshot; callers must capture it before starting network work. +class OpdsCacheToken { + OpdsCacheToken._(this._owner, this._scope, this._catalogId, this._root, this._uri, this._epoch, this._generation); + final OpdsResourceCache _owner; + final String _scope; + final String _catalogId; + final String _root; + final String _uri; + final int _epoch; + final int _generation; + String get _catalogKey => jsonEncode([_scope, _catalogId]); + String get _key => jsonEncode([_scope, _catalogId, _root, _uri]); +} + +class OpdsCachedResource { + const OpdsCachedResource({required this.response, required this.fetchedAt}); + final OpdsResponse response; + final DateTime fetchedAt; +} + +/// One bounded payload for every account, catalog, feed and thumbnail. +/// Only response bytes, the final URL and content type are retained. +class OpdsResourceCache extends ChangeNotifier { + OpdsResourceCache( + this._prefs, { + this.maxBytes = 2 * 1024 * 1024, + this.maxEntryBytes = 512 * 1024, + this.maxEntries = 64, + }) { + _restore(); + } + + static const _storageKey = 'papyrus.opds.resources.v1'; + final SharedPreferences _prefs; + final int maxBytes; + final int maxEntryBytes; + final int maxEntries; + final _entries = >{}; + final _generations = {}; + Future _pending = Future.value(); + String? _scope; + int _epoch = 0; + + void setScope(String? scope) { + if (_scope == scope) return; + _scope = scope; + _epoch++; + notifyListeners(); + } + + OpdsCacheToken? capture(OpdsCatalog catalog, Uri uri) { + final scope = _scope; + if (scope == null) return null; + OpdsHttpClient.validateUri(catalog.uri); + OpdsHttpClient.validateUri(uri); + final catalogKey = jsonEncode([scope, catalog.id]); + return OpdsCacheToken._( + this, + scope, + catalog.id, + catalog.uri.toString(), + uri.toString(), + _epoch, + _generations[catalogKey] ?? 0, + ); + } + + bool isCurrent(OpdsCacheToken token) => + identical(token._owner, this) && + token._scope == _scope && + token._epoch == _epoch && + token._generation == (_generations[token._catalogKey] ?? 0); + + OpdsCachedResource? read(OpdsCacheToken token) { + if (!isCurrent(token)) return null; + final entry = _entries.remove(token._key); + if (entry == null) return null; + try { + final resource = _decode(entry); + _entries[token._key] = entry; + return resource; + } catch (_) { + return null; + } + } + + Future write(OpdsCacheToken token, OpdsResponse response) { + if (!isCurrent(token) || response.bytes.length > maxEntryBytes || maxEntries <= 0) return Future.value(); + try { + OpdsHttpClient.validateUri(response.uri); + final entry = { + 'key': token._key, + 'uri': response.uri.toString(), + 'type': response.headers['content-type'], + 'bytes': base64Encode(response.bytes), + 'at': DateTime.now().toUtc().millisecondsSinceEpoch, + }; + if (utf8.encode(jsonEncode(entry)).length > maxEntryBytes) return Future.value(); + _entries.remove(token._key); + _entries[token._key] = entry; + _trim(); + return _persist(); + } catch (_) { + return Future.value(); + } + } + + Future remove(OpdsCacheToken token) { + if (!isCurrent(token)) return Future.value(); + _entries.remove(token._key); + return _persist(); + } + + Future invalidateCatalog(OpdsCatalog catalog, {String? scope}) { + scope ??= _scope; + if (scope == null) return Future.value(); + final catalogKey = jsonEncode([scope, catalog.id]); + _generations[catalogKey] = (_generations[catalogKey] ?? 0) + 1; + _entries.removeWhere((key, _) { + final parts = jsonDecode(key) as List; + return parts[0] == scope && parts[1] == catalog.id; + }); + notifyListeners(); + return _persist(); + } + + String _serialize() => jsonEncode({'version': 1, 'entries': _entries.values.toList()}); + + void _trim() { + while (_entries.isNotEmpty && (_entries.length > maxEntries || utf8.encode(_serialize()).length > maxBytes)) { + _entries.remove(_entries.keys.first); + } + } + + OpdsCachedResource _decode(Map entry) { + final parts = jsonDecode(entry['key'] as String) as List; + if (parts.length != 4 || parts.any((part) => part is! String)) throw const FormatException('Invalid cache key'); + OpdsHttpClient.validateUri(Uri.parse(parts[2] as String)); + OpdsHttpClient.validateUri(Uri.parse(parts[3] as String)); + final bytes = base64Decode(entry['bytes'] as String); + if (bytes.length > maxEntryBytes) throw const FormatException('Oversized cache entry'); + return OpdsCachedResource( + response: OpdsResponse( + uri: OpdsHttpClient.validateUri(Uri.parse(entry['uri'] as String)), + bytes: bytes, + headers: {if (entry['type'] != null) 'content-type': entry['type'] as String}, + ), + fetchedAt: DateTime.fromMillisecondsSinceEpoch(entry['at'] as int, isUtc: true), + ); + } + + void _restore() { + try { + final raw = _prefs.getString(_storageKey); + if (raw == null || utf8.encode(raw).length > maxBytes) return; + final payload = jsonDecode(raw) as Map; + if (payload['version'] != 1) return; + for (final rawEntry in payload['entries'] as List) { + try { + final entry = Map.from(rawEntry as Map); + if (utf8.encode(jsonEncode(entry)).length > maxEntryBytes) continue; + _decode(entry); + _entries[entry['key'] as String] = entry; + } catch (_) { + // One damaged entry does not discard other catalogs. + } + } + _trim(); + } catch (_) { + _entries.clear(); + } + } + + Future _persist() { + // Serialize writes so an older save cannot resurrect invalidated data. + _pending = _pending.then((_) async { + try { + final payload = _serialize(); + if (_entries.isEmpty || utf8.encode(payload).length > maxBytes) { + await _prefs.remove(_storageKey); + } else { + final saved = await _prefs.setString(_storageKey, payload); + if (!saved) await _prefs.remove(_storageKey); + } + } catch (_) { + // Prefer a missing disk cache to old protected data after invalidation. + try { + await _prefs.remove(_storageKey); + } catch (_) { + // Quota and storage failures never block catalog browsing. + } + } + }); + return _pending; + } +} diff --git a/app/lib/opds/opds_search.dart b/app/lib/opds/opds_search.dart new file mode 100644 index 0000000..8f2fcf7 --- /dev/null +++ b/app/lib/opds/opds_search.dart @@ -0,0 +1,113 @@ +import 'package:xml/xml.dart'; + +import 'opds_models.dart'; +import 'opds_xml_parser.dart' show xmlBase; + +class OpdsSearch { + static String expand(String template, String query) { + final decoded = template + .replaceAll(RegExp('%7b', caseSensitive: false), '{') + .replaceAll(RegExp('%7d', caseSensitive: false), '}'); + return decoded.replaceAllMapped(RegExp(r'\{([^{}]+)\}'), (match) { + final expression = match[1]!; + if (expression == 'searchTerms' || expression == 'searchTerms?') { + return _encode(query); + } + if (expression.endsWith('?')) return ''; + const defaults = { + 'startIndex': '1', + 'startPage': '1', + 'count': '20', + 'language': '*', + 'inputEncoding': 'UTF-8', + 'outputEncoding': 'UTF-8', + }; + if (defaults.containsKey(expression)) return defaults[expression]!; + if (RegExp(r'^[+#./;?&]|(^|,)query(?:[,*:]|$)').hasMatch(expression)) { + return _expandScalar(expression, query); + } + throw FormatException('Unsupported required search parameter: $expression'); + }); + } + + static String _expandScalar(String expression, String query) { + const operators = {'+', '#', '.', '/', ';', '?', '&'}; + final operator = operators.contains(expression[0]) ? expression[0] : ''; + final variables = (operator.isEmpty ? expression : expression.substring(1)).split(','); + final values = []; + for (final variable in variables) { + final match = RegExp(r'^query(?::([1-9][0-9]{0,3})|\*)?$').firstMatch(variable); + if (match == null) continue; + final prefix = match[1] == null ? null : int.parse(match[1]!); + final value = prefix == null ? query : String.fromCharCodes(query.runes.take(prefix)); + final encoded = _encode(value, allowReserved: operator == '+' || operator == '#'); + values.add(switch (operator) { + '?' || '&' => 'query=$encoded', + ';' => value.isEmpty ? 'query' : 'query=$encoded', + _ => encoded, + }); + } + if (values.isEmpty) return ''; + final prefix = operator == '+' ? '' : operator; + final separator = switch (operator) { + '?' || '&' => '&', + '.' || '/' || ';' => operator, + _ => ',', + }; + return '$prefix${values.join(separator)}'; + } + + // Uri.encodeComponent leaves some reserved characters unescaped; RFC6570's + // simple and query expansions allow only the unreserved character set. + static String _encode(String value, {bool allowReserved = false}) { + final encoded = Uri.encodeComponent( + value, + ).replaceAllMapped(RegExp(r"[!'()*]"), (match) => '%${match[0]!.codeUnitAt(0).toRadixString(16).toUpperCase()}'); + if (!allowReserved) return encoded; + return encoded + .replaceAllMapped(RegExp(r'%[0-9A-F]{2}'), (match) { + final char = String.fromCharCode(int.parse(match[0]!.substring(1), radix: 16)); + return r":/?#[]@!$&'()*+,;=".contains(char) ? char : match[0]!; + }) + .replaceAllMapped(RegExp(r'%25([0-9a-fA-F]{2})'), (match) => '%${match[1]}'); + } + + static OpdsLink fromOpenSearch(String xml, Uri uri) { + final XmlElement root; + try { + root = XmlDocument.parse(xml).rootElement; + } on XmlException { + throw const FormatException('The OpenSearch description is malformed.'); + } + const namespace = 'http://a9.com/-/spec/opensearch/1.1/'; + if (root.name.local != 'OpenSearchDescription' || root.name.namespaceUri != namespace) { + throw const FormatException('The response is not an OpenSearch description.'); + } + final base = xmlBase(root, uri); + for (final element in root.childElements) { + if (element.name.local != 'Url' || element.name.namespaceUri != namespace) continue; + final method = element.getAttribute('method')?.toUpperCase() ?? 'GET'; + final type = element.getAttribute('type'); + final mime = type?.split(';').first.trim().toLowerCase(); + final template = element.getAttribute('template'); + if (method != 'GET' || + template == null || + !{'application/atom+xml', 'application/opds+json'}.contains(mime) || + !RegExp(r'\{(?:searchTerms\??|query)\}').hasMatch(template)) { + continue; + } + try { + expand(template, ''); + } on FormatException { + continue; + } + return OpdsLink( + uri: xmlBase(element, base).resolve(template), + type: type, + rels: const ['search'], + templated: true, + ); + } + throw const FormatException('No supported OPDS GET search endpoint was found.'); + } +} diff --git a/app/lib/opds/opds_xml_parser.dart b/app/lib/opds/opds_xml_parser.dart new file mode 100644 index 0000000..4044248 --- /dev/null +++ b/app/lib/opds/opds_xml_parser.dart @@ -0,0 +1,180 @@ +import 'package:xml/xml.dart'; + +import 'opds_models.dart'; + +const _atom = 'http://www.w3.org/2005/Atom'; +const _opds = 'http://opds-spec.org/2010/catalog'; +const _dc = {'http://purl.org/dc/terms/', 'http://purl.org/dc/elements/1.1/'}; + +class OpdsXmlParser { + static OpdsFeed parse(String body, Uri uri) { + final XmlElement root; + try { + root = XmlDocument.parse(body).rootElement; + } on XmlException { + throw const FormatException('The OPDS XML document is malformed.'); + } + if (root.name.namespaceUri != _atom || !{'feed', 'entry'}.contains(root.name.local)) { + throw const FormatException('The response is not an Atom OPDS document.'); + } + final base = xmlBase(root, uri); + final title = _text(root, 'title') ?? 'Untitled catalog'; + if (root.name.local == 'entry') { + return OpdsFeed(uri: uri, title: title, publications: [_publication(root, base)]); + } + final links = _links(root, base); + final navigation = []; + final publications = []; + final groups = {}; + for (final entry in _children(root, 'entry')) { + final book = _publication(entry, xmlBase(entry, base)); + final isPublication = book.links.any((link) => link.isAcquisition) || book.detailLink != null; + final entryNavigation = isPublication + ? [] + : book.links + .where(_isNavigation) + .map( + (link) => OpdsLink( + uri: link.uri, + title: link.title ?? book.title, + description: book.description, + imageUri: book.images.isEmpty ? null : book.images.first.uri, + type: link.type, + rels: link.rels, + templated: link.templated, + ), + ) + .toList(); + final collections = book.links.where((link) => link.hasRel('collection')).toList(); + if (collections.isEmpty) { + if (isPublication) publications.add(book); + navigation.addAll(entryNavigation); + } else { + for (final collection in collections) { + final group = groups.putIfAbsent( + collection.uri.toString(), + () => _GroupBuilder(collection.title ?? 'Collection', [collection]), + ); + if (isPublication) group.publications.add(book); + group.navigation.addAll(entryNavigation); + } + } + } + final facets = >{}; + for (final element in _children(root, 'link')) { + final link = _link(element, base); + if (link == null || !link.hasRel('http://opds-spec.org/facet')) continue; + final group = element.getAttribute('facetGroup', namespace: _opds) ?? 'Filters'; + facets.putIfAbsent(group, () => []).add(link); + } + return OpdsFeed( + uri: uri, + title: title, + links: links, + navigation: navigation, + publications: publications, + groups: groups.values.map((group) => group.build()).toList(), + facets: facets.entries.map((entry) => OpdsGroup(title: entry.key, links: entry.value)).toList(), + ); + } + + static bool _isNavigation(OpdsLink link) { + if (link.hasRel('collection') || + link.hasRel('self') || + link.hasRel('http://opds-spec.org/image') || + link.hasRel('http://opds-spec.org/image/thumbnail')) { + return false; + } + final mime = link.type?.split(';').first.trim().toLowerCase(); + return mime == null || mime == 'application/atom+xml' || mime == 'application/opds+json'; + } + + static OpdsPublication _publication(XmlElement entry, Uri base) { + final links = _links(entry, base); + final title = _text(entry, 'title') ?? 'Untitled publication'; + final identifiers = entry.childElements.where( + (element) => element.name.local == 'identifier' && _dc.contains(element.name.namespaceUri), + ); + String? isbn; + for (final identifier in identifiers) { + isbn = opdsIsbn(identifier.innerText); + if (isbn != null) break; + } + return OpdsPublication( + id: _text(entry, 'id') ?? (links.isNotEmpty ? links.first.uri.toString() : title), + title: title, + authors: _children(entry, 'author').map((author) => _text(author, 'name')).whereType().toList(), + description: _text(entry, 'content') ?? _text(entry, 'summary') ?? _dcText(entry, 'description'), + publisher: _dcText(entry, 'publisher'), + language: _dcText(entry, 'language'), + isbn: isbn, + published: _dcText(entry, 'issued'), + rights: _text(entry, 'rights') ?? _dcText(entry, 'rights'), + subjects: _children(entry, 'category') + .map((category) => category.getAttribute('label') ?? category.getAttribute('term')) + .whereType() + .map(opdsPlainText) + .where((subject) => subject.isNotEmpty) + .toSet() + .toList(), + links: links, + images: links + .where( + (link) => link.hasRel('http://opds-spec.org/image/thumbnail') || link.hasRel('http://opds-spec.org/image'), + ) + .toList(), + ); + } + + static List _links(XmlElement parent, Uri base) => + _children(parent, 'link').map((element) => _link(element, base)).whereType().toList(); + + static OpdsLink? _link(XmlElement element, Uri base) { + final href = element.getAttribute('href'); + if (href == null || href.trim().isEmpty) return null; + return OpdsLink( + uri: xmlBase(element, base).resolve(href), + title: element.getAttribute('title'), + type: element.getAttribute('type'), + rels: (element.getAttribute('rel') ?? 'alternate').split(RegExp(r'\s+')), + templated: href.contains('{'), + indirect: element.descendantElements.any( + (child) => child.name.local == 'indirectAcquisition' && child.name.namespaceUri == _opds, + ), + ); + } + + static Iterable _children(XmlElement parent, String local) => + parent.childElements.where((child) => child.name.local == local && child.name.namespaceUri == _atom); + + static String? _text(XmlElement parent, String local) { + final children = _children(parent, local); + if (children.isEmpty) return null; + final element = children.first; + final text = opdsPlainText(element.getAttribute('type') == 'xhtml' ? element.innerXml : element.innerText); + return text.isEmpty ? null : text; + } + + static String? _dcText(XmlElement parent, String local) { + final children = parent.childElements.where( + (child) => child.name.local == local && _dc.contains(child.name.namespaceUri), + ); + return children.isEmpty ? null : opdsPlainText(children.first.innerText); + } +} + +/// Apply just this element's base; callers carry its ancestors' resolved base. +Uri xmlBase(XmlElement element, Uri inherited) { + final base = element.getAttribute('xml:base'); + return base == null ? inherited : inherited.resolve(base); +} + +class _GroupBuilder { + _GroupBuilder(this.title, this.links); + final String title; + final List links; + final navigation = []; + final publications = []; + + OpdsGroup build() => OpdsGroup(title: title, links: links, navigation: navigation, publications: publications); +} diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index 69dedcd..5e179ce 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -27,6 +27,8 @@ import 'package:papyrus/widgets/book/book_notes.dart'; import 'package:papyrus/widgets/book_details/annotation_dialog.dart'; import 'package:papyrus/widgets/book_details/book_action_buttons.dart'; import 'package:papyrus/widgets/book_details/book_header.dart'; +import 'package:papyrus/widgets/book_details/book_details_tab_rail.dart'; +import 'package:papyrus/widgets/book_details/book_details_scroll_view.dart'; import 'package:papyrus/widgets/book_details/bookmark_dialog.dart'; import 'package:papyrus/widgets/book_details/annotation_action_sheet.dart'; import 'package:papyrus/widgets/book_details/note_action_sheet.dart'; @@ -189,6 +191,35 @@ class _BookDetailsPageState extends State with TickerProviderSt return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + ConstrainedBox( + constraints: const BoxConstraints(minHeight: ComponentSizes.appBarHeight), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + child: Row( + children: [ + IconButton( + tooltip: 'Back', + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/library/books'); + } + }, + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + 'Book details', + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ), + const Divider(height: 1), // Scrollable content Expanded( child: SingleChildScrollView( @@ -208,10 +239,10 @@ class _BookDetailsPageState extends State with TickerProviderSt readingError: _readingError, onRetryReading: _onContinueReading, ), - const SizedBox(height: Spacing.xl), + const SizedBox(height: Spacing.md), // Tab bar - _buildDesktopTabBar(context, provider), + _buildTabRail(provider), const SizedBox(height: Spacing.md), // Tab content (embedded, not TabBarView) @@ -224,24 +255,15 @@ class _BookDetailsPageState extends State with TickerProviderSt ); } - Widget _buildDesktopTabBar(BuildContext context, BookDetailsProvider provider) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), - ), - child: TabBar( - controller: _tabController, - isScrollable: true, - tabAlignment: TabAlignment.start, - tabs: [ - const Tab(text: 'Details'), - Tab(text: 'Bookmarks (${provider.bookmarkCount})'), - Tab(text: 'Annotations (${provider.annotationCount})'), - Tab(text: 'Notes (${provider.noteCount})'), - ], - ), + Widget _buildTabRail(BookDetailsProvider provider) { + return BookDetailsTabRail( + controller: _tabController, + tabs: [ + const Tab(text: 'Details'), + Tab(text: 'Bookmarks (${provider.bookmarkCount})'), + Tab(text: 'Annotations (${provider.annotationCount})'), + Tab(text: 'Notes (${provider.noteCount})'), + ], ); } @@ -266,63 +288,41 @@ class _BookDetailsPageState extends State with TickerProviderSt ), ], ), - body: NestedScrollView( - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverToBoxAdapter( - child: BookHeader( + body: BookDetailsScrollView( + header: BookHeader( + book: provider.book!, + isDesktop: false, + onContinueReading: _onContinueReading, + onUpdateProgress: _onUpdateProgress, + onToggleFavorite: _provider.toggleFavorite, + onEdit: _onEdit, + readingActionState: readingActionState, + readingError: _readingError, + onRetryReading: _onContinueReading, + ), + rail: _buildTabRail(provider), + body: TabBarView( + controller: _tabController, + children: [ + BookDetails( book: provider.book!, - isDesktop: false, - onContinueReading: _onContinueReading, - onUpdateProgress: _onUpdateProgress, - onToggleFavorite: _provider.toggleFavorite, - onEdit: _onEdit, - readingActionState: readingActionState, - readingError: _readingError, - onRetryReading: _onContinueReading, + isDescriptionExpanded: provider.isDescriptionExpanded, + onToggleDescription: provider.toggleDescriptionExpanded, ), - ), - ], - body: Column( - children: [ - // Tab bar - TabBar( - controller: _tabController, - isScrollable: true, - tabAlignment: TabAlignment.start, - tabs: [ - const Tab(text: 'Details'), - Tab(text: 'Bookmarks (${provider.bookmarkCount})'), - Tab(text: 'Annotations (${provider.annotationCount})'), - Tab(text: 'Notes (${provider.noteCount})'), - ], + BookBookmarks( + bookmarks: provider.bookmarks, + bookTitle: provider.book!.title, + isPhysical: provider.book!.isPhysical, + onAddBookmark: _onAddBookmark, + onBookmarkActions: _onBookmarkActions, ), - // Tab content - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - BookDetails( - book: provider.book!, - isDescriptionExpanded: provider.isDescriptionExpanded, - onToggleDescription: provider.toggleDescriptionExpanded, - ), - BookBookmarks( - bookmarks: provider.bookmarks, - bookTitle: provider.book!.title, - isPhysical: provider.book!.isPhysical, - onAddBookmark: _onAddBookmark, - onBookmarkActions: _onBookmarkActions, - ), - BookAnnotations( - annotations: provider.annotations, - isPhysical: provider.book!.isPhysical, - onAddAnnotation: _onAddAnnotation, - onAnnotationActions: _onAnnotationActions, - ), - BookNotes(notes: provider.notes, onAddNote: _onAddNote, onNoteActions: _onNoteActions), - ], - ), + BookAnnotations( + annotations: provider.annotations, + isPhysical: provider.book!.isPhysical, + onAddAnnotation: _onAddAnnotation, + onAnnotationActions: _onAnnotationActions, ), + BookNotes(notes: provider.notes, onAddNote: _onAddNote, onNoteActions: _onNoteActions), ], ), ), diff --git a/app/lib/pages/catalog_book_page.dart b/app/lib/pages/catalog_book_page.dart new file mode 100644 index 0000000..54b3e1c --- /dev/null +++ b/app/lib/pages/catalog_book_page.dart @@ -0,0 +1,387 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/opds/opds_browser.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_parser.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/opds/opds_download_panel.dart'; +import 'package:papyrus/widgets/opds/opds_download_actions.dart'; +import 'package:papyrus/widgets/opds/opds_mobile_header.dart'; +import 'package:papyrus/widgets/opds/opds_publication_details.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:provider/provider.dart'; + +/// An optional in-memory preview, never a substitute for a reloadable URL. +class CatalogBookSelection { + const CatalogBookSelection({ + required this.catalog, + required this.publication, + required this.scope, + this.cached = false, + }); + final OpdsCatalog catalog; + final OpdsPublication publication; + final String? scope; + final bool cached; +} + +GoRoute catalogBookRoute() => GoRoute( + path: 'book', + name: 'CATALOG_BOOK', + pageBuilder: (_, state) => NoTransitionPage( + key: state.pageKey, + child: CatalogBookPage( + catalogId: state.pathParameters['catalogId']!, + source: Uri.tryParse(state.uri.queryParameters['feed'] ?? ''), + publicationId: state.uri.queryParameters['publication'] ?? '', + query: state.uri.queryParameters['q'] ?? '', + initial: state.extra is CatalogBookSelection ? state.extra as CatalogBookSelection : null, + ), + ), +); + +class CatalogBookPage extends StatefulWidget { + const CatalogBookPage({ + super.key, + required this.catalogId, + required this.source, + required this.publicationId, + this.query = '', + this.initial, + }); + final String catalogId; + final Uri? source; + final String publicationId; + final String query; + final CatalogBookSelection? initial; + + @override + State createState() => _CatalogBookPageState(); +} + +class _CatalogBookPageState extends State { + late final _browser = _createBrowser(); + OpdsCacheToken? _previewCacheToken; + String? _loadKey; + OpdsPublication? _publication; + OpdsCredentials? _credentials; + String? _error; + bool _loading = true; + bool _cachedPreview = false; + + OpdsBrowser _createBrowser() { + final browser = OpdsBrowser(httpClient: context.read()); + browser.httpClient.cache?.addListener(_cacheChanged); + return browser; + } + + void _cacheChanged() { + final token = _previewCacheToken; + if (!mounted || token == null || _browser.httpClient.cache!.isCurrent(token)) return; + _previewCacheToken = null; + setState(() { + _publication = null; + _loading = false; + _error = 'Catalog data changed. Refresh to load it again.'; + }); + } + + void _scheduleLoad(OpdsCatalogs catalogs, OpdsCatalog? catalog) { + final key = '${catalogs.scope}/${catalogs.revision}/${widget.catalogId}/${widget.source}/${widget.publicationId}'; + if (key == _loadKey) return; + _loadKey = key; + _browser.clear(); + _previewCacheToken = null; + _credentials = null; + _publication = null; + _cachedPreview = false; + _error = null; + _loading = catalog != null; + if (catalog == null) return; + _previewCacheToken = _browser.httpClient.cache?.capture(catalog, catalog.uri); + final initial = widget.initial; + if (initial != null && + initial.scope == catalogs.scope && + identical(initial.catalog, catalog) && + initial.publication.id == widget.publicationId) { + _publication = initial.publication; + } + final resolveSource = _publication == null || initial?.cached == true; + if (resolveSource) { + _publication = _readCachedPublication(catalog) ?? _publication; + _cachedPreview = _publication != null; + } + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted || key != _loadKey) return; + try { + final credentials = await catalogs.credentials(catalog.id); + if (!mounted || key != _loadKey) return; + _credentials = credentials; + if (resolveSource) { + final source = widget.source; + if (source == null || !source.hasScheme || widget.publicationId.isEmpty) { + throw const OpdsException('This book link is incomplete. Return to the catalog and select the book again.'); + } + await _browser.load(catalog, source, credentials: credentials); + if (!mounted || key != _loadKey) return; + if (_browser.authorizationFailed || _browser.cacheInvalidated) _publication = null; + final feed = _browser.feed; + if (feed == null) throw OpdsException(_browser.error ?? 'Could not load this book.'); + if (_browser.error != null) _error = 'Showing saved content. ${_browser.error}'; + _publication = [ + ...feed.publications, + for (final group in feed.groups) ...group.publications, + ].where((book) => book.id == widget.publicationId).firstOrNull; + if (_publication == null) throw const OpdsException('This book is no longer available in this catalog feed.'); + setState(() {}); + } + // Resolve standalone publication metadata using the existing OPDS parser + // and detail-link classification. Multi-edition feeds remain distinct. + final detail = _publication!.detailLink; + if (detail != null && detail.uri != widget.source) { + await _browser.load(catalog, detail.uri, credentials: credentials); + if (!mounted || key != _loadKey) return; + if (_browser.authorizationFailed || _browser.cacheInvalidated) { + _publication = null; + throw OpdsException(_browser.error!); + } + final books = _browser.feed?.publications ?? []; + final type = detail.type?.toLowerCase() ?? ''; + final standalone = + type.startsWith('application/opds-publication+json') || RegExp(r'type\s*=\s*"?entry').hasMatch(type); + final matching = books.where((book) => book.id == _publication!.id).firstOrNull; + if (matching != null || (standalone && books.length == 1)) { + _publication = matching ?? books.single; + if (_browser.error != null) _error = 'Showing saved content. ${_browser.error}'; + } else if (_browser.error != null) { + _error = _browser.error; + } + } + } catch (error) { + if (!mounted || key != _loadKey) return; + _error = opdsErrorMessage(error); + } finally { + if (mounted && key == _loadKey) setState(() => _loading = false); + } + }); + } + + OpdsPublication? _readCachedPublication(OpdsCatalog catalog) { + final source = widget.source; + if (source == null) return null; + final feed = _readCachedFeed(catalog, source); + if (feed == null) return null; + final publication = [ + ...feed.publications, + for (final group in feed.groups) ...group.publications, + ].where((book) => book.id == widget.publicationId).firstOrNull; + final detail = publication?.detailLink; + if (detail == null || detail.uri == source) return publication; + final books = _readCachedFeed(catalog, detail.uri)?.publications ?? []; + final type = detail.type?.toLowerCase() ?? ''; + final standalone = + type.startsWith('application/opds-publication+json') || RegExp(r'type\s*=\s*"?entry').hasMatch(type); + return books.where((book) => book.id == publication!.id).firstOrNull ?? + (standalone && books.length == 1 ? books.single : publication); + } + + OpdsFeed? _readCachedFeed(OpdsCatalog catalog, Uri source) { + final cache = _browser.httpClient.cache; + if (cache == null) return null; + try { + final token = cache.capture(catalog, source); + final response = token == null ? null : cache.read(token)?.response; + if (response == null) return null; + return OpdsParser.parse(response.text, response.uri, contentType: response.headers['content-type']); + } catch (_) { + return null; + } + } + + void _back() { + if (context.canPop()) { + context.pop(); + } else { + _navigate(widget.source, replace: true); + } + } + + void _navigate(Uri? uri, {bool replace = false}) { + final location = Uri( + path: '/library/catalogs/${Uri.encodeComponent(widget.catalogId)}', + queryParameters: {if (uri != null) 'feed': uri.toString(), if (widget.query.isNotEmpty) 'q': widget.query}, + ).toString(); + if (replace) { + context.go(location); + } else { + context.push(location); + } + } + + Future _download(OpdsCatalog catalog, OpdsPublication publication, OpdsLink link) async { + if (!mounted) return; + final catalogs = context.read(); + final downloads = context.read(); + final scope = catalogs.scope; + final revision = catalogs.revision; + if (!identical(catalogs.find(catalog.id), catalog)) { + _message('The catalog or account changed. Close these details and reopen the book.'); + return; + } + try { + final credentials = await catalogs.credentials(catalog.id); + if (!mounted || scope != catalogs.scope || revision != catalogs.revision) return; + await downloads.start(catalog, publication, link, credentials: credentials); + } catch (error) { + if (mounted) _message(opdsErrorMessage(error)); + } + } + + void _message(String message) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + + void _retry() { + final catalogs = context.read(); + if (catalogs.error != null) catalogs.reload(); + setState(() => _loadKey = null); + } + + @override + void dispose() { + _browser.httpClient.cache?.removeListener(_cacheChanged); + _browser.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final catalogs = context.watch(); + final catalog = catalogs.find(widget.catalogId); + final downloads = context.watch(); + _scheduleLoad(catalogs, catalog); + final publication = _publication; + return SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall) + OpdsMobileHeader( + key: const Key('catalog-book-mobile-header'), + leading: IconButton(tooltip: 'Back to catalog', onPressed: _back, icon: const BackButtonIcon()), + title: catalog?.name ?? 'Book details', + actions: [ + OpdsDownloadsButton( + compact: true, + downloads: downloads, + onRetry: (job) => unawaited(retryOpdsDownload(context, job)), + ), + ], + ) + else ...[ + ConstrainedBox( + constraints: const BoxConstraints(minHeight: ComponentSizes.appBarHeight), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + child: Row( + children: [ + IconButton(tooltip: 'Back to catalog', onPressed: _back, icon: const Icon(Icons.arrow_back)), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + catalog?.name ?? 'Book details', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), + ), + ), + OpdsDownloadsButton( + compact: MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall, + downloads: downloads, + onRetry: (job) => unawaited(retryOpdsDownload(context, job)), + ), + ], + ), + ), + ), + const Divider(key: Key('catalog-book-header-divider'), height: 1), + ], + Expanded( + child: Padding( + padding: _publication != null && constraints.maxWidth < Breakpoints.desktopSmall + ? EdgeInsets.zero + : EdgeInsets.all(constraints.maxWidth < Breakpoints.tablet ? Spacing.md : Spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_error != null && _publication != null) + Padding( + padding: constraints.maxWidth < Breakpoints.desktopSmall + ? const EdgeInsets.all(Spacing.md) + : const EdgeInsets.only(bottom: Spacing.md), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Spacing.sm, + children: [ + Text(_error!), + TextButton(onPressed: _retry, child: const Text('Retry')), + ], + ), + ), + Expanded( + child: catalog == null + ? _unavailable(catalogs.error ?? 'This catalog is not saved for the active account.') + : _loading && _publication == null + ? const Center(child: AppCircularProgressIndicator()) + : _publication == null + ? _unavailable(_error ?? 'This book is unavailable.') + : OpdsPublicationDetails( + key: ValueKey('${catalogs.scope}/${catalogs.revision}/${_publication!.id}'), + catalog: catalog, + publication: _publication!, + httpClient: _browser.httpClient, + credentials: _credentials, + downloads: downloads, + onNavigate: _navigate, + resolving: _loading && !_cachedPreview, + onDownload: (link) => unawaited(_download(catalog, publication!, link)), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _unavailable(String message) => Center( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.menu_book_outlined, size: 48), + const SizedBox(height: Spacing.md), + Text('Book unavailable', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: Spacing.sm), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: Spacing.md), + Wrap( + spacing: Spacing.sm, + children: [ + TextButton(onPressed: _retry, child: const Text('Retry')), + FilledButton(onPressed: _back, child: const Text('Back to catalog')), + ], + ), + ], + ), + ), + ); +} diff --git a/app/lib/pages/catalogs_page.dart b/app/lib/pages/catalogs_page.dart new file mode 100644 index 0000000..8d23fea --- /dev/null +++ b/app/lib/pages/catalogs_page.dart @@ -0,0 +1,486 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/opds/opds_browser.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/pages/catalog_book_page.dart'; +import 'package:papyrus/widgets/input/search_field.dart'; +import 'package:papyrus/widgets/opds/opds_sheet.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/opds/catalog_editor.dart'; +import 'package:papyrus/widgets/opds/catalog_source_tile.dart'; +import 'package:papyrus/widgets/opds/opds_feed_view.dart'; +import 'package:papyrus/widgets/opds/opds_download_panel.dart'; +import 'package:papyrus/widgets/opds/opds_download_actions.dart'; +import 'package:papyrus/widgets/opds/opds_mobile_header.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:provider/provider.dart'; + +class CatalogsPage extends StatefulWidget { + const CatalogsPage({super.key, this.catalogId, this.feedUri, this.query = '', this.httpClient}); + final String? catalogId; + final Uri? feedUri; + final String query; + final OpdsHttpClient? httpClient; + @override + State createState() => _CatalogsPageState(); +} + +class _CatalogsPageState extends State { + late final _browser = OpdsBrowser(httpClient: widget.httpClient ?? context.read()); + late final _search = TextEditingController(text: widget.query); + OpdsCredentials? _credentials; + String? _loadKey; + bool _reloadRequested = false; + OpdsFeed? _feedForHeader; + bool _searching = false; + bool _isGridView = true; + final _feedScroll = ScrollController(); + + @override + void didUpdateWidget(covariant CatalogsPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.query != widget.query) _search.text = widget.query; + } + + void _scheduleLoad(OpdsCatalogs catalogs) { + final catalog = widget.catalogId == null ? null : catalogs.find(widget.catalogId!); + final key = '${catalogs.scope}/${catalogs.revision}/${widget.catalogId}/${widget.feedUri ?? catalog?.uri}'; + if (_loadKey == key && !_reloadRequested) return; + final sameFeed = _loadKey == key; + _feedForHeader = sameFeed ? (_browser.feed ?? _feedForHeader) : null; + _reloadRequested = false; + _loadKey = key; + _credentials = null; + if (!sameFeed) _browser.clear(); + if (catalog == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted || _loadKey != key) return; + if (_feedScroll.hasClients) _feedScroll.jumpTo(0); + try { + final credentials = await catalogs.credentials(catalog.id); + if (!mounted || _loadKey != key) return; + _credentials = credentials; + await _browser.load(catalog, widget.feedUri ?? catalog.uri, credentials: credentials); + } catch (error) { + if (mounted && _loadKey == key) setState(() => _browser.error = opdsErrorMessage(error)); + } + }); + } + + Future _edit(OpdsCatalogs catalogs, [OpdsCatalog? catalog]) => + CatalogEditor.show(context, catalogs: catalogs, catalog: catalog); + + Future _remove(OpdsCatalogs catalogs, OpdsCatalog catalog) async { + final scope = catalogs.scope; + ScaffoldMessenger.of(context).clearSnackBars(); + final confirmed = await showOpdsSheet( + context, + title: 'Remove catalog', + cancelLabel: 'Cancel', + saveLabel: 'Remove', + onSave: () => Navigator.of(context, rootNavigator: true).pop(true), + child: Text( + 'This will remove “${catalog.name}” and its saved credentials. ' + 'Downloaded books will remain in your library.', + ), + ); + if (confirmed != true || !mounted || catalogs.scope != scope) return; + try { + await catalogs.remove(catalog.id); + } catch (error) { + if (mounted) _message(opdsErrorMessage(error)); + } + } + + void _navigate(OpdsCatalog catalog, Uri uri, {String query = ''}) { + try { + OpdsHttpClient.validateUri(uri); + context.push( + Uri( + path: '/library/catalogs/${Uri.encodeComponent(catalog.id)}', + queryParameters: {'feed': uri.toString(), if (query.isNotEmpty) 'q': query}, + ).toString(), + ); + } catch (error) { + _message(opdsErrorMessage(error)); + } + } + + void _back(OpdsCatalog? catalog) { + final router = GoRouter.of(context); + if (router.routerDelegate.currentConfiguration.last is ImperativeRouteMatch && router.canPop()) { + router.pop(); + } else if (catalog != null && widget.feedUri != null && widget.feedUri != catalog.uri) { + // A refreshed/shared subsection has no earlier visit in its route stack. + // Return to this catalog's root before leaving the catalog altogether. + router.go('/library/catalogs/${Uri.encodeComponent(catalog.id)}'); + } else { + router.go('/library/catalogs'); + } + } + + Future _submitSearch(OpdsCatalog catalog) async { + if (_searching || _search.text.trim().isEmpty) return; + final key = _loadKey; + final query = _search.text.trim(); + setState(() => _searching = true); + try { + final uri = await _browser.search(query); + if (mounted && key == _loadKey) _navigate(catalog, uri, query: query); + } catch (error) { + if (mounted && key == _loadKey) _message(opdsErrorMessage(error)); + } finally { + if (mounted) setState(() => _searching = false); + } + } + + void _message(String text) => ScaffoldMessenger.of( + context, + ).showSnackBar(snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar(content: Text(text))); + + @override + void dispose() { + _browser.dispose(); + _search.dispose(); + _feedScroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final catalogs = context.watch(); + _scheduleLoad(catalogs); + final catalog = widget.catalogId == null ? null : catalogs.find(widget.catalogId!); + return SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < Breakpoints.tablet; + final mobileHome = widget.catalogId == null && MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + final body = Padding( + padding: EdgeInsets.fromLTRB( + compact ? Spacing.md : Spacing.lg, + compact ? Spacing.md : Spacing.lg, + compact ? Spacing.md : Spacing.lg, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (catalog != null) + SearchField( + controller: _search, + hintText: 'Search this catalog', + height: MediaQuery.textScalerOf(context).scale(16) > 24 ? 56 : 40, + onSubmitted: (_) => _submitSearch(catalog), + onChanged: (_) => setState(() {}), + onClear: () { + setState(() {}); + if (widget.query.isNotEmpty) _navigate(catalog, catalog.uri); + }, + trailing: IconButton( + tooltip: 'Search catalog', + onPressed: _searching ? null : () => _submitSearch(catalog), + icon: _searching + ? const SizedBox.square(dimension: 20, child: AppCircularProgressIndicator()) + : const Icon(Icons.arrow_forward), + ), + ), + Expanded( + child: catalog == null + ? _catalogList(catalogs) + : AnimatedBuilder(animation: _browser, builder: (_, _) => _feedView(catalog)), + ), + ], + ), + ); + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall) + _header(catalogs, catalog) + else ...[ + ConstrainedBox( + constraints: const BoxConstraints(minHeight: ComponentSizes.appBarHeight), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + child: _header(catalogs, catalog), + ), + ), + const Divider(key: Key('catalog-header-divider'), height: 1), + ], + Expanded(child: body), + ], + ); + if (!mobileHome) return content; + return Stack( + fit: StackFit.expand, + children: [ + content, + Positioned( + right: Spacing.md, + bottom: Spacing.md, + child: FloatingActionButton( + tooltip: 'Add catalog', + onPressed: catalogs.scope == null ? null : () => _edit(catalogs), + child: const Icon(Icons.add), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _header(OpdsCatalogs catalogs, OpdsCatalog? catalog) { + final theme = Theme.of(context); + final mobile = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + final downloads = context.watch(); + final downloadsButton = OpdsDownloadsButton( + compact: mobile, + downloads: downloads, + onRetry: (job) => unawaited(retryOpdsDownload(context, job)), + ); + if (mobile) { + return OpdsMobileHeader( + key: const Key('catalog-mobile-header'), + title: catalog?.name ?? 'Catalogs', + leading: widget.catalogId != null + ? IconButton(tooltip: 'Back', icon: const BackButtonIcon(), onPressed: () => _back(catalog)) + : IconButton( + tooltip: 'Library sections', + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.maybeOf(context)?.openDrawer(), + ), + actions: [ + downloadsButton, + if (catalog != null) + IconButton( + tooltip: 'Edit catalog', + onPressed: () => _edit(catalogs, catalog), + icon: const Icon(Icons.settings_outlined), + ), + ], + ); + } + if (widget.catalogId != null) { + return Row( + children: [ + IconButton(tooltip: 'Back', icon: const Icon(Icons.arrow_back), onPressed: () => _back(catalog)), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + catalog?.name ?? 'Catalogs', + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + downloadsButton, + if (catalog != null) + IconButton( + tooltip: 'Edit catalog', + onPressed: () => _edit(catalogs, catalog), + icon: const Icon(Icons.settings_outlined), + ), + ], + ); + } + final heading = Row( + children: [ + Expanded( + child: Text('Catalogs', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600)), + ), + ], + ); + final actions = Wrap( + spacing: Spacing.xs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + downloadsButton, + FilledButton.icon( + onPressed: catalogs.scope == null ? null : () => _edit(catalogs), + icon: const Icon(Icons.add), + label: const Text('Add catalog'), + ), + ], + ); + return LayoutBuilder( + builder: (context, constraints) { + return constraints.maxWidth < Breakpoints.tablet || MediaQuery.textScalerOf(context).scale(1) > 1.4 + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + heading, + const SizedBox(height: Spacing.md), + Align(alignment: Alignment.centerRight, child: actions), + ], + ) + : Row( + children: [ + Expanded(child: heading), + const SizedBox(width: Spacing.md), + actions, + ], + ); + }, + ); + } + + Widget _catalogList(OpdsCatalogs catalogs) { + if (catalogs.error != null) { + return _empty( + 'Could not load catalogs', + detail: catalogs.error, + action: 'Retry', + onAction: catalogs.reload, + icon: Icons.cloud_off_outlined, + ); + } + if (catalogs.scope == null) return const Center(child: Text('Waiting for your library…')); + if (widget.catalogId != null) { + return _empty( + 'Catalog unavailable', + detail: 'This catalog is not saved for the active account.', + action: 'All catalogs', + onAction: () => context.go('/library/catalogs'), + ); + } + if (catalogs.catalogs.isEmpty) { + return _empty( + 'No catalogs yet', + detail: 'Connect an OPDS catalog to explore its collection and add books to your library.', + action: 'Add catalog', + onAction: () => _edit(catalogs), + ); + } + return ListView.separated( + key: ValueKey(catalogs.scope), + padding: EdgeInsets.only(bottom: MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall ? 96 : Spacing.lg), + itemCount: catalogs.catalogs.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, index) { + final catalog = catalogs.catalogs[index]; + return CatalogSourceTile( + key: ValueKey(catalog.id), + catalog: catalog, + onOpen: () => context.push('/library/catalogs/${Uri.encodeComponent(catalog.id)}'), + onEdit: () => _edit(catalogs, catalog), + onRemove: () => _remove(catalogs, catalog), + ); + }, + ); + } + + void _reloadFeed() => setState(() => _reloadRequested = true); + + Widget _feedView(OpdsCatalog catalog) { + Widget? status; + if (_browser.error != null && _browser.feed == null) { + status = _empty( + 'Could not open this catalog', + detail: _browser.error, + action: 'Retry', + onAction: _reloadFeed, + icon: Icons.cloud_off_outlined, + ); + } else if (_browser.feed == null) { + status = const Center(child: AppCircularProgressIndicator()); + } + final feed = _browser.feed ?? _feedForHeader; + if (feed == null) return status ?? const SizedBox.shrink(); + return OpdsFeedView( + libraryBookId: (publication) => context.read().libraryBookId(catalog, publication), + status: _browser.feed != null && _browser.error != null + ? Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Spacing.sm, + children: [ + const Text('Showing saved content. Could not refresh this catalog.'), + TextButton(onPressed: _reloadFeed, child: const Text('Retry')), + ], + ), + ) + : null, + isRefreshing: _browser.loading && _browser.feed != null, + contentOverride: status, + scrollController: _feedScroll, + catalog: catalog, + feed: feed, + credentials: _credentials, + httpClient: _browser.httpClient, + query: widget.query, + isGridView: _isGridView, + onViewChanged: (value) => setState(() => _isGridView = value), + onNavigate: (uri) => _navigate(catalog, uri), + onPage: (uri) => _navigate(catalog, uri, query: widget.query), + onRefresh: _browser.loading || (_browser.feed == null && _browser.error == null) ? null : _reloadFeed, + onOpenPublication: (publication) => context.push( + Uri( + path: '/library/catalogs/${Uri.encodeComponent(catalog.id)}/book', + queryParameters: { + 'feed': (widget.feedUri ?? catalog.uri).toString(), + 'publication': publication.id, + if (widget.query.isNotEmpty) 'q': widget.query, + }, + ).toString(), + extra: CatalogBookSelection( + catalog: catalog, + publication: publication, + scope: context.read().scope, + cached: _browser.isCached, + ), + ), + ); + } + + Widget _empty( + String title, { + String? detail, + required String action, + required VoidCallback onAction, + IconData icon = Icons.local_library_outlined, + }) => Center( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 56, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(height: Spacing.lg), + Text(title, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge), + if (detail != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Text( + detail, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + const SizedBox(height: Spacing.lg), + FilledButton( + onPressed: onAction, + style: FilledButton.styleFrom(minimumSize: const Size(0, 44)), + child: Text(action), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/app/lib/platform/hot_restart_cleanup.dart b/app/lib/platform/hot_restart_cleanup.dart deleted file mode 100644 index 6cb3bf2..0000000 --- a/app/lib/platform/hot_restart_cleanup.dart +++ /dev/null @@ -1 +0,0 @@ -export 'hot_restart_cleanup_stub.dart' if (dart.library.js_interop) 'hot_restart_cleanup_web.dart'; diff --git a/app/lib/platform/hot_restart_cleanup_stub.dart b/app/lib/platform/hot_restart_cleanup_stub.dart deleted file mode 100644 index 0b267ff..0000000 --- a/app/lib/platform/hot_restart_cleanup_stub.dart +++ /dev/null @@ -1,5 +0,0 @@ -typedef HotRestartCleanup = Future Function(); - -Future runPreviousHotRestartCleanup() async {} - -void registerHotRestartCleanup(HotRestartCleanup cleanup) {} diff --git a/app/lib/platform/hot_restart_cleanup_web.dart b/app/lib/platform/hot_restart_cleanup_web.dart deleted file mode 100644 index bc09fd7..0000000 --- a/app/lib/platform/hot_restart_cleanup_web.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:js_interop'; - -typedef HotRestartCleanup = Future Function(); - -@JS('__papyrusHotRestartCleanup') -external JSFunction? get _registeredCleanup; - -@JS('__papyrusHotRestartCleanup') -external set _registeredCleanup(JSFunction? cleanup); - -Future runPreviousHotRestartCleanup() async { - final cleanup = _registeredCleanup; - _registeredCleanup = null; - if (cleanup == null) return; - - final result = cleanup.callAsFunction(); - if (result != null) { - await (result as JSPromise).toDart; - } -} - -void registerHotRestartCleanup(HotRestartCleanup cleanup) { - _registeredCleanup = (() => cleanup().toJS).toJS; -} diff --git a/app/lib/services/book_import_session.dart b/app/lib/services/book_import_session.dart new file mode 100644 index 0000000..4b0c826 --- /dev/null +++ b/app/lib/services/book_import_session.dart @@ -0,0 +1,82 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/services/book_import_commit_service.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; +import 'package:provider/provider.dart'; + +/// Captures the destination before asynchronous work starts. A session must never +/// silently move an import into the account selected while it was downloading. +class BookImportSession { + const BookImportSession({ + required this.process, + required this.deleteFile, + required this.commit, + required this.isCurrent, + }); + final Future Function(Uint8List bytes, String filename) process; + final Future Function(String bookId) deleteFile; + final Future Function(BookImportResult result, String filename) commit; + final bool Function() isCurrent; + + factory BookImportSession.fromContext(BuildContext context) => BookImportSession.capture( + dataStore: context.read(), + queue: context.read(), + importService: context.read(), + authProvider: context.read(), + powerSyncService: context.read(), + ); + + factory BookImportSession.capture({ + required DataStore dataStore, + required MediaUploadQueue queue, + required BookImportService importService, + required AuthProvider authProvider, + required PapyrusPowerSyncService powerSyncService, + }) { + final repository = dataStore.requireBookRepository(); + final authenticated = authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; + final scope = authenticated ? queue.activeScope : null; + final userId = authProvider.user?.userId; + if (authenticated && scope == null) { + throw StateError('Cannot import account media without an active media storage scope'); + } + if ((authProvider.isSignedIn && (!authenticated || scope?.userId != userId)) || + (authProvider.isOfflineMode && powerSyncService.mode != LibraryDatabaseMode.guest)) { + throw StateError('Wait for the selected library to finish loading before importing.'); + } + bool isCurrent() => + dataStore.isBookRepositoryCurrent(repository) && + authProvider.user?.userId == userId && + (authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated) == authenticated && + queue.activeScope == scope; + final service = BookImportCommitService( + storePendingCover: importService.storePendingCoverFile, + storeGuestCover: importService.storeGuestCoverFile, + deletePendingCover: importService.deletePendingCoverFile, + deleteGuestCover: importService.deleteGuestCoverFile, + addBook: (book) => dataStore.addBookToRepositoryAndWait(repository, book), + deleteBook: (id) => dataStore.deleteBookFromRepositoryAndWait(repository, id), + enqueueImportedBookMedia: queue.enqueueImportedBookMedia, + isLibraryContextCurrent: isCurrent, + ); + return BookImportSession( + process: importService.importBook, + deleteFile: importService.deleteBookFile, + isCurrent: isCurrent, + commit: (result, filename) => service.commit( + result: result, + sourceFilename: filename, + addedAt: DateTime.now(), + localFilePath: kIsWeb ? 'opfs://books/${result.bookId}.${result.fileExtension}' : result.bookId, + accountScope: scope, + ), + ); + } +} diff --git a/app/lib/widgets/add_book/book_import_results_sheet.dart b/app/lib/widgets/add_book/book_import_results_sheet.dart index 2fcae1c..9c7c2a3 100644 --- a/app/lib/widgets/add_book/book_import_results_sheet.dart +++ b/app/lib/widgets/add_book/book_import_results_sheet.dart @@ -2,13 +2,8 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:papyrus/data/data_store.dart'; -import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; -import 'package:papyrus/powersync/powersync_service.dart'; -import 'package:papyrus/powersync/sync_state.dart'; -import 'package:papyrus/providers/auth_provider.dart'; -import 'package:papyrus/services/book_import_commit_service.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'; import 'package:papyrus/themes/design_tokens.dart'; @@ -44,45 +39,7 @@ class BookImportResultsSheet extends StatefulWidget { final ScrollController? scrollController; static Future _commitResult(BuildContext context, BookImportResult result, String sourceFilename) async { - final dataStore = context.read(); - final bookRepository = dataStore.requireBookRepository(); - final queue = context.read(); - final importService = context.read(); - final authProvider = context.read(); - final powerSyncService = context.read(); - final isOnlineAccount = authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; - final accountScope = isOnlineAccount ? queue.activeScope : null; - if (isOnlineAccount && accountScope == null) { - throw StateError('Cannot import account media without an active media storage scope'); - } - - final extension = result.fileExtension; - final filePath = kIsWeb - ? 'opfs://books/${result.bookId}.$extension' - : result.bookId; // Native resolves via BookImportService.getBookFile. - final commitService = BookImportCommitService( - storePendingCover: importService.storePendingCoverFile, - storeGuestCover: importService.storeGuestCoverFile, - deletePendingCover: importService.deletePendingCoverFile, - deleteGuestCover: importService.deleteGuestCoverFile, - addBook: (book) => dataStore.addBookToRepositoryAndWait(bookRepository, book), - deleteBook: (bookId) => dataStore.deleteBookFromRepositoryAndWait(bookRepository, bookId), - enqueueImportedBookMedia: queue.enqueueImportedBookMedia, - isLibraryContextCurrent: () { - final currentIsOnlineAccount = - authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; - return dataStore.isBookRepositoryCurrent(bookRepository) && - currentIsOnlineAccount == isOnlineAccount && - queue.activeScope == accountScope; - }, - ); - return commitService.commit( - result: result, - sourceFilename: sourceFilename, - addedAt: DateTime.now(), - localFilePath: filePath, - accountScope: accountScope, - ); + return BookImportSession.fromContext(context).commit(result, sourceFilename); } /// Opens the processing step as its own root-level modal sheet. diff --git a/app/lib/widgets/add_book/book_import_sheet.dart b/app/lib/widgets/add_book/book_import_sheet.dart index ccefec3..947b388 100644 --- a/app/lib/widgets/add_book/book_import_sheet.dart +++ b/app/lib/widgets/add_book/book_import_sheet.dart @@ -3,13 +3,8 @@ import 'dart:async'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:papyrus/data/data_store.dart'; -import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; -import 'package:papyrus/powersync/powersync_service.dart'; -import 'package:papyrus/powersync/sync_state.dart'; -import 'package:papyrus/providers/auth_provider.dart'; -import 'package:papyrus/services/book_import_commit_service.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'; import 'package:papyrus/themes/design_tokens.dart'; @@ -103,43 +98,7 @@ class BookImportSheet extends StatefulWidget { } static Future _commitResult(BuildContext context, BookImportResult result, String sourceFilename) async { - final dataStore = context.read(); - final bookRepository = dataStore.requireBookRepository(); - final queue = context.read(); - final importService = context.read(); - final authProvider = context.read(); - final powerSyncService = context.read(); - final isOnlineAccount = authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; - final accountScope = isOnlineAccount ? queue.activeScope : null; - if (isOnlineAccount && accountScope == null) { - throw StateError('Cannot import account media without an active media storage scope'); - } - - final extension = result.fileExtension; - final filePath = kIsWeb ? 'opfs://books/${result.bookId}.$extension' : result.bookId; - final commitService = BookImportCommitService( - storePendingCover: importService.storePendingCoverFile, - storeGuestCover: importService.storeGuestCoverFile, - deletePendingCover: importService.deletePendingCoverFile, - deleteGuestCover: importService.deleteGuestCoverFile, - addBook: (book) => dataStore.addBookToRepositoryAndWait(bookRepository, book), - deleteBook: (bookId) => dataStore.deleteBookFromRepositoryAndWait(bookRepository, bookId), - enqueueImportedBookMedia: queue.enqueueImportedBookMedia, - isLibraryContextCurrent: () { - final currentIsOnlineAccount = - authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; - return dataStore.isBookRepositoryCurrent(bookRepository) && - currentIsOnlineAccount == isOnlineAccount && - queue.activeScope == accountScope; - }, - ); - return commitService.commit( - result: result, - sourceFilename: sourceFilename, - addedAt: DateTime.now(), - localFilePath: filePath, - accountScope: accountScope, - ); + return BookImportSession.fromContext(context).commit(result, sourceFilename); } @override diff --git a/app/lib/widgets/book/book_annotations.dart b/app/lib/widgets/book/book_annotations.dart index 143ceab..3877044 100644 --- a/app/lib/widgets/book/book_annotations.dart +++ b/app/lib/widgets/book/book_annotations.dart @@ -136,13 +136,13 @@ class _BookAnnotationsState extends State { return Column( children: [ - _buildHeader(showAddButton: true), + _buildHeader(isDesktop: true), Expanded( child: filtered.isEmpty ? _buildNoResultsState(context, colorScheme) : _buildAnnotationsList( filtered, - padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.sm, Spacing.md, Spacing.md), + padding: const EdgeInsets.only(top: Spacing.sm, bottom: Spacing.md), separatorHeight: Spacing.md, showActionMenu: true, ), @@ -172,9 +172,9 @@ class _BookAnnotationsState extends State { ); } - Widget _buildHeader({bool showAddButton = false}) { + Widget _buildHeader({bool isDesktop = false}) { return Padding( - padding: const EdgeInsets.all(Spacing.md), + padding: isDesktop ? const EdgeInsets.symmetric(vertical: Spacing.md) : const EdgeInsets.all(Spacing.md), child: Row( children: [ Expanded( @@ -187,7 +187,7 @@ class _BookAnnotationsState extends State { ), const SizedBox(width: Spacing.sm), _buildSortButton(), - if (showAddButton && widget.isPhysical) ...[ + if (isDesktop && widget.isPhysical) ...[ const SizedBox(width: Spacing.md), FilledButton.icon( onPressed: widget.onAddAnnotation, diff --git a/app/lib/widgets/book/book_bookmarks.dart b/app/lib/widgets/book/book_bookmarks.dart index 2bfb0eb..0d4a29c 100644 --- a/app/lib/widgets/book/book_bookmarks.dart +++ b/app/lib/widgets/book/book_bookmarks.dart @@ -110,7 +110,7 @@ class _BookBookmarksState extends State { ? _buildNoResultsState(context, colorScheme) : _buildBookmarksList( filtered, - padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.sm, Spacing.md, Spacing.md), + padding: const EdgeInsets.only(top: Spacing.sm, bottom: Spacing.md), separatorHeight: Spacing.md, showActionMenu: true, ), @@ -121,7 +121,7 @@ class _BookBookmarksState extends State { Widget _buildDesktopHeader() { return Padding( - padding: EdgeInsets.all(Spacing.md), + padding: const EdgeInsets.symmetric(vertical: Spacing.md), child: Row( children: [ Expanded( diff --git a/app/lib/widgets/book/book_details.dart b/app/lib/widgets/book/book_details.dart index 6cde6c5..d1cbb68 100644 --- a/app/lib/widgets/book/book_details.dart +++ b/app/lib/widgets/book/book_details.dart @@ -3,6 +3,7 @@ import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book_details/book_info_grid.dart'; +import 'package:papyrus/widgets/book_details/book_details_sections.dart'; import 'package:papyrus/widgets/shelves/move_to_shelf_sheet.dart'; import 'package:papyrus/widgets/topics/manage_topics_sheet.dart'; import 'package:papyrus/widgets/topics/topic_detail_sheet.dart'; @@ -36,7 +37,7 @@ class _BookDetailsState extends State { Widget _buildDesktopLayout(BuildContext context) { return SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.md), + padding: EdgeInsets.zero, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -106,15 +107,7 @@ class _BookDetailsState extends State { } Widget _buildSectionTitle(BuildContext context, String title) { - final colorScheme = Theme.of(context).colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Divider(height: 1, thickness: 1, color: colorScheme.outlineVariant), - ], - ); + return BookDetailsSectionTitle(title); } Widget _buildDescription(BuildContext context, {bool showFull = false}) { diff --git a/app/lib/widgets/book/book_notes.dart b/app/lib/widgets/book/book_notes.dart index 7a3fa14..6b0918d 100644 --- a/app/lib/widgets/book/book_notes.dart +++ b/app/lib/widgets/book/book_notes.dart @@ -129,7 +129,7 @@ class _BookNotesState extends State { ? _buildNoResultsState(context, colorScheme) : _buildNotesList( filtered, - padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.sm, Spacing.md, Spacing.md), + padding: const EdgeInsets.only(top: Spacing.sm, bottom: Spacing.md), separatorHeight: Spacing.md, showActionMenu: true, ), @@ -140,7 +140,7 @@ class _BookNotesState extends State { Widget _buildDesktopHeader() { return Padding( - padding: const EdgeInsets.all(Spacing.md), + padding: const EdgeInsets.symmetric(vertical: Spacing.md), child: Row( children: [ Expanded( diff --git a/app/lib/widgets/book/cover_loading_placeholder.dart b/app/lib/widgets/book/cover_loading_placeholder.dart new file mode 100644 index 0000000..73f9f6c --- /dev/null +++ b/app/lib/widgets/book/cover_loading_placeholder.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +/// Shared loading presentation for library and remote catalog artwork. +class CoverLoadingPlaceholder extends StatelessWidget { + const CoverLoadingPlaceholder({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Semantics( + label: 'Loading book cover', + child: Container( + key: const Key('cover-image-loading'), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border.all(color: colorScheme.outlineVariant), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 72 || constraints.maxHeight < 96; + final icon = Icon( + Icons.auto_stories_rounded, + size: compact ? 22 : 36, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.75), + ); + if (compact) return Center(child: icon); + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + const SizedBox(height: 8), + Text( + 'Loading…', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index 29382f4..c3fb10a 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:papyrus/widgets/book/cover_loading_placeholder.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/media/cover_storage_bucket.dart'; import 'package:papyrus/media/local_cover_image_provider.dart'; @@ -271,7 +272,7 @@ class _PrivateBookCoverState extends State { fadeOutDuration: AppMotion.duration(context, const Duration(milliseconds: 1000)), imageUrl: widget.imageUrl!, fit: widget.fit, - placeholder: (_, _) => _buildLoadingPlaceholder(context), + placeholder: (_, _) => const CoverLoadingPlaceholder(), errorWidget: (_, _, _) => widget.placeholder, ); } @@ -284,7 +285,7 @@ class _PrivateBookCoverState extends State { gaplessPlayback: true, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded || frame != null) return child; - return _buildLoadingPlaceholder(context); + return const CoverLoadingPlaceholder(); }, errorBuilder: (_, _, _) => widget.placeholder, ); @@ -297,7 +298,7 @@ class _PrivateBookCoverState extends State { future: coverFuture, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { - return _buildLoadingPlaceholder(context); + return const CoverLoadingPlaceholder(); } final bytes = snapshot.data; if (bytes != null) { @@ -308,46 +309,4 @@ class _PrivateBookCoverState extends State { }, ); } - - Widget _buildLoadingPlaceholder(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Semantics( - label: 'Loading book cover', - child: Container( - key: const Key('cover-image-loading'), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - border: Border.all(color: colorScheme.outlineVariant), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final compact = constraints.maxWidth < 72 || constraints.maxHeight < 96; - final icon = Icon( - Icons.auto_stories_rounded, - size: compact ? 22 : 36, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.75), - ); - if (compact) return Center(child: icon); - - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - icon, - const SizedBox(height: 8), - Text( - 'Loading…', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600), - ), - ], - ), - ); - }, - ), - ), - ); - } } diff --git a/app/lib/widgets/book_details/book_action_buttons.dart b/app/lib/widgets/book_details/book_action_buttons.dart index 27682be..d24653b 100644 --- a/app/lib/widgets/book_details/book_action_buttons.dart +++ b/app/lib/widgets/book_details/book_action_buttons.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book_details/book_details_action_style.dart'; import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; enum BookReadingActionState { ready, download, checking, syncing, failed, downloading, unavailable } @@ -30,7 +31,8 @@ class BookActionButtons extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final buttonHeight = isDesktop ? ComponentSizes.buttonHeightDesktop : ComponentSizes.buttonHeightMobile; + final actionStyle = bookDetailsActionStyle(context); + final iconStyle = bookDetailsActionStyle(context, iconOnly: true); final normalReadingLabel = book.progress > 0 ? 'Continue' : isDesktop @@ -59,76 +61,37 @@ class BookActionButtons extends StatelessWidget { BookReadingActionState.unavailable => const Icon(Icons.file_download_off_outlined), }; - return Row( - mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max, + return Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - // Primary action button - if (isDesktop) - SizedBox( - width: 180, - height: buttonHeight, - child: book.isPhysical - ? FilledButton.icon( - onPressed: onUpdateProgress, - icon: const Icon(Icons.edit_note), - label: const Text('Update progress'), - ) - : FilledButton.icon( - onPressed: canUseDigitalAction ? onContinueReading : null, - icon: digitalIcon, - label: Text(digitalLabel), - ), + if (book.isPhysical) + FilledButton.icon( + style: actionStyle, + onPressed: onUpdateProgress, + icon: const Icon(Icons.edit_note), + label: const Text('Update progress'), ) else - Expanded( - flex: 2, - child: SizedBox( - height: buttonHeight, - child: book.isPhysical - ? FilledButton.icon( - onPressed: onUpdateProgress, - icon: const Icon(Icons.edit_note), - label: const Text('Update progress'), - ) - : FilledButton.icon( - onPressed: canUseDigitalAction ? onContinueReading : null, - icon: digitalIcon, - label: Text(digitalLabel), - ), - ), + FilledButton.icon( + style: actionStyle, + onPressed: canUseDigitalAction ? onContinueReading : null, + icon: digitalIcon, + label: Text(digitalLabel), ), - const SizedBox(width: Spacing.sm), - - // Favorite toggle button - SizedBox( - width: buttonHeight, - height: buttonHeight, - child: OutlinedButton( - onPressed: onToggleFavorite, - style: OutlinedButton.styleFrom( - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.button)), - ), - child: Icon( - book.isFavorite ? Icons.favorite : Icons.favorite_border, - color: book.isFavorite ? colorScheme.error : colorScheme.primary, - ), + OutlinedButton( + onPressed: onToggleFavorite, + style: iconStyle, + child: Icon( + book.isFavorite ? Icons.favorite : Icons.favorite_border, + color: book.isFavorite ? colorScheme.error : colorScheme.primary, ), ), - const SizedBox(width: Spacing.sm), - - // Edit button (icon only) - SizedBox( - width: buttonHeight, - height: buttonHeight, - child: OutlinedButton( - onPressed: onEdit, - style: OutlinedButton.styleFrom( - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.button)), - ), - child: Icon(Icons.edit_outlined, color: colorScheme.primary), - ), + OutlinedButton( + onPressed: onEdit, + style: iconStyle, + child: Icon(Icons.edit_outlined, color: colorScheme.primary), ), ], ); diff --git a/app/lib/widgets/book_details/book_details_action_style.dart b/app/lib/widgets/book_details/book_details_action_style.dart new file mode 100644 index 0000000..7d738e0 --- /dev/null +++ b/app/lib/widgets/book_details/book_details_action_style.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +/// Shared sizing for actions on local and catalog book details. +ButtonStyle bookDetailsActionStyle(BuildContext context, {bool iconOnly = false}) => ButtonStyle( + visualDensity: VisualDensity.standard, + shape: const WidgetStatePropertyAll(StadiumBorder()), + minimumSize: WidgetStatePropertyAll(Size(iconOnly ? 48 : 0, 48)), + textStyle: WidgetStatePropertyAll(Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 15)), + iconSize: const WidgetStatePropertyAll(20), + padding: WidgetStatePropertyAll( + iconOnly ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 20, vertical: Spacing.sm), + ), +); diff --git a/app/lib/widgets/book_details/book_details_scroll_view.dart b/app/lib/widgets/book_details/book_details_scroll_view.dart new file mode 100644 index 0000000..3532798 --- /dev/null +++ b/app/lib/widgets/book_details/book_details_scroll_view.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +/// Lets the book header scroll away while keeping the tab rail above its body. +class BookDetailsScrollView extends StatelessWidget { + const BookDetailsScrollView({super.key, required this.header, required this.rail, required this.body}); + + final Widget header; + final Widget rail; + final Widget body; + + @override + Widget build(BuildContext context) => NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [SliverToBoxAdapter(child: header)], + body: Column( + children: [ + rail, + Expanded(child: body), + ], + ), + ); +} diff --git a/app/lib/widgets/book_details/book_details_sections.dart b/app/lib/widgets/book_details/book_details_sections.dart new file mode 100644 index 0000000..7b604a8 --- /dev/null +++ b/app/lib/widgets/book_details/book_details_sections.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +class BookDetailsSectionTitle extends StatelessWidget { + const BookDetailsSectionTitle(this.title, {super.key}); + final String title; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: Spacing.xs), + Divider(height: 1, thickness: 1, color: Theme.of(context).colorScheme.outlineVariant), + ], + ); +} + +/// Presentation only: usable by both local books and remote publications. +class BookMetadataRows extends StatelessWidget { + const BookMetadataRows({super.key, required this.entries}); + final List<(String, String)> entries; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (label, value) in entries) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + Expanded(child: Text(value, style: Theme.of(context).textTheme.bodyMedium)), + ], + ), + ), + ], + ); +} diff --git a/app/lib/widgets/book_details/book_details_tab_rail.dart b/app/lib/widgets/book_details/book_details_tab_rail.dart new file mode 100644 index 0000000..01ad0cc --- /dev/null +++ b/app/lib/widgets/book_details/book_details_tab_rail.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// Common tab rail for local and catalog book details. +class BookDetailsTabRail extends StatelessWidget { + const BookDetailsTabRail({super.key, this.controller, required this.tabs}); + + final TabController? controller; + final List tabs; + + @override + Widget build(BuildContext context) => TabBar( + controller: controller, + isScrollable: true, + tabAlignment: TabAlignment.start, + dividerColor: Theme.of(context).colorScheme.outlineVariant, + tabs: tabs, + ); +} diff --git a/app/lib/widgets/book_details/book_header.dart b/app/lib/widgets/book_details/book_header.dart index 43308d2..ce30c02 100644 --- a/app/lib/widgets/book_details/book_header.dart +++ b/app/lib/widgets/book_details/book_header.dart @@ -79,24 +79,11 @@ class BookHeader extends StatelessWidget { // Author Text( book.allAuthors, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurface), + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: Spacing.md), - // Meta row: rating, format, topics - _buildMetaRow(context, colorScheme), - const SizedBox(height: Spacing.lg), - - // Progress bar - if (book.progress > 0 || book.isPhysical) ...[ - BookProgressBar( - progress: book.progress, - currentPage: book.currentPage, - totalPages: book.totalPages, - onTap: book.isPhysical ? onUpdateProgress : null, - ), - const SizedBox(height: Spacing.lg), - ], + if (book.topics.isNotEmpty) ...[_buildTopics(context), const SizedBox(height: Spacing.lg)], // Action buttons BookActionButtons( @@ -112,6 +99,7 @@ class BookHeader extends StatelessWidget { const SizedBox(height: Spacing.sm), _ReadingError(message: error, onRetry: onRetryReading), ], + if (book.progress > 0 || book.isPhysical) ...[const SizedBox(height: Spacing.md), _buildProgressBar()], ], ), ), @@ -159,27 +147,11 @@ class BookHeader extends StatelessWidget { // Author (centered) Text( book.allAuthors, - style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurface), + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: Spacing.md), - // Meta row: rating, format, pages - _buildMobileMetaRow(context, colorScheme), - const SizedBox(height: Spacing.md), - - // Progress bar - if (book.progress > 0 || book.isPhysical) ...[ - BookProgressBar( - progress: book.progress, - currentPage: book.currentPage, - totalPages: book.totalPages, - height: 4, - onTap: book.isPhysical ? onUpdateProgress : null, - ), - const SizedBox(height: Spacing.md), - ], - // Action buttons BookActionButtons( book: book, @@ -194,30 +166,29 @@ class BookHeader extends StatelessWidget { const SizedBox(height: Spacing.sm), _ReadingError(message: error, onRetry: onRetryReading), ], + if (book.progress > 0 || book.isPhysical) ...[const SizedBox(height: Spacing.md), _buildProgressBar()], const SizedBox(height: Spacing.md), ], ), ); } - Widget _buildMetaRow(BuildContext context, ColorScheme colorScheme) { + Widget _buildProgressBar() => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: BookProgressBar( + progress: book.progress, + currentPage: book.currentPage, + totalPages: book.totalPages, + onTap: book.isPhysical ? onUpdateProgress : null, + ), + ); + + Widget _buildTopics(BuildContext context) { return Wrap( spacing: Spacing.sm, runSpacing: Spacing.xs, crossAxisAlignment: WrapCrossAlignment.center, children: [ - // Format badge - Container( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: 2), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Text( - book.formatLabel, - style: Theme.of(context).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600), - ), - ), // Topics (first 2) ...book.topics .take(2) @@ -237,19 +208,6 @@ class BookHeader extends StatelessWidget { ], ); } - - Widget _buildMobileMetaRow(BuildContext context, ColorScheme colorScheme) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(book.formatLabel, style: Theme.of(context).textTheme.bodyMedium), - if (book.totalPages != null) ...[ - Text(' • ', style: TextStyle(color: colorScheme.onSurfaceVariant)), - Text('${book.totalPages} pages', style: Theme.of(context).textTheme.bodyMedium), - ], - ], - ); - } } class _ReadingError extends StatelessWidget { diff --git a/app/lib/widgets/book_details/book_info_grid.dart b/app/lib/widgets/book_details/book_info_grid.dart index 650554f..44045b9 100644 --- a/app/lib/widgets/book_details/book_info_grid.dart +++ b/app/lib/widgets/book_details/book_info_grid.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/providers/enums/library_reading_status.dart'; -import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book_details/book_details_sections.dart'; /// Information grid for book metadata. /// Displays key-value pairs like Publisher, ISBN, Format, etc. @@ -13,30 +13,8 @@ class BookInfoGrid extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; final entries = _buildEntries(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: entries.map((entry) { - return Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 100, - child: Text( - entry.label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ), - Expanded(child: Text(entry.value, style: Theme.of(context).textTheme.bodyMedium)), - ], - ), - ); - }).toList(), - ); + return BookMetadataRows(entries: entries.map((entry) => (entry.label, entry.value)).toList()); } List<_InfoEntry> _buildEntries() { diff --git a/app/lib/widgets/input/search_field.dart b/app/lib/widgets/input/search_field.dart index a8e023d..fb8b352 100644 --- a/app/lib/widgets/input/search_field.dart +++ b/app/lib/widgets/input/search_field.dart @@ -23,6 +23,7 @@ class SearchField extends StatelessWidget { /// Called when the text changes. final ValueChanged? onChanged; + final ValueChanged? onSubmitted; /// Called when the clear button is pressed. /// @@ -33,14 +34,19 @@ class SearchField extends StatelessWidget { /// Height of the search field. Defaults to 40. final double? height; + /// An optional action inside the field, after the clear button. + final Widget? trailing; + /// Creates a search field widget. const SearchField({ super.key, required this.controller, required this.hintText, this.onChanged, + this.onSubmitted, this.onClear, this.height, + this.trailing, }); /// Whether the search field has text content. @@ -62,10 +68,25 @@ class SearchField extends StatelessWidget { child: TextField( controller: controller, onChanged: onChanged, + onSubmitted: onSubmitted, + textInputAction: onSubmitted == null ? null : TextInputAction.search, decoration: InputDecoration( hintText: hintText, prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: _hasContent ? IconButton(icon: const Icon(Icons.close, size: 20), onPressed: _handleClear) : null, + suffixIcon: trailing == null && !_hasContent + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_hasContent) + IconButton( + tooltip: 'Clear search', + icon: const Icon(Icons.close, size: 20), + onPressed: _handleClear, + ), + ?trailing, + ], + ), border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppRadius.md)), contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.md), isDense: true, diff --git a/app/lib/widgets/library/book_grid.dart b/app/lib/widgets/library/book_grid.dart index e728a1f..f9b5582 100644 --- a/app/lib/widgets/library/book_grid.dart +++ b/app/lib/widgets/library/book_grid.dart @@ -10,10 +10,9 @@ import 'package:papyrus/providers/book_storage_status_controller.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/acquisition_placeholder_card.dart'; import 'package:papyrus/widgets/library/book_card.dart'; +import 'package:papyrus/widgets/library/book_grid_layout.dart'; import 'package:provider/provider.dart'; -typedef _GridLayout = ({int crossAxisCount, double spacing, double childAspectRatio}); - class BookGrid extends StatelessWidget { final List books; final LibraryViewMode libraryViewMode; @@ -38,31 +37,16 @@ class BookGrid extends StatelessWidget { this.onAcquisitionSelectionToggle, }); - _GridLayout _resolveGridLayout({required double width, required LibraryViewMode viewMode}) { + BookGridLayout _resolveGridLayout({required double width, required LibraryViewMode viewMode}) { if (viewMode == LibraryViewMode.list) { throw ArgumentError('List mode does not use a grid layout'); } - final isLargeGrid = viewMode == LibraryViewMode.largeGrid; - - if (width >= Breakpoints.desktopLarge) { - return (crossAxisCount: isLargeGrid ? 4 : 6, spacing: Spacing.md, childAspectRatio: 0.55); - } - - if (width >= Breakpoints.desktopSmall) { - return (crossAxisCount: isLargeGrid ? 3 : 5, spacing: Spacing.md, childAspectRatio: 0.55); - } - - if (width >= Breakpoints.tablet) { - return (crossAxisCount: isLargeGrid ? 3 : 4, spacing: Spacing.sm + 4, childAspectRatio: 0.55); - } - - return (crossAxisCount: 2, spacing: Spacing.sm, childAspectRatio: 0.58); + return bookGridLayout(width, large: viewMode == LibraryViewMode.largeGrid); } @override Widget build(BuildContext context) { - final layout = _resolveGridLayout(width: MediaQuery.sizeOf(context).width, viewMode: libraryViewMode); final libraryProvider = context.watch(); final storageStatusController = context.watch(); final bookIds = books.map((book) => book.id).toSet(); @@ -105,78 +89,83 @@ class BookGrid extends StatelessWidget { orphanJobs.add(job); } - return MediaQuery.removePadding( - context: context, - removeTop: true, - child: GridView.builder( - padding: padding ?? const EdgeInsets.only(left: Spacing.md, right: Spacing.md, bottom: Spacing.md), - // Flutter 3.41 compatibility; replaced by scrollCacheExtent in 3.42+. - // ignore: deprecated_member_use - cacheExtent: 200, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: layout.crossAxisCount, - mainAxisSpacing: layout.spacing, - crossAxisSpacing: layout.spacing, - childAspectRatio: layout.childAspectRatio, - ), - itemCount: books.length + orphanJobs.length, - itemBuilder: (context, index) { - if (index >= books.length) { - final job = orphanJobs[index - books.length]; - final canSelect = job.status != AcquisitionJobStatus.completed; - - return AcquisitionPlaceholderCard( - job: job, - onTap: onAcquisitionTap == null ? null : () => onAcquisitionTap!(job), - isSelectionMode: canSelect && selectedAcquisitionJobIds.isNotEmpty, - isSelected: selectedAcquisitionJobIds.contains(job.id), - onSelectToggle: !canSelect || onAcquisitionSelectionToggle == null - ? null - : () => onAcquisitionSelectionToggle!(job), - onEnterSelectionMode: !canSelect || onAcquisitionSelectionToggle == null - ? null - : () => onAcquisitionSelectionToggle!(job), - ); - } - - final book = books[index]; - final job = linkedJobsByBookId[book.id]; - final isFavorite = libraryProvider.isBookFavorite(book.id, book.isFavorite); - if (job == null) { - unawaited(storageStatusController?.ensureDeviceStatus(book)); - } - - return BookCard( - book: book, - isFavorite: isFavorite, - onToggleFavorite: job == null ? (current) => toggleBookFavorite(context, book.id, current) : null, - onTap: job != null - ? onAcquisitionTap == null - ? null - : () => onAcquisitionTap!(job) - : onBookTap == null - ? null - : () => onBookTap!(book), - isSelectionMode: job != null ? selectedAcquisitionJobIds.isNotEmpty : libraryProvider.isSelectionMode, - isSelected: job != null - ? selectedAcquisitionJobIds.contains(job.id) - : libraryProvider.isBookSelected(book.id), - onSelectToggle: job != null - ? onAcquisitionSelectionToggle == null + return LayoutBuilder( + builder: (context, constraints) { + final layout = _resolveGridLayout(width: constraints.maxWidth, viewMode: libraryViewMode); + return MediaQuery.removePadding( + context: context, + removeTop: true, + child: GridView.builder( + padding: padding ?? const EdgeInsets.only(left: Spacing.md, right: Spacing.md, bottom: Spacing.md), + // Flutter 3.41 compatibility; replaced by scrollCacheExtent in 3.42+. + // ignore: deprecated_member_use + cacheExtent: 200, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: layout.crossAxisCount, + mainAxisSpacing: layout.spacing, + crossAxisSpacing: layout.spacing, + childAspectRatio: layout.childAspectRatio, + ), + itemCount: books.length + orphanJobs.length, + itemBuilder: (context, index) { + if (index >= books.length) { + final job = orphanJobs[index - books.length]; + final canSelect = job.status != AcquisitionJobStatus.completed; + + return AcquisitionPlaceholderCard( + job: job, + onTap: onAcquisitionTap == null ? null : () => onAcquisitionTap!(job), + isSelectionMode: canSelect && selectedAcquisitionJobIds.isNotEmpty, + isSelected: selectedAcquisitionJobIds.contains(job.id), + onSelectToggle: !canSelect || onAcquisitionSelectionToggle == null ? null - : () => onAcquisitionSelectionToggle!(job) - : () => libraryProvider.toggleBookSelection(book.id), - onEnterSelectionMode: job != null - ? onAcquisitionSelectionToggle == null + : () => onAcquisitionSelectionToggle!(job), + onEnterSelectionMode: !canSelect || onAcquisitionSelectionToggle == null ? null - : () => onAcquisitionSelectionToggle!(job) - : () => libraryProvider.enterSelectionMode(book.id), - acquisitionJob: job, - accountStatus: job == null ? storageStatusController?.accountStatus(book) : null, - deviceStatus: job == null ? storageStatusController?.deviceStatus(book) : null, - ); - }, - ), + : () => onAcquisitionSelectionToggle!(job), + ); + } + + final book = books[index]; + final job = linkedJobsByBookId[book.id]; + final isFavorite = libraryProvider.isBookFavorite(book.id, book.isFavorite); + if (job == null) { + unawaited(storageStatusController?.ensureDeviceStatus(book)); + } + + return BookCard( + book: book, + isFavorite: isFavorite, + onToggleFavorite: job == null ? (current) => toggleBookFavorite(context, book.id, current) : null, + onTap: job != null + ? onAcquisitionTap == null + ? null + : () => onAcquisitionTap!(job) + : onBookTap == null + ? null + : () => onBookTap!(book), + isSelectionMode: job != null ? selectedAcquisitionJobIds.isNotEmpty : libraryProvider.isSelectionMode, + isSelected: job != null + ? selectedAcquisitionJobIds.contains(job.id) + : libraryProvider.isBookSelected(book.id), + onSelectToggle: job != null + ? onAcquisitionSelectionToggle == null + ? null + : () => onAcquisitionSelectionToggle!(job) + : () => libraryProvider.toggleBookSelection(book.id), + onEnterSelectionMode: job != null + ? onAcquisitionSelectionToggle == null + ? null + : () => onAcquisitionSelectionToggle!(job) + : () => libraryProvider.enterSelectionMode(book.id), + acquisitionJob: job, + accountStatus: job == null ? storageStatusController?.accountStatus(book) : null, + deviceStatus: job == null ? storageStatusController?.deviceStatus(book) : null, + ); + }, + ), + ); + }, ); } } diff --git a/app/lib/widgets/library/book_grid_layout.dart b/app/lib/widgets/library/book_grid_layout.dart new file mode 100644 index 0000000..ae401f9 --- /dev/null +++ b/app/lib/widgets/library/book_grid_layout.dart @@ -0,0 +1,17 @@ +import 'package:papyrus/themes/design_tokens.dart'; + +/// Shared library and catalog density, measured inside the app shell. +typedef BookGridLayout = ({int crossAxisCount, double spacing, double childAspectRatio}); + +BookGridLayout bookGridLayout(double width, {bool large = false}) { + if (width >= Breakpoints.desktopLarge) { + return (crossAxisCount: large ? 4 : 6, spacing: Spacing.md, childAspectRatio: 0.55); + } + if (width >= Breakpoints.desktopSmall) { + return (crossAxisCount: large ? 3 : 5, spacing: Spacing.md, childAspectRatio: 0.55); + } + if (width >= Breakpoints.tablet) { + return (crossAxisCount: large ? 3 : 4, spacing: Spacing.sm + 4, childAspectRatio: 0.55); + } + return (crossAxisCount: 2, spacing: Spacing.sm, childAspectRatio: 0.58); +} diff --git a/app/lib/widgets/library/library_drawer.dart b/app/lib/widgets/library/library_drawer.dart index fdfe59e..bdfdde3 100644 --- a/app/lib/widgets/library/library_drawer.dart +++ b/app/lib/widgets/library/library_drawer.dart @@ -52,6 +52,15 @@ class LibraryDrawer extends StatelessWidget { context.go('/library/shelves'); }, ), + _DrawerNavItem( + icon: Icons.public, + label: 'Catalogs', + isSelected: currentPath.startsWith('/library/catalogs'), + onTap: () { + Navigator.of(context).pop(); + context.go('/library/catalogs'); + }, + ), _DrawerNavItem( icon: Icons.bookmark, label: 'Bookmarks', diff --git a/app/lib/widgets/opds/catalog_editor.dart b/app/lib/widgets/opds/catalog_editor.dart new file mode 100644 index 0000000..895ddfd --- /dev/null +++ b/app/lib/widgets/opds/catalog_editor.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/opds/opds_browser.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/acquisition/guarded_bottom_sheet_route.dart'; +import 'package:papyrus/widgets/opds/opds_sheet.dart'; +import 'package:uuid/uuid.dart'; + +class CatalogEditor extends StatefulWidget { + const CatalogEditor({super.key, required this.catalogs, this.catalog}) : _busy = null; + const CatalogEditor._({required this.catalogs, this.catalog, required ValueNotifier busy}) : _busy = busy; + final OpdsCatalogs catalogs; + final OpdsCatalog? catalog; + final ValueNotifier? _busy; + + static Future show(BuildContext context, {required OpdsCatalogs catalogs, OpdsCatalog? catalog}) async { + final busy = ValueNotifier(false); + try { + await showGuardedModalBottomSheet( + context: context, + busy: busy, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg))), + builder: (_) => CatalogEditor._(catalogs: catalogs, catalog: catalog, busy: busy), + ); + } finally { + busy.dispose(); + } + } + + @override + State createState() => _CatalogEditorState(); +} + +class _CatalogEditorState extends State { + final _form = GlobalKey(); + late final _name = TextEditingController(text: widget.catalog?.name); + late final _url = TextEditingController(text: widget.catalog?.uri.toString()); + final _username = TextEditingController(); + final _password = TextEditingController(); + late final String? _scope; + bool _clearCredentials = false; + bool _saving = false; + String? _error; + @override + void initState() { + super.initState(); + _scope = widget.catalogs.scope; + } + + @override + void dispose() { + _name.dispose(); + _url.dispose(); + _username.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _save() async { + if (_saving) return; + if (!_form.currentState!.validate()) return; + widget._busy?.value = true; + setState(() { + _saving = true; + _error = null; + }); + try { + if (_scope != widget.catalogs.scope) { + throw const OpdsException('The active account changed. Close this editor and try again.'); + } + final uri = OpdsHttpClient.validateUri(Uri.parse(_url.text.trim())); + final changedOrigin = widget.catalog != null && widget.catalog!.uri.origin != uri.origin; + final credentials = !_clearCredentials && _username.text.isNotEmpty + ? OpdsCredentials(username: _username.text, password: _password.text) + : null; + await widget.catalogs.save( + OpdsCatalog(id: widget.catalog?.id ?? const Uuid().v4(), name: _name.text.trim(), uri: uri), + credentials: credentials, + clearCredentials: _clearCredentials || changedOrigin, + ); + if (mounted) Navigator.of(context).pop(); + } catch (error) { + if (mounted) { + setState(() { + _saving = false; + _error = opdsErrorMessage(error); + }); + } + } finally { + widget._busy?.value = false; + } + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_saving, + child: OpdsSheet( + title: widget.catalog == null ? 'Add catalog' : 'Edit catalog', + cancelLabel: 'Cancel', + onSave: _save, + canSave: !_saving, + canCancel: !_saving, + saveLabel: _saving ? 'Saving…' : 'Save', + child: Form( + key: _form, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + key: const Key('opds-name'), + controller: _name, + decoration: const InputDecoration(labelText: 'Name'), + validator: (value) => value == null || value.trim().isEmpty ? 'Enter a name' : null, + ), + const SizedBox(height: Spacing.md), + TextFormField( + key: const Key('opds-url'), + controller: _url, + keyboardType: TextInputType.url, + decoration: const InputDecoration(labelText: 'Catalog URL', hintText: 'https://example.com/opds'), + validator: (value) { + try { + OpdsHttpClient.validateUri(Uri.parse(value?.trim() ?? '')); + return null; + } catch (_) { + return 'Enter an HTTP(S) URL without credentials'; + } + }, + ), + const SizedBox(height: Spacing.md), + TextFormField( + key: const Key('opds-username'), + controller: _username, + enabled: !_clearCredentials, + decoration: const InputDecoration(labelText: 'Username (optional)'), + validator: (value) => value != null && value.contains(':') ? 'Username cannot contain a colon' : null, + ), + const SizedBox(height: Spacing.md), + TextFormField( + key: const Key('opds-password'), + controller: _password, + obscureText: true, + enabled: !_clearCredentials, + decoration: const InputDecoration(labelText: 'Password'), + validator: (value) => !_clearCredentials && (value?.isNotEmpty ?? false) && _username.text.isEmpty + ? 'Enter a username too' + : null, + ), + if (widget.catalog != null) ...[ + const SizedBox(height: Spacing.sm), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Remove saved credentials'), + value: _clearCredentials, + onChanged: _saving ? null : (value) => setState(() => _clearCredentials = value!), + ), + ], + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + ], + ), + ), + ), + ); +} diff --git a/app/lib/widgets/opds/catalog_source_tile.dart b/app/lib/widgets/opds/catalog_source_tile.dart new file mode 100644 index 0000000..bd8ae25 --- /dev/null +++ b/app/lib/widgets/opds/catalog_source_tile.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +class CatalogSourceTile extends StatelessWidget { + const CatalogSourceTile({ + super.key, + required this.catalog, + required this.onOpen, + required this.onEdit, + required this.onRemove, + }); + + final OpdsCatalog catalog; + final VoidCallback onOpen; + final VoidCallback onEdit; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final mobile = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + final colors = Theme.of(context).colorScheme; + final tile = ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + title: Text(catalog.name), + subtitle: Text(catalog.uri.host), + onTap: onOpen, + trailing: mobile + ? null + : PopupMenuButton( + tooltip: 'Catalog options', + onSelected: (value) => value == 'edit' ? onEdit() : onRemove(), + itemBuilder: (_) => const [ + PopupMenuItem(value: 'edit', child: Text('Edit')), + PopupMenuItem(value: 'remove', child: Text('Remove')), + ], + ), + ); + if (!mobile) return tile; + + return ClipRect( + child: Dismissible( + key: ValueKey(catalog.id), + direction: DismissDirection.horizontal, + movementDuration: AppMotion.duration(context, const Duration(milliseconds: 200)), + resizeDuration: null, + confirmDismiss: (direction) async { + if (direction == DismissDirection.startToEnd) { + onEdit(); + } else { + onRemove(); + } + // Swipes open a sheet; only its explicit confirmation may remove a source. + return false; + }, + background: _swipeBackground( + context, + label: 'Edit', + icon: Icons.edit_outlined, + color: colors.secondaryContainer, + foreground: colors.onSecondaryContainer, + ), + secondaryBackground: _swipeBackground( + context, + label: 'Delete', + icon: Icons.delete_outline, + color: colors.errorContainer, + foreground: colors.onErrorContainer, + trailing: true, + ), + child: Material(color: colors.surface, child: tile), + ), + ); + } + + Widget _swipeBackground( + BuildContext context, { + required String label, + required IconData icon, + required Color color, + required Color foreground, + bool trailing = false, + }) => ColoredBox( + color: color, + child: Align( + alignment: trailing ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: foreground), + const SizedBox(height: Spacing.xs), + Text(label, style: Theme.of(context).textTheme.labelLarge?.copyWith(color: foreground)), + ], + ), + ), + ), + ); +} diff --git a/app/lib/widgets/opds/opds_download_actions.dart b/app/lib/widgets/opds/opds_download_actions.dart new file mode 100644 index 0000000..037b4e0 --- /dev/null +++ b/app/lib/widgets/opds/opds_download_actions.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/opds/opds_browser.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:provider/provider.dart'; + +/// A retry may use corrected credentials, but never a different catalog URL or +/// a job removed by an account switch. Open format sheets retain stricter checks. +Future retryOpdsDownload(BuildContext context, OpdsDownloadJob job) async { + if (!context.mounted) return; + final catalogs = context.read(); + final downloads = context.read(); + final scope = catalogs.scope; + final revision = catalogs.revision; + if (!downloads.jobs.contains(job)) return; + try { + final catalog = catalogs.find(job.catalog.id); + if (catalog == null || catalog.uri != job.catalog.uri) { + throw const OpdsException('The catalog changed. Open the book again to choose a current download.'); + } + final credentials = await catalogs.credentials(catalog.id); + if (!context.mounted || catalogs.scope != scope || catalogs.revision != revision || !downloads.jobs.contains(job)) { + return; + } + await downloads.start(catalog, job.publication, job.link, credentials: credentials); + } catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(opdsErrorMessage(error)))); + } + } +} diff --git a/app/lib/widgets/opds/opds_download_panel.dart b/app/lib/widgets/opds/opds_download_panel.dart new file mode 100644 index 0000000..21089aa --- /dev/null +++ b/app/lib/widgets/opds/opds_download_panel.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/opds/opds_sheet.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; + +/// Opens transfer details without consuming space in the catalog feed. +class OpdsDownloadsButton extends StatelessWidget { + const OpdsDownloadsButton({super.key, required this.downloads, required this.onRetry, this.compact}); + + final OpdsDownloads downloads; + final ValueChanged onRetry; + final bool? compact; + + void _show(BuildContext context) { + showOpdsSheet( + context, + title: 'Downloads', + scrollable: false, + child: _DownloadsList(downloads: downloads, onRetry: onRetry), + ); + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: downloads, + builder: (context, _) { + final active = downloads.jobs.where((job) => job.isActive).length; + final failed = downloads.jobs.any((job) => job.status == OpdsDownloadStatus.failed); + final icon = Badge( + isLabelVisible: active > 0, + label: Text('$active'), + child: Icon( + failed ? Icons.error_outline : Icons.downloading_outlined, + color: failed ? Theme.of(context).colorScheme.error : null, + ), + ); + if (compact ?? MediaQuery.sizeOf(context).width < 600) { + return IconButton(tooltip: 'Downloads', onPressed: () => _show(context), icon: icon); + } + return Tooltip( + message: 'Downloads', + child: TextButton.icon(onPressed: () => _show(context), icon: icon, label: const Text('Downloads')), + ); + }, + ); +} + +class _DownloadsList extends StatelessWidget { + const _DownloadsList({required this.downloads, required this.onRetry}); + final OpdsDownloads downloads; + final ValueChanged onRetry; + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: downloads, + builder: (context, _) { + final jobs = downloads.jobs; + if (jobs.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: Spacing.xl), + child: Text('No downloads yet'), + ); + } + return ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: jobs.length, + separatorBuilder: (_, _) => const Padding( + padding: EdgeInsets.symmetric(vertical: Spacing.sm), + child: Divider(), + ), + itemBuilder: (context, index) => _job(context, jobs[index]), + ); + }, + ); + + Widget _job(BuildContext context, OpdsDownloadJob job) { + final theme = Theme.of(context); + final info = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(job.publication.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall), + const SizedBox(height: Spacing.xs), + Text( + job.error ?? opdsDownloadStatus(job), + style: theme.textTheme.bodySmall?.copyWith( + color: job.error == null ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.error, + ), + ), + ], + ); + return Column( + key: ValueKey(job.key), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LayoutBuilder( + builder: (context, constraints) { + if (!job.isCancellable && job.isActive) return info; + final actions = _actions(context, job); + if (job.isCancellable || constraints.maxWidth >= 300 * MediaQuery.textScalerOf(context).scale(1)) { + return Row( + children: [ + Expanded(child: info), + const SizedBox(width: Spacing.md), + actions, + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + info, + const SizedBox(height: Spacing.sm), + Align(alignment: AlignmentDirectional.centerEnd, child: actions), + ], + ); + }, + ), + if (job.isActive) ...[ + const SizedBox(height: Spacing.sm), + AppLinearProgressIndicator(value: job.status == OpdsDownloadStatus.downloading ? job.progress : null), + ], + ], + ); + } + + Widget _actions(BuildContext context, OpdsDownloadJob job) => Wrap( + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Spacing.xs, + children: [ + if (job.isCancellable) + IconButton( + tooltip: 'Cancel download', + onPressed: () => downloads.cancel(job.key), + icon: const Icon(Icons.close), + ), + if (!job.isActive) ...[ + if (job.status == OpdsDownloadStatus.complete) + TextButton( + style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge), + onPressed: job.bookId == null + ? null + : () { + final router = GoRouter.of(context); + Navigator.of(context).pop(); + router.go('/library/details/${job.bookId}'); + }, + child: const Text('Open book'), + ) + else + TextButton( + style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge), + onPressed: () => onRetry(job), + child: const Text('Retry'), + ), + IconButton( + tooltip: 'Dismiss download', + onPressed: () => downloads.dismiss(job.key), + icon: const Icon(Icons.close, size: IconSizes.small), + ), + ], + ], + ); +} + +String opdsDownloadStatus(OpdsDownloadJob job) => switch (job.status) { + OpdsDownloadStatus.downloading => + job.total == null + ? 'Downloading · ${job.received ~/ 1024} KB' + : 'Downloading · ${(100 * (job.progress ?? 0)).round()}%', + OpdsDownloadStatus.importing => 'Importing…', + OpdsDownloadStatus.committing => 'Adding to library…', + OpdsDownloadStatus.complete => 'Added to library', + OpdsDownloadStatus.failed => 'Download failed', + OpdsDownloadStatus.cancelled => 'Cancelled', +}; diff --git a/app/lib/widgets/opds/opds_feed_view.dart b/app/lib/widgets/opds/opds_feed_view.dart new file mode 100644 index 0000000..c56e6bd --- /dev/null +++ b/app/lib/widgets/opds/opds_feed_view.dart @@ -0,0 +1,324 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/library/book_grid_layout.dart'; +import 'package:papyrus/widgets/opds/opds_publication_tile.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; + +class OpdsFeedView extends StatelessWidget { + const OpdsFeedView({ + super.key, + required this.catalog, + required this.feed, + required this.httpClient, + required this.onNavigate, + required this.onOpenPublication, + required this.onRefresh, + required this.isGridView, + required this.onViewChanged, + required this.onPage, + this.credentials, + this.query = '', + this.scrollController, + this.contentOverride, + this.libraryBookId, + this.status, + this.isRefreshing = false, + }); + final OpdsCatalog catalog; + final String? Function(OpdsPublication)? libraryBookId; + final OpdsFeed feed; + final OpdsHttpClient httpClient; + final OpdsCredentials? credentials; + final ValueChanged onNavigate; + final ValueChanged onPage; + final ValueChanged onOpenPublication; + final VoidCallback? onRefresh; + final bool isGridView; + final ValueChanged onViewChanged; + final String query; + final ScrollController? scrollController; + final Widget? contentOverride; + final Widget? status; + final bool isRefreshing; + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + final colors = Theme.of(context).colorScheme; + final hasBooks = feed.publications.isNotEmpty || feed.groups.any((group) => group.publications.isNotEmpty); + return LayoutBuilder( + builder: (context, constraints) { + final layout = bookGridLayout(constraints.maxWidth); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.only( + top: constraints.maxWidth < Breakpoints.tablet ? Spacing.md : Spacing.lg, + bottom: Spacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + feed.title, + style: constraints.maxWidth < Breakpoints.tablet ? text.titleMedium : text.titleLarge, + ), + ), + IconButton( + key: const Key('opds-refresh'), + tooltip: 'Refresh catalog', + onPressed: isRefreshing ? null : onRefresh, + icon: isRefreshing + ? const SizedBox.square( + dimension: 24, + child: AppCircularProgressIndicator( + strokeWidth: 2, + semanticsLabel: 'Refreshing catalog', + ), + ) + : const Icon(Icons.refresh), + ), + if (hasBooks) + IconButton( + tooltip: isGridView ? 'List view' : 'Grid view', + onPressed: () => onViewChanged(!isGridView), + icon: Icon(isGridView ? Icons.view_list : Icons.grid_view), + ), + ], + ), + if (query.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: Spacing.xs), + child: Text( + 'Results for “$query”', + style: text.bodyMedium?.copyWith(color: colors.onSurfaceVariant), + ), + ), + ], + ), + ), + ?status, + Expanded( + child: + contentOverride ?? + CustomScrollView( + controller: scrollController, + key: PageStorageKey('${catalog.id}/${feed.uri}'), + slivers: [ + if (feed.facets.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final facet in feed.facets) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Spacing.sm, + runSpacing: Spacing.xs, + children: [ + Text('${facet.title}:', style: text.labelLarge), + for (final link in facet.links) + ActionChip( + label: Text(link.title ?? 'Filter'), + onPressed: () => onNavigate(link.uri), + ), + ], + ), + ), + ], + ), + ), + ), + ..._entries(context, feed.navigation, feed.publications, layout, constraints.maxWidth), + for (final group in feed.groups) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: Spacing.lg, bottom: Spacing.md), + child: Row( + children: [ + Expanded(child: Text(group.title, style: text.titleMedium)), + for (final link + in group.links + .where((link) => link.hasRel('self') || link.hasRel('collection')) + .take(1)) + TextButton(onPressed: () => onNavigate(link.uri), child: const Text('View all')), + ], + ), + ), + ), + ..._entries(context, group.navigation, group.publications, layout, constraints.maxWidth), + ], + if (feed.navigation.isEmpty && feed.publications.isEmpty && feed.groups.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.xxl), + child: Column( + children: [ + Icon(Icons.search_off, size: 48, color: colors.onSurfaceVariant), + const SizedBox(height: Spacing.md), + Text('No books or sections found.', style: text.titleMedium), + if (query.isNotEmpty) + const Padding( + padding: EdgeInsets.only(top: Spacing.sm), + child: Text('Try another title, author, or keyword.'), + ), + ], + ), + ), + ), + if (feed.previousLink != null || feed.nextLink != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Align( + alignment: Alignment.centerRight, + child: Wrap( + alignment: WrapAlignment.end, + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + if (feed.previousLink != null) + TextButton.icon( + style: TextButton.styleFrom(minimumSize: const Size(0, 44)), + onPressed: () => onPage(feed.previousLink!.uri), + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + if (feed.nextLink != null) + FilledButton.tonalIcon( + style: FilledButton.styleFrom(minimumSize: const Size(0, 44)), + onPressed: () => onPage(feed.nextLink!.uri), + iconAlignment: IconAlignment.end, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ], + ), + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)), + ], + ), + ), + ], + ); + }, + ); + } + + List _entries( + BuildContext context, + List navigation, + List publications, + BookGridLayout layout, + double width, + ) => [ + if (navigation.isNotEmpty) + SliverPadding( + padding: EdgeInsets.only(bottom: publications.isNotEmpty ? Spacing.md : 0), + sliver: SliverList.separated( + separatorBuilder: (_, _) => const Divider(height: 1), + itemCount: navigation.length, + itemBuilder: (_, index) { + final link = navigation[index]; + return Material( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(AppRadius.sm), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => onNavigate(link.uri), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.md), + child: Row( + children: [ + if (link.imageUri != null && ['http', 'https'].contains(link.imageUri!.scheme)) ...[ + OpdsCover( + catalog: catalog, + uri: link.imageUri, + httpClient: httpClient, + credentials: credentials, + width: 36, + height: 52, + ), + const SizedBox(width: Spacing.md), + ], + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + link.title ?? 'Browse section', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + if (link.description?.isNotEmpty ?? false) ...[ + const SizedBox(height: Spacing.xs), + Text( + link.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ], + ), + ), + const SizedBox(width: Spacing.sm), + const Icon(Icons.chevron_right, size: IconSizes.small), + ], + ), + ), + ), + ); + }, + ), + ), + if (publications.isNotEmpty) + if (isGridView) + SliverGrid.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: layout.crossAxisCount, + crossAxisSpacing: layout.spacing, + mainAxisSpacing: layout.spacing, + mainAxisExtent: + ((width - (layout.crossAxisCount - 1) * layout.spacing) / layout.crossAxisCount) * 1.5 + + 120 * MediaQuery.textScalerOf(context).scale(1), + ), + itemCount: publications.length, + itemBuilder: (_, index) => _publication(publications[index]), + ) + else + SliverList.separated( + itemCount: publications.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, index) => _publication(publications[index]), + ), + ]; + + Widget _publication(OpdsPublication publication) => OpdsPublicationTile( + inLibrary: libraryBookId?.call(publication) != null, + key: ValueKey(publication.id), + catalog: catalog, + publication: publication, + credentials: credentials, + httpClient: httpClient, + isGridView: isGridView, + onOpen: () => onOpenPublication(publication), + ); +} diff --git a/app/lib/widgets/opds/opds_mobile_header.dart b/app/lib/widgets/opds/opds_mobile_header.dart new file mode 100644 index 0000000..29adb06 --- /dev/null +++ b/app/lib/widgets/opds/opds_mobile_header.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +/// Standard mobile app bar for every catalog route, inside the page's SafeArea. +class OpdsMobileHeader extends StatelessWidget { + const OpdsMobileHeader({super.key, required this.title, required this.leading, required this.actions}); + + final String title; + final Widget leading; + final List actions; + + @override + Widget build(BuildContext context) => SizedBox( + height: Theme.of(context).appBarTheme.toolbarHeight ?? kToolbarHeight, + child: AppBar( + primary: false, + leading: leading, + title: Text(title, overflow: TextOverflow.ellipsis), + actions: actions, + ), + ); +} diff --git a/app/lib/widgets/opds/opds_publication_details.dart b/app/lib/widgets/opds/opds_publication_details.dart new file mode 100644 index 0000000..1b38682 --- /dev/null +++ b/app/lib/widgets/opds/opds_publication_details.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/widgets/book_details/book_details_action_style.dart'; +import 'package:papyrus/widgets/book_details/book_details_tab_rail.dart'; +import 'package:papyrus/widgets/book_details/book_details_scroll_view.dart'; +import 'package:papyrus/widgets/opds/opds_publication_information.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/opds/opds_download_panel.dart'; +import 'package:papyrus/widgets/opds/opds_publication_tile.dart'; +import 'package:papyrus/widgets/opds/opds_sheet.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; + +/// Read-only catalog metadata; library ownership is established only by import. +class OpdsPublicationDetails extends StatelessWidget { + const OpdsPublicationDetails({ + super.key, + required this.catalog, + required this.publication, + required this.httpClient, + required this.downloads, + required this.onDownload, + required this.onNavigate, + this.credentials, + this.resolving = false, + }); + final OpdsCatalog catalog; + final OpdsPublication publication; + final OpdsHttpClient httpClient; + final OpdsDownloads downloads; + final OpdsCredentials? credentials; + final ValueChanged onDownload; + final ValueChanged onNavigate; + final bool resolving; + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: downloads, + builder: (context, _) => LayoutBuilder( + builder: (context, constraints) { + final desktop = constraints.maxWidth >= Breakpoints.desktopSmall; + final theme = Theme.of(context); + final bookId = downloads.libraryBookId(catalog, publication); + final actionStyle = bookDetailsActionStyle(context); + void showOptions() => showOpdsSheet( + context, + title: 'Download options', + child: _DownloadOptions( + catalog: catalog, + publication: publication, + downloads: downloads, + onDownload: onDownload, + onNavigate: onNavigate, + ), + ); + final cover = ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.lg), + child: OpdsCover( + catalog: catalog, + uri: publication.coverLink?.uri, + httpClient: httpClient, + credentials: credentials, + width: desktop ? 240 : 180, + height: desktop ? 360 : 270, + ), + ); + final metadata = Column( + crossAxisAlignment: desktop ? CrossAxisAlignment.start : CrossAxisAlignment.center, + children: [ + Text( + publication.title, + textAlign: desktop ? TextAlign.start : TextAlign.center, + style: (desktop ? theme.textTheme.displaySmall : theme.textTheme.headlineMedium)?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + publication.authors.isEmpty ? 'Unknown author' : publication.authors.join(', '), + textAlign: desktop ? TextAlign.start : TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: Spacing.md), + Wrap( + alignment: desktop ? WrapAlignment.start : WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + FilledButton.icon( + style: actionStyle, + onPressed: bookId != null + ? () => context.go('/library/details/${Uri.encodeComponent(bookId)}') + : resolving + ? null + : showOptions, + icon: Icon(bookId != null ? Icons.menu_book_outlined : Icons.add), + label: Text(bookId != null ? 'Open book' : 'Add to library'), + ), + if (bookId != null) + OutlinedButton( + style: actionStyle, + onPressed: resolving ? null : showOptions, + child: const Text('Download options'), + ), + ], + ), + ], + ); + final information = OpdsPublicationInformation(catalog: catalog, publication: publication); + if (!desktop) { + return DefaultTabController( + length: 1, + child: BookDetailsScrollView( + key: PageStorageKey('publication/${catalog.id}/${publication.id}'), + header: Padding( + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.lg, Spacing.md, Spacing.md), + child: Column( + children: [ + Center(child: cover), + const SizedBox(height: Spacing.md), + metadata, + ], + ), + ), + rail: const BookDetailsTabRail(tabs: [Tab(text: 'Details')]), + body: TabBarView( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + child: information, + ), + ], + ), + ), + ); + } + return SingleChildScrollView( + key: PageStorageKey('publication/${catalog.id}/${publication.id}'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + cover, + const SizedBox(width: Spacing.xl), + Expanded(child: metadata), + ], + ), + const SizedBox(height: Spacing.md), + const DefaultTabController( + length: 1, + child: BookDetailsTabRail(tabs: [Tab(text: 'Details')]), + ), + information, + const SizedBox(height: Spacing.xl), + ], + ), + ); + }, + ), + ); +} + +class _DownloadOptions extends StatefulWidget { + const _DownloadOptions({ + required this.catalog, + required this.publication, + required this.downloads, + required this.onDownload, + required this.onNavigate, + }); + final OpdsCatalog catalog; + final OpdsPublication publication; + final OpdsDownloads downloads; + final ValueChanged onDownload; + final ValueChanged onNavigate; + @override + State<_DownloadOptions> createState() => _DownloadOptionsState(); +} + +class _DownloadOptionsState extends State<_DownloadOptions> { + bool _showUnsupported = false; + + @override + Widget build(BuildContext context) { + final links = widget.publication.links.where((link) => link.isAcquisition); + final supported = links.where(OpdsDownloads.supports).toList(); + final unsupported = links.where((link) => !OpdsDownloads.supports(link)).toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(widget.publication.title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.xs), + const Text('Choose a format to add to your library.'), + const SizedBox(height: Spacing.sm), + AnimatedBuilder( + animation: widget.downloads, + builder: (_, _) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [for (final link in supported) _format(context, link)], + ), + ), + if (supported.isEmpty) const Text('No direct downloads are available for this publication.'), + if (widget.publication.detailLink != null) + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () { + Navigator.of(context).pop(); + widget.onNavigate(widget.publication.detailLink!.uri); + }, + icon: const Icon(Icons.arrow_forward), + label: const Text('Full catalog details'), + ), + ), + if (unsupported.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), + child: Semantics( + expanded: _showUnsupported, + child: TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(0, TouchTargets.mobileRecommended), + padding: const EdgeInsets.all(Spacing.sm), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.sm)), + foregroundColor: Theme.of(context).colorScheme.onSurface, + textStyle: Theme.of(context).textTheme.titleSmall, + alignment: Alignment.centerLeft, + ), + onPressed: () => setState(() => _showUnsupported = !_showUnsupported), + child: Row( + children: [ + Expanded(child: Text('Other catalog options (${unsupported.length})')), + const SizedBox(width: Spacing.md), + Icon( + _showUnsupported ? Icons.expand_less : Icons.expand_more, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + if (_showUnsupported) ...[ + const Text('These formats or acquisition methods cannot be imported on this device.'), + for (final link in unsupported) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.info_outline), + title: Text(link.title ?? link.type ?? 'Catalog acquisition'), + ), + ], + ], + ], + ); + } + + Widget _format(BuildContext context, OpdsLink link) { + final theme = Theme.of(context); + final key = OpdsDownloads.jobKey(widget.catalog, widget.publication, link); + final jobs = widget.downloads.jobs.where((job) => job.key == key); + final job = jobs.isEmpty ? null : jobs.first; + final format = link.supportedExtension!.toUpperCase(); + final bookId = + widget.downloads.libraryBookId(widget.catalog, widget.publication, link: link) ?? + (widget.downloads.library == null && job?.status == OpdsDownloadStatus.complete ? job?.bookId : null); + final complete = bookId != null; + final active = job?.isActive ?? false; + return Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.colorScheme.outlineVariant)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.listItemPaddingVertical), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LayoutBuilder( + builder: (context, constraints) { + final label = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(format, style: theme.textTheme.titleSmall), + if (link.title != null && link.title!.toUpperCase() != format) + Text( + link.title!, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ); + final button = FilledButton.icon( + style: FilledButton.styleFrom(minimumSize: const Size(0, 40)), + onPressed: active + ? null + : complete + ? () { + Navigator.of(context).pop(); + context.go('/library/details/${Uri.encodeComponent(bookId)}'); + } + : () => widget.onDownload(link), + icon: Icon(complete ? Icons.check : Icons.download_outlined, size: IconSizes.small), + label: Text( + complete + ? 'Open book' + : job?.error != null + ? 'Retry download' + : 'Download $format', + ), + ); + return constraints.maxWidth < 300 * MediaQuery.textScalerOf(context).scale(1) + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + label, + const SizedBox(height: Spacing.sm), + button, + ], + ) + : Row( + children: [ + Expanded(child: label), + const SizedBox(width: Spacing.md), + button, + ], + ); + }, + ), + if (active) ...[ + const SizedBox(height: Spacing.sm), + Row( + children: [ + Expanded(child: Text(opdsDownloadStatus(job!), style: theme.textTheme.bodySmall)), + if (job.isCancellable) + TextButton(onPressed: () => widget.downloads.cancel(job.key), child: const Text('Cancel')), + ], + ), + const SizedBox(height: Spacing.sm), + AppLinearProgressIndicator(value: job.progress), + ], + if (job?.error != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Text(job!.error!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/opds/opds_publication_information.dart b/app/lib/widgets/opds/opds_publication_information.dart new file mode 100644 index 0000000..8f768f5 --- /dev/null +++ b/app/lib/widgets/opds/opds_publication_information.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_publication_content.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book_details/book_details_sections.dart'; + +class OpdsPublicationInformation extends StatelessWidget { + const OpdsPublicationInformation({super.key, required this.catalog, required this.publication}); + final OpdsCatalog catalog; + final OpdsPublication publication; + + @override + Widget build(BuildContext context) { + final content = OpdsPublicationContent.from(catalog, publication); + final theme = Theme.of(context); + final description = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const BookDetailsSectionTitle('Description'), + const SizedBox(height: Spacing.sm), + Text( + content.description?.isNotEmpty == true ? content.description! : 'No description available.', + key: const Key('opds-description'), + style: content.description?.isNotEmpty == true + ? theme.textTheme.bodyLarge?.copyWith(height: 1.6) + : theme.textTheme.bodyMedium?.copyWith( + fontStyle: FontStyle.italic, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + final additionalDetails = [ + if (content.subjects.isNotEmpty) ...[ + const SizedBox(height: Spacing.lg), + const BookDetailsSectionTitle('Subjects'), + const SizedBox(height: Spacing.sm), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.xs, + children: [ + for (final subject in content.subjects) Chip(label: Text(subject), visualDensity: VisualDensity.compact), + ], + ), + ], + ]; + final information = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const BookDetailsSectionTitle('Information'), + const SizedBox(height: Spacing.sm), + BookMetadataRows(entries: content.information.entries.map((entry) => (entry.key, entry.value)).toList()), + ], + ); + return Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.md), + child: LayoutBuilder( + builder: (context, constraints) { + if (content.information.isEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [description, ...additionalDetails], + ); + } + if (constraints.maxWidth >= 700 * MediaQuery.textScalerOf(context).scale(1)) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [description, ...additionalDetails], + ), + ), + const SizedBox(width: Spacing.xxl), + Expanded(flex: 4, child: information), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + description, + const SizedBox(height: Spacing.lg), + information, + ...additionalDetails, + ], + ); + }, + ), + ); + } +} diff --git a/app/lib/widgets/opds/opds_publication_tile.dart b/app/lib/widgets/opds/opds_publication_tile.dart new file mode 100644 index 0000000..e926540 --- /dev/null +++ b/app/lib/widgets/opds/opds_publication_tile.dart @@ -0,0 +1,213 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book/cover_loading_placeholder.dart'; + +class OpdsPublicationTile extends StatelessWidget { + const OpdsPublicationTile({ + super.key, + required this.catalog, + required this.publication, + required this.onOpen, + required this.httpClient, + this.credentials, + this.isGridView = false, + this.inLibrary = false, + }); + final OpdsCatalog catalog; + final OpdsPublication publication; + final VoidCallback onOpen; + final OpdsHttpClient httpClient; + final OpdsCredentials? credentials; + final bool isGridView; + final bool inLibrary; + + Widget _cover({double width = double.infinity, double height = double.infinity}) => OpdsCover( + catalog: catalog, + uri: publication.images.isEmpty ? null : publication.images.first.uri, + credentials: credentials, + httpClient: httpClient, + width: width, + height: height, + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final acquisition = publication.links.where((link) => link.isAcquisition); + final formats = acquisition + .map((link) => link.supportedExtension?.toUpperCase()) + .whereType() + .toSet() + .join(' · '); + final edition = acquisition.isEmpty ? null : acquisition.first.title; + final formatCaption = edition ?? (formats.isEmpty ? 'View details' : formats); + final caption = inLibrary ? 'In library · $formatCaption' : formatCaption; + final info = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(publication.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall), + const SizedBox(height: Spacing.xs), + Text( + publication.authors.isEmpty ? 'Unknown author' : publication.authors.join(', '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: Spacing.xs), + Text( + caption, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.primary), + ), + ], + ); + return Material( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpen, + child: isGridView + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AspectRatio(aspectRatio: 2 / 3, child: _cover()), + Padding(padding: const EdgeInsets.all(Spacing.sm), child: info), + ], + ) + : Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Row( + children: [ + ClipRRect(borderRadius: BorderRadius.circular(AppRadius.sm), child: _cover(width: 60, height: 90)), + const SizedBox(width: Spacing.md), + Expanded(child: info), + const SizedBox(width: Spacing.sm), + const Icon(Icons.chevron_right, size: IconSizes.small), + ], + ), + ), + ), + ); + } +} + +class OpdsCover extends StatefulWidget { + const OpdsCover({ + super.key, + required this.catalog, + required this.uri, + required this.httpClient, + this.credentials, + this.width = 40, + this.height = 56, + }); + final OpdsCatalog catalog; + final Uri? uri; + final OpdsHttpClient httpClient; + final OpdsCredentials? credentials; + final double width; + final double height; + @override + State createState() => _OpdsCoverState(); +} + +class _OpdsCoverState extends State { + OpdsCancellation _token = OpdsCancellation(); + Uint8List? _bytes; + bool _loading = false; + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant OpdsCover oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.uri != widget.uri || + oldWidget.catalog != widget.catalog || + oldWidget.credentials != widget.credentials) { + _load(); + } + } + + Future _load() async { + _token.cancel(); + final token = _token = OpdsCancellation(); + _bytes = null; + _loading = widget.uri != null && ['http', 'https'].contains(widget.uri!.scheme); + if (!_loading) return; + final cache = widget.httpClient.cache; + OpdsCacheToken? cacheToken; + try { + cacheToken = cache?.capture(widget.catalog, widget.uri!); + final cached = cacheToken == null ? null : cache?.read(cacheToken); + if (cached != null && cached.response.headers['content-type']?.startsWith('image/') == true) { + _bytes = cached.response.bytes; + _loading = false; + if (DateTime.now().difference(cached.fetchedAt) < const Duration(days: 1)) return; + } + final response = await widget.httpClient.get( + widget.catalog, + widget.uri!, + credentials: widget.credentials, + cancellation: token, + ); + if (!mounted || token.isCancelled || (cacheToken != null && !cache!.isCurrent(cacheToken))) return; + setState(() => _bytes = response.bytes); + if (cacheToken != null && response.headers['content-type']?.startsWith('image/') == true) { + await cache!.write(cacheToken, response); + } + } on OpdsAuthorizationException { + if (mounted && !token.isCancelled) setState(() => _bytes = null); + if (cacheToken != null) await cache!.remove(cacheToken); + } catch (_) { + // Catalog artwork is optional; keep the themed cover placeholder. + } finally { + if (mounted && !token.isCancelled && _loading) setState(() => _loading = false); + } + } + + @override + void dispose() { + _token.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final placeholder = ColoredBox( + color: colors.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.menu_book, + size: widget.width <= 60 ? IconSizes.medium : IconSizes.display, + color: colors.onSurfaceVariant.withValues(alpha: 0.5), + ), + ), + ); + return SizedBox( + width: widget.width, + height: widget.height, + child: _loading + ? const CoverLoadingPlaceholder() + : _bytes == null + ? placeholder + : Image.memory( + _bytes!, + fit: BoxFit.cover, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) => + wasSynchronouslyLoaded || frame != null ? child : const CoverLoadingPlaceholder(), + errorBuilder: (_, _, _) => placeholder, + ), + ); + } +} diff --git a/app/lib/widgets/opds/opds_sheet.dart b/app/lib/widgets/opds/opds_sheet.dart new file mode 100644 index 0000000..f7068bc --- /dev/null +++ b/app/lib/widgets/opds/opds_sheet.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; + +Future showOpdsSheet( + BuildContext context, { + required String title, + required Widget child, + VoidCallback? onSave, + String saveLabel = 'Save', + String cancelLabel = 'Close', + bool canSave = true, + bool canCancel = true, + bool scrollable = true, + EdgeInsetsGeometry contentPadding = const EdgeInsets.all(Spacing.lg), +}) { + final reduceAnimations = AppMotion.disabled(context); + return showModalBottomSheet( + context: context, + useRootNavigator: true, + useSafeArea: true, + isScrollControlled: true, + showDragHandle: false, + sheetAnimationStyle: AppMotion.animationStyle(context), + builder: (_) => AppMotionScope( + reduceAnimations: reduceAnimations, + child: OpdsSheet( + title: title, + onSave: onSave, + saveLabel: saveLabel, + cancelLabel: cancelLabel, + canSave: canSave, + canCancel: canCancel, + scrollable: scrollable, + contentPadding: contentPadding, + child: child, + ), + ), + ); +} + +/// A keyboard-safe sheet with a fixed title, close control, and action footer. +class OpdsSheet extends StatelessWidget { + const OpdsSheet({ + super.key, + required this.title, + required this.child, + this.onSave, + this.saveLabel = 'Save', + this.cancelLabel = 'Close', + this.canSave = true, + this.canCancel = true, + this.scrollable = true, + this.contentPadding = const EdgeInsets.all(Spacing.lg), + }); + + final String title; + final Widget child; + final VoidCallback? onSave; + final String saveLabel; + final String cancelLabel; + final bool canSave; + final bool canCancel; + final bool scrollable; + final EdgeInsetsGeometry contentPadding; + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * .85), + child: Material( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: LayoutBuilder( + builder: (context, constraints) { + final compactHeight = constraints.maxHeight < 280; + final verticalPadding = compactHeight ? 0.0 : Spacing.md; + void close() => Navigator.of(context).pop(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + key: const Key('opds-sheet-header'), + padding: EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: verticalPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const BottomSheetHandle(), + SizedBox(height: compactHeight ? 0 : Spacing.lg), + Row( + children: [ + Expanded( + child: Text( + title, + maxLines: compactHeight ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + IconButton( + tooltip: 'Close', + onPressed: canCancel ? close : null, + icon: const Icon(Icons.close), + ), + ], + ), + ], + ), + ), + const Divider(height: 1), + Flexible( + child: scrollable + ? SingleChildScrollView(padding: contentPadding, child: child) + : Padding(padding: contentPadding, child: child), + ), + Container( + key: const Key('opds-sheet-footer'), + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: verticalPadding), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant)), + ), + child: Wrap( + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Spacing.md, + runSpacing: Spacing.sm, + children: [ + TextButton(onPressed: canCancel ? close : null, child: Text(cancelLabel)), + if (onSave != null) FilledButton(onPressed: canSave ? onSave : null, child: Text(saveLabel)), + ], + ), + ), + ], + ); + }, + ), + ), + ), + ), + ); +} diff --git a/app/lib/widgets/shared/bottom_sheet_header.dart b/app/lib/widgets/shared/bottom_sheet_header.dart index 1e7e2d9..14c0c9c 100644 --- a/app/lib/widgets/shared/bottom_sheet_header.dart +++ b/app/lib/widgets/shared/bottom_sheet_header.dart @@ -12,6 +12,8 @@ class BottomSheetHeader extends StatelessWidget { final Key? saveButtonKey; final bool canCancel; final bool canSave; + final String cancelLabel; + final bool stacked; const BottomSheetHeader({ super.key, @@ -22,34 +24,45 @@ class BottomSheetHeader extends StatelessWidget { this.saveButtonKey, this.canCancel = true, this.canSave = true, + this.cancelLabel = 'Cancel', + this.stacked = false, }); @override Widget build(BuildContext context) { + final titleWidget = Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ); + final cancelButton = TextButton(onPressed: canCancel ? onCancel : null, child: Text(cancelLabel)); + final saveButton = onSave == null + ? const SizedBox.shrink() + : FilledButton(key: saveButtonKey, onPressed: canSave ? onSave : null, child: Text(saveLabel)); + if (stacked) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + titleWidget, + const SizedBox(height: 8), + Wrap( + alignment: WrapAlignment.spaceBetween, + spacing: 8, + children: [cancelButton, if (onSave != null) saveButton], + ), + ], + ); + } return Row( children: [ Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: TextButton(onPressed: canCancel ? onCancel : null, child: const Text('Cancel')), - ), + child: Align(alignment: Alignment.centerLeft, child: cancelButton), ), + Expanded(child: titleWidget), Expanded( - child: Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Expanded( - child: Align( - alignment: Alignment.centerRight, - child: onSave == null - ? const SizedBox.shrink() - : FilledButton(key: saveButtonKey, onPressed: canSave ? onSave : null, child: Text(saveLabel)), - ), + child: Align(alignment: Alignment.centerRight, child: saveButton), ), ], ); diff --git a/app/lib/widgets/shell/adaptive_app_shell.dart b/app/lib/widgets/shell/adaptive_app_shell.dart index cd9b15b..e411bf7 100644 --- a/app/lib/widgets/shell/adaptive_app_shell.dart +++ b/app/lib/widgets/shell/adaptive_app_shell.dart @@ -2,9 +2,11 @@ import 'package:papyrus/themes/app_motion.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shell/desktop_sidebar.dart'; import 'package:papyrus/widgets/shell/mobile_bottom_nav.dart'; +import 'package:papyrus/widgets/shell/nav_item_count.dart'; import 'package:provider/provider.dart'; /// Navigation item for the app shell. @@ -34,7 +36,7 @@ class AdaptiveAppShell extends StatelessWidget { const AdaptiveAppShell({super.key, required this.child}); - static List buildNavItems(DataStore dataStore) { + static List buildNavItems(DataStore dataStore, {required int catalogCount}) { return [ const AppShellNavItem( path: '/dashboard', @@ -62,6 +64,13 @@ class AdaptiveAppShell extends StatelessWidget { count: dataStore.shelves.length, selectedIcon: Icons.shelves, ), + AppShellNavItem( + path: '/library/catalogs', + label: 'Catalogs', + icon: Icons.public_outlined, + selectedIcon: Icons.public, + count: catalogCount, + ), AppShellNavItem( path: '/library/bookmarks', label: 'Bookmarks', @@ -104,7 +113,8 @@ class AdaptiveAppShell extends StatelessWidget { @override Widget build(BuildContext context) { final dataStore = context.watch(); - final navItems = buildNavItems(dataStore); + final catalogCount = context.select((catalogs) => catalogs.catalogs.length); + final navItems = buildNavItems(dataStore, catalogCount: catalogCount); final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth >= Breakpoints.desktopSmall; @@ -172,6 +182,7 @@ class AdaptiveAppShell extends StatelessWidget { return ListTile( leading: Icon(isSelected ? item.selectedIcon ?? item.icon : item.icon), title: Text(item.label), + trailing: (item.count ?? 0) > 0 ? NavItemCount(count: item.count!, selected: isSelected) : null, selected: isSelected, onTap: () { Navigator.of(context).pop(); diff --git a/app/lib/widgets/shell/desktop_sidebar.dart b/app/lib/widgets/shell/desktop_sidebar.dart index 3f24176..e426f44 100644 --- a/app/lib/widgets/shell/desktop_sidebar.dart +++ b/app/lib/widgets/shell/desktop_sidebar.dart @@ -6,6 +6,7 @@ import 'package:papyrus/providers/sidebar_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/navigation_utils.dart'; import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; +import 'package:papyrus/widgets/shell/nav_item_count.dart'; import 'package:provider/provider.dart'; /// Desktop sidebar with collapsible navigation. @@ -288,26 +289,10 @@ class DesktopSidebar extends StatelessWidget { ), ), ), - const SizedBox(width: Spacing.xs), - - if (item.count != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: 2), - decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.12) - : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(AppRadius.full), - ), - child: Text( - item.count.toString(), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: isSelected - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + if ((item.count ?? 0) > 0) ...[ + const SizedBox(width: Spacing.xs), + NavItemCount(count: item.count!, selected: isSelected), + ], ], ), ), diff --git a/app/lib/widgets/shell/nav_item_count.dart b/app/lib/widgets/shell/nav_item_count.dart new file mode 100644 index 0000000..f637a96 --- /dev/null +++ b/app/lib/widgets/shell/nav_item_count.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +/// Count badge shared by the desktop sidebar and mobile library drawer. +class NavItemCount extends StatelessWidget { + const NavItemCount({super.key, required this.count, required this.selected}); + + final int count; + final bool selected; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final foreground = selected ? colors.onPrimaryContainer : colors.onSurfaceVariant; + return Container( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: 2), + decoration: BoxDecoration( + color: foreground.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text('$count', style: Theme.of(context).textTheme.labelSmall?.copyWith(color: foreground)), + ); + } +} diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart index 9a89d23..73e1151 100644 --- a/app/test/media/media_profile_switch_contract_test.dart +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -3,6 +3,21 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('OPDS catalogs become available only after the target database activates', () { + final source = File('lib/main.dart').readAsStringSync(); + final activation = source.substring( + source.indexOf('Future _applyPowerSyncAuthState()'), + source.indexOf('void _clearAuthStateOperation'), + ); + expect( + activation.indexOf('_opdsCatalogs.setScope(scope.persistenceKey)'), + greaterThan(activation.indexOf('await _powerSyncService.activateAuthenticated')), + ); + expect( + activation.indexOf('_opdsCatalogs.setScope(MediaStorageScope.localGuest.persistenceKey)'), + greaterThan(activation.indexOf('await _powerSyncService.activateGuest')), + ); + }); test('profile switch publishes its key with the replacement repository', () { final source = File('lib/main.dart').readAsStringSync(); final handler = source.substring( @@ -52,21 +67,23 @@ void main() { commitStart, importSource.indexOf('/// Opens the processing step', commitStart), ); - expect(commit, contains('final accountScope = isOnlineAccount ? queue.activeScope : null;')); - expect(commit, contains("throw StateError('Cannot import account media without an active media storage scope')")); - expect(commit, contains('storePendingCover: importService.storePendingCoverFile')); - expect(commit, contains('storeGuestCover: importService.storeGuestCoverFile')); - expect(commit, contains('deletePendingCover: importService.deletePendingCoverFile')); - expect(commit, contains('deleteGuestCover: importService.deleteGuestCoverFile')); - expect(commit, contains('final bookRepository = dataStore.requireBookRepository();')); - expect(commit, contains('dataStore.addBookToRepositoryAndWait(bookRepository, book)')); - expect(commit, contains('dataStore.deleteBookFromRepositoryAndWait(bookRepository, bookId)')); - expect(commit, contains('enqueueImportedBookMedia: queue.enqueueImportedBookMedia')); - expect(commit, contains('isLibraryContextCurrent: ()')); - expect(commit, contains('dataStore.isBookRepositoryCurrent(bookRepository)')); - expect(commit, contains('queue.activeScope == accountScope')); - expect(commit, contains('accountScope: accountScope')); - expect(commit, isNot(contains('bytesToDataUri'))); + expect(commit, contains('BookImportSession.fromContext(context).commit(result, sourceFilename)')); + final session = File('lib/services/book_import_session.dart').readAsStringSync(); + expect(session, contains('final scope = authenticated ? queue.activeScope : null;')); + expect(session, contains("throw StateError('Cannot import account media without an active media storage scope')")); + expect(session, contains('storePendingCover: importService.storePendingCoverFile')); + expect(session, contains('storeGuestCover: importService.storeGuestCoverFile')); + expect(session, contains('deletePendingCover: importService.deletePendingCoverFile')); + expect(session, contains('deleteGuestCover: importService.deleteGuestCoverFile')); + expect(session, contains('final repository = dataStore.requireBookRepository();')); + expect(session, contains('dataStore.addBookToRepositoryAndWait(repository, book)')); + expect(session, contains('dataStore.deleteBookFromRepositoryAndWait(repository, id)')); + expect(session, contains('enqueueImportedBookMedia: queue.enqueueImportedBookMedia')); + expect(session, contains('isLibraryContextCurrent: isCurrent')); + expect(session, contains('dataStore.isBookRepositoryCurrent(repository)')); + expect(session, contains('queue.activeScope == scope')); + expect(session, contains('accountScope: scope')); + expect(session, isNot(contains('bytesToDataUri'))); }); test('import commit guard prevents repeat commits and disables mutable actions', () { diff --git a/app/test/opds/browser_test.dart b/app/test/opds/browser_test.dart new file mode 100644 index 0000000..8f0d5b0 --- /dev/null +++ b/app/test/opds/browser_test.dart @@ -0,0 +1,295 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:papyrus/opds/opds_browser.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'relay_fixture_client.dart'; + +void main() { + final catalog = OpdsCatalog(id: 'c', name: 'Books', uri: Uri.parse('https://books.test/feed')); + Future warmCache() async { + SharedPreferences.setMockInitialValues({}); + final cache = OpdsResourceCache(await SharedPreferences.getInstance())..setScope('alice'); + await cache.write( + cache.capture(catalog, catalog.uri)!, + OpdsResponse( + uri: Uri.parse('https://cdn.test/redirect/feed'), + bytes: Uint8List.fromList( + utf8.encode('{"metadata":{"title":"Cached"},"navigation":[{"title":"Next","href":"next"}]}'), + ), + headers: {'content-type': 'application/opds+json'}, + ), + ); + return cache; + } + + test('shows cached feed immediately with redirect base while refreshing', () async { + final cache = await warmCache(); + final response = Completer(); + final browser = OpdsBrowser( + httpClient: OpdsHttpClient(cache: cache, clientFactory: () => MockRelayClient((_) => response.future)), + ); + final load = browser.load(catalog, catalog.uri); + expect(browser.feed?.title, 'Cached'); + expect(browser.feed?.navigation.single.uri.toString(), 'https://cdn.test/redirect/next'); + expect(browser.loading, isTrue); + expect(browser.isCached, isTrue); + expect(browser.fetchedAt, isNotNull); + response.complete(http.Response('{"metadata":{"title":"Fresh"},"navigation":[]}', 200)); + await load; + expect(browser.feed?.title, 'Fresh'); + expect(browser.isCached, isFalse); + expect(browser.loading, isFalse); + expect(cache.read(cache.capture(catalog, catalog.uri)!)!.response.text, contains('Fresh')); + browser.dispose(); + }); + + for (final status in [500, 401, 403]) { + test('revalidation HTTP $status ${status == 500 ? 'retains' : 'invalidates'} protected cached feed', () async { + final cache = await warmCache(); + final browser = OpdsBrowser( + httpClient: OpdsHttpClient( + cache: cache, + clientFactory: () => MockRelayClient((_) async => http.Response('failure', status)), + ), + ); + await browser.load(catalog, catalog.uri); + expect(browser.error, isNotNull); + expect(browser.feed?.title, status == 500 ? 'Cached' : null); + expect(cache.read(cache.capture(catalog, catalog.uri)!), status == 500 ? isNotNull : isNull); + browser.dispose(); + }); + } + + test('scope A to B to A rejects a late feed and cache write', () async { + final cache = await warmCache(); + final response = Completer(); + final browser = OpdsBrowser( + httpClient: OpdsHttpClient(cache: cache, clientFactory: () => MockRelayClient((_) => response.future)), + ); + final load = browser.load(catalog, catalog.uri); + await Future.delayed(Duration.zero); + cache.setScope('bob'); + cache.setScope('alice'); + response.complete(http.Response('{"metadata":{"title":"Obsolete"},"navigation":[]}', 200)); + await load; + expect(browser.feed, isNull); + expect(cache.read(cache.capture(catalog, catalog.uri)!)!.response.text, contains('Cached')); + browser.dispose(); + }); + + test('one browser authorization failure immediately clears another idle feed', () async { + final cache = await warmCache(); + final client = OpdsHttpClient( + cache: cache, + clientFactory: () => MockRelayClient( + (request) async => request.url.path == '/details' + ? http.Response('denied', 401) + : http.Response('{"metadata":{"title":"Private"},"navigation":[]}', 200), + ), + ); + final parent = OpdsBrowser(httpClient: client); + final details = OpdsBrowser(httpClient: client); + await parent.load(catalog, catalog.uri); + expect(parent.feed?.title, 'Private'); + var notifications = 0; + parent.addListener(() => notifications++); + await details.load(catalog, catalog.uri.resolve('details')); + expect(parent.feed, isNull); + expect(parent.error, contains('Refresh')); + expect(parent.loading, isFalse); + expect(parent.cacheInvalidated, isTrue); + expect(notifications, 1); + expect(details.authorizationFailed, isTrue); + expect(details.error, contains('credentials')); + await parent.load(catalog, catalog.uri); + expect(parent.feed?.title, 'Private'); + expect(parent.cacheInvalidated, isFalse); + parent.dispose(); + details.dispose(); + }); + + test('scope switch clears an idle feed immediately and unsubscribes on disposal', () async { + final cache = await warmCache(); + final browser = OpdsBrowser( + httpClient: OpdsHttpClient( + cache: cache, + clientFactory: () => + MockRelayClient((_) async => http.Response('{"metadata":{"title":"Private"},"navigation":[]}', 200)), + ), + ); + await browser.load(catalog, catalog.uri); + var notifications = 0; + browser.addListener(() => notifications++); + cache.setScope('bob'); + expect(browser.feed, isNull); + expect(browser.error, contains('Refresh')); + expect(browser.loading, isFalse); + expect(browser.cacheInvalidated, isTrue); + expect(notifications, 1); + browser.dispose(); + cache.setScope('alice'); + expect(notifications, 1); + }); + + test('simultaneous authorization failures mark both browser contexts invalid', () async { + final cache = await warmCache(); + final parentResponse = Completer(); + final detailResponse = Completer(); + final client = OpdsHttpClient( + cache: cache, + clientFactory: () => + MockRelayClient((request) => request.url.path == '/details' ? detailResponse.future : parentResponse.future), + ); + final parent = OpdsBrowser(httpClient: client); + final details = OpdsBrowser(httpClient: client); + final parentLoad = parent.load(catalog, catalog.uri); + final detailLoad = details.load(catalog, catalog.uri.resolve('details')); + await Future.delayed(Duration.zero); + parentResponse.complete(http.Response('denied', 401)); + await parentLoad; + expect(parent.authorizationFailed, isTrue); + expect(details.cacheInvalidated, isTrue); + detailResponse.complete(http.Response('denied', 401)); + await detailLoad; + expect(parent.feed, isNull); + expect(details.feed, isNull); + expect(details.loading, isFalse); + expect(details.error, isNotNull); + parent.dispose(); + details.dispose(); + }); + + test('invalidating another catalog retains the loaded feed', () async { + final cache = await warmCache(); + final browser = OpdsBrowser( + httpClient: OpdsHttpClient( + cache: cache, + clientFactory: () => + MockRelayClient((_) async => http.Response('{"metadata":{"title":"Private"},"navigation":[]}', 200)), + ), + ); + await browser.load(catalog, catalog.uri); + await cache.invalidateCatalog(OpdsCatalog(id: 'another', name: 'Other', uri: catalog.uri)); + expect(browser.feed?.title, 'Private'); + expect(browser.cacheInvalidated, isFalse); + browser.dispose(); + }); + + test('unsupported responses never replace the cached parsed feed', () async { + final cache = await warmCache(); + final browser = OpdsBrowser( + httpClient: OpdsHttpClient( + cache: cache, + clientFactory: () => MockRelayClient((_) async => http.Response('Login', 200)), + ), + ); + await browser.load(catalog, catalog.uri); + expect(browser.feed?.title, 'Cached'); + expect(browser.error, isNotNull); + expect(cache.read(cache.capture(catalog, catalog.uri)!)!.response.text, contains('Cached')); + final other = catalog.uri.resolve('uncached'); + await browser.load(catalog, other); + expect(browser.feed, isNull); + expect(cache.read(cache.capture(catalog, other)!), isNull); + browser.dispose(); + }); + + test('root search and OpenSearch remain usable offline with redirected bases', () async { + final cache = await warmCache(); + Future store(Uri requested, Uri finalUri, String body) => cache.write( + cache.capture(catalog, requested)!, + OpdsResponse(uri: finalUri, bytes: Uint8List.fromList(utf8.encode(body)), headers: {}), + ); + await store( + catalog.uri, + Uri.parse('https://cdn.test/base/feed'), + '{"metadata":{"title":"Root"},"links":[{"rel":"search","href":"search.xml","type":"application/opensearchdescription+xml"}],"navigation":[]}', + ); + await store( + Uri.parse('https://cdn.test/base/search.xml'), + Uri.parse('https://search.test/redirect/description.xml'), + '', + ); + var requests = 0; + final browser = OpdsBrowser( + httpClient: OpdsHttpClient( + cache: cache, + clientFactory: () => MockRelayClient((_) async { + requests++; + throw StateError('offline'); + }), + ), + ); + await browser.load(catalog, catalog.uri.resolve('section')); + expect((await browser.search('some books')).toString(), 'https://search.test/redirect/results?q=some%20books'); + expect(requests, 1); + browser.dispose(); + }); + + test('authorization failure resolving search clears the previous feed', () async { + final cache = await warmCache(); + final browser = OpdsBrowser( + httpClient: OpdsHttpClient( + cache: cache, + clientFactory: () => MockRelayClient( + (request) async => request.url.path == '/section' + ? http.Response('{"metadata":{"title":"Private"},"navigation":[]}', 200) + : http.Response('denied', 403), + ), + ), + ); + // Remove the cached root so resolving search must ask the server. + await cache.remove(cache.capture(catalog, catalog.uri)!); + await browser.load(catalog, catalog.uri.resolve('section')); + expect(browser.feed?.title, 'Private'); + await expectLater(browser.search('books'), throwsA(isA())); + expect(browser.feed, isNull); + expect(browser.authorizationFailed, isTrue); + browser.dispose(); + }); + test('late feed results cannot replace the current navigation', () async { + final oldResponse = Completer(); + final browser = OpdsBrowser( + httpClient: OpdsHttpClient( + clientFactory: () => MockRelayClient((request) async { + if (request.url.path == '/old') return oldResponse.future; + return http.Response('{"metadata":{"title":"New"},"navigation":[]}', 200); + }), + ), + ); + final old = browser.load(catalog, Uri.parse('https://books.test/old')); + await Future.delayed(Duration.zero); + await browser.load(catalog, Uri.parse('https://books.test/new')); + oldResponse.complete(http.Response('{"metadata":{"title":"Old"},"navigation":[]}', 200)); + await old; + expect(browser.feed?.title, 'New'); + browser.dispose(); + }); + + test('resolves root search while browsing a subsection', () async { + final browser = OpdsBrowser( + httpClient: OpdsHttpClient( + clientFactory: () => MockRelayClient((request) async { + return http.Response( + request.url.path == '/feed' + ? '{"metadata":{"title":"Root"},"links":[{"rel":"search","href":"search{?query}","templated":true,"type":"application/opds+json"}],"navigation":[]}' + : '{"metadata":{"title":"Section"},"navigation":[]}', + 200, + ); + }), + ), + ); + await browser.load(catalog, Uri.parse('https://books.test/section')); + final uri = await browser.search('a & b'); + expect(uri.queryParameters['query'], 'a & b'); + browser.dispose(); + }); +} diff --git a/app/test/opds/catalog_store_test.dart b/app/test/opds/catalog_store_test.dart new file mode 100644 index 0000000..d815c86 --- /dev/null +++ b/app/test/opds/catalog_store_test.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +// The platform fake exercises the real SharedPreferences cache and write path. +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; + +class MemorySecrets implements OpdsSecretStorage { + final values = {}; + @override + Future read(String key) async => values[key]; + @override + Future write(String key, String value) async { + values[key] = value; + } + + @override + Future delete(String key) async { + values.remove(key); + } +} + +class _FailingPreferences extends InMemorySharedPreferencesStore { + _FailingPreferences() : super.empty(); + + bool rejectWrites = false; + Object? writeError; + + @override + Future setValue(String valueType, String key, Object value) async { + if (writeError != null) throw writeError!; + if (rejectWrites) return false; + return super.setValue(valueType, key, value); + } +} + +class _FailingSecrets extends MemorySecrets { + Object? writeError; + + @override + Future write(String key, String value) async { + if (writeError != null) throw writeError!; + await super.write(key, value); + } +} + +void main() { + const scope = 'guest'; + const catalogKey = 'papyrus.opds.catalogs.guest'; + const secretKey = '$catalogKey.one.credentials'; + final originA = OpdsCatalog(id: 'one', name: 'One', uri: Uri.parse('https://one.test/feed')); + final originB = OpdsCatalog(id: 'one', name: 'Two', uri: Uri.parse('https://two.test/feed')); + + Future failingPreferences(_FailingPreferences platform) async { + SharedPreferences.setMockInitialValues({}); + SharedPreferencesStorePlatform.instance = platform; + return SharedPreferences.getInstance(); + } + + test('isolates catalogs and credentials by account, and deletes both', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final secrets = MemorySecrets(); + final store = OpdsCatalogStore(prefs, secrets: secrets); + final catalog = OpdsCatalog(id: 'one', name: 'Private', uri: Uri.parse('https://books.test/feed')); + await store.save( + 'server--alice', + catalog, + credentials: const OpdsCredentials(username: 'alice', password: 'secret'), + ); + expect(store.load('server--alice').single.name, 'Private'); + expect(store.load('server--bob'), isEmpty); + expect(store.load('local--guest'), isEmpty); + expect((await store.credentials('server--alice', 'one'))?.password, 'secret'); + expect(await store.credentials('server--bob', 'one'), isNull); + expect(prefs.getKeys().map(prefs.get).join(), isNot(contains('secret'))); + await store.remove('server--alice', 'one'); + expect(store.load('server--alice'), isEmpty); + expect(secrets.values, isEmpty); + }); + + test('changing a catalog origin clears saved credentials', () async { + SharedPreferences.setMockInitialValues({}); + final store = OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets()); + await store.save( + 'guest', + OpdsCatalog(id: 'one', name: 'One', uri: Uri.parse('https://one.test')), + credentials: const OpdsCredentials(username: 'u', password: 'p'), + ); + await store.save('guest', OpdsCatalog(id: 'one', name: 'Two', uri: Uri.parse('https://two.test'))); + expect(await store.credentials('guest', 'one'), isNull); + }); + + test('credentials are bound to their saved origin and refuse a mismatched catalog', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final secrets = MemorySecrets(); + final store = OpdsCatalogStore(prefs, secrets: secrets); + await store.save( + scope, + originA, + credentials: const OpdsCredentials(username: 'alice', password: 'origin-a'), + ); + expect((jsonDecode(secrets.values[secretKey]!) as Map)['origin'], originA.uri.origin); + await prefs.setString(catalogKey, jsonEncode([originB.toJson()])); + expect(await OpdsCatalogStore(prefs, secrets: secrets).credentials(scope, 'one'), isNull); + await prefs.setString(catalogKey, '[]'); + expect(await store.credentials(scope, 'one'), isNull); + }); + + test('legacy secrets without an origin are not returned', () async { + SharedPreferences.setMockInitialValues({ + catalogKey: jsonEncode([originA.toJson()]), + }); + final prefs = await SharedPreferences.getInstance(); + final secrets = MemorySecrets()..values[secretKey] = jsonEncode({'username': 'alice', 'password': 'unbound'}); + expect(await OpdsCatalogStore(prefs, secrets: secrets).credentials(scope, 'one'), isNull); + }); + + for (final throwsOnWrite in [false, true]) { + final mode = throwsOnWrite ? 'throws' : 'returns false'; + test('save restores catalog cache and old credentials when preferences $mode', () async { + final platform = _FailingPreferences(); + final prefs = await failingPreferences(platform); + final secrets = MemorySecrets(); + final store = OpdsCatalogStore(prefs, secrets: secrets); + await store.save( + scope, + originA, + credentials: const OpdsCredentials(username: 'alice', password: 'origin-a'), + ); + final originalJson = prefs.getString(catalogKey); + final error = StateError('Preference save failed'); + platform.rejectWrites = !throwsOnWrite; + platform.writeError = throwsOnWrite ? error : null; + + await expectLater( + store.save( + scope, + originB, + credentials: const OpdsCredentials(username: 'bob', password: 'origin-b'), + ), + throwsOnWrite ? throwsA(same(error)) : throwsA(isA()), + ); + expect(prefs.getString(catalogKey), originalJson); + final reloaded = OpdsCatalogStore(prefs, secrets: secrets); + expect(reloaded.load(scope).single.uri, originA.uri); + expect((await reloaded.credentials(scope, 'one'))?.password, 'origin-a'); + + // Even a different writer changing the cached catalog cannot rebind A's secret. + platform.rejectWrites = false; + platform.writeError = null; + await prefs.setString(catalogKey, jsonEncode([originB.toJson()])); + expect(await reloaded.credentials(scope, 'one'), isNull); + }); + + test('failed remove keeps catalog and credentials when preferences $mode', () async { + final platform = _FailingPreferences(); + final prefs = await failingPreferences(platform); + final secrets = MemorySecrets(); + final store = OpdsCatalogStore(prefs, secrets: secrets); + await store.save( + scope, + originA, + credentials: const OpdsCredentials(username: 'alice', password: 'origin-a'), + ); + final originalJson = prefs.getString(catalogKey); + final error = StateError('Preference removal failed'); + platform.rejectWrites = !throwsOnWrite; + platform.writeError = throwsOnWrite ? error : null; + + await expectLater( + store.remove(scope, 'one'), + throwsOnWrite ? throwsA(same(error)) : throwsA(isA()), + ); + expect(prefs.getString(catalogKey), originalJson); + final reloaded = OpdsCatalogStore(prefs, secrets: secrets); + expect(reloaded.load(scope).single.uri, originA.uri); + expect((await reloaded.credentials(scope, 'one'))?.password, 'origin-a'); + }); + + test('failed initial save removes new catalog and secret when preferences $mode', () async { + final platform = _FailingPreferences(); + final prefs = await failingPreferences(platform); + final secrets = MemorySecrets(); + final store = OpdsCatalogStore(prefs, secrets: secrets); + platform.rejectWrites = !throwsOnWrite; + platform.writeError = throwsOnWrite ? StateError('Preference save failed') : null; + await expectLater( + store.save( + scope, + originA, + credentials: const OpdsCredentials(username: 'alice', password: 'origin-a'), + ), + throwsA(anything), + ); + expect(prefs.containsKey(catalogKey), isFalse); + expect(store.load(scope), isEmpty); + expect(secrets.values, isEmpty); + }); + } + + test('rollback attempts both stores and preserves the original error if compensation fails', () async { + final platform = _FailingPreferences(); + final prefs = await failingPreferences(platform); + final secrets = _FailingSecrets(); + final store = OpdsCatalogStore(prefs, secrets: secrets); + await store.save( + scope, + originA, + credentials: const OpdsCredentials(username: 'alice', password: 'origin-a'), + ); + final originalJson = prefs.getString(catalogKey); + final originalError = StateError('Preference save failed'); + platform.writeError = originalError; + secrets.writeError = StateError('Secret rollback failed'); + await expectLater(store.save(scope, originB), throwsA(same(originalError))); + expect(prefs.getString(catalogKey), originalJson); + expect(await store.credentials(scope, 'one'), isNull); + }); +} diff --git a/app/test/opds/catalogs_page_test.dart b/app/test/opds/catalogs_page_test.dart new file mode 100644 index 0000000..df923b7 --- /dev/null +++ b/app/test/opds/catalogs_page_test.dart @@ -0,0 +1,1263 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:papyrus/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; +import 'package:papyrus/pages/catalogs_page.dart'; +import 'package:papyrus/pages/catalog_book_page.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/opds/catalog_source_tile.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'catalog_store_test.dart' show MemorySecrets; +import 'relay_fixture_client.dart'; + +class _WidgetCatalogStore extends OpdsCatalogStore { + _WidgetCatalogStore(super.prefs) : super(secrets: MemorySecrets()); + String? saveFailure; + String? removeFailure; + String? loadFailure; + + @override + List load(String scope) { + if (loadFailure != null) throw OpdsException(loadFailure!); + return super.load(scope); + } + + @override + Future save(String scope, OpdsCatalog catalog, {OpdsCredentials? credentials, bool clearCredentials = false}) { + if (saveFailure != null) throw OpdsException(saveFailure!); + return super.save(scope, catalog, credentials: credentials, clearCredentials: clearCredentials); + } + + @override + Future remove(String scope, String id) { + if (removeFailure != null) throw OpdsException(removeFailure!); + return super.remove(scope, id); + } +} + +class _CatalogPageHarness { + _CatalogPageHarness(this.store, this.catalogs); + final _WidgetCatalogStore store; + final OpdsCatalogs catalogs; + final requests = []; + int importAttempts = 0; + late final OpdsDownloads downloads; + late final GoRouter router; + + static Future<_CatalogPageHarness> mount( + WidgetTester tester, + FutureOr Function(Uri uri) respond, { + String initialLocation = '/library/catalogs/one', + Future Function(OpdsResourceCache cache, OpdsCatalog catalog)? prepareCache, + }) async { + final previousUrlReflection = GoRouter.optionURLReflectsImperativeAPIs; + GoRouter.optionURLReflectsImperativeAPIs = true; + addTearDown(() => GoRouter.optionURLReflectsImperativeAPIs = previousUrlReflection); + tester.view.physicalSize = const Size(1100, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({}); + final store = _WidgetCatalogStore(await SharedPreferences.getInstance()); + await store.save( + 'local--guest', + OpdsCatalog(id: 'one', name: 'My catalog', uri: Uri.parse('https://books.test/feed')), + ); + final cache = prepareCache == null ? null : OpdsResourceCache(await SharedPreferences.getInstance()); + final catalogs = OpdsCatalogs(store, cache: cache)..setScope('local--guest'); + if (cache != null) await prepareCache!(cache, catalogs.find('one')!); + final harness = _CatalogPageHarness(store, catalogs); + final gateway = OpdsHttpClient( + cache: cache, + clientFactory: () => MockRelayClient((request) async { + harness.requests.add(request.url); + return respond(request.url); + }), + ); + harness.downloads = OpdsDownloads( + httpClient: gateway, + captureImport: () { + harness.importAttempts++; + throw StateError('This widget test must not start an import'); + }, + ); + Widget page(GoRouterState state) => CatalogsPage( + catalogId: state.pathParameters['catalogId'], + feedUri: state.uri.queryParameters['feed'] == null ? null : Uri.parse(state.uri.queryParameters['feed']!), + query: state.uri.queryParameters['q'] ?? '', + ); + harness.router = GoRouter( + initialLocation: initialLocation, + routes: [ + GoRoute( + path: '/library/catalogs', + builder: (_, _) => const CatalogsPage(), + routes: [ + GoRoute(path: ':catalogId', builder: (_, state) => page(state), routes: [catalogBookRoute()]), + ], + ), + ], + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: catalogs), + ChangeNotifierProvider.value(value: harness.downloads), + Provider.value(value: gateway), + ], + child: MaterialApp.router( + theme: AppTheme.eink, + routerConfig: harness.router, + builder: (_, child) => AppMotionScope(reduceAnimations: true, child: Scaffold(body: child!)), + ), + ), + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + harness.router.dispose(); + harness.catalogs.dispose(); + harness.downloads.dispose(); + }); + await _settleNetwork(tester); + return harness; + } +} + +Future _settleNetwork(WidgetTester tester) async { + // A deep link loads its source feed and then its publication document. + for (var hop = 0; hop < 3; hop++) { + await tester.pumpAndSettle(); + await tester.runAsync(() => Future.delayed(Duration.zero)); + } + await tester.pumpAndSettle(); +} + +http.Response _feedResponse( + String title, { + List> publications = const [], + List> navigation = const [], + List> links = const [], +}) => http.Response( + jsonEncode({ + 'metadata': {'title': title}, + 'publications': publications, + 'navigation': navigation, + 'links': links, + }), + 200, + headers: {'content-type': 'application/opds+json; charset=utf-8'}, +); + +Map _book() => { + 'metadata': {'identifier': 'book-one', 'title': 'A book', 'author': 'An author'}, + 'links': [ + {'rel': 'download', 'href': '/book.epub', 'type': 'application/epub+zip'}, + ], +}; + +void main() { + testWidgets('back arrows retrace catalog, editions, and publication pages without skipping', (tester) async { + final harness = await _CatalogPageHarness.mount( + tester, + (uri) => uri.path == '/editions' + ? _feedResponse('Available editions', publications: [_book()]) + : _feedResponse( + 'Gutenberg books', + navigation: [ + {'title': 'Choose an edition', 'href': '/editions'}, + ], + ), + initialLocation: '/library/catalogs', + ); + await tester.tap(find.text('My catalog')); + await _settleNetwork(tester); + await tester.tap(find.text('Choose an edition')); + await _settleNetwork(tester); + expect(find.text('Available editions'), findsOneWidget); + await tester.tap(find.text('A book')); + await _settleNetwork(tester); + expect(find.text('Description'), findsOneWidget); + await tester.tap(find.byIcon(Icons.arrow_back)); + await _settleNetwork(tester); + expect(find.text('Available editions'), findsOneWidget); + await tester.tap(find.byIcon(Icons.arrow_back)); + await _settleNetwork(tester); + expect(find.text('Gutenberg books'), findsOneWidget); + expect(find.byTooltip('Catalog options'), findsNothing); + await tester.tap(find.byIcon(Icons.arrow_back)); + await _settleNetwork(tester); + expect(harness.router.routeInformationProvider.value.uri.path, '/library/catalogs'); + expect(find.byTooltip('Catalog options'), findsOneWidget); + }); + + testWidgets('subsection back restores scroll and list mode without reloading the parent', (tester) async { + final harness = await _CatalogPageHarness.mount( + tester, + (uri) => uri.path == '/editions' + ? _feedResponse('Available editions', publications: [_book()]) + : _feedResponse( + 'Gutenberg books', + publications: [_book()], + navigation: List.generate(30, (index) => {'title': 'Edition group $index', 'href': '/editions'}), + ), + ); + await tester.tap(find.byTooltip('List view')); + await _settleNetwork(tester); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -450)); + await tester.pumpAndSettle(); + final group = find.text('Edition group 10'); + await tester.ensureVisible(group); + await tester.pumpAndSettle(); + final scroll = tester.widget(find.byType(CustomScrollView)).controller!; + final offset = scroll.offset; + expect(offset, greaterThan(0)); + await tester.tap(group); + await _settleNetwork(tester); + expect(find.text('Available editions'), findsOneWidget); + final requests = harness.requests.length; + // System Back uses the same route stack as the header arrow. + await tester.binding.handlePopRoute(); + await _settleNetwork(tester); + expect(find.text('Gutenberg books'), findsOneWidget); + expect(find.byTooltip('Grid view'), findsOneWidget); + expect(scroll.offset, offset); + expect(harness.requests.length, requests); + expect(tester.takeException(), isNull); + }); + + testWidgets('a shared subsection falls back to its catalog before all catalogs', (tester) async { + final harness = await _CatalogPageHarness.mount( + tester, + (uri) => _feedResponse(uri.path == '/editions' ? 'Available editions' : 'Gutenberg books'), + initialLocation: Uri( + path: '/library/catalogs/one', + queryParameters: {'feed': 'https://books.test/editions', 'q': 'Austen'}, + ).toString(), + ); + expect(find.text('Available editions'), findsOneWidget); + await tester.tap(find.byTooltip('Back')); + await _settleNetwork(tester); + expect(find.text('Gutenberg books'), findsOneWidget); + expect(harness.router.routeInformationProvider.value.uri.queryParameters, isEmpty); + await tester.tap(find.byTooltip('Back')); + await _settleNetwork(tester); + expect(find.byTooltip('Catalog options'), findsOneWidget); + }); + + testWidgets('web route restoration preserves search and one-step header back', (tester) async { + const query = 'tea & coffee/ž'; + final harness = await _CatalogPageHarness.mount( + tester, + (uri) => uri.path == '/search' + ? _feedResponse( + 'Search results', + navigation: [ + {'title': 'Editions', 'href': '/editions'}, + ], + ) + : uri.path == '/editions' + ? _feedResponse('Available editions', publications: [_book()]) + : _feedResponse( + 'Gutenberg books', + links: [ + {'rel': 'search', 'href': 'https://books.test/search{?query}', 'templated': true}, + ], + ), + ); + await tester.enterText(find.byType(TextField), query); + await tester.tap(find.byTooltip('Search catalog')); + await _settleNetwork(tester); + final parser = harness.router.routeInformationParser; + final searchHistory = parser.restoreRouteInformation(harness.router.routerDelegate.currentConfiguration)!; + expect(searchHistory.uri.queryParameters['q'], query); + expect(Uri.parse(searchHistory.uri.queryParameters['feed']!).path, '/search'); + await tester.tap(find.text('Editions')); + await _settleNetwork(tester); + final editionsHistory = parser.restoreRouteInformation(harness.router.routerDelegate.currentConfiguration)!; + expect(Uri.parse(editionsHistory.uri.queryParameters['feed']!).path, '/editions'); + // A browser Back/Forward event includes the router's serialized history state. + await harness.router.routeInformationProvider.didPushRouteInformation(searchHistory); + await _settleNetwork(tester); + expect(find.text('Search results'), findsOneWidget); + expect(tester.widget(find.byType(TextField)).controller!.text, query); + await harness.router.routeInformationProvider.didPushRouteInformation(editionsHistory); + await _settleNetwork(tester); + expect(find.text('Available editions'), findsOneWidget); + await tester.tap(find.byTooltip('Back')); + await _settleNetwork(tester); + expect(find.text('Search results'), findsOneWidget); + expect(tester.widget(find.byType(TextField)).controller!.text, query); + await tester.tap(find.byTooltip('Back')); + await _settleNetwork(tester); + expect(find.text('Gutenberg books'), findsOneWidget); + }); + + Future seedCache(OpdsResourceCache cache, OpdsCatalog catalog) async { + final response = _feedResponse('Saved books', publications: [_book()]); + await cache.write( + cache.capture(catalog, catalog.uri)!, + OpdsResponse(uri: catalog.uri, bytes: response.bodyBytes, headers: response.headers), + ); + } + + testWidgets('saved feed appears during refresh and stays usable after network failure', (tester) async { + final response = Completer(); + await _CatalogPageHarness.mount(tester, (_) => response.future, prepareCache: seedCache); + expect(find.text('Saved books'), findsOneWidget); + expect(find.text('A book'), findsOneWidget); + response.completeError(http.ClientException('Offline')); + await _settleNetwork(tester); + expect(find.text('A book'), findsOneWidget); + expect(find.text('Showing saved content. Could not refresh this catalog.'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Could not open this catalog'), findsNothing); + }); + + for (final width in [424.0, 1100.0]) { + for (final listView in [false, true]) { + testWidgets('refresh keeps ${listView ? 'list' : 'grid'} content in place at $width', (tester) async { + final initial = Completer(); + final refreshed = Completer(); + var requests = 0; + await _CatalogPageHarness.mount( + tester, + (_) => ++requests == 1 ? initial.future : refreshed.future, + prepareCache: seedCache, + ); + tester.view.physicalSize = Size(width, 1000); + await tester.pumpAndSettle(); + if (listView) { + await tester.tap(find.byIcon(Icons.view_list)); + await tester.pumpAndSettle(); + } + final bookPosition = tester.getTopLeft(find.text('A book')); + final contentPosition = tester.getTopLeft(find.byType(CustomScrollView)); + initial.complete(_feedResponse('Saved books', publications: [_book()])); + await _settleNetwork(tester); + expect(tester.getTopLeft(find.text('A book')), bookPosition); + expect(tester.getTopLeft(find.byType(CustomScrollView)), contentPosition); + final refresh = find.byKey(const Key('opds-refresh')); + expect(contentPosition.dy - tester.getBottomLeft(refresh).dy, 8); + await tester.tap(refresh); + await _settleNetwork(tester); + expect(requests, 2); + expect(tester.getTopLeft(find.text('A book')), bookPosition); + expect(tester.getTopLeft(find.byType(CustomScrollView)), contentPosition); + expect(find.byType(AppLinearProgressIndicator), findsNothing); + expect(find.descendant(of: refresh, matching: find.byType(AppCircularProgressIndicator)), findsOneWidget); + expect(tester.widget(refresh).onPressed, isNull); + refreshed.complete(_feedResponse('Saved books', publications: [_book()])); + await _settleNetwork(tester); + expect(tester.getTopLeft(find.text('A book')), bookPosition); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('details deep link renders cached publication before refresh and survives offline', (tester) async { + final response = Completer(); + await _CatalogPageHarness.mount( + tester, + (_) => response.future, + prepareCache: seedCache, + initialLocation: Uri( + path: '/library/catalogs/one/book', + queryParameters: {'feed': 'https://books.test/feed', 'publication': 'book-one'}, + ).toString(), + ); + expect(find.text('A book'), findsOneWidget); + expect(find.text('Description'), findsOneWidget); + response.completeError(http.ClientException('Offline')); + await _settleNetwork(tester); + expect(find.text('A book'), findsOneWidget); + expect(find.textContaining('Showing saved content.'), findsOneWidget); + expect(find.text('Book unavailable'), findsNothing); + }); + + testWidgets('authorization rejection removes cached details', (tester) async { + final response = Completer(); + await _CatalogPageHarness.mount( + tester, + (_) => response.future, + prepareCache: seedCache, + initialLocation: Uri( + path: '/library/catalogs/one/book', + queryParameters: {'feed': 'https://books.test/feed', 'publication': 'book-one'}, + ).toString(), + ); + expect(find.text('A book'), findsOneWidget); + response.complete(http.Response('Unauthorized', 401)); + await _settleNetwork(tester); + expect(find.text('A book'), findsNothing); + expect(find.text('Book unavailable'), findsOneWidget); + }); + + testWidgets('cached standalone metadata appears before the source refresh completes', (tester) async { + final response = Completer(); + final detailUri = Uri.parse('https://books.test/detail'); + await _CatalogPageHarness.mount( + tester, + (_) => response.future, + prepareCache: (cache, catalog) async { + final source = _feedResponse( + 'Saved books', + publications: [ + { + ..._book(), + 'links': [ + {'rel': 'alternate', 'href': '/detail', 'type': 'application/opds-publication+json'}, + ], + }, + ], + ); + await cache.write( + cache.capture(catalog, catalog.uri)!, + OpdsResponse(uri: catalog.uri, bytes: source.bodyBytes, headers: source.headers), + ); + await cache.write( + cache.capture(catalog, detailUri)!, + OpdsResponse( + uri: detailUri, + bytes: http.Response( + jsonEncode({ + ..._book(), + 'metadata': {'identifier': 'book-one', 'title': 'Complete title', 'description': 'Cached description'}, + }), + 200, + ).bodyBytes, + headers: {'content-type': 'application/opds-publication+json'}, + ), + ); + }, + initialLocation: Uri( + path: '/library/catalogs/one/book', + queryParameters: {'feed': 'https://books.test/feed', 'publication': 'book-one'}, + ).toString(), + ); + expect(find.text('Complete title'), findsOneWidget); + expect(find.text('Cached description'), findsOneWidget); + response.completeError(http.ClientException('Offline')); + await _settleNetwork(tester); + expect(find.text('Complete title'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('details authorization failure clears the retained parent feed on back', (tester) async { + await _CatalogPageHarness.mount( + tester, + (uri) => uri.path == '/detail' + ? http.Response('Unauthorized', 401) + : _feedResponse( + 'Protected books', + publications: [ + { + ..._book(), + 'links': [ + {'rel': 'alternate', 'href': '/detail', 'type': 'application/opds-publication+json'}, + ], + }, + ], + ), + prepareCache: (_, _) async {}, + ); + await tester.tap(find.text('A book')); + await _settleNetwork(tester); + expect(find.text('Book unavailable'), findsOneWidget); + await tester.tap(find.byTooltip('Back to catalog')); + await _settleNetwork(tester); + expect(find.text('A book'), findsNothing); + expect(find.text('Could not open this catalog'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets('late parent authorization failure clears already-resolved book details', (tester) async { + final parentResponse = Completer(); + var requests = 0; + await _CatalogPageHarness.mount( + tester, + (_) => ++requests == 1 ? parentResponse.future : _feedResponse('Books', publications: [_book()]), + prepareCache: seedCache, + initialLocation: Uri( + path: '/library/catalogs/one/book', + queryParameters: {'feed': 'https://books.test/feed', 'publication': 'book-one'}, + ).toString(), + ); + expect(find.text('A book'), findsOneWidget); + expect(find.text('Add to library'), findsOneWidget); + parentResponse.complete(http.Response('Unauthorized', 401)); + await _settleNetwork(tester); + expect(find.text('A book'), findsNothing); + expect(find.text('Add to library'), findsNothing); + expect(find.text('Book unavailable'), findsOneWidget); + }); + + testWidgets('catalog heading waits for the actual feed title', (tester) async { + final response = Completer(); + await _CatalogPageHarness.mount(tester, (_) => response.future); + expect(find.text('Browse catalog'), findsNothing); + expect(find.byTooltip('Refresh catalog'), findsNothing); + response.complete(_feedResponse('All books')); + await _settleNetwork(tester); + expect(find.text('All books'), findsOneWidget); + expect(find.text('Browse catalog'), findsNothing); + }); + + testWidgets('navigation does not substitute the clicked label for the feed title', (tester) async { + final response = Completer(); + const title = 'Austen & Brontë / novels'; + final harness = await _CatalogPageHarness.mount( + tester, + (uri) => uri.path == '/feed' + ? _feedResponse( + 'All books', + navigation: [ + {'title': title, 'href': '/authors'}, + ], + ) + : response.future, + ); + await tester.tap(find.text(title)); + await _settleNetwork(tester); + expect(find.text(title), findsNothing); + expect(find.text('All books'), findsNothing); + expect(find.byType(CustomScrollView), findsNothing); + expect(harness.router.routeInformationProvider.value.uri.queryParameters.containsKey('title'), isFalse); + response.complete(_feedResponse('Novels by Austen and Brontë')); + await _settleNetwork(tester); + expect(find.text('Novels by Austen and Brontë'), findsOneWidget); + }); + + testWidgets('a direct feed URL ignores outdated title hints', (tester) async { + final response = Completer(); + await _CatalogPageHarness.mount( + tester, + (_) => response.future, + initialLocation: Uri( + path: '/library/catalogs/one', + queryParameters: {'feed': 'https://books.test/authors', 'title': 'Austen & Brontë / novels'}, + ).toString(), + ); + expect(find.text('Austen & Brontë / novels'), findsNothing); + response.complete(_feedResponse('Author novels')); + await _settleNetwork(tester); + expect(find.text('Author novels'), findsOneWidget); + }); + + for (final view in ['navigation', 'grid', 'list']) { + testWidgets('feed heading stays fixed while $view scrolls and reloads', (tester) async { + final refreshed = Completer(); + var requests = 0; + final response = _feedResponse( + 'All books', + publications: view == 'navigation' + ? [] + : List.generate( + 40, + (index) => { + ..._book(), + 'metadata': {'identifier': 'book-$index', 'title': 'Book $index'}, + }, + ), + navigation: view == 'navigation' + ? List.generate(40, (index) => {'title': 'Section $index', 'href': '/section-$index'}) + : [], + ); + await _CatalogPageHarness.mount(tester, (_) => ++requests == 2 ? refreshed.future : response); + if (view == 'list') { + await tester.tap(find.byIcon(Icons.view_list)); + await tester.pumpAndSettle(); + } + final headingPosition = tester.getTopLeft(find.text('All books')); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -700)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('All books')), headingPosition); + expect(find.byTooltip('Refresh catalog').hitTestable(), findsOneWidget); + await tester.tap(find.byTooltip('Refresh catalog')); + await tester.pump(); + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(); + expect(requests, 2); + expect(tester.getTopLeft(find.text('All books')), headingPosition); + expect(tester.widget(find.byKey(const Key('opds-refresh'))).onPressed, isNull); + refreshed.complete(http.Response('Unavailable', 503)); + await _settleNetwork(tester); + expect(find.text('Could not open this catalog'), findsOneWidget); + expect(tester.getTopLeft(find.text('All books')), headingPosition); + await tester.tap(find.text('Retry')); + await _settleNetwork(tester); + expect(requests, 3); + expect(find.byType(CustomScrollView), findsOneWidget); + expect(tester.getTopLeft(find.text('All books')), headingPosition); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('mobile catalog home uses a FAB and keeps Downloads in the title row', (tester) async { + await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books'), initialLocation: '/library/catalogs'); + tester.view.physicalSize = const Size(424, 951); + await _settleNetwork(tester); + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Add catalog'), findsNothing); + expect(tester.getTopLeft(find.byTooltip('Downloads')).dy, lessThan(tester.getBottomLeft(find.text('Catalogs')).dy)); + await tester.tap(find.byTooltip('Add catalog')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byKey(const Key('opds-name')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('the last mobile source scrolls above the Add catalog FAB', (tester) async { + final harness = await _CatalogPageHarness.mount( + tester, + (_) => _feedResponse('Books'), + initialLocation: '/library/catalogs', + ); + for (var index = 0; index < 12; index++) { + await harness.store.save( + 'local--guest', + OpdsCatalog(id: 'source-$index', name: 'Source $index', uri: Uri.parse('https://books.test/$index')), + ); + } + harness.catalogs.reload(); + tester.view.physicalSize = const Size(360, 640); + await _settleNetwork(tester); + await tester.drag(find.byType(ListView), const Offset(0, -2000)); + await tester.pumpAndSettle(); + expect( + tester.getBottomRight(find.byType(CatalogSourceTile).last).dy, + lessThan(tester.getTopLeft(find.byType(FloatingActionButton)).dy), + ); + expect(find.byTooltip('Catalog options'), findsNothing); + await tester.drag(find.byType(CatalogSourceTile).last, const Offset(-250, 0)); + await tester.pumpAndSettle(); + expect(find.text('Remove catalog'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('mobile swipe actions edit and confirm deletion without opening the catalog', (tester) async { + final harness = await _CatalogPageHarness.mount( + tester, + (_) => _feedResponse('Books'), + initialLocation: '/library/catalogs', + ); + tester.view.physicalSize = const Size(424, 951); + await _settleNetwork(tester); + expect(find.byTooltip('Catalog options'), findsNothing); + final editGesture = await tester.startGesture(tester.getCenter(find.byType(CatalogSourceTile))); + await editGesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await editGesture.moveBy(const Offset(230, 0)); + await tester.pump(); + expect(find.byType(BottomSheet), findsNothing); + await editGesture.up(); + await tester.pumpAndSettle(); + expect(harness.catalogs.catalogs, hasLength(1)); + expect(harness.requests, isEmpty); + expect(find.byKey(const Key('opds-name')), findsOneWidget); + await tester.enterText(find.byKey(const Key('opds-name')), 'Renamed catalog'); + await tester.tap(find.text('Save')); + await _settleNetwork(tester); + expect(find.text('Renamed catalog'), findsOneWidget); + + await tester.drag(find.byType(CatalogSourceTile), const Offset(-300, 0)); + await tester.pumpAndSettle(); + expect(find.text('Remove catalog'), findsOneWidget); + expect(harness.catalogs.catalogs, hasLength(1)); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Renamed catalog'), findsOneWidget); + expect(find.text('Delete').hitTestable(), findsNothing); + + await tester.tap(find.text('Renamed catalog')); + await _settleNetwork(tester); + expect(find.text('Books'), findsOneWidget); + harness.router.go('/library/catalogs'); + await _settleNetwork(tester); + await tester.drag(find.byType(CatalogSourceTile), const Offset(-300, 0)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Remove')); + await _settleNetwork(tester); + expect(find.text('No catalogs yet'), findsOneWidget); + expect(harness.catalogs.catalogs, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('short mobile swipes snap back without an action and leave rows tappable', (tester) async { + final harness = await _CatalogPageHarness.mount( + tester, + (_) => _feedResponse('Books'), + initialLocation: '/library/catalogs', + ); + tester.view.physicalSize = const Size(424, 951); + await _settleNetwork(tester); + final originalPosition = tester.getTopLeft(find.text('My catalog')); + for (final distance in [-60.0, 60.0]) { + await tester.timedDrag(find.byType(CatalogSourceTile), Offset(distance, 0), const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); + expect(tester.getTopLeft(find.text('My catalog')), originalPosition); + expect(harness.catalogs.catalogs, hasLength(1)); + expect(harness.requests, isEmpty); + } + await tester.tap(find.text('My catalog')); + await _settleNetwork(tester); + expect(find.text('Books'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('phone header keeps actions together and embeds search submission', (tester) async { + await _CatalogPageHarness.mount( + tester, + (_) => _feedResponse('Pride and Prejudice by Jane Austen', publications: [_book()]), + ); + tester.view.physicalSize = const Size(424, 951); + await _settleNetwork(tester); + final heading = tester.getRect(find.text('My catalog')); + final downloads = tester.getRect(find.byTooltip('Downloads')); + expect(downloads.top, lessThan(heading.bottom)); + expect(find.byTooltip('Catalog home'), findsNothing); + expect(find.text('books.test'), findsNothing); + final header = tester.getRect(find.byKey(const Key('catalog-mobile-header'))); + expect(header.left, 0); + expect(header.width, 424); + expect(header.height, kToolbarHeight); + expect(heading.left, 72); + expect(find.byKey(const Key('catalog-header-divider')), findsNothing); + final field = tester.getRect(find.byType(TextField)); + expect(field.contains(tester.getCenter(find.byTooltip('Search catalog'))), isTrue); + expect(tester.getTopLeft(find.text('A book')).dy, lessThan(560)); + await tester.tap(find.byTooltip('Edit catalog')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('catalog sources use flat rows and edit in a bottom sheet', (tester) async { + await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books'), initialLocation: '/library/catalogs'); + expect(find.byType(GridView), findsNothing); + expect(find.byType(Card), findsNothing); + await tester.tap(find.text('Add catalog').first); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + }); + + testWidgets('publication opens a routed page instead of a modal', (tester) async { + final harness = await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books', publications: [_book()])); + await tester.tap(find.text('A book')); + await _settleNetwork(tester); + expect(harness.router.routeInformationProvider.value.uri.path, '/library/catalogs/one/book'); + expect(find.byType(Dialog), findsNothing); + expect(find.byType(BottomSheet), findsNothing); + expect(find.text('Add to library'), findsOneWidget); + }); + + testWidgets('a direct book URL resolves metadata and missing publications can recover', (tester) async { + var available = false; + final location = Uri( + path: '/library/catalogs/one/book', + queryParameters: { + 'feed': 'https://books.test/search?q=tea%20%26%20coffee', + 'publication': 'urn:book/one?edition=2', + 'q': 'tea & coffee', + }, + ).toString(); + final harness = await _CatalogPageHarness.mount(tester, (uri) { + if (uri.path == '/detail') { + return http.Response( + jsonEncode({ + 'metadata': { + 'identifier': 'urn:book/one?edition=2', + 'title': 'Full title', + 'description': 'Complete description.', + }, + 'links': [ + {'rel': 'download', 'href': '/book.epub', 'type': 'application/epub+zip'}, + ], + }), + 200, + headers: {'content-type': 'application/opds-publication+json'}, + ); + } + return _feedResponse( + 'Search results', + publications: available + ? [ + { + 'metadata': {'identifier': 'urn:book/one?edition=2', 'title': 'Preview'}, + 'links': [ + {'rel': 'self', 'href': '/detail', 'type': 'application/opds-publication+json'}, + ], + }, + ] + : [], + ); + }, initialLocation: location); + expect(find.text('Book unavailable'), findsOneWidget); + available = true; + await tester.tap(find.text('Retry')); + await _settleNetwork(tester); + expect( + find.text('Full title'), + findsOneWidget, + reason: '${harness.requests}; ${tester.widgetList(find.byType(Text)).map((t) => t.data).join(' | ')}', + ); + expect(find.text('Complete description.'), findsOneWidget); + expect(harness.requests.last.path, '/detail'); + expect(find.text('Add to library'), findsOneWidget); + await tester.tap(find.byTooltip('Back to catalog')); + await _settleNetwork(tester); + expect(harness.router.routeInformationProvider.value.uri.queryParameters['q'], 'tea & coffee'); + expect(tester.widget(find.byType(TextField)).controller!.text, 'tea & coffee'); + expect(tester.takeException(), isNull); + }); + + testWidgets('returning from a book keeps the feed position and list view', (tester) async { + final harness = await _CatalogPageHarness.mount( + tester, + (_) => _feedResponse( + 'Books', + publications: [ + for (var index = 0; index < 40; index++) + { + 'metadata': {'identifier': 'book-$index', 'title': 'Book $index'}, + 'links': [ + {'rel': 'download', 'href': '/$index.epub', 'type': 'application/epub+zip'}, + ], + }, + ], + ), + ); + await tester.tap(find.byIcon(Icons.view_list)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Book 20'), + 500, + scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first, + ); + await tester.pumpAndSettle(); + final y = tester.getTopLeft(find.text('Book 20')).dy; + final requests = harness.requests.length; + await tester.tap(find.text('Book 20')); + await _settleNetwork(tester); + await tester.tap(find.byTooltip('Back to catalog')); + await _settleNetwork(tester); + expect(tester.getTopLeft(find.text('Book 20')).dy, closeTo(y, 1)); + expect(harness.requests.length, requests, reason: 'Returning should not reload the retained source feed.'); + expect(find.byType(SliverGrid), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('book unavailable retries a transient catalog storage failure', (tester) async { + final harness = await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books', publications: [_book()])); + await tester.tap(find.text('A book')); + await _settleNetwork(tester); + harness.store.loadFailure = 'Could not read saved catalogs.'; + harness.catalogs.reload(); + await _settleNetwork(tester); + expect(find.text('Book unavailable'), findsOneWidget); + harness.store.loadFailure = null; + await tester.tap(find.text('Retry')); + await _settleNetwork(tester); + expect(find.text('Add to library'), findsOneWidget); + }); + + testWidgets('an edition feed never silently replaces the selected publication', (tester) async { + await _CatalogPageHarness.mount(tester, (uri) { + if (uri.path == '/editions') { + return _feedResponse( + 'Editions', + publications: [ + { + 'metadata': {'identifier': 'other-edition', 'title': 'Another edition'}, + 'links': [ + {'rel': 'download', 'href': '/other.epub', 'type': 'application/epub+zip'}, + ], + }, + ], + ); + } + return _feedResponse( + 'Books', + publications: [ + { + 'metadata': {'identifier': 'original', 'title': 'Selected book', 'author': 'Writer'}, + 'links': [ + {'rel': 'alternate', 'href': '/editions', 'type': 'application/atom+xml;kind=acquisition'}, + ], + }, + ], + ); + }); + await tester.tap(find.text('Selected book')); + await _settleNetwork(tester); + expect(find.text('Selected book'), findsOneWidget); + expect(find.text('Another edition'), findsNothing); + await tester.tap(find.text('Add to library')); + await _settleNetwork(tester); + await tester.tap(find.text('Full catalog details')); + await _settleNetwork(tester); + expect(find.text('Another edition'), findsOneWidget); + }); + + testWidgets('downloads retry with refreshed catalog settings after a catalog reload', (tester) async { + final harness = await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books', publications: [_book()])); + final catalog = harness.catalogs.catalogs.single; + await harness.downloads.start( + catalog, + OpdsPublication(id: 'test', title: 'Failed book'), + OpdsLink(uri: Uri.parse('https://books.test/book.epub'), type: 'application/epub+zip', rels: ['download']), + ); + expect(harness.importAttempts, 1); + harness.catalogs.reload(); + await _settleNetwork(tester); + await tester.tap(find.byTooltip('Downloads')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Retry')); + await _settleNetwork(tester); + expect(harness.importAttempts, 2); + expect(tester.takeException(), isNull); + }); + + testWidgets('download sheet closes without losing jobs and search fits above keyboard', (tester) async { + final harness = await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books')); + tester.view.physicalSize = const Size(360, 640); + addTearDown(tester.view.resetViewInsets); + for (var index = 0; index < 3; index++) { + await harness.downloads.start( + harness.catalogs.catalogs.single, + OpdsPublication(id: 'download-$index', title: 'A failed download $index'), + OpdsLink(uri: Uri.parse('https://books.test/book.epub'), type: 'application/epub+zip', rels: ['download']), + ); + } + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Downloads')); + await tester.pumpAndSettle(); + expect(find.text('Retry'), findsWidgets); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(TextField)); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + expect(find.text('Retry'), findsNothing); + expect(tester.getBottomLeft(find.byType(TextField)).dy, lessThan(340)); + expect(tester.takeException(), isNull); + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Downloads')); + await tester.pumpAndSettle(); + expect(find.text('Retry'), findsWidgets); + }); + + testWidgets('facet choices and collection View all links navigate to their feeds', (tester) async { + final harness = await _CatalogPageHarness.mount(tester, (uri) { + if (uri.path == '/filtered') return _feedResponse('Filtered books'); + if (uri.path == '/collection') return _feedResponse('Full collection'); + return http.Response( + jsonEncode({ + 'metadata': {'title': 'Browse books'}, + 'facets': [ + { + 'metadata': {'title': 'Language'}, + 'links': [ + {'title': 'English', 'href': '/filtered?language=en'}, + ], + }, + ], + 'groups': [ + { + 'metadata': {'title': 'Featured'}, + 'publications': [_book()], + 'links': [ + {'rel': 'collection', 'href': '/collection'}, + ], + }, + ], + }), + 200, + ); + }); + expect(find.text('Language:'), findsOneWidget); + expect(find.text('Featured'), findsOneWidget); + await tester.tap(find.widgetWithText(ActionChip, 'English')); + await _settleNetwork(tester); + expect(find.text('Filtered books'), findsOneWidget); + expect(harness.requests.last, Uri.parse('https://books.test/filtered?language=en')); + await tester.tap(find.byTooltip('Back')); + await _settleNetwork(tester); + expect(find.text('Browse books'), findsOneWidget); + await tester.tap(find.text('View all')); + await _settleNetwork(tester); + expect(find.text('Full collection'), findsOneWidget); + expect(harness.requests.last, Uri.parse('https://books.test/collection')); + expect(tester.takeException(), isNull); + }); + + testWidgets('search keeps encoded feed and query through pagination, refresh and route history', (tester) async { + const query = 'tea & coffee/ž'; + final searchUri = Uri.https('books.test', '/search', {'query': query}); + final pageTwoUri = searchUri.replace(queryParameters: {'query': query, 'page': '2'}); + final harness = await _CatalogPageHarness.mount(tester, (uri) { + if (uri.path == '/search') { + final pageTwo = uri.queryParameters['page'] == '2'; + return _feedResponse( + pageTwo ? 'Search page two' : 'Search results', + links: [ + {'rel': pageTwo ? 'previous' : 'next', 'href': (pageTwo ? searchUri : pageTwoUri).toString()}, + ], + ); + } + return _feedResponse( + 'Catalog home feed', + links: [ + {'rel': 'search', 'href': 'https://books.test/search{?query}', 'templated': true}, + ], + ); + }); + final homeRoute = harness.router.routeInformationProvider.value.uri; + await tester.enterText(find.byType(TextField), query); + await tester.tap(find.byTooltip('Search catalog')); + await _settleNetwork(tester); + expect(find.text('Search results'), findsOneWidget); + final searchRoute = harness.router.routeInformationProvider.value.uri; + expect(searchRoute.queryParameters['q'], query); + final encodedFeed = Uri.parse(searchRoute.queryParameters['feed']!); + expect(encodedFeed.queryParameters, {'query': query}); + expect(harness.requests.last.queryParameters, {'query': query}); + await tester.tap(find.text('Next')); + await _settleNetwork(tester); + expect(find.text('Search page two'), findsOneWidget); + expect(harness.router.routeInformationProvider.value.uri.queryParameters['q'], query); + await tester.tap(find.text('Previous')); + await _settleNetwork(tester); + expect(find.text('Search results'), findsOneWidget); + final requestsBeforeRefresh = harness.requests.length; + await tester.tap(find.byTooltip('Refresh catalog')); + await _settleNetwork(tester); + expect(harness.requests.length, requestsBeforeRefresh + 1); + expect(harness.requests.last.queryParameters, {'query': query}); + expect(tester.widget(find.byType(TextField)).controller!.text, query); + + // The browser delivers history entries through the route information provider. + await harness.router.routeInformationProvider.didPushRouteInformation(RouteInformation(uri: homeRoute)); + await _settleNetwork(tester); + expect(find.text('Catalog home feed'), findsOneWidget); + expect(tester.widget(find.byType(TextField)).controller!.text, query); + await harness.router.routeInformationProvider.didPushRouteInformation(RouteInformation(uri: searchRoute)); + await _settleNetwork(tester); + expect(find.text('Search results'), findsOneWidget); + expect(tester.widget(find.byType(TextField)).controller!.text, query); + expect(harness.requests.last.queryParameters, {'query': query}); + expect(tester.takeException(), isNull); + }); + + testWidgets('edit and remove failures keep the saved catalog and allow retry', (tester) async { + final harness = await _CatalogPageHarness.mount( + tester, + (_) => _feedResponse('Books'), + initialLocation: '/library/catalogs', + ); + harness.store.saveFailure = 'Catalog save failed.'; + await tester.tap(find.byTooltip('Catalog options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('opds-name')), 'Renamed catalog'); + await tester.tap(find.text('Save')); + await _settleNetwork(tester); + expect(find.text('Catalog save failed.'), findsOneWidget); + expect(find.byKey(const Key('opds-name')), findsOneWidget); + expect(harness.catalogs.catalogs.single.name, 'My catalog'); + harness.store.saveFailure = null; + await tester.tap(find.text('Save')); + await _settleNetwork(tester); + expect(find.byKey(const Key('opds-name')), findsNothing); + expect(find.text('Renamed catalog'), findsOneWidget); + + harness.store.removeFailure = 'Catalog removal failed.'; + await tester.tap(find.byTooltip('Catalog options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Remove')); + await _settleNetwork(tester); + expect(find.text('Catalog removal failed.'), findsOneWidget); + expect(find.text('Renamed catalog'), findsOneWidget); + expect(harness.catalogs.catalogs, hasLength(1)); + harness.store.removeFailure = null; + await tester.tap(find.byTooltip('Catalog options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Remove')); + await _settleNetwork(tester); + expect(find.text('No catalogs yet'), findsOneWidget); + expect(harness.catalogs.catalogs, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('a failed feed can retry to empty results and refresh to books', (tester) async { + var attempt = 0; + final harness = await _CatalogPageHarness.mount(tester, (_) { + attempt++; + if (attempt == 1) return http.Response('Unavailable', 503); + return _feedResponse('Books', publications: attempt == 2 ? [] : [_book()]); + }); + expect( + find.textContaining('HTTP 503'), + findsOneWidget, + reason: + 'requests=${harness.requests}; texts=${tester.widgetList(find.byType(Text)).map((text) => text.data).join(' | ')}', + ); + await tester.tap(find.text('Retry')); + await _settleNetwork(tester); + expect(find.textContaining('HTTP 503'), findsNothing); + expect(find.text('No books or sections found.'), findsOneWidget); + await tester.tap(find.byTooltip('Refresh catalog')); + await _settleNetwork(tester); + expect(find.text('A book'), findsOneWidget); + expect(find.text('No books or sections found.'), findsNothing); + expect(harness.requests, hasLength(3)); + expect(tester.takeException(), isNull); + }); + + testWidgets('open publication details cannot download after catalog or account changes', (tester) async { + final harness = await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books', publications: [_book()])); + for (final changeAccount in [false, true]) { + await tester.tap(find.text('A book')); + await _settleNetwork(tester); + await tester.tap(find.text('Add to library')); + await tester.pumpAndSettle(); + expect(find.text('Download EPUB'), findsOneWidget); + if (changeAccount) { + harness.catalogs.setScope('local--other'); + } else { + await harness.catalogs.save( + OpdsCatalog(id: 'one', name: 'Changed catalog', uri: Uri.parse('https://other-books.test/feed')), + ); + } + await _settleNetwork(tester); + await tester.tap(find.text('Download EPUB')); + await _settleNetwork(tester); + expect(find.text('The catalog or account changed. Close these details and reopen the book.'), findsOneWidget); + expect(harness.downloads.jobs, isEmpty); + expect(harness.importAttempts, 0); + expect(harness.requests.where((uri) => uri.path.endsWith('.epub')), isEmpty); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Back to catalog')); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('an editor opened in another account cannot save into the active account', (tester) async { + final harness = await _CatalogPageHarness.mount( + tester, + (_) => _feedResponse('Books'), + initialLocation: '/library/catalogs', + ); + await tester.tap(find.byTooltip('Catalog options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('opds-name')), 'Wrong account catalog'); + harness.catalogs.setScope('local--other'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save')); + await _settleNetwork(tester); + expect(find.text('The active account changed. Close this editor and try again.'), findsOneWidget); + expect(find.byKey(const Key('opds-name')), findsOneWidget); + expect(harness.catalogs.catalogs, isEmpty); + expect(harness.store.load('local--other'), isEmpty); + expect(harness.store.load('local--guest').single.name, 'My catalog'); + expect(tester.takeException(), isNull); + }); + + for (final width in [360.0, 1280.0]) { + testWidgets('add and browse a catalog at width $width', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({}); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) + ..setScope('local--guest'); + final downloads = OpdsDownloads(captureImport: () => throw StateError('unused')); + final gateway = OpdsHttpClient( + clientFactory: () => MockRelayClient( + (_) async => http.Response( + '{"metadata":{"title":"Fixture books"},"publications":[{"metadata":{"title":"A book","author":"An author"},"links":[{"rel":"http://opds-spec.org/acquisition/open-access","href":"book.epub","type":"application/epub+zip"}]}]}', + 200, + ), + ), + ); + final router = GoRouter( + initialLocation: '/library/catalogs', + routes: [ + GoRoute( + path: '/library/catalogs', + builder: (_, _) => CatalogsPage(httpClient: gateway), + routes: [ + GoRoute( + path: ':catalogId', + builder: (_, state) => CatalogsPage(catalogId: state.pathParameters['catalogId'], httpClient: gateway), + ), + ], + ), + ], + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: catalogs), + ChangeNotifierProvider.value(value: downloads), + ], + child: MaterialApp.router( + theme: AppTheme.eink, + routerConfig: router, + builder: (_, child) => AppMotionScope(reduceAnimations: true, child: Scaffold(body: child!)), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('No catalogs yet'), findsOneWidget); + await tester.tap(find.text('Add catalog').first); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('opds-name')), 'My catalog'); + await tester.enterText(find.byKey(const Key('opds-url')), 'https://books.test/feed'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(find.text('My catalog'), findsOneWidget); + await tester.tap(find.text('My catalog')); + await tester.pumpAndSettle(); + await tester.pump(); + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pumpAndSettle(); + expect( + find.text('A book'), + findsOneWidget, + reason: + '${tester.widgetList(find.byType(Text)).map((widget) => widget.data).join(' | ')}; progress=${find.byType(CircularProgressIndicator).evaluate().length}', + ); + expect(find.text('An author'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + router.dispose(); + catalogs.dispose(); + downloads.dispose(); + }); + } +} diff --git a/app/test/opds/catalogs_provider_test.dart b/app/test/opds/catalogs_provider_test.dart new file mode 100644 index 0000000..39aa917 --- /dev/null +++ b/app/test/opds/catalogs_provider_test.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'catalog_store_test.dart' show MemorySecrets; + +class DelayedSecrets extends MemorySecrets { + Completer? nextRead; + @override + Future read(String key) { + final pending = nextRead; + nextRead = null; + return pending?.future ?? super.read(key); + } +} + +void main() { + test('credential lookups are invalidated by edits and scope changes', () async { + SharedPreferences.setMockInitialValues({}); + final secrets = DelayedSecrets(); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: secrets)) + ..setScope('guest'); + await catalogs.save( + OpdsCatalog(id: 'c', name: 'One', uri: Uri.parse('https://one.test')), + credentials: const OpdsCredentials(username: 'one', password: 'secret'), + ); + final oldSecret = secrets.values.values.single; + final pending = secrets.nextRead = Completer(); + final lookup = catalogs.credentials('c'); + final expectation = expectLater(lookup, throwsA(isA())); + await catalogs.save( + OpdsCatalog(id: 'c', name: 'Two', uri: Uri.parse('https://two.test')), + credentials: const OpdsCredentials(username: 'two', password: 'secret'), + ); + pending.complete(oldSecret); + await expectation; + final pendingScope = secrets.nextRead = Completer(); + final scopeLookup = catalogs.credentials('c'); + final scopeExpectation = expectLater(scopeLookup, throwsA(isA())); + catalogs.setScope('another-account'); + pendingScope.complete(null); + await scopeExpectation; + catalogs.dispose(); + }); +} diff --git a/app/test/opds/cover_test.dart b/app/test/opds/cover_test.dart new file mode 100644 index 0000000..922afdd --- /dev/null +++ b/app/test/opds/cover_test.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/book/cover_loading_placeholder.dart'; +import 'package:papyrus/widgets/opds/opds_publication_tile.dart'; + +final _catalog = OpdsCatalog(id: 'catalog', name: 'Catalog', uri: Uri.parse('https://books.test/feed')); +final _coverUri = Uri.parse('https://books.test/cover.png'); + +class _PendingCoverClient extends OpdsHttpClient { + _PendingCoverClient({super.cache}); + final response = Completer(); + var requests = 0; + + @override + Future get( + OpdsCatalog catalog, + Uri uri, { + OpdsCredentials? credentials, + OpdsCancellation? cancellation, + void Function(int, int?)? onProgress, + int maxBytes = 8 * 1024 * 1024, + }) { + requests++; + return response.future; + } +} + +Future _mount( + WidgetTester tester, + OpdsHttpClient client, { + required ThemeData theme, + required Size size, + Uri? uri, +}) => tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: OpdsCover(catalog: _catalog, uri: uri, httpClient: client, width: size.width, height: size.height), + ), + ), +); + +void main() { + testWidgets('persisted artwork appears without a new request or loading placeholder', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final cache = OpdsResourceCache(prefs)..setScope('guest'); + await cache.write( + cache.capture(_catalog, _coverUri)!, + OpdsResponse( + uri: _coverUri, + bytes: base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + ), + headers: {'content-type': 'image/png'}, + ), + ); + final client = _PendingCoverClient(cache: OpdsResourceCache(prefs)..setScope('guest')); + await _mount(tester, client, theme: AppTheme.eink, size: const Size(180, 270), uri: _coverUri); + await tester.pumpAndSettle(); + final image = tester.widget(find.byType(Image)); + await tester.runAsync(() => precacheImage(image.image, tester.element(find.byType(OpdsCover)))); + await tester.pumpAndSettle(); + expect(find.byType(CoverLoadingPlaceholder), findsNothing); + expect(find.byType(Image), findsOneWidget); + expect(client.requests, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('invalid optional artwork falls back when caching is enabled', (tester) async { + SharedPreferences.setMockInitialValues({}); + final cache = OpdsResourceCache(await SharedPreferences.getInstance())..setScope('guest'); + final client = _PendingCoverClient(cache: cache); + await _mount( + tester, + client, + theme: AppTheme.eink, + size: const Size(180, 270), + uri: Uri.parse('https://user:secret@books.test/cover.png'), + ); + await tester.pumpAndSettle(); + expect(find.byType(CoverLoadingPlaceholder), findsNothing); + expect(find.byIcon(Icons.menu_book), findsOneWidget); + expect(client.requests, 0); + expect(tester.takeException(), isNull); + }); + for (final (name, theme) in [('light', AppTheme.light), ('dark', AppTheme.dark), ('eink', AppTheme.eink)]) { + for (final size in [const Size(60, 90), const Size(180, 270)]) { + testWidgets('$name $size shares library loading state until request fails', (tester) async { + final client = _PendingCoverClient(); + await _mount(tester, client, theme: theme, size: size, uri: _coverUri); + + expect(find.byType(CoverLoadingPlaceholder), findsOneWidget); + expect(find.byIcon(Icons.auto_stories_rounded), findsOneWidget); + expect(find.text('Loading…'), size.width < 72 ? findsNothing : findsOneWidget); + expect(tester.getSize(find.byType(OpdsCover)), size); + + client.response.completeError(const OpdsException('Artwork unavailable')); + await tester.pumpAndSettle(); + expect(find.byType(CoverLoadingPlaceholder), findsNothing); + expect(find.byIcon(Icons.menu_book), findsOneWidget); + expect(tester.getSize(find.byType(OpdsCover)), size); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('missing cover skips loading and does not request artwork', (tester) async { + final client = _PendingCoverClient(); + await _mount(tester, client, theme: AppTheme.light, size: const Size(180, 270)); + expect(find.byType(CoverLoadingPlaceholder), findsNothing); + expect(find.byIcon(Icons.menu_book), findsOneWidget); + expect(client.requests, 0); + }); + + testWidgets('downloaded artwork replaces loading after image decoding', (tester) async { + final client = _PendingCoverClient(); + await _mount(tester, client, theme: AppTheme.dark, size: const Size(180, 270), uri: _coverUri); + expect(find.byType(CoverLoadingPlaceholder), findsOneWidget); + + client.response.complete( + OpdsResponse( + uri: _coverUri, + bytes: base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + ), + headers: {'content-type': 'image/png'}, + ), + ); + await tester.pumpAndSettle(); + final image = tester.widget(find.byType(Image)); + await tester.runAsync(() => precacheImage(image.image, tester.element(find.byType(OpdsCover)))); + await tester.pumpAndSettle(); + expect(find.byType(CoverLoadingPlaceholder), findsNothing); + expect(find.byType(RawImage), findsOneWidget); + expect(tester.widget(find.byType(RawImage)).image, isNotNull); + expect(tester.takeException(), isNull); + }); +} diff --git a/app/test/opds/downloads_test.dart b/app/test/opds/downloads_test.dart new file mode 100644 index 0000000..2180f27 --- /dev/null +++ b/app/test/opds/downloads_test.dart @@ -0,0 +1,191 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; + +import 'package:papyrus/services/book_import_session.dart'; +import 'package:papyrus/services/book_import_result.dart'; + +import 'relay_fixture_client.dart'; + +void main() { + final catalog = OpdsCatalog(id: 'c', name: 'Books', uri: Uri.parse('https://books.test/feed')); + final link = OpdsLink( + uri: Uri.parse('https://books.test/book.epub'), + type: 'application/epub+zip', + rels: ['http://opds-spec.org/acquisition/open-access'], + ); + final publication = OpdsPublication(id: 'p', title: 'Catalog title', authors: ['Catalog author'], links: [link]); + BookImportResult result() => const BookImportResult( + bookId: 'imported', + title: 'book', + author: 'Unknown', + fileSize: 4, + fileHash: 'hash', + fileExtension: 'epub', + ); + + test('network failure offers manual recovery without processing or committing a book', () async { + var processes = 0; + final downloads = OpdsDownloads( + httpClient: OpdsHttpClient( + clientFactory: () => MockRelayClient((_) async { + throw http.ClientException('Failed to fetch'); + }), + ), + captureImport: () => BookImportSession( + process: (_, _) async { + processes++; + return result(); + }, + deleteFile: (_) async {}, + isCurrent: () => true, + commit: (_, _) async => throw StateError('must not commit'), + ), + ); + await downloads.start(catalog, publication, link); + expect(downloads.jobs.single.status, OpdsDownloadStatus.failed); + expect(downloads.jobs.single.networkFailure, isTrue); + expect(processes, 0); + downloads.dispose(); + }); + + test('rejects a disguised login page before importing a text download', () async { + var processed = false; + final textLink = OpdsLink( + uri: Uri.parse('https://books.test/book.txt'), + type: 'text/plain', + rels: ['http://opds-spec.org/acquisition'], + ); + final downloads = OpdsDownloads( + httpClient: OpdsHttpClient( + clientFactory: () => + MockRelayClient((_) async => http.Response('Sign in', 200)), + ), + captureImport: () => BookImportSession( + process: (_, _) async { + processed = true; + return result(); + }, + deleteFile: (_) async {}, + isCurrent: () => true, + commit: (_, _) async => throw StateError('unused'), + ), + ); + await downloads.start(catalog, publication, textLink); + expect(processed, isFalse); + expect(downloads.jobs.single.status, OpdsDownloadStatus.failed); + expect(downloads.jobs.single.networkFailure, isFalse); + downloads.dispose(); + }); + + test('commits a downloaded book and fills missing embedded metadata', () async { + BookImportResult? imported; + final downloads = OpdsDownloads( + httpClient: OpdsHttpClient(clientFactory: () => MockRelayClient((_) async => http.Response('book', 200))), + captureImport: () => BookImportSession( + process: (_, _) async => result(), + deleteFile: (_) async {}, + isCurrent: () => true, + commit: (value, _) async { + imported = value; + return Book(id: value.bookId, title: value.title, author: value.author, addedAt: DateTime.now()); + }, + ), + ); + await downloads.start(catalog, publication, link); + expect(imported?.title, 'Catalog title'); + expect(imported?.author, 'Catalog author'); + expect(downloads.jobs.single.status, OpdsDownloadStatus.complete); + downloads.dispose(); + }); + + test('the final commit phase does not offer cancellation', () async { + final committing = Completer(); + final finish = Completer(); + final downloads = OpdsDownloads( + httpClient: OpdsHttpClient(clientFactory: () => MockRelayClient((_) async => http.Response('book', 200))), + captureImport: () => BookImportSession( + process: (_, _) async => result(), + deleteFile: (_) async {}, + isCurrent: () => true, + commit: (_, _) { + committing.complete(); + return finish.future; + }, + ), + ); + final operation = downloads.start(catalog, publication, link); + await committing.future; + expect(downloads.jobs.single.isCancellable, isFalse); + downloads.cancel(downloads.jobs.single.key); + finish.complete(Book(id: 'imported', title: 'Title', author: 'Author', addedAt: DateTime.now())); + await operation; + expect(downloads.jobs.single.status, OpdsDownloadStatus.complete); + downloads.dispose(); + }); + + test('account change while processing cleans up without committing', () async { + final processing = Completer(); + final started = Completer(); + final deleted = []; + var commits = 0; + final downloads = OpdsDownloads( + httpClient: OpdsHttpClient(clientFactory: () => MockRelayClient((_) async => http.Response('book', 200))), + captureImport: () => BookImportSession( + process: (Uint8List _, String _) { + started.complete(); + return processing.future; + }, + deleteFile: (id) async => deleted.add(id), + isCurrent: () => true, + commit: (_, _) async { + commits++; + throw StateError('must not commit'); + }, + ), + ); + final operation = downloads.start(catalog, publication, link); + await started.future; + downloads.reset(); + processing.complete(result()); + await operation; + expect(deleted, ['imported']); + expect(commits, 0); + expect(downloads.jobs, isEmpty); + downloads.dispose(); + }); + + test('duplicate in-flight requests share one job and failed imports clean up', () async { + final processing = Completer(); + var requests = 0; + final deleted = []; + final downloads = OpdsDownloads( + httpClient: OpdsHttpClient( + clientFactory: () => MockRelayClient((_) async { + requests++; + return http.Response('book', 200); + }), + ), + captureImport: () => BookImportSession( + process: (_, _) => processing.future, + deleteFile: (id) async => deleted.add(id), + isCurrent: () => true, + commit: (_, _) async => throw StateError('commit failed'), + ), + ); + final first = downloads.start(catalog, publication, link); + final second = downloads.start(catalog, publication, link); + processing.complete(result()); + await Future.wait([first, second]); + expect(requests, 1); + expect(deleted, ['imported']); + expect(downloads.jobs.single.status, OpdsDownloadStatus.failed); + downloads.dispose(); + }); +} diff --git a/app/test/opds/http_client_test.dart b/app/test/opds/http_client_test.dart new file mode 100644 index 0000000..ceebbcb --- /dev/null +++ b/app/test/opds/http_client_test.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; + +void main() { + final catalog = OpdsCatalog(id: 'one', name: 'Books', uri: Uri.parse('https://books.test/opds')); + const credentials = OpdsCredentials(username: 'reader', password: 'secret'); + + test('uses the currently selected server for subsequent requests', () async { + final seen = []; + var server = 'https://papyrus.test'; + final gateway = OpdsHttpClient( + apiConfig: () => PapyrusApiConfig(serverBaseUri: Uri.parse(server)), + clientFactory: () => MockClient((request) async { + seen.add(request); + return http.Response('book', 200, headers: {'x-opds-url': 'https://cdn.test/book.epub'}); + }), + ); + final response = await gateway.get(catalog, catalog.uri, credentials: credentials); + server = 'https://custom.test'; + await gateway.get(catalog, catalog.uri); + expect(seen.first.url.host, 'papyrus.test'); + expect(seen.last.url.host, 'custom.test'); + expect(seen.first.headers.containsKey('authorization'), isFalse); + expect(seen.last.headers.containsKey('authorization'), isFalse); + expect(response.uri, Uri.parse('https://cdn.test/book.epub')); + expect(utf8.decode(response.bytes), 'book'); + }); + + test('reports authentication errors without exposing credentials', () async { + final gateway = OpdsHttpClient(clientFactory: () => MockClient((_) async => http.Response('private', 401))); + await expectLater( + gateway.get(catalog, catalog.uri, credentials: credentials), + throwsA(isA().having((e) => e.message, 'message', contains('credentials'))), + ); + }); + + test('distinguishes relay connection failures from HTTP failures', () async { + final gateway = OpdsHttpClient( + clientFactory: () => MockClient((_) async { + throw http.ClientException('Failed to fetch'); + }), + ); + await expectLater(gateway.get(catalog, catalog.uri), throwsA(isA())); + final denied = OpdsHttpClient(clientFactory: () => MockClient((_) async => http.Response('', 403))); + await expectLater( + denied.get(catalog, catalog.uri), + throwsA(isA().having((error) => error is OpdsConnectionException, 'connection failure', isFalse)), + ); + }); + + test('rejects unsafe and credential-bearing resource URLs', () async { + final gateway = OpdsHttpClient(); + for (final url in ['file:///etc/passwd', 'https://reader:secret@books.test/opds']) { + await expectLater(gateway.get(catalog, Uri.parse(url)), throwsA(isA())); + } + }); + + test('enforces response size and reports progress', () async { + final gateway = OpdsHttpClient( + clientFactory: () => + MockClient((_) async => http.Response('12345', 200, headers: {'x-opds-url': catalog.uri.toString()})), + ); + await expectLater(gateway.get(catalog, catalog.uri, maxBytes: 4), throwsA(isA())); + final progress = []; + await gateway.get(catalog, catalog.uri, onProgress: (received, total) => progress.add(received)); + expect(progress.last, 5); + }); + + test('cancelled operations cannot start requests', () async { + final cancellation = OpdsCancellation()..cancel(); + await expectLater( + OpdsHttpClient().get(catalog, catalog.uri, cancellation: cancellation), + throwsA(isA()), + ); + }); +} diff --git a/app/test/opds/layout_test.dart b/app/test/opds/layout_test.dart new file mode 100644 index 0000000..6f22020 --- /dev/null +++ b/app/test/opds/layout_test.dart @@ -0,0 +1,496 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_library.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; +import 'package:papyrus/widgets/opds/opds_publication_details.dart'; +import 'package:papyrus/widgets/book_details/book_details_tab_rail.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/pages/catalog_book_page.dart'; +import 'package:papyrus/pages/catalogs_page.dart'; +import 'package:papyrus/providers/library_provider.dart'; +import 'package:papyrus/providers/sidebar_provider.dart'; +import 'package:papyrus/services/book_import_result.dart'; +import 'package:papyrus/services/book_import_session.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/book_details/book_header.dart'; +import 'package:papyrus/widgets/library/book_grid.dart'; +import 'package:papyrus/widgets/opds/catalog_source_tile.dart'; +import 'package:papyrus/providers/enums/library_view_mode.dart'; +import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'catalog_store_test.dart' show MemorySecrets; +import 'relay_fixture_client.dart'; + +const _capture = bool.fromEnvironment('CAPTURE_OPDS'); +const _fontDirectory = String.fromEnvironment('FLUTTER_FONT_DIR'); +final _boundary = GlobalKey(); + +ThemeData _captureTheme(ThemeData theme) { + if (_fontDirectory.isEmpty) return theme; + // Explicit component styles omit a font family in the app. Flutter's test + // fallback is Ahem rather than the platform font, so resolve it for captures. + ButtonStyle? font(ButtonStyle? style) => style?.copyWith( + textStyle: WidgetStateProperty.resolveWith( + (states) => style.textStyle?.resolve(states)?.copyWith(fontFamily: 'Roboto'), + ), + ); + return theme.copyWith( + appBarTheme: theme.appBarTheme.copyWith( + titleTextStyle: theme.appBarTheme.titleTextStyle?.copyWith(fontFamily: 'Roboto'), + ), + 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 _settle(WidgetTester tester) async { + for (var hop = 0; hop < 3; hop++) { + await tester.pumpAndSettle(); + await tester.runAsync(() => Future.delayed(Duration.zero)); + } + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); +} + +Future _snapshot(WidgetTester tester, String name) async { + await _settle(tester); + if (!_capture) return; + // Image codecs run outside the widget-test fake clock. + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 120))); + await tester.pumpAndSettle(); + await tester.runAsync(() async { + final boundary = _boundary.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + final directory = Directory('build/catalog-review')..createSync(recursive: true); + await File('${directory.path}/$name.png').writeAsBytes(data!.buffer.asUint8List()); + image.dispose(); + }); +} + +void main() { + setUpAll(() async { + if (_fontDirectory.isNotEmpty) { + for (final (family, file) in [('Roboto', 'Roboto-Regular.ttf'), ('MaterialIcons', 'MaterialIcons-Regular.otf')]) { + await (FontLoader( + family, + )..addFont(Future.value(ByteData.sublistView(File('$_fontDirectory/$file').readAsBytesSync())))).load(); + } + await (FontLoader('MadimiOne')..addFont(rootBundle.load('fonts/MadimiOne-Regular.ttf'))).load(); + } + }); + + for (final (name, theme) in [('light', AppTheme.light), ('dark', AppTheme.dark), ('eink', AppTheme.eink)]) { + for (final (width, scale) in [ + (360.0, 1.0), + (424.0, 1.0), + (600.0, 1.0), + (840.0, 1.0), + (1280.0, 1.0), + (2507.0, 1.0), + (360.0, 2.0), + ]) { + testWidgets('$name $width text $scale catalog journey in the app shell', (tester) async { + tester.view.physicalSize = Size( + width, + width > 2000 + ? 1322 + : width == 424 + ? 951 + : 900, + ); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + SharedPreferences.setMockInitialValues({}); + final store = OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets()); + await store.save( + 'local--guest', + OpdsCatalog(id: 'gutenberg', name: 'Project Gutenberg', uri: Uri.parse('https://www.gutenberg.org/feed')), + ); + await store.save( + 'local--guest', + OpdsCatalog(id: 'standard', name: 'Standard Ebooks', uri: Uri.parse('https://standard.test/opds')), + ); + final dataStore = DataStore(); + final opdsLibrary = OpdsLibrary(await SharedPreferences.getInstance(), dataStore: dataStore); + final cache = OpdsResourceCache(await SharedPreferences.getInstance()); + final catalogs = OpdsCatalogs(store, library: opdsLibrary, cache: cache)..setScope('local--guest'); + final cover = File('assets/images/book_placeholder.jpg').readAsBytesSync(); + var offline = false; + final gateway = OpdsHttpClient( + cache: cache, + clientFactory: () => MockRelayClient((request) async { + if (offline) throw http.ClientException('Offline'); + if (request.url.path.endsWith('.jpg')) { + return http.Response.bytes(cover, 200, headers: {'content-type': 'image/jpeg'}); + } + if (request.url.path.endsWith('.epub')) { + return http.Response('Review book bytes', 200, headers: {'content-type': 'application/epub+zip'}); + } + return http.Response( + jsonEncode({ + 'metadata': { + 'title': request.url.path == '/editions' ? 'Pride and Prejudice by Jane Austen' : 'Popular books', + }, + 'links': [ + {'rel': 'previous', 'href': '/feed?page=1'}, + {'rel': 'next', 'href': '/feed?page=3'}, + ], + 'navigation': [ + if (request.url.path != '/editions') + { + 'title': 'Browse by author', + 'href': '/authors', + 'description': 'Explore writers and their collections', + }, + ], + 'publications': [ + for (final (id, title, author) + in request.url.path == '/editions' + ? [ + ('pride', 'Pride and Prejudice', 'Jane Austen'), + ('pride-images', 'Pride and Prejudice', 'Jane Austen'), + ] + : [ + ('pride', 'Pride and Prejudice', 'Jane Austen'), + ('moby', 'Moby Dick; or, The Whale', 'Herman Melville'), + ( + 'frankenstein', + 'Frankenstein; or, The Modern Prometheus', + 'Mary Wollstonecraft Shelley', + ), + ('alice', 'Alice’s Adventures in Wonderland', 'Lewis Carroll'), + ]) + { + 'metadata': { + 'identifier': id, + 'title': title, + 'author': author, + 'language': 'English', + 'publisher': 'Project Gutenberg', + 'subject': ['Fiction', 'Courtship', 'England — Social life and customs'], + 'description': + 'Title: $title\n\nSummary: A classic novel, available to add to your Papyrus library.\n\n' + 'This edition preserves the original text and includes illustrations and notes.\n\n' + 'Language: English\n\nPublished: 1813\n\nRights: Public domain in the USA.\n\n' + 'Credits: Prepared by volunteers.\n\nNote: Additional catalog information is preserved here.', + }, + if (id != 'frankenstein') + 'images': [ + {'href': '/cover.jpg', 'type': 'image/jpeg'}, + ], + 'links': [ + { + 'rel': 'download', + 'href': '/$id.epub', + 'type': 'application/epub+zip', + 'title': request.url.path == '/editions' + ? id == 'pride' + ? 'EPUB (no images, older E-readers)' + : 'EPUB3 (E-readers incl. Send-to-Kindle)' + : 'EPUB with illustrations', + }, + { + 'rel': 'download', + 'href': '/$id-text.epub', + 'type': 'application/epub+zip', + 'title': 'EPUB, text only', + }, + {'rel': 'download', 'href': '/$id.daisy', 'type': 'application/x-dtbook+xml', 'title': 'DAISY'}, + ], + }, + ], + }), + 200, + headers: {'content-type': 'application/opds+json; charset=utf-8'}, + ); + }), + ); + final downloads = OpdsDownloads( + library: opdsLibrary, + httpClient: gateway, + captureImport: () => BookImportSession( + process: (_, _) async => const BookImportResult( + bookId: 'review-book', + title: 'Pride and Prejudice', + author: 'Jane Austen', + fileSize: 17, + fileHash: 'review', + fileExtension: 'epub', + ), + deleteFile: (_) async {}, + isCurrent: () => true, + commit: (_, _) async { + final book = Book( + id: 'review-book', + title: 'Pride and Prejudice', + author: 'Jane Austen', + addedAt: DateTime(2026), + ); + dataStore.replaceBooksFromSync([book]); + return book; + }, + ), + ); + final sidebar = SidebarProvider(); + final library = LibraryProvider(); + final reference = Book( + id: 'reference', + title: 'Pride and Prejudice', + author: 'Jane Austen', + addedAt: DateTime(2026), + ); + final router = GoRouter( + initialLocation: '/library/catalogs', + routes: [ + ShellRoute( + builder: (_, _, child) => AdaptiveAppShell(child: child), + routes: [ + GoRoute( + path: '/library/catalogs', + builder: (_, _) => const CatalogsPage(), + routes: [ + GoRoute( + path: ':catalogId', + builder: (_, state) => CatalogsPage( + catalogId: state.pathParameters['catalogId'], + feedUri: Uri.tryParse(state.uri.queryParameters['feed'] ?? '')?.hasScheme == true + ? Uri.parse(state.uri.queryParameters['feed']!) + : null, + ), + routes: [catalogBookRoute()], + ), + ], + ), + GoRoute( + path: '/reference', + builder: (context, _) => Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + BookHeader(book: reference, isDesktop: width >= 1280), + const SizedBox(height: 24), + Expanded( + child: BookGrid(books: [reference], libraryViewMode: LibraryViewMode.smallGrid), + ), + ], + ), + ), + ), + ], + ), + ], + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: catalogs), + ChangeNotifierProvider.value(value: downloads), + ChangeNotifierProvider.value(value: dataStore), + ChangeNotifierProvider.value(value: sidebar), + ChangeNotifierProvider.value(value: library), + Provider.value(value: gateway), + ], + child: RepaintBoundary( + key: _boundary, + child: MaterialApp.router( + theme: _captureTheme(theme), + debugShowCheckedModeBanner: false, + routerConfig: router, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: AppMotionScope(reduceAnimations: true, child: child!), + ), + ), + ), + ), + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + router.dispose(); + catalogs.dispose(); + downloads.dispose(); + opdsLibrary.dispose(); + cache.dispose(); + dataStore.dispose(); + sidebar.dispose(); + library.dispose(); + }); + final label = '$name-${width.toInt()}-${scale.toInt()}x'; + await _snapshot(tester, '$label-sources'); + if (width < 840) { + expect(tester.getSize(find.byKey(const Key('catalog-mobile-header'))).height, kToolbarHeight); + expect(find.byKey(const Key('catalog-header-divider')), findsNothing); + expect(find.byTooltip('Catalog options'), findsNothing); + for (final (direction, action) in [(1.0, 'edit'), (-1.0, 'delete')]) { + final sourceRect = tester.getRect(find.byType(CatalogSourceTile).first); + final gesture = await tester.startGesture(sourceRect.center); + await gesture.moveBy(Offset(direction * 20, 0)); + await tester.pump(); + await gesture.moveBy(Offset(direction * sourceRect.width * 0.6, 0)); + await _snapshot(tester, '$label-source-$action-swipe'); + expect(find.byType(BottomSheet), findsNothing); + await gesture.up(); + await _settle(tester); + expect(find.byType(BottomSheet), findsOneWidget); + if (action == 'edit') { + expect(find.byKey(const Key('opds-name')), findsOneWidget); + } else { + expect(find.text('Remove catalog'), findsOneWidget); + } + await tester.tap(find.text('Cancel')); + await _settle(tester); + } + expect(find.byType(FloatingActionButton), findsOneWidget); + expect( + tester.getBottomRight(find.byType(FloatingActionButton)).dy, + lessThan(tester.getTopLeft(find.byType(NavigationBar)).dy), + ); + expect( + tester.getTopLeft(find.byTooltip('Downloads')).dy, + lessThan(tester.getBottomLeft(find.text('Catalogs').last).dy), + ); + } else { + expect(find.byType(FloatingActionButton), findsNothing); + expect(find.widgetWithText(FilledButton, 'Add catalog'), findsOneWidget); + } + await tester.tap(find.text('Project Gutenberg')); + await _snapshot(tester, '$label-feed'); + offline = true; + await tester.tap(find.byTooltip('Refresh catalog')); + await _snapshot(tester, '$label-cached-feed'); + expect(find.text('Showing saved content. Could not refresh this catalog.'), findsOneWidget); + offline = false; + await tester.tap(find.text('Retry')); + await _settle(tester); + expect(find.byType(FloatingActionButton), findsNothing); + expect( + find.text('Popular books'), + findsOneWidget, + reason: tester.widgetList(find.byType(Text)).map((t) => t.data).join(' | '), + ); + await tester.scrollUntilVisible( + find.text('Next'), + 200, + scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first, + ); + await _snapshot(tester, '$label-pagination'); + tester + .state( + find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first, + ) + .position + .jumpTo(0); + await tester.pumpAndSettle(); + router.go( + Uri(path: '/library/catalogs/gutenberg', queryParameters: {'feed': 'https://books.test/editions'}).toString(), + ); + await _snapshot(tester, '$label-editions'); + expect(find.text('Pride and Prejudice'), findsNWidgets(2)); + router.go('/library/catalogs/gutenberg'); + await _settle(tester); + await tester.scrollUntilVisible( + find.text('Pride and Prejudice'), + 200, + scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pride and Prejudice')); + await _snapshot(tester, '$label-details'); + if (width < 840) { + final header = find.byKey(const Key('catalog-book-mobile-header')); + expect(tester.getSize(header).height, kToolbarHeight); + expect(find.byKey(const Key('catalog-book-header-divider')), findsNothing); + final rail = find.byType(BookDetailsTabRail); + await tester.dragFrom(Offset(width / 2, 700), const Offset(0, -1600)); + await tester.pumpAndSettle(); + expect(tester.getRect(rail).left, 0); + expect(tester.getRect(rail).width, width); + expect(tester.getTopLeft(rail).dy, tester.getBottomLeft(header).dy); + await _snapshot(tester, '$label-details-scrolled'); + } + expect(find.text('Details'), findsOneWidget); + await tester.ensureVisible(find.text('Description')); + await _snapshot(tester, '$label-details-content'); + if (find.byType(NestedScrollView).evaluate().isNotEmpty) { + await tester.dragFrom(Offset(width / 2, 200), const Offset(0, 2500)); + await tester.pumpAndSettle(); + } + await tester.ensureVisible(find.text('Add to library')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add to library')); + await _snapshot(tester, '$label-formats'); + await tester.ensureVisible(find.textContaining('Other catalog options')); + await tester.tap(find.textContaining('Other catalog options')); + await _snapshot(tester, '$label-formats-expanded'); + if (_capture && width >= 840) { + final mouse = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.textContaining('Other catalog options'))); + await _snapshot(tester, '$label-formats-hover'); + await mouse.removePointer(); + } + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + final selected = tester.widget(find.byType(OpdsPublicationDetails)).publication; + await tester.tap(find.byTooltip('Downloads')); + await _snapshot(tester, '$label-downloads'); + final reviewLink = selected.links.firstWhere(OpdsDownloads.supports); + final transfer = downloads.start(catalogs.catalogs.first, selected, reviewLink); + await _settle(tester); + await transfer; + await _snapshot(tester, '$label-downloads-complete'); + expect(find.text('Added to library'), findsOneWidget); + expect(find.descendant(of: find.byType(BottomSheet), matching: find.text('Open book')), findsOneWidget); + await tester.tap(find.text('Close')); + await _snapshot(tester, '$label-owned-details'); + expect(find.text('In library'), findsNothing); + expect(find.text('Open book'), findsOneWidget); + await tester.tap(find.text('Download options')); + await _snapshot(tester, '$label-owned-formats'); + await tester.tap(find.text('Close')); + router.go('/library/catalogs'); + await _settle(tester); + await tester.tap( + find.byType(FloatingActionButton).evaluate().isNotEmpty + ? find.byTooltip('Add catalog') + : find.text('Add catalog'), + ); + await _snapshot(tester, '$label-editor'); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await _snapshot(tester, '$label-editor-keyboard'); + tester.view.resetViewInsets(); + await tester.tap(find.text('Cancel')); + await _settle(tester); + if (_capture && width >= 1280 && scale == 1) { + router.go('/reference'); + await _snapshot(tester, '$label-library-reference'); + } + }); + } + } +} diff --git a/app/test/opds/library_test.dart b/app/test/opds/library_test.dart new file mode 100644 index 0000000..1397f5d --- /dev/null +++ b/app/test/opds/library_test.dart @@ -0,0 +1,233 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_library.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/services/book_import_result.dart'; +import 'package:papyrus/services/book_import_session.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'catalog_store_test.dart' show MemorySecrets; +import 'relay_fixture_client.dart'; + +final _catalog = OpdsCatalog(id: 'one', name: 'Books', uri: Uri.parse('https://books.test/feed')); +final _epub = OpdsLink( + uri: Uri.parse('https://books.test/book.epub'), + type: 'application/epub+zip', + rels: ['download'], +); +final _pdf = OpdsLink(uri: Uri.parse('https://books.test/book.pdf'), type: 'application/pdf', rels: ['download']); +final _publication = OpdsPublication(id: 'publication', title: 'Book', links: [_epub, _pdf]); +final _book = Book(id: 'local-book', title: 'Book', author: 'Author', addedAt: DateTime(2026)); + +void main() { + late SharedPreferences prefs; + late DataStore store; + late OpdsLibrary library; + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + store = DataStore(); + await store.waitUntilLoaded(); + library = OpdsLibrary(prefs, dataStore: store)..setScope('guest'); + }); + tearDown(() { + library.dispose(); + store.dispose(); + }); + + test('exact publication and format survive restart and wait for loaded library', () async { + await library.record(library.capture(_catalog, _publication, _epub)!, _book.id); + library.dispose(); + library = OpdsLibrary(prefs, dataStore: store)..setScope('guest'); + expect(library.bookId(_catalog, _publication), isNull); + store.replaceBooksFromSync([_book]); + expect(library.bookId(_catalog, _publication), _book.id); + expect(library.bookId(_catalog, _publication, link: _epub), _book.id); + expect(library.bookId(_catalog, _publication, link: _pdf), isNull); + expect(library.bookId(_catalog, OpdsPublication(id: 'other-edition', title: 'Book')), isNull); + expect(library.bookId(OpdsCatalog(id: 'other', name: 'Books', uri: _catalog.uri), _publication), isNull); + expect( + library.bookId(OpdsCatalog(id: 'one', name: 'Books', uri: Uri.parse('https://other.test')), _publication), + isNull, + ); + store.replaceBooksFromSync([]); + expect(library.bookId(_catalog, _publication), isNull); + }); + + test('preview import does not mark the full publication as owned', () async { + store.replaceBooksFromSync([_book]); + final sample = OpdsLink(uri: Uri.parse('https://books.test/sample.epub'), rels: ['preview']); + await library.record(library.capture(_catalog, _publication, sample)!, _book.id); + expect(library.bookId(_catalog, _publication), isNull); + expect(library.bookId(_catalog, _publication, link: sample), _book.id); + }); + + test('scope switches isolate matches and reject late results even after switching back', () async { + store.replaceBooksFromSync([_book]); + final lateImport = library.capture(_catalog, _publication, _epub)!; + library.setScope('account'); + await library.record(lateImport, _book.id); + expect(library.bookId(_catalog, _publication), isNull); + library.setScope('guest'); + await library.record(lateImport, _book.id); + expect(library.bookId(_catalog, _publication), isNull); + await library.record(library.capture(_catalog, _publication, _epub)!, _book.id); + library.setScope('account'); + expect(library.bookId(_catalog, _publication), isNull); + library.setScope('guest'); + expect(library.bookId(_catalog, _publication), _book.id); + }); + + test('corrupt persisted identities do not break library lookup', () async { + await prefs.setString('papyrus.opds.imports.v1.account', '{"bad-key":{"book":"local-book","sample":false}}'); + library.setScope('account'); + store.replaceBooksFromSync([_book]); + expect(library.bookId(_catalog, _publication), isNull); + }); + + test('switching back before a pending persistence write preserves the in-memory identity', () async { + store.replaceBooksFromSync([_book]); + final save = library.record(library.capture(_catalog, _publication, _epub)!, _book.id); + library.setScope('account'); + library.setScope('guest'); + expect(library.bookId(_catalog, _publication), _book.id); + await save; + library.setScope('account'); + final remove = library.forgetCatalog(_catalog.id, scope: 'guest'); + library.setScope('guest'); + expect(library.bookId(_catalog, _publication), isNull); + await remove; + }); + + test('renaming preserves provenance; credentials and removal invalidate it', () async { + final catalogs = OpdsCatalogs(OpdsCatalogStore(prefs, secrets: MemorySecrets()), library: library) + ..setScope('guest'); + addTearDown(catalogs.dispose); + await catalogs.save(_catalog); + store.replaceBooksFromSync([_book]); + await library.record(library.capture(_catalog, _publication, _epub)!, _book.id); + await catalogs.save(OpdsCatalog(id: 'one', name: 'Renamed', uri: _catalog.uri)); + expect(library.bookId(_catalog, _publication), _book.id); + await catalogs.save( + _catalog, + credentials: const OpdsCredentials(username: 'new', password: 'secret'), + ); + expect(library.bookId(_catalog, _publication), isNull); + await library.record(library.capture(_catalog, _publication, _epub)!, _book.id); + await catalogs.remove(_catalog.id); + expect(library.bookId(_catalog, _publication), isNull); + expect(store.getBook(_book.id), isNotNull); + }); + + test('successful import is remembered; duplicate acquisition skips network; deletion permits reimport', () async { + var requests = 0; + var commits = 0; + final downloads = OpdsDownloads( + library: library, + httpClient: OpdsHttpClient( + clientFactory: () => MockRelayClient((_) async { + requests++; + return http.Response('book', 200); + }), + ), + captureImport: () => BookImportSession( + process: (_, _) async => const BookImportResult( + bookId: 'local-book', + title: 'Book', + author: 'Author', + fileSize: 4, + fileHash: 'hash', + fileExtension: 'epub', + ), + deleteFile: (_) async {}, + isCurrent: () => true, + commit: (_, _) async { + commits++; + store.replaceBooksFromSync([_book]); + return _book; + }, + ), + ); + addTearDown(downloads.dispose); + await downloads.start(_catalog, _publication, _epub); + expect(library.bookId(_catalog, _publication), _book.id); + downloads.reset(); + await downloads.start(_catalog, _publication, _epub); + expect(requests, 1); + expect(commits, 1); + expect(downloads.jobs.single.status, OpdsDownloadStatus.complete); + store.replaceBooksFromSync([]); + await downloads.start(_catalog, _publication, _epub); + expect(requests, 2); + expect(commits, 2); + }); + + test('commit failure never records ownership', () async { + final downloads = OpdsDownloads( + library: library, + httpClient: OpdsHttpClient(clientFactory: () => MockRelayClient((_) async => http.Response('book', 200))), + captureImport: () => BookImportSession( + process: (_, _) async => const BookImportResult( + bookId: 'local-book', + title: 'Book', + author: 'Author', + fileSize: 4, + fileHash: 'hash', + fileExtension: 'epub', + ), + deleteFile: (_) async {}, + isCurrent: () => true, + commit: (_, _) async => throw StateError('Commit failed'), + ), + ); + addTearDown(downloads.dispose); + await downloads.start(_catalog, _publication, _epub); + store.replaceBooksFromSync([_book]); + expect(library.bookId(_catalog, _publication), isNull); + expect(downloads.jobs.single.status, OpdsDownloadStatus.failed); + }); + + test('account switch during commit cannot record in the newly selected library', () async { + final commitStarted = Completer(); + final committed = Completer(); + final downloads = OpdsDownloads( + library: library, + httpClient: OpdsHttpClient(clientFactory: () => MockRelayClient((_) async => http.Response('book', 200))), + captureImport: () => BookImportSession( + process: (_, _) async => const BookImportResult( + bookId: 'local-book', + title: 'Book', + author: 'Author', + fileSize: 4, + fileHash: 'hash', + fileExtension: 'epub', + ), + deleteFile: (_) async {}, + isCurrent: () => true, + commit: (_, _) { + commitStarted.complete(); + return committed.future; + }, + ), + ); + addTearDown(downloads.dispose); + final operation = downloads.start(_catalog, _publication, _epub); + await commitStarted.future; + library.setScope('other'); + downloads.reset(); + store.replaceBooksFromSync([_book]); + committed.complete(_book); + await operation; + expect(library.bookId(_catalog, _publication), isNull); + library.setScope('guest'); + expect(library.bookId(_catalog, _publication), isNull); + }); +} diff --git a/app/test/opds/library_widgets_test.dart b/app/test/opds/library_widgets_test.dart new file mode 100644 index 0000000..561e58c --- /dev/null +++ b/app/test/opds/library_widgets_test.dart @@ -0,0 +1,169 @@ +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/models/book.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_library.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/opds/opds_feed_view.dart'; +import 'package:papyrus/widgets/opds/opds_publication_details.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final catalog = OpdsCatalog(id: 'c', name: 'Catalog', uri: Uri.parse('https://books.test/feed')); + final epub = OpdsLink( + uri: Uri.parse('https://books.test/book.epub'), + type: 'application/epub+zip', + title: 'EPUB (no images, older E-readers)', + rels: ['download'], + ); + final pdf = OpdsLink(uri: Uri.parse('https://books.test/book.pdf'), type: 'application/pdf', rels: ['download']); + final publication = OpdsPublication(id: 'one', title: 'A book', links: [epub, pdf]); + final book = Book(id: 'local', title: 'A book', author: 'Author', addedAt: DateTime(2026)); + late DataStore store; + late OpdsLibrary library; + late OpdsDownloads downloads; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + store = DataStore(); + await store.waitUntilLoaded(); + store.replaceBooksFromSync([book]); + library = OpdsLibrary(prefs, dataStore: store)..setScope('guest'); + await library.record(library.capture(catalog, publication, epub)!, book.id); + library.dispose(); + library = OpdsLibrary(prefs, dataStore: store)..setScope('guest'); + downloads = OpdsDownloads(library: library, captureImport: () => throw StateError('Must not import')); + }); + tearDown(() { + downloads.dispose(); + library.dispose(); + store.dispose(); + }); + + for (final (width, scale) in [(360.0, 1.0), (424.0, 1.0), (360.0, 2.0)]) { + testWidgets('restored ownership opens local book and updates details/options after deletion at $width / $scale', ( + tester, + ) async { + tester.view.physicalSize = Size(width, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final router = GoRouter( + initialLocation: '/catalog', + routes: [ + GoRoute( + path: '/catalog', + builder: (_, _) => Scaffold( + body: OpdsPublicationDetails( + catalog: catalog, + publication: publication, + httpClient: OpdsHttpClient(), + downloads: downloads, + onDownload: (_) => throw StateError('Already imported'), + onNavigate: (_) {}, + ), + ), + ), + GoRoute( + path: '/library/details/:id', + builder: (_, state) => Scaffold(body: Text('Local ${state.pathParameters['id']}')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + theme: AppTheme.eink, + routerConfig: router, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('In library'), findsNothing); + expect(find.text('Open book'), findsOneWidget); + expect(find.text('Add to library'), findsNothing); + await tester.tap(find.text('Download options')); + await tester.pumpAndSettle(); + final sheetOpen = find.descendant(of: find.byType(BottomSheet), matching: find.text('Open book')); + expect(sheetOpen, findsOneWidget); + final formatRect = tester.getRect(find.text('EPUB')); + final detailRect = tester.getRect(find.text(epub.title!)); + final openRect = tester.getRect(find.ancestor(of: sheetOpen, matching: find.byType(FilledButton))); + if (scale == 1) { + expect(openRect.center.dy, closeTo((formatRect.top + detailRect.bottom) / 2, 0.1)); + expect(openRect.left, greaterThan(detailRect.right)); + } else { + expect(openRect.top, greaterThan(detailRect.bottom)); + } + expect(find.text('Download PDF'), findsOneWidget); + expect(find.text('Download EPUB'), findsNothing); + await tester.tap(sheetOpen); + await tester.pumpAndSettle(); + expect(find.text('Local local'), findsOneWidget); + router.go('/catalog'); + await tester.pumpAndSettle(); + // A completed job must not override a later deletion from the real library. + await downloads.start(catalog, publication, epub); + store.replaceBooksFromSync([]); + await tester.pumpAndSettle(); + expect(find.text('In library'), findsNothing); + expect(find.text('Add to library'), findsOneWidget); + await tester.tap(find.text('Add to library')); + await tester.pumpAndSettle(); + expect(find.text('Download EPUB'), findsOneWidget); + expect(find.text('Open book'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + for (final grid in [true, false]) { + testWidgets('publication ${grid ? 'grid' : 'list'} distinguishes owned and other editions', (tester) async { + final feed = OpdsFeed( + uri: catalog.uri, + title: 'Books', + publications: [ + publication, + OpdsPublication(id: 'another-edition', title: 'A book', links: [epub]), + ], + ); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + body: AnimatedBuilder( + animation: downloads, + builder: (_, _) => OpdsFeedView( + catalog: catalog, + feed: feed, + httpClient: OpdsHttpClient(), + onNavigate: (_) {}, + onOpenPublication: (_) {}, + onRefresh: () {}, + isGridView: grid, + onViewChanged: (_) {}, + onPage: (_) {}, + libraryBookId: (entry) => downloads.libraryBookId(catalog, entry), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('In library'), findsOneWidget); + store.replaceBooksFromSync([]); + await tester.pumpAndSettle(); + expect(find.textContaining('In library'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } +} diff --git a/app/test/opds/network_smoke_test.dart b/app/test/opds/network_smoke_test.dart new file mode 100644 index 0000000..440652b --- /dev/null +++ b/app/test/opds/network_smoke_test.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_parser.dart'; +import 'package:papyrus/opds/opds_search.dart'; + +const _smokeUrl = String.fromEnvironment('OPDS_SMOKE_URL'); +const _credentials = OpdsCredentials(username: 'reader', password: 'secret'); + +void main() { + group('local OPDS network smoke', () { + final base = Uri.parse(_smokeUrl); + final gateway = OpdsHttpClient(apiConfig: () => PapyrusApiConfig(serverBaseUri: base)); + + for (final path in ['/no-cors/redirect', '/no-cors/book.epub']) { + test('relay downloads $path even when the upstream omits CORS headers', () async { + final catalog = OpdsCatalog(id: 'cors', name: 'No CORS', uri: base.resolve(path)); + final response = await gateway.get(catalog, catalog.uri); + final archive = ZipDecoder().decodeBytes(response.bytes, verify: true); + expect(utf8.decode(archive.findFile('mimetype')!.content), 'application/epub+zip'); + }); + } + + Future fetch(OpdsCatalog catalog, Uri uri, {OpdsCredentials? credentials}) async { + final response = await gateway.get(catalog, uri, credentials: credentials); + return OpdsParser.parse(response.text, response.uri, contentType: response.headers['content-type']); + } + + for (final version in ['v1', 'v2']) { + for (final protected in [false, true]) { + final access = protected ? 'protected' : 'public'; + final extension = version == 'v1' ? 'xml' : 'json'; + test('$access $version navigation, details, search, pagination and EPUB download', () async { + final catalog = OpdsCatalog( + id: '$access-$version', + name: 'Fixture', + uri: base.resolve('/$access/$version/catalog.$extension'), + ); + final credentials = protected ? _credentials : null; + final root = await fetch(catalog, catalog.uri, credentials: credentials); + expect(root.title, 'Fixture catalog'); + final booksUri = root.navigation.single.uri; + expect(booksUri.path, '/$access/$version/books/feed.$extension'); + final books = await fetch(catalog, booksUri, credentials: credentials); + final publication = books.publications.single; + expect(publication.title, 'Network fixture book'); + expect(publication.authors, ['Fixture Author']); + + final detail = await fetch(catalog, publication.detailLink!.uri, credentials: credentials); + expect(detail.publications.single.description, 'Complete publication details.'); + final next = await fetch(catalog, books.nextLink!.uri, credentials: credentials); + expect(next.title, 'Second page'); + expect(next.previousLink!.uri, booksUri); + + var search = root.searchLink!; + if (version == 'v1') { + final description = await gateway.get(catalog, search.uri, credentials: credentials); + search = OpdsSearch.fromOpenSearch(description.text, description.uri); + } + const query = 'tea & coffee ž'; + final results = await fetch( + catalog, + Uri.parse(OpdsSearch.expand(search.template, query)), + credentials: credentials, + ); + expect(results.title, 'Search: $query'); + + final download = publication.links.singleWhere((link) => link.supportedExtension == 'epub'); + expect(download.uri.path, '/$access/$version/book.epub'); + final received = []; + final response = await gateway.get( + catalog, + download.uri, + credentials: credentials, + onProgress: (bytes, total) => received.add(bytes), + ); + expect(received.last, response.bytes.length); + final archive = ZipDecoder().decodeBytes(response.bytes, verify: true); + expect(utf8.decode(archive.findFile('mimetype')!.content), 'application/epub+zip'); + expect(archive.findFile('META-INF/container.xml'), isNotNull); + expect(utf8.decode(archive.findFile('EPUB/package.opf')!.content), contains('Network fixture book')); + }); + } + } + + test('protected catalog rejects missing and incorrect credentials', () async { + final catalog = OpdsCatalog(id: 'auth', name: 'Protected', uri: base.resolve('/protected/v1/catalog.xml')); + for (final credentials in [null, const OpdsCredentials(username: 'reader', password: 'wrong')]) { + await expectLater( + gateway.get(catalog, catalog.uri, credentials: credentials), + throwsA(isA().having((error) => error.message, 'message', contains('credentials'))), + ); + } + }); + + for (final version in ['v1', 'v2']) { + test('$version redirects retain the final URL for relative publication links', () async { + final extension = version == 'v1' ? 'xml' : 'json'; + final catalog = OpdsCatalog( + id: 'redirect-$version', + name: 'Redirect', + uri: base.resolve('/protected/redirect-$version'), + ); + final response = await gateway.get(catalog, catalog.uri, credentials: _credentials); + expect(response.uri.path, '/protected/$version/books/feed.$extension'); + final feed = OpdsParser.parse(response.text, response.uri); + final download = feed.publications.single.links.singleWhere((link) => link.supportedExtension == 'epub'); + expect(download.uri.path, '/protected/$version/book.epub'); + expect((await gateway.get(catalog, download.uri, credentials: _credentials)).bytes.take(2), [0x50, 0x4b]); + }); + } + + test('cross-origin redirect never forwards Basic credentials', () async { + final catalog = OpdsCatalog( + id: 'redirect-auth', + name: 'Redirect auth', + uri: base.resolve('/protected/cross-origin'), + ); + final response = await gateway.get(catalog, catalog.uri, credentials: _credentials); + expect(response.uri.host, 'localhost'); + expect(response.uri.origin, isNot(catalog.uri.origin)); + expect(jsonDecode(response.text), {'authorizationReceived': false}); + }); + }, skip: _smokeUrl.isEmpty ? 'Set OPDS_SMOKE_URL to the running local fixture server.' : false); +} diff --git a/app/test/opds/parser_presentation_test.dart b/app/test/opds/parser_presentation_test.dart new file mode 100644 index 0000000..0475cf3 --- /dev/null +++ b/app/test/opds/parser_presentation_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_parser.dart'; + +void main() { + final uri = Uri.parse('https://catalog.example/opds/index.xml'); + + test('plain-text descriptions retain paragraph breaks without excessive whitespace', () { + expect( + opdsPlainText(' First paragraph.\r\n\r\n\r\n Second paragraph. '), + 'First paragraph.\n\nSecond paragraph.', + ); + }); + + test('HTML blocks, line breaks and lists remain readable without markup', () { + expect( + opdsPlainText(''' +

About this book

+

A long\n paragraph with emphasis & a link + here.

+

Another paragraph.
On a new line.

+
  • First item
  • Second item
+ + +
'''), + 'About this book\n\nA long paragraph with emphasis & a link here.\n\n' + 'Another paragraph.\nOn a new line.\n\nFirst item\nSecond item', + ); + }); + + test('XHTML descriptions preserve metadata paragraphs across namespace prefixes', () { + final feed = OpdsParser.parse(''' + + book-1Book one + + This edition has images. + Title:\n Book one + Summary:\n A story with some emphasis. + Language: EnglishSubject: Fiction + + ''', uri); + expect( + feed.publications.single.description, + 'This edition has images.\n\nTitle: Book one\n\nSummary: A story with some emphasis.\n\n' + 'Language: English\n\nSubject: Fiction', + ); + }); + + test('JSON publication descriptions preserve paragraph formatting', () { + final feed = OpdsParser.parse('''{ + "metadata":{"title":"Books"}, + "publications":[{"metadata":{"title":"Book one", + "description":"

First paragraph.

Second & last paragraph.

"}}] + }''', uri); + expect(feed.publications.single.description, 'First paragraph.\n\nSecond & last paragraph.'); + }); + + test('authored partial entries can link to a complete acquisition feed', () { + final feed = OpdsParser.parse(''' + Books + book-1Book one + Book Author + + + + ''', uri); + final book = feed.publications.single; + expect(feed.navigation, isEmpty); + expect(book.title, 'Book one'); + expect(book.authors, ['Book Author']); + expect(book.detailLink!.uri, uri.resolve('book/1')); + expect(book.links.where((link) => link.isAcquisition), isEmpty); + }); + + test('category thumbnails and subsection author metadata do not imply publications', () { + final feed = OpdsParser.parse(''' + Browse + categoryFictionStories to explore. + + + + authorBooks by this author + An Author + + + ''', uri); + expect(feed.publications, isEmpty); + expect(feed.navigation.map((link) => link.title), ['Fiction', 'Books by this author']); + expect(feed.navigation.first.description, 'Stories to explore.'); + expect(feed.navigation.first.imageUri, uri.resolve('fiction.jpg')); + }); + + test('Gutenberg-style book navigation retains its author label and thumbnail', () { + // Minimal shape observed at https://www.gutenberg.org/ebooks/search.opds/: + // book links are subsections, with an author label in text content and a + // data URI placeholder image. Keep their metadata without guessing a type. + final feed = OpdsParser.parse(''' + All Books + https://www.gutenberg.org/ebooks/1342.opds + Pride and PrejudiceJane Austen + + + + ''', Uri.parse('https://www.gutenberg.org/ebooks/search.opds/')); + expect(feed.publications, isEmpty); + final link = feed.navigation.single; + expect(link.title, 'Pride and Prejudice'); + expect(link.description, 'Jane Austen'); + expect(link.uri.toString(), 'https://www.gutenberg.org/ebooks/1342.opds'); + expect(link.imageUri!.scheme, 'data'); + }); + + test('JSON navigation retains optional descriptions and image links', () { + final feed = OpdsParser.parse('''{ + "metadata":{"title":"Browse"}, + "navigation":[{"title":"Fiction","href":"fiction", + "description":"

Stories to explore.

", + "images":[{"href":"covers/fiction.jpg","type":"image/jpeg"}]}] + }''', uri); + expect(feed.navigation.single.description, 'Stories to explore.'); + expect(feed.navigation.single.imageUri, uri.resolve('covers/fiction.jpg')); + }); +} diff --git a/app/test/opds/parser_test.dart b/app/test/opds/parser_test.dart new file mode 100644 index 0000000..54f8f5f --- /dev/null +++ b/app/test/opds/parser_test.dart @@ -0,0 +1,335 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_parser.dart'; +import 'package:papyrus/opds/opds_search.dart'; + +void main() { + final uri = Uri.parse('https://books.example/catalog/index'); + const acquisition = 'http://opds-spec.org/acquisition'; + + test('details prefer a full cover even when a thumbnail is listed first', () { + final thumbnail = OpdsLink(uri: uri.resolve('small.jpg'), rels: ['http://opds-spec.org/image/thumbnail']); + final cover = OpdsLink(uri: uri.resolve('cover.jpg'), rels: ['http://opds-spec.org/image']); + expect(OpdsPublication(id: 'book', title: 'Book', images: [thumbnail, cover]).coverLink, same(cover)); + expect(OpdsPublication(id: 'book', title: 'Book', images: [thumbnail]).coverLink, same(thumbnail)); + expect(OpdsPublication(id: 'book', title: 'Book').coverLink, isNull); + final unmarked = OpdsLink(uri: uri.resolve('opds2-cover.jpg')); + expect(OpdsPublication(id: 'book', title: 'Book', images: [unmarked]).coverLink, same(unmarked)); + }); + + test('catalog serialization excludes embedded credentials', () { + final catalog = OpdsCatalog(id: 'one', name: 'Books', uri: Uri.parse('https://user:secret@books.example/catalog')); + expect(catalog.uri.userInfo, isEmpty); + expect(jsonEncode(catalog.toJson()), isNot(contains('secret'))); + final restored = OpdsCatalog.fromJson(catalog.toJson()); + expect(restored.id, 'one'); + expect(restored.name, 'Books'); + expect(restored.uri, catalog.uri); + }); + + test('sniffs XML with generic MIME and resolves nested xml:base', () { + final feed = OpdsParser.parse( + ''' + + Wrong titleCatalog + ''', + uri, + contentType: 'application/octet-stream', + ); + expect(feed.title, 'Catalog'); + expect(feed.navigation.single.title, 'Fiction'); + expect(feed.navigation.single.uri.toString(), 'https://books.example/root/genres/fiction'); + expect(feed.nextLink!.uri.toString(), 'https://books.example/root/?page=2'); + }); + + test('parses Atom publication details, metadata and indirect acquisitions', () { + final publication = OpdsParser.parse(''' + + urn:book:1Book + AliceBob +

A story.

+ Presslt + urn:uuid:other + urn:isbn:9781234567890 + + + + + + +
''', uri).publications.single; + expect(publication.id, 'urn:book:1'); + expect(publication.authors, ['Alice', 'Bob']); + expect(publication.description, 'A story.'); + expect(publication.publisher, 'Press'); + expect(publication.language, 'lt'); + expect(publication.isbn, '9781234567890'); + expect(publication.images.single.uri.toString(), 'https://books.example/cover.jpg'); + expect(publication.detailLink!.uri.path, '/catalog/details/1'); + expect(publication.links.where((link) => link.supportedExtension != null).length, 1); + expect(publication.links.last.indirect, isTrue); + }); + + test('Atom facet groups and publication collections preserve grouping', () { + final feed = OpdsParser.parse(''' + + Grouped + + 1First + + + + 2Second + + + + ''', uri); + expect(feed.facets.single.title, 'Language'); + expect(feed.facets.single.links.single.title, 'English'); + expect(feed.groups.single.title, 'Featured'); + expect(feed.groups.single.publications.map((book) => book.title), ['First', 'Second']); + expect(feed.publications, isEmpty); + }); + + test('JSON feed supports groups, facets, rel arrays and metadata variants', () { + final feed = OpdsParser.parse( + jsonEncode({ + 'metadata': { + 'title': {'en': 'Catalog', 'lt': 'Katalogas'}, + }, + 'links': [ + { + 'rel': ['self', 'next'], + 'href': '?page=2', + }, + {'rel': 'previous', 'href': '?page=0'}, + {'rel': 'search', 'href': 'search{?query,author}', 'templated': true}, + ], + 'navigation': [ + {'title': 'Genres', 'href': 'genres'}, + ], + 'groups': [ + { + 'metadata': {'title': 'Featured'}, + 'links': [ + {'rel': 'self', 'href': 'featured'}, + ], + 'publications': [ + { + 'metadata': { + 'identifier': 'urn:isbn:9781234567890', + 'title': 'Book', + 'author': [ + 'Alice', + { + 'name': {'en': 'Bob'}, + }, + ], + 'publisher': [ + {'name': 'Press'}, + ], + 'language': ['en', 'lt'], + 'description': '

A story.

', + }, + 'links': [ + {'rel': acquisition, 'href': 'download', 'type': 'application/pdf'}, + ], + 'images': [ + {'href': '/cover.jpg', 'type': 'image/jpeg'}, + ], + }, + ], + }, + ], + 'facets': [ + { + 'metadata': {'title': 'Language'}, + 'links': [ + {'title': 'English', 'href': '?lang=en'}, + ], + }, + ], + }), + uri, + contentType: 'text/plain', + ); + expect(feed.title, 'Catalog'); + expect(feed.nextLink!.uri.query, 'page=2'); + expect(feed.previousLink!.uri.query, 'page=0'); + expect(feed.navigation.single.uri.path, '/catalog/genres'); + expect(feed.searchLink!.template, 'https://books.example/catalog/search{?query,author}'); + final book = feed.groups.single.publications.single; + expect(book.authors, ['Alice', 'Bob']); + expect(book.publisher, 'Press'); + expect(book.language, 'en, lt'); + expect(book.isbn, '9781234567890'); + expect(book.description, 'A story.'); + expect(feed.facets.single.links.single.title, 'English'); + }); + + test('JSON standalone publication and indirect acquisition are recognized', () { + final feed = OpdsParser.parse( + '''{ + "metadata":{"title":"A book","identifier":"id"}, + "links":[{"rel":"$acquisition", "href":"file.epub", "type":"application/epub+zip", + "properties":{"indirectAcquisition":[{"type":"application/pdf"}]}}] + }''', + uri, + contentType: 'application/opds-publication+json', + ); + expect(feed.publications.single.title, 'A book'); + expect(feed.publications.single.links.single.indirect, isTrue); + expect(feed.publications.single.links.single.supportedExtension, isNull); + }); + + test('does not invent a publication from an empty JSON feed', () { + expect(OpdsParser.parse('{"metadata":{"title":"Empty"},"publications":[]}', uri).publications, isEmpty); + }); + + test('rejects HTML, wrong Atom namespaces, malformed and unrelated JSON', () { + for (final body in [ + 'Login', + 'Wrong', + '', + '{"error":"Forbidden"}', + '[1,2]', + '{broken', + '', + ]) { + expect(() => OpdsParser.parse(body, uri), throwsFormatException, reason: body); + } + }); + + test('models expose only supported direct downloadable acquisitions', () { + OpdsLink link(String href, String? type, {String rel = acquisition, bool indirect = false}) => + OpdsLink(uri: uri.resolve(href), type: type, rels: [rel], indirect: indirect); + expect(link('download', 'application/epub+zip').supportedExtension, 'epub'); + expect(link('FILE.CBZ?token=x', null).supportedExtension, 'cbz'); + expect(link('book.mobi', 'application/octet-stream').supportedExtension, 'mobi'); + expect(link('book.epub', 'text/html').supportedExtension, isNull); + expect(link('book.epub', 'application/vnd.adobe.adept+xml').supportedExtension, isNull); + expect(link('book.epub', 'application/epub+zip', rel: '$acquisition/buy').supportedExtension, isNull); + expect(link('book.epub', 'application/epub+zip', rel: '$acquisition/borrow').supportedExtension, isNull); + expect(link('book.epub', 'application/epub+zip', indirect: true).supportedExtension, isNull); + expect(link('book.epub', null, rel: 'alternate').isAcquisition, isFalse); + expect(link('book', 'application/epub+zip', rel: '$acquisition/open-access').supportedExtension, 'epub'); + expect(link('book', 'application/pdf', rel: '$acquisition/sample').supportedExtension, 'pdf'); + expect(link('book', 'application/x-cbr').supportedExtension, 'cbr'); + expect(link('book', 'application/vnd.amazon.mobi8-ebook').supportedExtension, 'azw3'); + expect(link('book', 'text/plain; charset=utf-8').supportedExtension, 'txt'); + }); + + test('model collections cannot change after parsing', () { + final source = ['Alice']; + final book = OpdsPublication(id: 'id', title: 'Book', authors: source); + source.add('Bob'); + expect(book.authors, ['Alice']); + expect(() => book.authors.add('Mallory'), throwsUnsupportedError); + }); + + test('accepts OPDS2 compact acquisition relations and excludes transactions', () { + for (final rel in ['acquisition', 'download', 'preview']) { + final link = OpdsLink(uri: uri.resolve('book.epub'), rels: [rel]); + expect(link.isAcquisition, isTrue, reason: rel); + expect(link.supportedExtension, 'epub', reason: rel); + } + for (final rel in ['buy', 'borrow', 'subscribe']) { + final link = OpdsLink(uri: uri.resolve('book.epub'), rels: [rel]); + expect(link.isAcquisition, isTrue, reason: rel); + expect(link.supportedExtension, isNull, reason: rel); + } + final feed = OpdsParser.parse('''{ + "metadata":{"title":"Book"}, + "links":[{"rel":"download","href":"book.epub"}] + }''', uri); + expect(feed.publications.single.title, 'Book'); + }); + + test('rejects invalid JSON member types with a format error', () { + expect(() => OpdsParser.parse('{"metadata":{"title":"Broken"},"publications":{}}', uri), throwsFormatException); + }); + + test('Atom escaped HTML and XHTML descriptions preserve word boundaries', () { + for (final content in [ + '<p>One</p><p>Two &amp; three</p>', + '

One

Two & three

', + ]) { + final feed = OpdsParser.parse(''' + Book$content''', uri); + expect(feed.publications.single.description, 'One\n\nTwo & three'); + } + }); + + test('expands RFC6570 query parameters and safely encodes scalar input', () { + expect( + OpdsSearch.expand('https://example.test/search{?query,author}', 'A & B/ž'), + 'https://example.test/search?query=A%20%26%20B%2F%C5%BE', + ); + expect( + OpdsSearch.expand('https://example.test/search?format=json{&query,lang}', 'a+b'), + 'https://example.test/search?format=json&query=a%2Bb', + ); + expect(OpdsSearch.expand('https://example.test/search?q={query}', 'a b'), 'https://example.test/search?q=a%20b'); + expect( + OpdsSearch.expand(Uri.parse('https://example.test/search{?query}').toString(), 'book'), + 'https://example.test/search?query=book', + ); + }); + + test('expands OpenSearch parameters with defaults and optional omissions', () { + expect( + OpdsSearch.expand( + 'https://example.test/?q={searchTerms}&start={startIndex}&page={startPage}&n={count?}&lang={language?}&encoding={inputEncoding}', + 'tea & coffee', + ), + 'https://example.test/?q=tea%20%26%20coffee&start=1&page=1&n=&lang=&encoding=UTF-8', + ); + expect(() => OpdsSearch.expand('https://example.test/?q={unknown}', 'book'), throwsFormatException); + }); + + test('expands RFC6570 scalar operators, prefix modifiers and omitted variables', () { + expect(OpdsSearch.expand('https://example.test/{query:3}', 'žabc'), 'https://example.test/%C5%BEab'); + expect(OpdsSearch.expand('https://example.test{/query}', 'a/b'), 'https://example.test/a%2Fb'); + expect(OpdsSearch.expand('https://example.test{;query}', ''), 'https://example.test;query'); + expect(OpdsSearch.expand('https://example.test{#query}', 'a/b?c'), 'https://example.test#a/b?c'); + expect(OpdsSearch.expand('https://example.test/{+query}', 'a/b'), 'https://example.test/a/b'); + expect(OpdsSearch.expand('https://example.test/{query,author}', 'a b'), 'https://example.test/a%20b'); + expect(OpdsSearch.expand('https://example.test{?query:2,author}', 'abc'), 'https://example.test?query=ab'); + expect(OpdsSearch.expand('https://example.test{?query*}', 'a b'), 'https://example.test?query=a%20b'); + }); + + test('selects supported GET OpenSearch URL and resolves its XML base', () { + final link = OpdsSearch.fromOpenSearch(''' + + + + + ''', uri); + expect(link.templated, isTrue); + expect(link.hasRel('search'), isTrue); + expect(OpdsSearch.expand(link.template, 'two words'), 'https://books.example/v1/search?q=two%20words&page='); + }); + + test('rejects unsupported OpenSearch descriptions', () { + for (final xml in [ + '', + '', + '', + ]) { + expect(() => OpdsSearch.fromOpenSearch(xml, uri), throwsFormatException); + } + }); +} diff --git a/app/test/opds/parser_validation_test.dart b/app/test/opds/parser_validation_test.dart new file mode 100644 index 0000000..da9dfca --- /dev/null +++ b/app/test/opds/parser_validation_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/opds/opds_parser.dart'; + +void main() { + final uri = Uri.parse('https://books.test/opds'); + test('rejects JSON without catalog structure or valid publication members', () { + for (final body in [ + '{"metadata":{"title":"x"}}', + '{"metadata":{"title":"x"},"publications":[false]}', + '{"metadata":{"title":"x"},"publications":[{}]}', + ]) { + expect(() => OpdsParser.parse(body, uri), throwsFormatException); + } + }); + test('selects supported keyword search after an HTML search link', () { + final feed = OpdsParser.parse('''{"metadata":{"title":"Books"},"navigation":[],"links":[ + {"rel":"search","href":"/website-search","type":"text/html"}, + {"rel":"search","href":"/opds-search{?query}","type":"application/opds+json","templated":true}]}''', uri); + expect(feed.searchLink!.template, contains('opds-search{?query}')); + }); +} diff --git a/app/test/opds/presentation_test.dart b/app/test/opds/presentation_test.dart new file mode 100644 index 0000000..38e57a7 --- /dev/null +++ b/app/test/opds/presentation_test.dart @@ -0,0 +1,472 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/services/book_import_result.dart'; +import 'package:papyrus/services/book_import_session.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/book_details/book_details_tab_rail.dart'; +import 'package:papyrus/widgets/opds/opds_download_panel.dart'; +import 'package:papyrus/widgets/opds/opds_feed_view.dart'; +import 'package:papyrus/widgets/opds/opds_publication_tile.dart'; +import 'package:papyrus/widgets/opds/opds_publication_details.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:provider/provider.dart'; + +import 'relay_fixture_client.dart'; + +final _catalog = OpdsCatalog(id: 'catalog', name: 'Public library', uri: Uri.parse('https://books.test/feed')); +final _epub = OpdsLink( + uri: Uri.parse('https://books.test/book.epub'), + type: 'application/epub+zip', + title: 'EPUB with images for current e-readers', + rels: ['download'], +); +final _pdf = OpdsLink(uri: Uri.parse('https://books.test/book.pdf'), type: 'application/pdf', rels: ['download']); +final _gateway = OpdsHttpClient(clientFactory: () => MockRelayClient((_) async => http.Response('book', 200))); + +OpdsPublication _publication({String id = 'one', String title = 'Pride and Prejudice', String? description}) => + OpdsPublication( + id: id, + title: title, + authors: ['Jane Austen'], + description: description, + language: 'English', + publisher: 'A publisher', + links: [_epub, _pdf], + ); + +Future _mount( + WidgetTester tester, + Widget child, { + required ThemeData theme, + double width = 360, + OpdsDownloads? downloads, +}) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final transfers = downloads ?? OpdsDownloads(captureImport: () => throw StateError('Unused import')); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: transfers, + child: MaterialApp( + theme: theme, + home: AppMotionScope( + reduceAnimations: true, + child: Scaffold(body: SafeArea(child: child)), + ), + ), + ), + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + transfers.dispose(); + }); + await _settle(tester); +} + +Future _settle(WidgetTester tester) async { + await tester.pumpAndSettle(); + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pumpAndSettle(); +} + +BookImportSession _unusedSession() => BookImportSession( + process: (_, _) async => throw StateError('Import must not start'), + deleteFile: (_) async {}, + commit: (_, _) async => throw StateError('Commit must not start'), + isCurrent: () => true, +); + +class _PendingHttp extends OpdsHttpClient { + final response = Completer(); + + @override + Future get( + OpdsCatalog catalog, + Uri uri, { + OpdsCredentials? credentials, + OpdsCancellation? cancellation, + void Function(int, int?)? onProgress, + int maxBytes = 8 * 1024 * 1024, + }) { + cancellation?.addListener(() { + if (!response.isCompleted) response.completeError(const OpdsCancelled()); + }); + onProgress?.call(512, 1024); + return response.future; + } +} + +Widget _panel(OpdsDownloads downloads, {ValueChanged? onRetry}) => AnimatedBuilder( + animation: downloads, + builder: (_, _) => Align( + alignment: Alignment.bottomCenter, + child: OpdsDownloadsButton(downloads: downloads, onRetry: onRetry ?? (_) {}), + ), +); + +void main() { + for (final width in [360.0, 424.0]) { + testWidgets('mobile $width details rail stays below the header while content scrolls', (tester) async { + final downloads = OpdsDownloads(captureImport: _unusedSession); + await _mount( + tester, + Scaffold( + appBar: AppBar(title: const Text('Catalog header')), + body: OpdsPublicationDetails( + catalog: _catalog, + publication: _publication(description: List.filled(50, 'A long book description.').join('\n\n')), + httpClient: _gateway, + downloads: downloads, + onDownload: (_) {}, + onNavigate: (_) {}, + ), + ), + theme: AppTheme.dark, + width: width, + downloads: downloads, + ); + final rail = find.byType(BookDetailsTabRail); + final headerBottom = tester.getBottomLeft(find.byType(AppBar)).dy; + final description = find.byKey(const Key('opds-description')); + expect(tester.getRect(rail).left, 0); + expect(tester.getRect(rail).width, width); + await tester.dragFrom(Offset(width / 2, 750), const Offset(0, -650)); + await _settle(tester); + expect(tester.getTopLeft(rail).dy, headerBottom); + expect(tester.getTopLeft(description).dx, 16); + expect(find.text('Add to library').hitTestable(), findsNothing); + final descriptionTop = tester.getTopLeft(description).dy; + await tester.dragFrom(Offset(width / 2, 750), const Offset(0, -200)); + await _settle(tester); + expect(tester.getTopLeft(rail).dy, headerBottom); + expect(tester.getTopLeft(description).dy, lessThan(descriptionTop)); + await tester.dragFrom(Offset(width / 2, 200), const Offset(0, 1800)); + await _settle(tester); + expect(find.text('Add to library').hitTestable(), findsOneWidget); + await tester.tap(find.text('Add to library')); + await _settle(tester); + expect(find.text('Download options'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('phone cards keep author next to title and preserve equal cover sizes', (tester) async { + await _mount( + tester, + OpdsFeedView( + catalog: _catalog, + feed: OpdsFeed( + uri: _catalog.uri, + title: 'Pride and Prejudice by Jane Austen', + publications: [ + _publication(title: 'Short title'), + _publication(id: 'two', title: 'A much longer title that needs two lines'), + ], + ), + httpClient: _gateway, + onNavigate: (_) {}, + onOpenPublication: (_) {}, + onRefresh: () {}, + isGridView: true, + onViewChanged: (_) {}, + onPage: (_) {}, + ), + theme: AppTheme.dark, + width: 424, + ); + final card = find.byKey(const ValueKey('one')); + final title = find.descendant(of: card, matching: find.text('Short title')); + expect(tester.getSize(title).height, lessThan(26)); + final author = find.descendant(of: card, matching: find.text('Jane Austen')); + expect(tester.getTopLeft(author).dy - tester.getBottomLeft(title).dy, lessThanOrEqualTo(8)); + expect(tester.widget(find.descendant(of: card, matching: find.text(_epub.title!))).maxLines, 2); + final cover = tester.getRect(find.descendant(of: card, matching: find.byType(OpdsCover))); + final other = tester.getRect( + find.descendant(of: find.byKey(const ValueKey('two')), matching: find.byType(OpdsCover)), + ); + expect(cover.height, closeTo(cover.width * 1.5, 1)); + expect(cover.size, other.size); + expect( + tester + .getRect(find.byTooltip('List view')) + .overlaps(tester.getRect(find.text('Pride and Prejudice by Jane Austen'))), + isFalse, + ); + expect(tester.takeException(), isNull); + }); + testWidgets('relay failure offers retry without a manual browser download', (tester) async { + final downloads = OpdsDownloads( + captureImport: _unusedSession, + httpClient: OpdsHttpClient( + clientFactory: () => MockRelayClient((_) async { + throw http.ClientException('Relay unavailable'); + }), + ), + ); + await downloads.start(_catalog, _publication(), _epub); + await _mount(tester, _panel(downloads), theme: AppTheme.light, downloads: downloads); + await tester.tap(find.byTooltip('Downloads')); + await _settle(tester); + expect(find.text('Retry'), findsOneWidget); + expect(find.textContaining('Papyrus catalog server'), findsOneWidget); + expect(find.text('Download in browser'), findsNothing); + expect(find.textContaining('Library → Add book'), findsNothing); + }); + + final themes = [('light', AppTheme.light), ('dark', AppTheme.dark), ('eink', AppTheme.eink)]; + for (final (name, theme) in themes) { + for (final width in [360.0, 1280.0]) { + testWidgets('$name $width feed switches grid/list and keeps navigation and pagination usable', (tester) async { + var grid = true; + Uri? navigated; + Uri? paged; + final feed = OpdsFeed( + uri: _catalog.uri, + title: 'Popular books', + navigation: [ + OpdsLink( + uri: Uri.parse('https://books.test/author'), + title: 'An author collection', + description: 'Jane Austen · novels and stories', + imageUri: Uri.parse('data:image/png;base64,AA=='), + ), + ], + publications: [ + _publication(), + _publication(id: 'two', title: 'Short'), + ], + links: [ + OpdsLink(uri: Uri.parse('https://books.test/feed?page=1'), rels: ['previous']), + OpdsLink(uri: Uri.parse('https://books.test/feed?page=3'), rels: ['next']), + ], + ); + await _mount( + tester, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: StatefulBuilder( + builder: (_, setState) => OpdsFeedView( + catalog: _catalog, + feed: feed, + httpClient: _gateway, + isGridView: grid, + onViewChanged: (value) => setState(() => grid = value), + onNavigate: (uri) => navigated = uri, + onPage: (uri) => paged = uri, + onOpenPublication: (_) {}, + onRefresh: () {}, + ), + ), + ), + theme: theme, + width: width, + ); + expect(find.text('Jane Austen · novels and stories'), findsOneWidget); + expect(find.byIcon(Icons.folder_outlined), findsNothing); + final first = find.byKey(const ValueKey('one')); + final second = find.byKey(const ValueKey('two')); + expect(tester.getTopLeft(first).dy, tester.getTopLeft(second).dy); + expect(tester.getTopLeft(second).dx, greaterThan(tester.getTopLeft(first).dx)); + expect( + tester.getBottomLeft(find.descendant(of: first, matching: find.byType(OpdsCover))).dy, + tester.getBottomLeft(find.descendant(of: second, matching: find.byType(OpdsCover))).dy, + reason: 'Grid covers align when titles occupy different numbers of lines.', + ); + await tester.tap(find.text('An author collection')); + expect(navigated, Uri.parse('https://books.test/author')); + expect(find.byType(SegmentedButton), findsNothing); + expect(find.byTooltip('List view'), findsOneWidget); + await tester.tap(find.byIcon(Icons.view_list)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(first).dx, tester.getTopLeft(second).dx); + expect(tester.getTopLeft(second).dy, greaterThan(tester.getTopLeft(first).dy)); + await tester.tap(find.byIcon(Icons.grid_view)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(first).dy, tester.getTopLeft(second).dy); + await tester.scrollUntilVisible(find.text('Next'), 200, scrollable: find.byType(Scrollable).first); + final previous = find.widgetWithText(TextButton, 'Previous'); + final next = find.widgetWithText(FilledButton, 'Next'); + expect(tester.getSize(previous).width, lessThanOrEqualTo(width - 32)); + expect(tester.getRect(previous).overlaps(tester.getRect(next)), isFalse); + expect(tester.getSize(next).width, lessThan(160)); + expect(tester.getRect(previous).left, greaterThanOrEqualTo(16)); + expect(tester.getRect(next).right, lessThanOrEqualTo(width - 16)); + await tester.tap(next); + expect(paged, feed.nextLink!.uri); + await tester.tap(previous); + expect(paged, feed.previousLink!.uri); + expect(tester.takeException(), isNull); + }); + } + + for (final width in [360.0, 1280.0]) { + testWidgets('$name $width full details preserve paragraphs and open format sheet', (tester) async { + final description = List.generate( + 12, + (index) => + 'Paragraph ${index + 1}. This edition includes notes about the story, its characters, and the original publication.', + ).join('\n\n'); + final selected = []; + final downloads = OpdsDownloads(captureImport: _unusedSession); + await _mount( + tester, + Padding( + padding: const EdgeInsets.all(16), + child: OpdsPublicationDetails( + catalog: _catalog, + publication: _publication(description: description), + httpClient: _gateway, + downloads: downloads, + onNavigate: (_) {}, + onDownload: selected.add, + ), + ), + theme: theme, + width: width, + downloads: downloads, + ); + expect(find.byType(Dialog), findsNothing); + expect(find.byType(BottomSheet), findsNothing); + expect(find.text(_catalog.name), findsNothing); + final text = tester.widget(find.byKey(const Key('opds-description'))); + expect(text.maxLines, isNull); + expect(text.data, description); + await tester.tap(find.text('Add to library')); + await _settle(tester); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.text('Download options'), findsOneWidget); + await tester.tap(find.text('Download EPUB')); + expect(selected, [_epub]); + if (OpdsDownloads.supports(_pdf)) { + await tester.tap(find.text('Download PDF')); + expect(selected, [_epub, _pdf]); + } else { + expect(find.text('Download PDF'), findsNothing); + await tester.tap(find.text('Other catalog options (1)')); + await _settle(tester); + expect(find.textContaining('cannot be imported'), findsOneWidget); + } + await tester.tap(find.text('Close')); + await _settle(tester); + expect(find.byType(BottomSheet), findsNothing); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('360 download panel stays compact and protects active transfers until cancelled', (tester) async { + final gateway = _PendingHttp(); + final downloads = OpdsDownloads(httpClient: gateway, captureImport: _unusedSession); + final operation = downloads.start(_catalog, _publication(), _epub); + await _mount(tester, _panel(downloads), theme: AppTheme.eink, downloads: downloads); + expect(find.byType(Badge), findsOneWidget); + expect(find.text('Pride and Prejudice'), findsNothing); + expect(tester.getSize(find.byType(OpdsDownloadsButton)).height, lessThan(80)); + downloads.dismiss(downloads.jobs.single.key); + expect(downloads.jobs, hasLength(1)); + await tester.tap(find.byTooltip('Downloads')); + await tester.pumpAndSettle(); + expect(find.text('Downloading · 50%'), findsOneWidget); + expect(tester.widget(find.byType(AppLinearProgressIndicator)).value, 0.5); + expect(find.byTooltip('Dismiss download'), findsNothing); + expect(find.byTooltip('Cancel download'), findsOneWidget); + await tester.tap(find.byTooltip('Cancel download')); + await operation; + await _settle(tester); + expect(find.text('Cancelled'), findsOneWidget); + expect(find.byTooltip('Cancel download'), findsNothing); + await tester.tap(find.byTooltip('Dismiss download')); + await tester.pumpAndSettle(); + expect(downloads.jobs, isEmpty); + expect(find.textContaining('Downloads ·'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('360 collapsed download panel announces errors and exposes retry and dismiss', (tester) async { + final gateway = _PendingHttp(); + final downloads = OpdsDownloads(httpClient: gateway, captureImport: _unusedSession); + final operation = downloads.start(_catalog, _publication(), _epub); + final retried = []; + await _mount( + tester, + _panel(downloads, onRetry: retried.add), + theme: AppTheme.dark, + downloads: downloads, + ); + gateway.response.completeError(const OpdsException('The catalog could not send this book.')); + await operation; + await _settle(tester); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(find.text('The catalog could not send this book.'), findsNothing); + await tester.tap(find.byTooltip('Downloads')); + await tester.pumpAndSettle(); + expect(find.text('The catalog could not send this book.'), findsOneWidget); + expect(find.byType(AppLinearProgressIndicator), findsNothing); + await tester.tap(find.text('Retry')); + expect(retried.single, same(downloads.jobs.single)); + await tester.tap(find.byTooltip('Dismiss download')); + await tester.pumpAndSettle(); + expect(downloads.jobs, isEmpty); + expect(tester.takeException(), isNull); + }); + + for (final width in [360.0, 424.0, 1280.0]) { + testWidgets('$width download panel keeps completed actions beside the book and protects commit', (tester) async { + final finish = Completer(); + final downloads = OpdsDownloads( + httpClient: _gateway, + captureImport: () => BookImportSession( + process: (Uint8List _, String _) async => const BookImportResult( + bookId: 'imported', + title: 'Book', + author: 'Author', + fileSize: 4, + fileHash: 'hash', + fileExtension: 'epub', + ), + deleteFile: (_) async {}, + isCurrent: () => true, + commit: (_, _) => finish.future, + ), + ); + final operation = downloads.start(_catalog, _publication(), _epub); + await _mount(tester, _panel(downloads), theme: AppTheme.light, width: width, downloads: downloads); + expect(downloads.jobs.single.status, OpdsDownloadStatus.committing); + await tester.tap(find.byTooltip('Downloads')); + await tester.pumpAndSettle(); + expect(find.text('Adding to library…'), findsOneWidget); + expect(find.byTooltip('Cancel download'), findsNothing); + expect(find.byTooltip('Dismiss download'), findsNothing); + downloads.dismiss(downloads.jobs.single.key); + expect(downloads.jobs, hasLength(1)); + finish.complete(Book(id: 'imported', title: 'Book', author: 'Author', addedAt: DateTime(2026))); + await operation; + await _settle(tester); + expect(find.text('Added to library'), findsOneWidget); + expect(find.text('Open book'), findsOneWidget); + expect( + tester.getCenter(find.text('Open book')).dy, + lessThan(tester.getBottomLeft(find.text('Added to library')).dy), + ); + expect( + tester.getCenter(find.text('Open book')).dy, + greaterThan(tester.getTopLeft(find.text('Pride and Prejudice')).dy), + ); + await tester.tap(find.byTooltip('Dismiss download')); + await tester.pumpAndSettle(); + expect(downloads.jobs, isEmpty); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/app/test/opds/publication_content_test.dart b/app/test/opds/publication_content_test.dart new file mode 100644 index 0000000..65a1613 --- /dev/null +++ b/app/test/opds/publication_content_test.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_parser.dart'; +import 'package:papyrus/opds/opds_publication_content.dart'; + +void main() { + final gutenberg = OpdsCatalog( + id: 'g', + name: 'Renamed catalog', + uri: Uri.parse('https://www.gutenberg.org/ebooks.opds'), + ); + const description = '''This edition has images. + +Title: A book + +Summary: A story about a reader. + +Author: Writer, A., 1800–1850 + +Language: English + +Subject: Fiction + +Subject: Fiction + +Published: Oct 1, 1994 + +Downloads: 123 + +EBook No.: 174 + +Credits: Someone helpful. + +Note: https://example.test/a-very-long-link + +Unfamiliar field: Preserve this. + +Another paragraph of prose.'''; + + test('Gutenberg presentation separates summary and keeps all extra information', () { + final publication = OpdsPublication( + id: '174', + title: 'A book', + authors: ['Writer, A.'], + description: description, + language: 'en', + subjects: ['Fiction'], + ); + final content = OpdsPublicationContent.from(gutenberg, publication); + expect(content.description, startsWith('A story about a reader.\n\nThis edition has images.')); + expect(content.description, endsWith('Another paragraph of prose.')); + expect(content.information['Language'], 'English (en)'); + expect(content.information['Gutenberg ID'], '174'); + expect(content.information['Downloads'], '123'); + expect(content.information['Published'], 'Oct 1, 1994'); + expect(content.subjects, ['Fiction']); + expect(content.description, contains('Credits: Someone helpful.')); + expect(content.information['Author'], 'Writer, A., 1800–1850'); + expect(content.description, contains('Unfamiliar field: Preserve this.')); + expect(content.description, contains('https://example.test/a-very-long-link')); + expect(content.description, isNot(contains('Title: A book'))); + expect(publication.description, description); + }); + + test('other catalogs and unrecognized Gutenberg prose remain unchanged', () { + for (final host in ['books.test', 'gutenberg.org.example.test']) { + final catalog = OpdsCatalog(id: 'other', name: 'Project Gutenberg', uri: Uri.parse('https://$host/feed')); + final result = OpdsPublicationContent.from( + catalog, + OpdsPublication(id: '1', title: 'A book', description: description), + ); + expect(result.description, description); + } + const prose = 'Summary: A single ordinary paragraph.\n\nNothing to extract.'; + expect( + OpdsPublicationContent.from(gutenberg, OpdsPublication(id: '1', title: 'Book', description: prose)).description, + prose, + ); + }); + + test('conflicting or repeated metadata is preserved in its information row', () { + final result = OpdsPublicationContent.from( + gutenberg, + OpdsPublication( + id: '1', + title: 'Book', + publisher: 'Structured publisher', + description: + 'Title: Another title\n\nPublisher: First publisher\n\nPublisher: Second publisher\n\nSummary: Story.', + ), + ); + expect(result.information['Publisher'], 'Structured publisher\nFirst publisher\nSecond publisher'); + expect(result.information['Catalog title'], 'Another title'); + expect(result.description, 'Story.'); + }); + + test('reading level and classification belong to information rather than subject chips', () { + final result = OpdsPublicationContent.from( + gutenberg, + OpdsPublication( + id: '1', + title: 'Book', + subjects: ['Fiction', 'English literature', 'Text'], + description: + 'Summary: Story.\n\nReading Level: Reading ease score: 84.0\n\nLoCC: English literature\n\nCategory: Text', + ), + ); + expect(result.information['Reading level'], 'Reading ease score: 84.0'); + expect(result.information['Classification'], 'English literature'); + expect(result.information['Category'], 'Text'); + expect(result.subjects, ['Fiction']); + expect(result.description, 'Story.'); + }); + + test('Atom metadata retains subjects and rights without confusing entry timestamps with publication dates', () { + final publication = OpdsParser.parse(''' + + bookBook2026-09-23T00:00:00Z + 1900Public domain + + + ''', gutenberg.uri).publications.single; + expect(publication.published, '1900'); + expect(publication.rights, 'Public domain'); + expect(publication.subjects, ['Fiction', 'History']); + final onlyTimestamp = OpdsParser.parse(''' + Book2026-09-23 + ''', gutenberg.uri).publications.single; + expect(onlyTimestamp.published, isNull); + }); + + test('JSON publication metadata accepts string and named subjects and positive page counts', () { + final publication = OpdsParser.parse( + jsonEncode({ + 'metadata': {'title': 'Catalog'}, + 'publications': [ + { + 'metadata': { + 'title': 'Book', + 'published': '1900-06-01', + 'numberOfPages': 240, + 'subject': [ + 'Fiction', + {'name': 'History'}, + {'name': 'Fiction'}, + ], + }, + }, + ], + }), + gutenberg.uri, + ).publications.single; + expect(publication.numberOfPages, 240); + expect(publication.subjects, ['Fiction', 'History']); + expect(OpdsPublicationContent.from(gutenberg, publication).information['Published'], 'June 1, 1900'); + }); + + test('missing metadata produces no empty rows or invented values', () { + final result = OpdsPublicationContent.from(gutenberg, OpdsPublication(id: 'empty', title: 'Book', publisher: ' ')); + expect(result.information, isEmpty); + expect(result.subjects, isEmpty); + expect(result.description, isNull); + }); +} diff --git a/app/test/opds/publication_information_test.dart b/app/test/opds/publication_information_test.dart new file mode 100644 index 0000000..39e0cef --- /dev/null +++ b/app/test/opds/publication_information_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/opds/opds_publication_information.dart'; + +void main() { + final catalog = OpdsCatalog(id: 'g', name: 'Gutenberg', uri: Uri.parse('https://www.gutenberg.org/ebooks.opds')); + final publication = OpdsPublication( + id: 'book', + title: 'Book', + language: 'en', + publisher: 'A publisher with a very long name', + description: + 'Title: Book\n\nSummary: First paragraph.\n\nSecond paragraph.\n\nLanguage: English\n\nCredits: A helpful person.', + subjects: ['An unusually long subject heading that should wrap safely on small screens without clipping'], + ); + + for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { + for (final (width, scale) in [(360.0, 1.0), (840.0, 1.0), (1280.0, 1.0), (360.0, 2.0)]) { + testWidgets('publication sections ${theme.brightness} ${theme.colorScheme.primary} width $width text $scale', ( + tester, + ) async { + tester.view.physicalSize = Size(width, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: MediaQuery( + data: MediaQueryData(size: Size(width, 1000), textScaler: TextScaler.linear(scale)), + child: SingleChildScrollView( + child: OpdsPublicationInformation(catalog: catalog, publication: publication), + ), + ), + ), + ), + ); + expect(find.text('Description'), findsOneWidget); + expect(find.text('Information'), findsOneWidget); + expect(find.text('Subjects'), findsOneWidget); + expect(find.text('Language'), findsOneWidget); + expect(find.text('English (en)'), findsOneWidget); + expect(find.textContaining('Credits: A helpful person.'), findsOneWidget); + expect(find.text('More details'), findsNothing); + expect(find.byType(ExpansionTile), findsNothing); + final description = tester.getTopLeft(find.text('Description')); + final information = tester.getTopLeft(find.text('Information')); + if (width >= 840 && scale == 1) { + expect(information.dy, description.dy); + expect(information.dx, greaterThan(description.dx)); + } else { + expect(information.dy, greaterThan(description.dy)); + expect(information.dx, description.dx); + } + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('missing metadata hides optional sections and preserves description fallback', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OpdsPublicationInformation( + catalog: catalog, + publication: OpdsPublication(id: 'empty', title: 'Book'), + ), + ), + ), + ); + expect(find.text('No description available.'), findsOneWidget); + expect(find.text('Information'), findsNothing); + expect(find.text('Subjects'), findsNothing); + expect(find.text('More details'), findsNothing); + }); +} diff --git a/app/test/opds/relay_fixture_client.dart b/app/test/opds/relay_fixture_client.dart new file mode 100644 index 0000000..7a19c3f --- /dev/null +++ b/app/test/opds/relay_fixture_client.dart @@ -0,0 +1,19 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// Adapts catalog fixtures to the relay protocol; transport contract tests use raw mocks. +class MockRelayClient extends MockClient { + MockRelayClient(Future Function(http.Request) handler) + : super((request) async { + final payload = jsonDecode(request.body) as Map; + final upstream = http.Request('GET', Uri.parse(payload['url'] as String)); + final response = await handler(upstream); + return http.Response.bytes( + response.bodyBytes, + response.statusCode, + headers: {...response.headers, 'x-opds-url': upstream.url.toString()}, + ); + }); +} diff --git a/app/test/opds/relay_http_client_test.dart b/app/test/opds/relay_http_client_test.dart new file mode 100644 index 0000000..59e71a9 --- /dev/null +++ b/app/test/opds/relay_http_client_test.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; + +void main() { + final catalog = OpdsCatalog(id: 'one', name: 'Books', uri: Uri.parse('https://books.test/opds')); + const credentials = OpdsCredentials(username: 'reader', password: 'secret'); + + test('queues cover fanout and cancels waiting requests before they reach the server', () async { + final pending = >[]; + final gateway = OpdsHttpClient( + clientFactory: () => MockClient((_) { + final response = Completer(); + pending.add(response); + return response.future; + }), + ); + final running = List.generate(4, (_) => gateway.get(catalog, catalog.uri)); + await Future.delayed(Duration.zero); + expect(pending, hasLength(4)); + final cancellation = OpdsCancellation(); + final queued = gateway.get(catalog, catalog.uri, cancellation: cancellation); + final cancelled = expectLater(queued, throwsA(isA())); + await Future.delayed(Duration.zero); + expect(pending, hasLength(4)); + cancellation.cancel(); + await cancelled; + for (final response in pending) { + response.complete(http.Response('book', 200, headers: {'x-opds-url': catalog.uri.toString()})); + } + await Future.wait(running); + expect(pending, hasLength(4)); + }); + + test('retries temporary relay capacity errors without a direct request', () async { + var attempts = 0; + final gateway = OpdsHttpClient( + clientFactory: () => MockClient((request) async { + expect(request.method, 'POST'); + if (++attempts == 1) { + return http.Response( + jsonEncode({ + 'error': { + 'code': 'OPDS_RELAY_ERROR', + 'message': 'The catalog relay is busy.', + 'details': {'retryable': true}, + }, + }), + 503, + ); + } + return http.Response('book', 200, headers: {'x-opds-url': catalog.uri.toString()}); + }), + ); + expect((await gateway.get(catalog, catalog.uri)).text, 'book'); + expect(attempts, 2); + }); + + test('fetches through the backend without account authentication or a direct catalog request', () async { + final seen = []; + final gateway = OpdsHttpClient( + clientFactory: () => MockClient((request) async { + seen.add(request); + return http.Response('book', 200, headers: {'x-opds-url': 'https://cdn.test/book.epub'}); + }), + ); + final response = await gateway.get(catalog, catalog.uri, credentials: credentials); + expect(seen, hasLength(1)); + expect(seen.single.url, PapyrusApiConfig.fromEnvironment().endpoint('/opds/relay')); + expect(seen.single.method, 'POST'); + expect(seen.single.headers.containsKey('authorization'), isFalse); + expect(jsonDecode(seen.single.body), { + 'url': catalog.uri.toString(), + 'catalog_url': catalog.uri.toString(), + 'max_bytes': 8 * 1024 * 1024, + 'credentials': {'username': 'reader', 'password': 'secret'}, + }); + expect(response.uri, Uri.parse('https://cdn.test/book.epub')); + expect(utf8.decode(response.bytes), 'book'); + }); + + test('reports relay errors without returning upstream pages or credentials', () async { + final gateway = OpdsHttpClient( + clientFactory: () => MockClient( + (_) async => http.Response( + jsonEncode({ + 'error': {'code': 'OPDS_RELAY_ERROR', 'message': 'Check the catalog credentials.'}, + }), + 401, + ), + ), + ); + await expectLater( + gateway.get(catalog, catalog.uri, credentials: credentials), + throwsA(isA().having((e) => e.message, 'message', 'Check the catalog credentials.')), + ); + }); + + test('network failure reports the server and never falls back to a direct request', () async { + var attempts = 0; + final gateway = OpdsHttpClient( + clientFactory: () => MockClient((_) async { + attempts++; + throw http.ClientException('Failed to fetch'); + }), + ); + await expectLater( + gateway.get(catalog, catalog.uri), + throwsA(isA().having((e) => e.message, 'message', contains('server'))), + ); + expect(attempts, 1); + }); + + test('rejects successful responses without upstream URL metadata', () async { + final gateway = OpdsHttpClient(clientFactory: () => MockClient((_) async => http.Response('book', 200))); + await expectLater(gateway.get(catalog, catalog.uri), throwsA(isA())); + }); +} diff --git a/app/test/opds/resource_cache_test.dart b/app/test/opds/resource_cache_test.dart new file mode 100644 index 0000000..ca18df1 --- /dev/null +++ b/app/test/opds/resource_cache_test.dart @@ -0,0 +1,178 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_resource_cache.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({this.throwsOnWrite = true}) : super.empty(); + final bool throwsOnWrite; + @override + Future setValue(String valueType, String key, Object value) async { + if (throwsOnWrite) throw StateError('Quota exceeded'); + return false; + } +} + +class _DelayedPreferences extends InMemorySharedPreferencesStore { + _DelayedPreferences() : super.empty(); + final firstWrite = Completer(); + @override + Future setValue(String valueType, String key, Object value) async { + await firstWrite.future; + return super.setValue(valueType, key, value); + } +} + +void main() { + final catalog = OpdsCatalog(id: 'one', name: 'Books', uri: Uri.parse('https://books.test/root')); + final resource = Uri.parse('https://books.test/feed'); + OpdsResponse response([String body = 'feed']) => OpdsResponse( + uri: Uri.parse('https://cdn.test/redirected/feed'), + bytes: Uint8List.fromList(utf8.encode(body)), + headers: {'content-type': 'application/opds+json', 'authorization': 'secret', 'set-cookie': 'secret'}, + ); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('cold cache persists bytes, redirect URI and only content type', () async { + final prefs = await SharedPreferences.getInstance(); + final cache = OpdsResourceCache(prefs)..setScope('alice'); + final token = cache.capture(catalog, resource)!; + expect(cache.read(token), isNull); + await cache.write(token, response()); + final stored = prefs.getKeys().map(prefs.get).join(); + expect(stored, isNot(contains('secret'))); + final restarted = OpdsResourceCache(prefs)..setScope('alice'); + final warm = restarted.read(restarted.capture(catalog, resource)!)!; + expect(warm.response.text, 'feed'); + expect(warm.response.uri.host, 'cdn.test'); + expect(warm.response.headers, {'content-type': 'application/opds+json'}); + expect(warm.fetchedAt.isAfter(DateTime.now().subtract(const Duration(minutes: 1))), isTrue); + }); + + test('scope switching and catalog invalidation reject obsolete writes', () async { + final cache = OpdsResourceCache(await SharedPreferences.getInstance())..setScope('alice'); + final old = cache.capture(catalog, resource)!; + await cache.write(old, response()); + cache.setScope('bob'); + expect(cache.read(cache.capture(catalog, resource)!), isNull); + cache.setScope('alice'); + expect(cache.isCurrent(old), isFalse); + await cache.write(old, response('obsolete')); + expect(cache.read(cache.capture(catalog, resource)!)!.response.text, 'feed'); + final beforeEdit = cache.capture(catalog, resource)!; + await cache.invalidateCatalog(catalog); + await cache.write(beforeEdit, response('obsolete')); + expect(cache.read(cache.capture(catalog, resource)!), isNull); + cache.setScope(null); + expect(cache.capture(catalog, resource), isNull); + }); + + test('LRU entry cap applies across accounts and survives restart', () async { + final prefs = await SharedPreferences.getInstance(); + final cache = OpdsResourceCache(prefs, maxEntries: 2)..setScope('alice'); + final first = cache.capture(catalog, resource)!; + final second = cache.capture(catalog, resource.resolve('second'))!; + await cache.write(first, response('first')); + await cache.write(second, response('second')); + cache.read(first); + cache.setScope('bob'); + await cache.write(cache.capture(catalog, resource)!, response('bob')); + final restarted = OpdsResourceCache(prefs, maxEntries: 2)..setScope('alice'); + expect(restarted.read(restarted.capture(catalog, resource)!)!.response.text, 'first'); + expect(restarted.read(restarted.capture(catalog, resource.resolve('second'))!), isNull); + }); + + test('an edit finishing in an inactive scope invalidates only that account', () async { + final cache = OpdsResourceCache(await SharedPreferences.getInstance())..setScope('alice'); + await cache.write(cache.capture(catalog, resource)!, response('alice')); + cache.setScope('bob'); + final bob = cache.capture(catalog, resource)!; + await cache.write(bob, response('bob')); + await cache.invalidateCatalog(catalog, scope: 'alice'); + expect(cache.read(bob)!.response.text, 'bob'); + cache.setScope('alice'); + expect(cache.read(cache.capture(catalog, resource)!), isNull); + }); + + test('invalidation wins over an already pending persistent write', () async { + final platform = _DelayedPreferences(); + SharedPreferencesStorePlatform.instance = platform; + final prefs = await SharedPreferences.getInstance(); + final cache = OpdsResourceCache(prefs)..setScope('alice'); + final token = cache.capture(catalog, resource)!; + final writing = cache.write(token, response()); + await Future.delayed(Duration.zero); + final invalidating = cache.invalidateCatalog(catalog); + platform.firstWrite.complete(); + await Future.wait([writing, invalidating]); + final restarted = OpdsResourceCache(prefs)..setScope('alice'); + expect(restarted.read(restarted.capture(catalog, resource)!), isNull); + expect((await platform.getAll()).values.join(), isNot(contains('redirected'))); + }); + + test('a rejected preference write remains usable in memory', () async { + SharedPreferencesStorePlatform.instance = _FailingPreferences(throwsOnWrite: false); + final cache = OpdsResourceCache(await SharedPreferences.getInstance())..setScope('alice'); + final token = cache.capture(catalog, resource)!; + await cache.write(token, response()); + expect(cache.read(token)!.response.text, 'feed'); + await cache.remove(token); + expect(cache.read(token), isNull); + }); + + test('only scope switches and catalog invalidation notify listeners', () async { + final cache = OpdsResourceCache(await SharedPreferences.getInstance()); + var notifications = 0; + cache.addListener(() => notifications++); + cache.setScope('alice'); + expect(notifications, 1); + cache.setScope('alice'); + final token = cache.capture(catalog, resource)!; + await cache.write(token, response()); + cache.read(token); + await cache.remove(token); + expect(notifications, 1); + await cache.invalidateCatalog(catalog); + expect(notifications, 2); + cache.setScope(null); + expect(notifications, 3); + cache.dispose(); + }); + + test('bounds serialized bytes and rejects oversized resources', () async { + final prefs = await SharedPreferences.getInstance(); + final cache = OpdsResourceCache(prefs, maxBytes: 900, maxEntryBytes: 500)..setScope('alice'); + for (var i = 0; i < 10; i++) { + await cache.write(cache.capture(catalog, resource.resolve('$i'))!, response('x' * 100)); + } + expect(utf8.encode(prefs.getKeys().map(prefs.get).join()).length, lessThanOrEqualTo(900)); + final big = cache.capture(catalog, resource)!; + await cache.write(big, response('x' * 1000)); + expect(cache.read(big), isNull); + }); + + test('corrupt persistence and quota failures do not break callers', () async { + final prefs = await SharedPreferences.getInstance(); + final cache = OpdsResourceCache(prefs)..setScope('alice'); + await cache.write(cache.capture(catalog, resource)!, response()); + final key = prefs.getKeys().single; + await prefs.setString(key, '{corrupt'); + final corrupted = OpdsResourceCache(prefs)..setScope('alice'); + expect(corrupted.read(corrupted.capture(catalog, resource)!), isNull); + SharedPreferences.setMockInitialValues({}); + SharedPreferencesStorePlatform.instance = _FailingPreferences(); + final failing = OpdsResourceCache(await SharedPreferences.getInstance())..setScope('alice'); + final token = failing.capture(catalog, resource)!; + await failing.write(token, response()); + expect(failing.read(token)!.response.text, 'feed'); + await failing.invalidateCatalog(catalog); + expect(failing.read(failing.capture(catalog, resource)!), isNull); + }); +} diff --git a/app/test/opds/sheets_test.dart b/app/test/opds/sheets_test.dart new file mode 100644 index 0000000..1ac65fa --- /dev/null +++ b/app/test/opds/sheets_test.dart @@ -0,0 +1,390 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/services/book_import_session.dart'; +import 'package:papyrus/services/book_import_result.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/opds/catalog_editor.dart'; +import 'package:papyrus/widgets/opds/opds_download_panel.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'catalog_store_test.dart' show MemorySecrets; + +class _PendingStore extends OpdsCatalogStore { + _PendingStore(super.prefs) : super(secrets: MemorySecrets()); + final finish = Completer(); + + @override + Future save( + String scope, + OpdsCatalog catalog, { + OpdsCredentials? credentials, + bool clearCredentials = false, + }) async { + await finish.future; + return super.save(scope, catalog, credentials: credentials, clearCredentials: clearCredentials); + } +} + +final _catalog = OpdsCatalog(id: 'one', name: 'Books', uri: Uri.parse('https://books.test/feed')); +final _publication = OpdsPublication(id: 'book', title: 'A long book title that wraps on a small display'); +final _link = OpdsLink( + uri: Uri.parse('https://books.test/book.epub'), + type: 'application/epub+zip', + rels: ['download'], +); + +class _PendingHttp extends OpdsHttpClient { + final response = Completer(); + void Function(int, int?)? progress; + + @override + Future get( + OpdsCatalog catalog, + Uri uri, { + OpdsCredentials? credentials, + OpdsCancellation? cancellation, + void Function(int, int?)? onProgress, + int maxBytes = 8 * 1024 * 1024, + }) { + progress = onProgress; + cancellation?.addListener(() { + if (!response.isCompleted) response.completeError(const OpdsCancelled()); + }); + onProgress?.call(20, 100); + return response.future; + } +} + +BookImportSession _unusedSession() => BookImportSession( + process: (_, _) async => throw StateError('Unexpected import'), + deleteFile: (_) async {}, + commit: (_, _) async => throw StateError('Unexpected commit'), + isCurrent: () => true, +); + +Future _mountDownloads(WidgetTester tester, OpdsDownloads downloads, ValueChanged retry) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + appBar: AppBar( + actions: [OpdsDownloadsButton(downloads: downloads, onRetry: retry)], + ), + ), + ), + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + downloads.dispose(); + }); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('narrow catalog footer keeps enlarged actions reachable above the keyboard', (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + SharedPreferences.setMockInitialValues({}); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) + ..setScope('local--guest'); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => CatalogEditor.show(context, catalogs: catalogs), + child: const Text('Open editor'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open editor')); + await tester.pumpAndSettle(); + final title = tester.getRect(find.text('Add catalog')); + final close = tester.getRect(find.byTooltip('Close')); + final footer = tester.getRect(find.byKey(const Key('opds-sheet-footer'))); + expect(title.left, lessThan(close.left)); + expect(footer.top, greaterThan(tester.getBottomLeft(find.byKey(const Key('opds-name'))).dy)); + expect(tester.getTopLeft(find.text('Save')).dy, greaterThan(footer.top)); + final cancel = tester.renderObject(find.text('Cancel')); + final oneLine = TextPainter(text: cancel.text, textDirection: cancel.textDirection, textScaler: cancel.textScaler) + ..layout(); + expect(cancel.size.height, closeTo(oneLine.height, .1)); + oneLine.dispose(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + for (final label in ['Cancel', 'Save']) { + expect(find.text(label).hitTestable(), findsOneWidget); + expect(tester.getBottomLeft(find.text(label)).dy, lessThan(340)); + } + expect(tester.takeException(), isNull); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox.shrink()); + catalogs.dispose(); + }); + + testWidgets('root downloads sheet preserves local reduced motion for indeterminate progress', (tester) async { + final gateway = _PendingHttp(); + final downloads = OpdsDownloads(httpClient: gateway, captureImport: _unusedSession); + final operation = downloads.start(_catalog, _publication, _link); + gateway.progress!(20, null); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: AppMotionScope( + reduceAnimations: true, + child: Scaffold( + body: OpdsDownloadsButton(downloads: downloads, onRetry: (_) {}), + ), + ), + ), + ); + addTearDown(() async { + downloads.cancel(downloads.jobs.single.key); + await tester.pump(); + await tester.pumpWidget(const SizedBox.shrink()); + downloads.dispose(); + }); + await tester.tap(find.byTooltip('Downloads')); + await tester.pump(); + final progress = find.byType(AppLinearProgressIndicator); + expect(AppMotion.disabled(tester.element(progress)), isTrue); + expect(tester.widget(progress).value, isNull); + await tester.pumpAndSettle(); + expect(tester.binding.hasScheduledFrame, isFalse); + expect(tester.takeException(), isNull); + downloads.cancel(downloads.jobs.single.key); + await operation; + }); + + testWidgets('catalog sheet blocks drag, barrier and back while saving then allows retry', (tester) async { + SharedPreferences.setMockInitialValues({}); + final store = _PendingStore(await SharedPreferences.getInstance()); + final catalogs = OpdsCatalogs(store)..setScope('local--guest'); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => CatalogEditor.show(context, catalogs: catalogs), + child: const Text('Open editor'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open editor')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('opds-name')), 'My catalog'); + await tester.enterText(find.byKey(const Key('opds-url')), 'https://books.test/feed'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(tester.widget(find.widgetWithText(FilledButton, 'Saving…')).onPressed, isNull); + expect(tester.widget(find.widgetWithText(TextButton, 'Cancel')).onPressed, isNull); + expect(tester.widget(find.widgetWithIcon(IconButton, Icons.close)).onPressed, isNull); + await tester.tapAt(const Offset(10, 10)); + await tester.drag(find.byType(BottomSheetHandle), const Offset(0, 400)); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + store.finish.completeError(const OpdsException('Save failed.')); + await tester.pumpAndSettle(); + expect(find.text('Save failed.'), findsOneWidget); + expect(tester.widget(find.widgetWithText(FilledButton, 'Save')).onPressed, isNotNull); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + catalogs.dispose(); + }); + + testWidgets('catalog editor uses a sheet header and keeps Save above the keyboard', (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + SharedPreferences.setMockInitialValues({}); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) + ..setScope('local--guest'); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => CatalogEditor.show(context, catalogs: catalogs), + child: const Text('Open editor'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open editor')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('opds-sheet-header')), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(find.text('Enter a name'), findsOneWidget); + await tester.enterText(find.byKey(const Key('opds-name')), 'My catalog'); + await tester.enterText(find.byKey(const Key('opds-url')), 'https://books.test/feed'); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + expect(tester.getBottomLeft(find.text('Save')).dy, lessThan(340)); + expect(find.text('Save').hitTestable(), findsOneWidget); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(catalogs.catalogs.single.name, 'My catalog'); + expect(find.byType(BottomSheet), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + catalogs.dispose(); + }); + + testWidgets('downloads button opens a live sheet with progress and cancellation', (tester) async { + final gateway = _PendingHttp(); + final downloads = OpdsDownloads(httpClient: gateway, captureImport: _unusedSession); + final operation = downloads.start(_catalog, _publication, _link); + await _mountDownloads(tester, downloads, (_) {}); + expect(find.byType(BottomSheet), findsNothing); + expect(find.text('1'), findsOneWidget); + await tester.tap(find.byTooltip('Downloads')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('opds-sheet-header')), findsOneWidget); + expect(find.text('Downloading · 20%'), findsOneWidget); + gateway.progress!(70, 100); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(AppLinearProgressIndicator)).value, .7); + expect(find.byTooltip('Dismiss download'), findsNothing); + await tester.tap(find.byTooltip('Cancel download')); + await operation; + await tester.pumpAndSettle(); + expect(find.text('Cancelled'), findsOneWidget); + await tester.tap(find.byTooltip('Dismiss download')); + await tester.pumpAndSettle(); + expect(find.text('No downloads yet'), findsOneWidget); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('download failure updates header indicator and supports retry in the open sheet', (tester) async { + final gateway = _PendingHttp(); + final downloads = OpdsDownloads(httpClient: gateway, captureImport: _unusedSession); + final operation = downloads.start(_catalog, _publication, _link); + final retried = []; + await _mountDownloads(tester, downloads, retried.add); + await tester.tap(find.byTooltip('Downloads')); + await tester.pumpAndSettle(); + gateway.response.completeError(const OpdsException('Catalog unavailable.')); + await operation; + await tester.pumpAndSettle(); + expect(find.text('Catalog unavailable.'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + await tester.tap(find.text('Retry')); + expect(retried.single, same(downloads.jobs.single)); + expect(find.text('Download in browser'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('completed download opens its library book and closes the sheet', (tester) async { + tester.view.physicalSize = const Size(280, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final gateway = _PendingHttp(); + final finish = Completer(); + final downloads = OpdsDownloads( + httpClient: gateway, + captureImport: () => BookImportSession( + process: (_, _) async => const BookImportResult( + bookId: 'imported', + title: 'Book', + author: 'Author', + fileSize: 4, + fileHash: 'hash', + fileExtension: 'epub', + ), + deleteFile: (_) async {}, + commit: (_, _) => finish.future, + isCurrent: () => true, + ), + ); + final operation = downloads.start(_catalog, _publication, _link); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => Scaffold( + body: OpdsDownloadsButton(downloads: downloads, onRetry: (_) {}), + ), + ), + GoRoute( + path: '/library/details/:id', + builder: (_, state) => Scaffold(body: Text('Opened ${state.pathParameters['id']}')), + ), + ], + ); + await tester.pumpWidget( + MaterialApp.router( + theme: AppTheme.eink, + routerConfig: router, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + ), + ); + await tester.tap(find.byTooltip('Downloads')); + await tester.pumpAndSettle(); + gateway.response.complete(OpdsResponse(uri: _link.uri, bytes: Uint8List.fromList([1, 2, 3]), headers: const {})); + await tester.pumpAndSettle(); + expect(find.text('Adding to library…'), findsOneWidget); + expect(find.byTooltip('Cancel download'), findsNothing); + expect(find.byTooltip('Dismiss download'), findsNothing); + finish.complete(Book(id: 'imported', title: 'Book', author: 'Author', addedAt: DateTime(2026))); + await operation; + await tester.pumpAndSettle(); + expect(find.byTooltip('Dismiss download'), findsOneWidget); + await tester.tap(find.text('Open book')); + await tester.pumpAndSettle(); + expect(find.text('Opened imported'), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + router.dispose(); + downloads.dispose(); + }); +} diff --git a/app/test/pages/book_details_reader_test.dart b/app/test/pages/book_details_reader_test.dart index d683399..16da5a2 100644 --- a/app/test/pages/book_details_reader_test.dart +++ b/app/test/pages/book_details_reader_test.dart @@ -4,11 +4,107 @@ import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/pages/book_details_page.dart'; +import 'package:papyrus/widgets/book_details/book_cover_image.dart'; +import 'package:papyrus/widgets/book_details/book_details_tab_rail.dart'; import 'package:provider/provider.dart'; import '../helpers/test_helpers.dart'; void main() { + testWidgets('mobile library tabs stay pinned and remain interactive after scrolling', (tester) async { + tester.view.physicalSize = const Size(424, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final book = buildTestBook(pageCount: 735, description: List.filled(40, 'A long book description.').join('\n\n')); + final dataStore = DataStore()..loadData(books: [book]); + addTearDown(dataStore.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: dataStore, + child: MaterialApp(home: BookDetailsPage(id: book.id)), + ), + ); + await tester.pumpAndSettle(); + final rail = find.byType(BookDetailsTabRail); + final headerBottom = tester.getBottomLeft(find.byType(AppBar)).dy; + expect(find.text('735 pages'), findsNothing); + expect(tester.getTopLeft(find.byType(CoverImagePreview)).dy - headerBottom, 24); + expect(tester.getRect(rail).left, 0); + expect(tester.getRect(rail).width, 424); + await tester.dragFrom(const Offset(212, 750), const Offset(0, -650)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(rail).dy, headerBottom); + await tester.drag(rail, const Offset(-600, 0)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Notes (0)')); + await tester.pumpAndSettle(); + expect(find.text('No notes yet'), findsOneWidget); + expect(tester.getTopLeft(rail).dy, headerBottom); + await tester.drag(rail, const Offset(600, 0)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + expect(find.text('Description'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final direct in [false, true]) { + testWidgets('desktop details header goes back from ${direct ? 'a direct link' : 'a pushed page'}', (tester) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final book = buildTestBook(id: 'header-book'); + final dataStore = DataStore()..loadData(books: [book]); + addTearDown(dataStore.dispose); + final detailsPath = '/library/details/${book.id}'; + final router = GoRouter( + initialLocation: direct ? detailsPath : '/previous', + routes: [ + GoRoute( + path: '/previous', + builder: (_, _) => const Scaffold(body: Text('Previous page')), + ), + GoRoute( + path: '/library/books', + builder: (_, _) => const Scaffold(body: Text('Library books')), + ), + GoRoute( + path: detailsPath, + builder: (_, _) => Scaffold(body: BookDetailsPage(id: book.id)), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: dataStore, + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + if (!direct) { + router.push(detailsPath); + await tester.pumpAndSettle(); + } + expect(find.text('Book details'), findsOneWidget); + final cover = tester.getRect(find.byType(CoverImagePreview)); + final rail = tester.getRect(find.byType(BookDetailsTabRail)); + expect(rail.top - cover.bottom, 16); + expect(tester.getTopLeft(find.text('Description')).dx, cover.left); + expect(tester.getTopLeft(find.text('Description')).dy - rail.bottom, 16); + final headerPosition = tester.getTopLeft(find.text('Book details')); + await tester.drag(find.byType(SingleChildScrollView).first, const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Book details')), headerPosition); + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + expect(find.text(direct ? 'Library books' : 'Previous page'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + testWidgets('Start reading explains unsupported formats', (tester) async { final book = buildTestBook(id: 'mobi-book', fileFormat: BookFormat.mobi); final dataStore = DataStore()..loadData(books: [book]); diff --git a/app/test/platform/hot_restart_cleanup_test.dart b/app/test/platform/hot_restart_cleanup_test.dart deleted file mode 100644 index 94dce16..0000000 --- a/app/test/platform/hot_restart_cleanup_test.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:papyrus/platform/hot_restart_cleanup.dart'; - -void main() { - setUp(runPreviousHotRestartCleanup); - - test('registered web cleanup runs once before the next app starts', () async { - var calls = 0; - registerHotRestartCleanup(() async => calls++); - - await runPreviousHotRestartCleanup(); - await runPreviousHotRestartCleanup(); - - expect(calls, 1); - }, skip: !kIsWeb); -} diff --git a/app/test/services/book_import_session_test.dart b/app/test/services/book_import_session_test.dart new file mode 100644 index 0000000..234c97e --- /dev/null +++ b/app/test/services/book_import_session_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.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'; + +class _Repository extends Fake implements BookRepository {} + +class _DataStore extends Fake implements DataStore { + final repository = _Repository(); + @override + BookRepository requireBookRepository() => repository; + @override + bool isBookRepositoryCurrent(BookRepository repository) => identical(this.repository, repository); +} + +class _Auth extends Fake implements AuthProvider { + @override + bool get isSignedIn => true; + @override + bool get isOfflineMode => false; + @override + PapyrusUser get user => PapyrusUser.fromJson({'user_id': 'bob'}); +} + +class _Queue extends Fake implements MediaUploadQueue { + @override + MediaStorageScope get activeScope => MediaStorageScope(profileKey: 'official', userId: 'alice'); +} + +class _Database extends Fake implements PapyrusPowerSyncService { + _Database(this.mode); + @override + final LibraryDatabaseMode mode; +} + +class _Importer extends Fake implements BookImportService {} + +void main() { + test('cannot capture the previous account media scope during account activation', () { + expect( + () => BookImportSession.capture( + dataStore: _DataStore(), + queue: _Queue(), + importService: _Importer(), + authProvider: _Auth(), + powerSyncService: _Database(LibraryDatabaseMode.authenticated), + ), + throwsStateError, + ); + }); + test('cannot capture a guest database while an authenticated account is activating', () { + expect( + () => BookImportSession.capture( + dataStore: _DataStore(), + queue: _Queue(), + importService: _Importer(), + authProvider: _Auth(), + powerSyncService: _Database(LibraryDatabaseMode.guest), + ), + throwsStateError, + ); + }); +} diff --git a/app/test/web/flutter_bootstrap_test.cjs b/app/test/web/flutter_bootstrap_test.cjs new file mode 100644 index 0000000..98a9e43 --- /dev/null +++ b/app/test/web/flutter_bootstrap_test.cjs @@ -0,0 +1,60 @@ +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 bootstrap = readFileSync(join(__dirname, '../../web/flutter_bootstrap.js'), 'utf8') + .replace('{{flutter_js}}', '') + .replace('{{flutter_build_config}}', ''); + +function browserDocument() { + const events = []; + let onEntrypointLoaded; + runInNewContext(bootstrap, { + _flutter: { + loader: { + load: (options) => { onEntrypointLoaded = options.onEntrypointLoaded; }, + }, + }, + window: { location: { reload: () => events.push('reload') } }, + }); + const engine = { + initializeEngine: async () => { + events.push('initialize'); + return { runApp: async () => { events.push('run'); } }; + }, + }; + return { events, start: () => onEntrypointLoaded(engine) }; +} + +test('cold startup initializes and runs the app without reloading', async () => { + const page = browserDocument(); + await page.start(); + assert.deepEqual(page.events, ['initialize', 'run']); +}); + +test('hot restart reloads the document without invoking a second Dart runtime', async () => { + const page = browserDocument(); + await page.start(); + await page.start(); + assert.deepEqual(page.events, ['initialize', 'run', 'reload']); +}); + +test('the refreshed document boots normally and handles another restart', async () => { + const first = browserDocument(); + await first.start(); + await first.start(); + const next = browserDocument(); + await next.start(); + assert.deepEqual(next.events, ['initialize', 'run']); + await next.start(); + assert.deepEqual(next.events, ['initialize', 'run', 'reload']); +}); + +test('a restart during initialization does not initialize a second engine', async () => { + const page = browserDocument(); + await Promise.all([page.start(), page.start()]); + assert.equal(page.events.filter((event) => event === 'initialize').length, 1); + assert.equal(page.events.filter((event) => event === 'reload').length, 1); +}); diff --git a/app/test/widgets/book_details/book_action_buttons_test.dart b/app/test/widgets/book_details/book_action_buttons_test.dart index 2e3cd2e..9bcfa7a 100644 --- a/app/test/widgets/book_details/book_action_buttons_test.dart +++ b/app/test/widgets/book_details/book_action_buttons_test.dart @@ -8,6 +8,24 @@ import '../../helpers/test_helpers.dart'; void main() { group('BookActionButtons', () { + testWidgets('physical book actions wrap without clipping at enlarged text sizes', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: SizedBox( + width: 288, + child: BookActionButtons(book: buildTestBook(isPhysical: true), onUpdateProgress: () {}), + ), + ), + ), + ), + ); + expect(find.text('Update progress'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + Widget buildWidget({ Book? book, VoidCallback? onContinueReading, diff --git a/app/test/widgets/library/book_grid_test.dart b/app/test/widgets/library/book_grid_test.dart index 0771359..885d042 100644 --- a/app/test/widgets/library/book_grid_test.dart +++ b/app/test/widgets/library/book_grid_test.dart @@ -13,6 +13,33 @@ import 'package:provider/provider.dart'; void main() { group('BookGrid ordinary behavior', () { + testWidgets('grid density follows available content width inside a desktop shell', (tester) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 500, + child: ChangeNotifierProvider( + create: (_) => LibraryProvider(), + child: BookGrid( + books: [_book(id: 'one', title: 'A book')], + libraryViewMode: LibraryViewMode.smallGrid, + ), + ), + ), + ), + ), + ), + ); + final grid = tester.widget(find.byType(GridView)); + expect((grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount).crossAxisCount, 2); + expect(tester.takeException(), isNull); + }); testWidgets('renders books in a GridView', (tester) async { final books = [_book(id: 'book-1', title: 'First Book'), _book(id: 'book-2', title: 'Second Book')]; @@ -325,10 +352,13 @@ void main() { group('BookGrid responsiveness', () { testWidgets('preserves responsive columns and matching item widths', (tester) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); final book = _book(id: 'book-1', title: 'Book'); final orphan = _job(id: 'job-1', bookId: null, title: 'Orphan'); for (final (width, columns) in const [(400.0, 2), (700.0, 4), (900.0, 5), (1300.0, 6)]) { + tester.view.physicalSize = Size(width, 800); await tester.pumpWidget(_buildGrid(books: [book], placeholderJobs: [orphan], screenSize: Size(width, 800))); final grid = tester.widget(find.byType(GridView)); diff --git a/app/test/widgets/shell/eink_layout_test.dart b/app/test/widgets/shell/eink_layout_test.dart index f57cd9c..8b6f339 100644 --- a/app/test/widgets/shell/eink_layout_test.dart +++ b/app/test/widgets/shell/eink_layout_test.dart @@ -2,6 +2,8 @@ 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/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sidebar_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; @@ -20,6 +22,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); SharedPreferences.setMockInitialValues({'theme_mode': 'light'}); final prefs = PreferencesProvider(await SharedPreferences.getInstance()); + final catalogStore = OpdsCatalogStore(await SharedPreferences.getInstance()); final router = GoRouter( initialLocation: '/library/books', routes: [ @@ -35,6 +38,7 @@ void main() { providers: [ ChangeNotifierProvider.value(value: prefs), ChangeNotifierProvider(create: (_) => DataStore()), + ChangeNotifierProvider(create: (_) => OpdsCatalogs(catalogStore)), ChangeNotifierProvider(create: (_) => SidebarProvider()), ], child: Consumer( diff --git a/app/test/widgets/shell/navigation_counts_test.dart b/app/test/widgets/shell/navigation_counts_test.dart new file mode 100644 index 0000000..413dbe4 --- /dev/null +++ b/app/test/widgets/shell/navigation_counts_test.dart @@ -0,0 +1,83 @@ +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/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/providers/sidebar_provider.dart'; +import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; +import 'package:papyrus/widgets/shell/nav_item_count.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../opds/catalog_store_test.dart' show MemorySecrets; + +void main() { + for (final width in [390.0, 1280.0]) { + testWidgets('navigation hides empty counts and tracks saved catalogs at $width', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({}); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) + ..setScope('guest'); + addTearDown(catalogs.dispose); + final router = GoRouter( + initialLocation: '/library/catalogs', + routes: [ + ShellRoute( + builder: (_, _, child) => AdaptiveAppShell(child: child), + routes: [GoRoute(path: '/library/catalogs', builder: (_, _) => const Text('Page content'))], + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => DataStore()), + ChangeNotifierProvider(create: (_) => SidebarProvider()), + ChangeNotifierProvider.value(value: catalogs), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + if (width < 840) { + tester.state(find.byType(Scaffold).first).openDrawer(); + await tester.pumpAndSettle(); + } + expect(find.text('Catalogs'), findsOneWidget); + expect(find.byType(NavItemCount), findsNothing); + expect(find.text('0'), findsNothing); + + await catalogs.save(OpdsCatalog(id: 'one', name: 'One', uri: Uri.parse('https://one.test/feed'))); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(NavItemCount)).count, 1); + expect(tester.widget(find.byType(NavItemCount)).selected, isTrue); + + await catalogs.save(OpdsCatalog(id: 'two', name: 'Two', uri: Uri.parse('https://two.test/feed'))); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(NavItemCount)).count, 2); + + await catalogs.remove('one'); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(NavItemCount)).count, 1); + + catalogs.setScope('another-account'); + await tester.pumpAndSettle(); + expect(find.byType(NavItemCount), findsNothing); + catalogs.setScope('guest'); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(NavItemCount)).count, 1); + + await catalogs.remove('two'); + await tester.pumpAndSettle(); + expect(find.byType(NavItemCount), findsNothing); + expect(find.text('0'), findsNothing); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/app/tool/opds_fixture_server.py b/app/tool/opds_fixture_server.py new file mode 100644 index 0000000..9f264e6 --- /dev/null +++ b/app/tool/opds_fixture_server.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Serve deterministic OPDS fixtures using only the Python standard library. + +Run from app/: python3 tool/opds_fixture_server.py --port 8766 +Then: flutter test test/opds/network_smoke_test.dart \ + --dart-define=OPDS_SMOKE_URL=http://127.0.0.1:8766 +Add --platform chrome for the same checks through the browser HTTP transport. +Protected routes use the fixture credentials reader / secret. +""" + +import argparse +import base64 +import io +import json +from http.client import HTTPConnection +import struct +import zlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urljoin, urlsplit +from xml.sax.saxutils import escape +from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile + + +ATOM = "http://www.w3.org/2005/Atom" +ACQUISITION = "http://opds-spec.org/acquisition" +BASIC_AUTH = "Basic " + base64.b64encode(b"reader:secret").decode("ascii") +XML_TYPE = "application/atom+xml;profile=opds-catalog" +JSON_TYPE = "application/opds+json" + + +def make_epub(): + """Build a small EPUB 3 publication without downloading external fixtures.""" + buffer = io.BytesIO() + with ZipFile(buffer, "w") as archive: + archive.writestr("mimetype", "application/epub+zip", compress_type=ZIP_STORED) + archive.writestr( + "META-INF/container.xml", + '' + '', + compress_type=ZIP_DEFLATED, + ) + archive.writestr( + "EPUB/package.opf", + '' + '' + 'urn:papyrus:network-fixture' + 'Network fixture booken' + 'Fixture Author' + '2026-01-01T00:00:00Z' + '' + '' + '', + compress_type=ZIP_DEFLATED, + ) + archive.writestr( + "EPUB/chapter.xhtml", + '' + 'Network fixture book' + '

Network fixture book

A local smoke test publication.

' + '', + compress_type=ZIP_DEFLATED, + ) + archive.writestr( + "EPUB/nav.xhtml", + 'Contents' + '', + compress_type=ZIP_DEFLATED, + ) + return buffer.getvalue() + + +EPUB_BYTES = make_epub() + + +def cover_png(): + """A small solid cover fixture for exercising image decoding and layout.""" + def chunk(kind, payload): + return struct.pack('!I', len(payload)) + kind + payload + struct.pack('!I', zlib.crc32(kind + payload)) + pixels = b''.join(b'\x00' + bytes((70, 80, 105)) * 96 for _ in range(144)) + return (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('!2I5B', 96, 144, 8, 2, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(pixels)) + chunk(b'IEND', b'')) + + +def xml_publication(detail=False): + download = "../../book.epub" if detail else "../book.epub" + details = "one.xml" if detail else "details/one.xml" + description = "Complete publication details." if detail else "A fixture book." + return ( + f'' + 'urn:papyrus:network-fixtureNetwork fixture book' + '2026-01-01T00:00:00Z' + 'Fixture Authoren' + f'{description}' + f'' + f'' + '' + ) + + +def xml_feed(title, content): + return ( + f'' + f'urn:papyrus:fixture-feed{escape(title)}' + '2026-01-01T00:00:00Z' + 'Fixture Author' + f'{content}' + ) + + +def json_publication(detail=False): + return { + "metadata": { + "identifier": "urn:papyrus:network-fixture", + "title": "Network fixture book", + "author": {"name": "Fixture Author"}, + "language": "en", + "description": "Complete publication details." if detail else "A fixture book.", + }, + "links": [ + { + "rel": "self", + "type": "application/opds-publication+json", + "href": "one.json" if detail else "details/one.json", + }, + { + "rel": "download", + "type": "application/epub+zip", + "href": "../../book.epub" if detail else "../book.epub", + }, + ], + } + + +class FixtureHandler(BaseHTTPRequestHandler): + def respond(self, status, body=b"", content_type="text/plain; charset=utf-8", cors=True, **headers): + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + if cors: + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") + self.send_header("Access-Control-Expose-Headers", "Content-Type, Content-Length, Location, X-OPDS-URL") + self.send_header("Access-Control-Allow-Private-Network", "true") + self.send_header("Cache-Control", "no-store") + for name, value in headers.items(): + self.send_header(name.replace("_", "-"), value) + self.end_headers() + self.wfile.write(body) + + def json_response(self, body, content_type=JSON_TYPE): + self.respond(200, json.dumps(body, ensure_ascii=False), content_type) + + def do_OPTIONS(self): + self.respond(204) + + def do_POST(self): + """Emulate the relay contract only for this loopback fixture's own resources.""" + if self.path != "/v1/opds/relay": + self.respond(404) + return + payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + current = payload["url"] + catalog = urlsplit(payload["catalog_url"]) + for _ in range(6): + target = urlsplit(current) + if (target.scheme != "http" or target.hostname not in ("127.0.0.1", "localhost") + or target.port != self.server.server_port or target.username is not None): + self.respond(400, "Fixture relay only accepts its own loopback URLs.") + return + headers = {} + credentials = payload.get("credentials") + if credentials and (target.scheme, target.netloc) == (catalog.scheme, catalog.netloc): + token = f"{credentials['username']}:{credentials['password']}".encode() + headers["Authorization"] = "Basic " + base64.b64encode(token).decode() + connection = HTTPConnection(target.hostname, target.port, timeout=5) + try: + path = target.path + ("?" + target.query if target.query else "") + connection.request("GET", path, headers=headers) + response = connection.getresponse() + if response.status in (301, 302, 303, 307, 308): + current = urljoin(current, response.getheader("Location")) + continue + if response.status != 200: + message = "Check the catalog credentials." if response.status == 401 else "Fixture resource failed." + self.respond(response.status, json.dumps({"error": { + "code": "OPDS_RELAY_ERROR", "message": message, + }}), "application/json") + return + body = response.read(payload["max_bytes"] + 1) + if len(body) > payload["max_bytes"]: + self.respond(413) + return + self.respond(200, body, response.getheader("Content-Type"), X_OPDS_URL=current) + return + finally: + connection.close() + self.respond(502, "Too many fixture redirects.") + + def do_GET(self): + request = urlsplit(self.path) + path = request.path + if path == '/no-cors/redirect': + self.respond(302, cors=False, Location='/no-cors/book.epub') + return + if path == '/no-cors/book.epub': + self.respond(200, EPUB_BYTES, 'application/epub+zip', cors=False) + return + if path.startswith("/protected/") and self.headers.get("Authorization") != BASIC_AUTH: + self.respond(401, "Fixture credentials required.", WWW_Authenticate='Basic realm="OPDS fixture"') + return + if path == "/auth-probe": + self.json_response({"authorizationReceived": self.headers.get("Authorization") is not None}) + return + if path == "/protected/cross-origin": + self.respond(302, Location=f"http://localhost:{self.server.server_port}/auth-probe") + return + for access in ("public", "protected"): + for version, extension in (("v1", "xml"), ("v2", "json")): + if path == f"/{access}/redirect-{version}": + self.respond(302, Location=f"/{access}/{version}/books/feed.{extension}") + return + parts = path.strip("/").split("/", 2) + if len(parts) != 3 or parts[0] not in ("public", "protected") or parts[1] not in ("v1", "v2"): + self.respond(404, "Unknown fixture route.") + return + _, version, resource = parts + if resource == "book.epub": + self.respond(200, EPUB_BYTES, "application/epub+zip") + return + query = parse_qs(request.query) + if version == "v1": + self.serve_xml(resource, query) + else: + self.serve_json(resource, query) + + def serve_xml(self, resource, query): + if resource == "catalog.xml": + content = ( + '' + 'urn:papyrus:all-booksAll books' + '2026-01-01T00:00:00Z' + f'' + ) + self.respond(200, xml_feed("Fixture catalog", content), XML_TYPE) + elif resource == "search.xml": + self.respond( + 200, + '' + 'Fixture searchLocal book search' + f'' + '', + "application/opensearchdescription+xml", + ) + elif resource == "books/details/one.xml": + self.respond(200, xml_publication(detail=True), XML_TYPE + ";type=entry") + elif resource in ("books/feed.xml", "books/page2.xml", "books/search"): + title = "Books" + links = '' + if resource == "books/page2.xml": + title, links = "Second page", '' + elif resource == "books/search": + title = "Search: " + query.get("q", [""])[0] + links = "" + self.respond(200, xml_feed(title, links + xml_publication()), XML_TYPE) + else: + self.respond(404, "Unknown XML fixture route.") + + def serve_json(self, resource, query): + if resource == 'blocked.json': + publication = json_publication() + publication['links'] = [{'rel': 'download', 'type': 'application/epub+zip', + 'href': '/no-cors/redirect'}] + self.json_response({'metadata': {'title': 'Browser-restricted downloads'}, 'publications': [publication]}) + elif resource == "cover.png": + self.respond(200, cover_png(), "image/png") + elif resource == "showcase.json": + titles = [("Pride and Prejudice", "Jane Austen"), ("Crime and Punishment", "Fyodor Dostoyevsky"), + ("The Odyssey", "Homer"), ("Alice’s Adventures in Wonderland", "Lewis Carroll"), + ("A Room with a View", "E. M. Forster"), ("The Adventures of Sherlock Holmes", "Arthur Conan Doyle")] + books = [] + for index, (title, author) in enumerate(titles): + book = json_publication() + book['metadata'].update(identifier=f'preview-{index}', title=title, author=author, + publisher='Fixture Library', description=( + '

A classic story from the catalog, ready to add to your personal library.

' + '

This preview includes a longer catalog description to check reading comfort on small screens. ' + 'The description keeps its paragraphs, while download options stay easy to find.

' + '

Edition notes: this copy includes the original text. Catalogs can provide multiple editions ' + 'of the same work, with different illustrations and file formats.

' + '

Source: local OPDS fixture. Rights: demonstration metadata.

')) + book['links'] = [ + {'rel': 'download', 'type': 'application/epub+zip', 'href': 'book.epub', 'title': 'EPUB with illustrations'}, + {'rel': 'download', 'type': 'application/pdf', 'href': 'book.pdf', 'title': 'PDF'}, + {'rel': 'http://opds-spec.org/acquisition/buy', 'type': 'text/html', 'href': 'purchase', 'title': 'Purchase edition'}, + ] + if index % 2 == 0: + book['images'] = [{'href': 'cover.png', 'type': 'image/png'}] + books.append(book) + self.json_response({'metadata': {'title': 'Popular classics'}, + 'navigation': [{'title': 'Browse by author', 'description': 'Find your favorite writers', 'href': 'sections.json'}, + {'title': 'Recently added', 'description': 'New books in the collection', 'href': 'sections.json'}], + 'links': [{'rel': 'search', 'href': 'search{?query}', 'templated': True}, + {'rel': 'next', 'href': 'sections.json'}], + 'publications': books}) + elif resource == 'sections.json': + self.json_response({'metadata': {'title': 'Browse the collection'}, + 'navigation': [{'title': f'Collected works, volume {index + 1}', + 'description': 'By a catalog author', 'href': 'showcase.json'} for index in range(24)], + 'links': [{'rel': 'previous', 'href': 'showcase.json'}]}) + elif resource == "catalog.json": + self.json_response({ + "metadata": {"title": "Fixture catalog"}, + "links": [{"rel": "search", "href": "search{?query}", "templated": True, "type": JSON_TYPE}], + "navigation": [{"title": "All books", "href": "books/feed.json", "type": JSON_TYPE}], + }) + elif resource == "books/details/one.json": + self.json_response(json_publication(detail=True), "application/opds-publication+json") + elif resource in ("books/feed.json", "books/page2.json", "search"): + title = "Books" + links = [{"rel": "next", "href": "page2.json", "type": JSON_TYPE}] + publication = json_publication() + if resource == "books/page2.json": + title, links = "Second page", [{"rel": "previous", "href": "feed.json", "type": JSON_TYPE}] + elif resource == "search": + title, links = "Search: " + query.get("query", [""])[0], [] + publication["links"][0]["href"] = "books/details/one.json" + publication["links"][1]["href"] = "book.epub" + self.json_response({"metadata": {"title": title}, "links": links, "publications": [publication]}) + else: + self.respond(404, "Unknown JSON fixture route.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=8766) + args = parser.parse_args() + with ThreadingHTTPServer(("127.0.0.1", args.port), FixtureHandler) as server: + print(f"OPDS fixtures: http://127.0.0.1:{server.server_port}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/app/web/flutter_bootstrap.js b/app/web/flutter_bootstrap.js new file mode 100644 index 0000000..f15f382 --- /dev/null +++ b/app/web/flutter_bootstrap.js @@ -0,0 +1,21 @@ +{{flutter_js}} +{{flutter_build_config}} + +(() => { + let started = false; + + _flutter.loader.load({ + 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. + if (started) { + window.location.reload(); + return; + } + + started = true; + const appRunner = await engineInitializer.initializeEngine(); + await appRunner.runApp(); + }, + }); +})(); diff --git a/docs/catalogs-ui-redesign.md b/docs/catalogs-ui-redesign.md new file mode 100644 index 0000000..5128e1f --- /dev/null +++ b/docs/catalogs-ui-redesign.md @@ -0,0 +1,65 @@ +# Catalogs UI redesign + +Catalogs now uses the library's page gutters, shared search field, grid-density rules, typography, and theme surfaces. Sources and navigation links are flat rows. Publication grids retain distinct editions and show covers, authors, and format descriptions without elevated card wrappers. + +Book details are full pages at `/library/catalogs/:catalogId/book?feed=…&publication=…&q=…`. The source-feed URL and publication identifier are encoded query values; credentials are never included. In-memory metadata provides a quick preview, while direct navigation resolves the source feed and its publication document. Missing publications and catalog-store failures expose retry and return navigation. Returning preserves the source feed's position and view mode. + +The main book action opens a format-selection sheet. Catalog forms, removal confirmation, and transfer activity also use bottom sheets. The Downloads header control replaces the persistent bottom bar and displays activity and failure indicators. Jobs continue after sheet dismissal and page navigation, preserve cancellation restrictions during commit, and link completed imports to their library book. + +Catalog sheets follow the Import books styling: a left-aligned title and top-right close icon, a divider below the header, and a fixed footer with a top divider for actions. Content scrolls independently above the keyboard. Save, Cancel, and Remove stay in the footer; per-format and per-download actions stay with their corresponding items. Both close controls are disabled while a catalog is saving. + +The existing relay, import pipeline, account isolation, and application-level job reset on catalog-setting changes remain in place. No backend API, database, or global theme changes are required. + +## Verification and visual review + +Verified on 2026-09-22: + +- 485 OPDS, routing, library, search, book-detail, and shared-header regression tests passed. Ten opt-in network-fixture tests were skipped; the relay transport was not changed by this redesign. +- 39 catalog-page, presentation, and sheet tests passed in Chrome. +- Flutter analysis passed with no issues; the production web build passed. +- All 18 responsive/theme journeys passed and generated review screenshots, including enlarged text and keyboard insets. The shared library grid now uses its available content width so sidebar space cannot produce overly dense cards. +- Formatting and Git whitespace checks passed. Changes remain in the existing feature-branch working tree; nothing was deployed. + +Regression coverage includes encoded deep links, detail resolution, edition identity, return navigation, stale sheets after account/catalog changes, storage-error recovery, grid/list switching, removal failures, keyboard-safe forms, progress/cancel/retry/commit behavior, and reduced motion. + +The layout journey renders the actual app shell at 360, 600, 840, 1280, and 2507 logical pixels, in light, dark, and e-ink themes, plus 360px at twice the normal text size. It checks source lists, feeds, details, format sheets, transfer sheets, and the editor with an open keyboard. Screenshots use fixture data and existing repository cover artwork, not live catalog metadata. + +Generate review images in `app/build/catalog-review`: + +```sh +cd app +flutter test --no-pub test/opds/layout_test.dart \ + --dart-define=CAPTURE_OPDS=true \ + --dart-define=FLUTTER_FONT_DIR="$FLUTTER_ROOT/bin/cache/artifacts/material_fonts" +``` + +The font directory is optional for layout assertions. For screenshots it replaces Flutter's test-only Ahem fallback with the platform font. Desktop reference images render the existing library book header and grid for comparison. + +- [Desktop catalog feed](../app/build/catalog-review/dark-1280-1x-feed.png) +- [Desktop book details](../app/build/catalog-review/dark-1280-1x-details.png) +- [Mobile download options](../app/build/catalog-review/light-360-1x-formats.png) +- [Wide catalog sources](../app/build/catalog-review/dark-2507-1x-sources.png) + +Review images are generated build artifacts and are not committed. + +## Mobile spacing refinement + +The catalog browsing header keeps Back, the catalog name, Downloads, and settings together, above a full-width divider matching Edit book. The redundant Catalog home action and hostname subtitle have been removed. Search submission sits inside the search field, and a single grid/list switch shares the feed-heading row with refresh. + +The feed heading and its controls stay below search while entries scroll. Refresh replaces only the content area with loading or error feedback; the heading stays visible through retry. A single grid/list icon button is used at every screen size. + +On first load, the heading waits for the actual feed title. Link labels and placeholder titles are not used, so the title does not change when metadata arrives. Changing feeds or accounts clears the previous section's heading; refreshing the current feed keeps its loaded heading visible. + +Book covers keep a fixed 2:3 ratio. Titles use their natural height, authors follow immediately, and edition captions can occupy two lines. This removes the empty title slot and makes similar editions easier to distinguish. + +The visual fixture now also reproduces a two-edition feed with the long Pride and Prejudice heading and format labels, including the reported 424px screen width. [Updated mobile preview](../app/build/catalog-review/dark-424-1x-editions.png). + +Follow-up verification: 224 targeted regression tests passed (ten opt-in network tests skipped), 41 Chrome tests passed, and 21 responsive/theme capture journeys passed. Analysis and the production web build also passed. + +## Mobile catalog creation + +Below the library's 840px desktop breakpoint, Add catalog uses the same floating action button as Add book. Downloads stays beside the page heading, removing the separate actions row. The source list includes bottom clearance so its last menu can scroll above the floating button. Desktop keeps its existing header action. + +The Catalogs source page and catalog book details use the same header height, padding, title weight, and full-width divider as catalog browsing, without a subtitle. Book details keep Back and Downloads in the header, with compact Downloads on mobile. + +Verification: 19 catalog-page tests passed in Chrome, including creation through the FAB and scrolling the last source menu clear of it. The 21 responsive/theme capture journeys check button placement above bottom navigation, desktop actions, and enlarged text. Flutter analysis passed. [Updated mobile sources](../app/build/catalog-review/dark-424-1x-sources.png). diff --git a/docs/opds-browser-access-analysis.md b/docs/opds-browser-access-analysis.md new file mode 100644 index 0000000..ae99730 --- /dev/null +++ b/docs/opds-browser-access-analysis.md @@ -0,0 +1,85 @@ +# OPDS browser access: findings and proposed solution + +Status: original investigation, superseded by the backend relay implementation on 2026-09-21. Investigated against client commit `bb25365`. Audience: Papyrus maintainers deciding how to support OPDS without requiring accounts. + +**Chosen solution: route all OPDS resources through the backend relay.** The user rejected direct/manual downloads. The implementation keeps guest imports local and requires a reachable backend without requiring an account. See [current OPDS behavior and setup](opds-support.md). The evidence below explains the CORS failure; the earlier direct-first proposal is historical and is not the implemented design. + +## What fails today + +The screenshot is consistent with a confirmed CORS restriction at Project Gutenberg's EPUB endpoints. The catalog itself permits browser access. The download redirects and final book files do not. + +Live GET requests with `Origin: http://localhost:8080` returned: + +| Gutenberg path | Response | `Access-Control-Allow-Origin` | +|---|---|---| +| `/ebooks/search.opds/` | 200, Atom feed | `*` | +| `/ebooks/1342.opds` | 200, publication entry | `*` | +| `/ebooks/1342.epub3.images` | 302 to `/cache/epub/1342/pg1342-images-3.epub` | Absent | +| `/ebooks/1342.epub.images` | 302 to `/cache/epub/1342/pg1342-images.epub` | Absent | +| Both image EPUB destinations above | 200, EPUB | Absent | +| `/ebooks/1342.epub.noimages` | 302 to `/cache/epub/1342/pg1342.epub` | Absent | +| `/cache/epub/1342/pg1342.epub` | 200, EPUB | Absent | + +A separate reproduction in Chrome 153, using a page served on localhost with normal browser security, confirmed: + +| Browser request | Observed result | +|---|---| +| Publication feed | Readable 200 response, 12,476 bytes | +| EPUB redirect URL | `TypeError: Failed to fetch` | +| Final EPUB URL directly | `TypeError: Failed to fetch` | +| Final EPUB with `mode: 'no-cors'` | Opaque response, status 0, zero readable bytes | +| Same EPUB through a temporary same-origin relay | Readable 200 response, 558,381 bytes | + +This was a transport experiment, not an end-to-end Papyrus import test. The temporary relay was stopped afterwards. The user's exact browser session was not inspected, but the tested endpoints independently reproduce the reported failure. + +The existing [transport](../app/lib/opds/opds_http_client.dart) uses direct HTTP requests and browser-managed redirects on web. Public downloads do not add an Authorization header, so removing unnecessary authentication headers would not resolve this case. [Downloads](../app/lib/opds/opds_downloads.dart) need readable bytes before passing the file to the importer. [Import sessions](../app/lib/services/book_import_session.dart) already support guest libraries, and [commit logic](../app/lib/services/book_import_commit_service.dart) queues account uploads only when an account scope exists. + +## What browsers can and cannot do + +A browser can navigate to a foreign website and save a file without giving Papyrus access to the response bytes. That explains why **Download in browser** works while automatic import fails. The user can subsequently grant access by choosing the saved file through **Add book**. + +`no-cors` allows an opaque response; it does not grant readable file contents. Service workers and installed PWAs retain this restriction. Changing Dart libraries, compiling to WebAssembly, using an iframe, or adding CORS headers to Papyrus's own server cannot grant access to Gutenberg's direct responses. The upstream host must allow access, or a permitted intermediary must supply the bytes. See the [Fetch standard](https://fetch.spec.whatwg.org/#concept-filtered-response-opaque) and [MDN's Fetch guidance](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). + +## Viable options + +All options below can avoid requiring a Papyrus account. + +| Option | Papyrus server needed? | Trade-off | +|---|---|---| +| Direct browser requests | No | Automatic import works with CORS-enabled feeds, files, and redirect chains. Other catalogs remain incompatible. | +| Catalog operator enables CORS, or serves Papyrus under the same origin | No separate Papyrus backend | Good for catalogs under the operator's control; requires cooperation for third-party hosts. | +| Browser download followed by file import | No | Works with accessible downloads, but requires manual save and import. Already supported. | +| Optional anonymous relay | Yes, a small relay | Automatic import for supported public resources despite missing upstream CORS. Adds bandwidth costs and an operational dependency. | +| Native app or browser extension | No Papyrus relay | Native HTTP avoids browser CORS. An extension needs installation and host permissions; it is a separate distribution and trust model. | + +Chrome documents the extension exception in [cross-origin network requests](https://developer.chrome.com/docs/extensions/develop/concepts/network-requests). It does not apply to ordinary web pages or extension content scripts without the appropriate privileged request path. + +## Original proposal (superseded) + +Keep catalog transport separate from library ownership and synchronization: + +```text +Papyrus browser → catalog directly → readable bytes → local import + │ + └─ eligible transport failure → optional relay → catalog + │ + └─ readable bytes → same local import +``` + +1. Add relay support at the `OpdsHttpClient` boundary so it can serve feeds, OpenSearch descriptions, covers, and books. Fixing downloads alone addresses Gutenberg today but leaves catalogs whose feeds block CORS unsupported. +2. Try direct requests first. In web builds, retry an eligible connection failure once through a configured relay. Browser errors cannot reliably distinguish CORS from connectivity, so do not label every failure as confirmed CORS. Do not retry cancellations, invalid files, or readable authentication failures through another transport. +3. Make relay configuration independent of login and the sync-server selection. The hosted app can provide an anonymous default for supported public catalogs; self-hosted deployments can provide their own endpoint. Static deployments without a relay retain direct access and manual import. +4. Stream the upstream response back to the browser and reuse the existing import session. No account, database record, server-side book library, or persistent file storage is required for relaying. Preserve cancellation, progress, and the existing protection against committing into a different library after an account switch. +5. Return the final upstream URL alongside the content type and binary body. The parser uses `response.uri` to resolve relative links; using the relay URL would break navigation, search, and covers. Follow redirects inside the relay instead of redirecting the browser back to the blocked resource. A separately hosted relay must permit the app's origin and expose the necessary response metadata. + +For the first version, implement an anonymous public-catalog relay endpoint in the existing FastAPI backend, with no user/session or database dependency in its request path. The frontend may use it while remaining in guest mode. A standalone edge function is also viable if deploying the full Papyrus backend is undesirable; it remains server-side infrastructure, even if marketed as “serverless.” Hosting choice and bandwidth budget remain deployment decisions. + +Start with explicitly supported public hosts and paths, including Gutenberg's catalog and acquisition resources. Revalidate every redirect and actual network destination, blocking private, loopback, link-local, and metadata addresses, including DNS-rebinding routes. Apply request/concurrency limits, timeouts, redirect limits, and enforced byte limits while streaming. CORS origin checks alone do not prevent relay abuse. Preserve the current client limits of 8 MiB for catalog resources and 256 MiB for books as upper bounds, with deployment quotas allowed to be lower. Treat relayed content as data; do not serve arbitrary upstream HTML as an executable page on the app origin. + +Keep authenticated catalogs direct initially. Supporting them through a relay later means trusting that relay with catalog credentials and download URLs; it should be explicit, scoped to the catalog origin, and excluded from shared caches and credential-bearing logs. Never forward Papyrus account tokens upstream. Private LAN catalogs need direct access or a deliberately configured local relay reachable from that network. A relay does not bypass catalog authentication, DRM, or upstream availability restrictions. + +## Original verification proposal (superseded) + +Before shipping, verify guest browsing and EPUB import with the backend's account and sync services unused; direct success without relay traffic; blocked feeds and blocked downloads through the relay; relative links after upstream redirects; cancellation and library switching; size limits and rejected redirect destinations. Use controlled CORS fixtures for regression tests and Gutenberg only for an occasional compatibility check. + +In the chosen implementation, disabling the relay makes OPDS unavailable; there is no direct-fetch or manual-download fallback. [OPDS support documentation](opds-support.md) describes the current behavior. diff --git a/docs/opds-support.md b/docs/opds-support.md new file mode 100644 index 0000000..f6709ae --- /dev/null +++ b/docs/opds-support.md @@ -0,0 +1,106 @@ +# OPDS support + +Papyrus browses OPDS 1.2 (Atom/XML) and OPDS 2.0 (JSON) catalogs through its backend relay and imports book downloads automatically into the active library. A running Papyrus backend is required; a Papyrus account is not. + +## Using catalogs + +1. Open **Library → Catalogs**. On a narrow screen, open **Library sections** first. +2. Select **Add catalog** and enter a name and the catalog's HTTP or HTTPS URL. Enter a username and password if the catalog uses HTTP Basic authentication. +3. Open the catalog, browse its sections or use keyword search, and select a publication to open its book details page. Switch between cover grids and lists using the view control. Details preserve description paragraphs and can be refreshed or opened directly from their URL. Back returns through each visited feed, edition selection and details page, preserving search, scroll position and view mode. A shared subsection with no prior visit returns to its catalog's root, then to all catalogs. Browser URLs continue to identify the visible feed or publication. +4. Select **Add to library** to open the download-options sheet, then choose a supported format. The **Downloads** control in the page header opens progress, cancellation, retry, and completed transfers. Downloads continue when you close a sheet or navigate elsewhere in Papyrus. Use **Open book** after the import finishes. + +Catalog creation, settings, removal confirmation, download options, and transfer activity use bottom sheets on all screen sizes. Forms keep their actions above the keyboard; catalog and book lists use the library's page alignment and theme. + +Web imports support EPUB. Native imports support EPUB, PDF, MOBI, AZW3, TXT, CBZ, and CBR, matching the existing file importer. The short final **Adding to library** step cannot be cancelled; earlier download and processing steps can. Failed downloads offer Retry. + +Catalog settings stay on this device, separately for each server/account and for the guest library. Credentials are stored through the platform's secure-storage implementation, separately from catalog preferences and bound to the catalog origin. Removing a catalog also removes its credentials; downloaded books remain in your library. On an existing catalog, leave the credentials blank to retain them, or select **Remove saved credentials**. Changing its URL origin clears saved credentials unless replacement credentials are entered. + +Imported books use the existing local file storage, metadata persistence, cover handling, and media-upload queue. Embedded book metadata takes precedence; catalog metadata fills missing values. Signed-in libraries retain their existing synchronization behavior. Switching accounts invalidates in-flight OPDS work before it can import into the newly selected library. + +## Previously imported books and saved catalog content + +After a successful import, publications show **In library** and their details offer **Open book**, including after restarting Papyrus. **Download options** remains available for other formats. Recognition uses the saved catalog, source URL, exact publication identifier and acquisition URL; matching titles or ISBNs never combine editions. Samples do not mark the full publication as imported. Removing a book from the active library makes it downloadable again. + +Recognition is local to this device and library account, including the guest library. Older imports and manually added books have no OPDS provenance and are not matched automatically. Renaming a catalog preserves recognition; removing it, changing its source URL, or replacing/removing its credentials clears that catalog's recognition without deleting any books. + +Previously visited feeds appear immediately and refresh through the relay in the background. If refreshing fails, Papyrus keeps saved content visible with a retry notice. Previously cached publication details can also open from their URLs offline. Cached cover images are reused for up to one day before refreshing. Offline browsing covers saved pages only; new searches/pages and book downloads still need a connection, though saved search descriptions can resolve previously visited search URLs. + +The response cache is shared across scopes with a total serialized limit of 2 MiB, 64 entries, and 512 KiB per entry. Least recently used entries are evicted; larger responses still load online but are not saved. The cache retains response bytes, final URLs, timestamps and content types, never authorization headers or passwords. It does not cache book files. Account switching hides other accounts' content; source/credential edits, removal and authorization failures invalidate affected cached content and outstanding requests. Corruption or unavailable browser storage does not block browsing or importing. + +## Compatibility and troubleshooting + +- Feeds, search descriptions, covers, and book files all pass through the selected Papyrus backend. Catalogs do not need browser CORS support. The app must be able to reach its backend, whose `CORS_ORIGINS` must allow the web app. Use an HTTPS backend when hosting the web app over HTTPS. +- Authentication supports HTTP Basic. The selected backend receives catalog credentials for the request and forwards them only to that catalog's origin. Use a trusted backend and HTTPS for the backend and protected catalog. Papyrus account tokens and browser cookies are never forwarded upstream. +- The relay only permits public network destinations. Loopback, private LAN, link-local, and metadata-service addresses are rejected, including after redirects. Administrators can further restrict catalog hosts. Private LAN catalogs are not supported by this relay. +- Browsing includes groups, facets, pagination, complete publication entries, and advertised keyword search. OPDS 1.2 uses OpenSearch descriptions; OPDS 2.0 uses URI templates. +- Purchases, loans, subscriptions, DRM, indirect acquisition, and OAuth are displayed as unsupported acquisition methods. Papyrus does not follow those links as book downloads. +- Catalog settings, import recognition and cached catalog content do not sync between devices. Downloads do not resume after the app closes. +- Feed, search-description, and image responses are limited to 8 MiB; book downloads to 256 MiB. DNS and opening a response each have a 30-second limit, stalled reads time out after 30 seconds, and each upstream request has a five-minute total deadline. These limits protect the current in-memory import pipeline. +- The client queues excess requests and briefly retries temporary relay-capacity errors. Authentication errors offer guidance to edit catalog credentials. Other failures remain in the download panel with Retry; no manual save-and-import step is required. + +### Server setup + +The official backend comes from `PAPYRUS_API_BASE_URL` (default `http://localhost:8080`). Selecting a custom server uses that server's relay, including in guest mode. Deploy the updated client and backend together. If OPDS requests return 404, update the backend and check its API prefix and reverse-proxy routing. + +The relay is enabled by default. Operators can disable it with `OPDS_RELAY_ENABLED=false` or restrict destinations using `OPDS_RELAY_ALLOWED_HOSTS`, a JSON list of exact hostnames. All redirect and image/CDN hosts must also be permitted. An empty list allows public hosts. Guest imports stay local; relaying does not create an account or upload the guest library. + +## Implementation + +Catalog book details share the library's section headings, description typography and metadata rows. Description and Information use two columns when space permits, and stack on mobile; subjects are read-only chips. The parser retains available publication dates, page counts, rights and subjects. Atom entry timestamps are not treated as publication dates. + +For catalogs hosted on `gutenberg.org` or its subdomains, a presentation adapter recognizes Gutenberg's labeled description paragraphs. Summary, edition prose, credits, notes and unfamiliar paragraphs appear in Description. Metadata, including reading level and classification, appears in Information; conflicting values remain visible in the corresponding row. Subject chips exclude classification and category labels already shown in Information. All content is visible without expanding a section. The original description remains unchanged in the publication model. Other catalogs retain their supplied descriptions without this extraction. + +The `lib/opds` module separates normalized models, XML/JSON parsing, search expansion, scoped persistence, HTTP transport, browsing state, and application-scoped downloads. `BookImportSession` captures the existing import destination and supplies the shared `BookImportCommitService` composition used by OPDS and file-import widgets. + +The routes `/library/catalogs` and `/library/catalogs/:catalogId` live inside the existing Library shell. The `feed` query parameter stores the current resource URL, and `q` retains the displayed keyword query. Neither contains Basic credentials. Browser history and refresh reload the selected feed. + +`OpdsHttpClient` posts resource URLs and optional catalog credentials to `/v1/opds/relay`. It reads the final upstream URL from `X-OPDS-URL` so relative links resolve against the catalog. The backend streams bytes without storing books. No database schema or dependency changes are required. + +## Verification + +From `client/app`, run: + +```sh +flutter test --coverage +flutter analyze --no-pub +dart format --output=none --set-exit-if-changed lib test +flutter build web --no-pub +flutter build linux --no-pub +``` + +Network smoke tests use a local fixture server that emulates the relay contract for its own loopback resources. These tests are skipped in the normal suite. Start it in a separate terminal: + +```sh +python3 tool/opds_fixture_server.py --port 8766 +``` + +Then run the same tests on the Linux Dart VM and Chrome: + +```sh +flutter test test/opds/network_smoke_test.dart --no-pub --dart-define=OPDS_SMOKE_URL=http://127.0.0.1:8766 +flutter test test/opds/network_smoke_test.dart --no-pub --platform chrome --dart-define=OPDS_SMOKE_URL=http://127.0.0.1:8766 +``` + +The fixtures cover public and Basic-auth catalogs in both formats, browsing, details, search, pagination, EPUB bytes, redirect URL resolution, authentication failures, and cross-origin credential stripping. They use the test credentials `reader` / `secret` and bind only to localhost. + +The smoke suite checks that missing CORS headers on upstream redirects and book responses do not prevent relay downloads on Chrome or native platforms. The catalog at `/public/v2/blocked.json` exercises this flow. + +For visual fixture checks, launch the client with `--dart-define=PAPYRUS_API_BASE_URL=http://127.0.0.1:8766` and add `http://127.0.0.1:8766/public/v2/showcase.json` as a catalog. This fixture includes books, covers, sections, and long descriptions. Replace `public` with `protected` to check Basic authentication. This loopback exception exists only in the fixture server; the production backend rejects these destinations. + +### Relay verification — 2026-09-21 + +- 93 Flutter OPDS tests passed, including ten native fixture network tests. +- 30 Chrome network, relay-contract, and presentation tests passed. +- 48 backend tests passed, including destination/deadline/cleanup coverage and health-route regression tests. Ruff and scoped mypy passed. +- Flutter analysis and the production web build passed. +- Chrome fetched Gutenberg's OPDS entry and a complete 558,381-byte EPUB through the actual FastAPI relay without account authentication. The client import pipeline is covered by download regression tests; this live browser check verified transport, not a full interactive app import. +- The updated client and backend must be deployed together. No deployment was performed during this verification. + +### Original implementation verification — 2026-09-06 + +- Full Flutter suite: 1,222 passed, 18 skipped (including the eight opt-in network smoke tests). +- Real HTTP smoke suite: eight passed on the Linux Dart VM and eight passed in Chrome. +- Flutter analysis: no issues. Formatting verification: 406 Dart files checked, no changes. Git whitespace check passed. +- Web and Linux production builds passed. +- Built web application: verified guest catalog browsing and EPUB download/import into the library, then entered Basic credentials through the catalog editor and browsed the protected catalog. Visually checked the redesigned desktop and phone layouts. Regression tests cover light, dark, and e-ink themes, grid/list switching, aligned covers, pagination, expandable descriptions, transfer controls, and opening the keyboard while transfers are expanded. +- Independent specification and code reviews completed; their credential, account-transition, facet, search, response-validation, and cancellation findings were fixed and covered by regression tests. diff --git a/docs/superpowers/plans/2026-09-21-opds-relay.md b/docs/superpowers/plans/2026-09-21-opds-relay.md new file mode 100644 index 0000000..a052ba0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-opds-relay.md @@ -0,0 +1,18 @@ +# OPDS Relay Implementation Plan + +**Goal:** Route OPDS browsing and automatic library imports through the Papyrus backend, including guest use. + +**Architecture:** The client posts resource requests to the selected backend's `/v1/opds/relay`. An anonymous, rate-limited endpoint streams resources from validated public destinations. Catalog credentials stay scoped to their origin; final upstream URLs preserve relative links. The existing local import pipeline owns library writes. + +**Tech stack:** FastAPI, Python standard-library HTTP/TLS with pinned destination addresses, Flutter's existing HTTP client. No new dependencies or database schema changes. + +- [x] Add failing backend route/service tests for anonymous streaming, redirects, origin-scoped authentication, public destination validation, response limits, errors, and cleanup. +- [x] Implement request schema, bounded relay service, thin route, router registration, and exposed CORS metadata. Use existing rate limiting. Validate each destination and connect to the checked IP while preserving TLS hostname verification. +- [x] Replace client direct requests with relay POSTs; test the request contract, final URLs, errors, progress, cancellation, and no outbound account tokens. +- [x] Share the configured transport between catalog browsing and downloads. Remove manual browser-download recovery from the UI and adjust regression coverage. +- [x] Update operator/client documentation and the earlier analysis to reflect the user's chosen backend relay design. +- [x] Run focused Python and Flutter tests, lint/type checks, and a live Gutenberg relay smoke test. Review resource cleanup, redirect validation, and guest composition before completion. + +Validation commands: `uv run pytest tests/services/test_opds.py tests/api/routes/test_opds.py`; `uv run ruff check` and `uv run mypy` on changed backend modules; `flutter test test/opds --no-pub`; `flutter analyze` on changed client code. Changes remain in the current checkout for review. + +Verified: 48 backend tests including health-route regression tests; 93 Flutter OPDS tests including ten native network smoke cases; 30 Chrome network/transport/presentation tests. Ruff, scoped mypy, Flutter analysis, and the production web build passed. Chrome retrieved Gutenberg's publication entry and a complete 558,381-byte EPUB through the actual anonymous FastAPI relay. Independent review found deadline and cover-concurrency issues, both fixed and covered by regression tests. No deployment was performed. diff --git a/docs/superpowers/plans/2026-09-22-opds-library-cache.md b/docs/superpowers/plans/2026-09-22-opds-library-cache.md new file mode 100644 index 0000000..901d981 --- /dev/null +++ b/docs/superpowers/plans/2026-09-22-opds-library-cache.md @@ -0,0 +1,50 @@ +# OPDS library recognition and caching implementation plan + +**Goal:** Recognize previously imported catalog publications after restart and show previously visited feeds immediately, including when offline. + +**Architecture:** Keep local provenance and a bounded resource cache alongside the existing scoped catalog preferences. Match catalog identity plus exact publication ID and acquisition URL, never title or ISBN. Validate recorded local book IDs against the currently loaded DataStore. Cache raw feed responses (preserving redirect bases) and artwork, without authentication headers or credentials. Render cached feeds while refreshing through the existing relay. + +**Tech stack:** Flutter, ChangeNotifier, SharedPreferences, existing OPDS parsers and import sessions; no backend/schema changes. + +## Decisions + +- Anonymous and authenticated libraries use the existing catalog persistence scope; no cross-account matches or cached responses. +- Only successful imports record provenance; completion after an account switch cannot modify the active library's state. +- Publication-level recognition provides Open book; format options match their exact acquisition URL so additional formats remain downloadable. +- Deleting a book makes its provenance non-matching. Do not remove mappings merely because the library is still loading. +- Catalog URL/credential changes invalidate affected cache entries and outstanding writes. Removing a catalog removes its cached data. Name-only edits preserve import recognition. +- Feed/cache storage is bounded and evicts older entries. Corruption or quota errors must never prevent browsing or imports. +- Cache reads retain the final response URI so relative links resolve identically online and offline. Book binaries are never cached here. +- Refresh errors preserve available content with an explicit cached-content notice and retry. Authentication failures invalidate the affected cached content rather than silently presenting it as current. +- No title-based backfill for earlier imports: they lack reliable provenance. Recognition starts with imports made after this change. + +## Task 1 — Persistent import recognition + +- [x] Add `app/lib/opds/opds_library.dart`: scoped persisted exact identities, read only when the referenced book exists in DataStore, change notifications for live deletion/import updates. +- [x] Update `app/lib/opds/opds_downloads.dart`: capture provenance destination at import start; record only after commit; prevent repeated imports of an already-owned acquisition. +- [x] Wire lifecycle in `app/lib/main.dart` and invalidation in `app/lib/opds/opds_catalogs.dart`. +- [x] Update publication tiles, details and download options to display In library / Open book, preserving access to other formats. +- [x] Test persistence, deletion, failed import, concurrent imports, scope changes, URL changes and distinct editions in OPDS unit/widget tests. + +## Task 2 — Bounded feed and cover cache + +- [x] Add `app/lib/opds/opds_resource_cache.dart`: bounded persistent response storage; scoped keys; invalidation generation; corruption/quota recovery; content-type-only header persistence. +- [x] Update `app/lib/opds/opds_browser.dart`: render cached data before network completion, revalidate, retain it on connection failures, and reject late results after navigation/scope changes. +- [x] Integrate cache into catalog pages/details and covers without caching book downloads. +- [x] Test warm and cold reads, background replacement, offline reuse, eviction, redirect-relative URLs, corrupt storage, auth failure, and invalidation races. + +## Task 3 — Integration and verification + +- [x] Add widget coverage for In library / Open book, deletion, cached-content notices and refresh behavior. +- [x] Update `docs/opds-support.md` with local-only storage, limits, and recognition limitations. +- [x] Run `flutter analyze --no-pub`, OPDS tests, shell/routing/library tests relevant to the modified lifecycle, and the web bootstrap tests. +- [x] Inspect changes and `git diff --check`; report results and any remaining limitations. Leave changes reviewable in the working tree. + +## Verification + +- Flutter analysis: no issues. +- OPDS, routing, shell, import-session/commit and library-page tests: 278 passed, 10 skipped. +- Web bootstrap tests: 4 passed. +- Production web build passed (including the Wasm compatibility dry run). +- Responsive layout journeys include light/dark/e-ink, mobile/tablet/desktop/wide and enlarged text; captures exercise owned details, per-format ownership, and offline cached feeds. +- Review regressions cover invalid optional artwork and both authorization-response orderings across retained parent feeds and details pages. diff --git a/docs/superpowers/plans/2026-09-23-catalog-details-content.md b/docs/superpowers/plans/2026-09-23-catalog-details-content.md new file mode 100644 index 0000000..a8e1d95 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-catalog-details-content.md @@ -0,0 +1,9 @@ +# Catalog details content implementation plan + +Use the existing library visual identity and keep remote publications separate from local books. + +1. Extract shared section-title and metadata-row widgets from the library details presentation. Preserve its layout and ownership controls. +2. Retain structured publication dates, rights, subjects and page counts where the existing OPDS formats provide them. Do not treat Atom entry timestamps as book publication dates. +3. Add a Gutenberg-only description adapter. Separate recognized summary, edition notes, information and subjects; retain unknown paragraphs and conflicting/repeated values in expandable More details. Other catalogs retain their descriptions verbatim. +4. Render catalog Description/Information in the library's desktop 60/40 layout, stacking on narrow widths. Omit empty optional sections; show read-only subject chips and preserve description paragraphs. +5. Test parsing, conservative fallback, metadata preservation, responsive rendering and disclosure actions. Run OPDS and affected library/widget tests, analysis and representative screenshots. diff --git a/docs/web-hot-restart.md b/docs/web-hot-restart.md new file mode 100644 index 0000000..544c172 --- /dev/null +++ b/docs/web-hot-restart.md @@ -0,0 +1,56 @@ +# Web hot restart + +Papyrus performs a full browser refresh when Flutter requests a web hot restart +(`R` in the development terminal). Normal hot reload (`r`) is unchanged. + +## Why the previous startup failed + +The old web cleanup helper stored a Dart callback on `window`, then called it +from the next invocation of `main()`. By that point, hot restart had already +reset Dart's runtime types. PowerSync/SQLite worker messages and other browser +callbacks could still reach objects from the previous runtime. This produced +type errors involving `LegacyJavaScriptObject`, update notifications, and +Flutter diagnostics. + +Cleanup was awaited before `runApp()`. When it failed, startup stopped, leaving +a blank page. The callback was cleared before invocation, which explains why a +second restart could succeed. A unit test calling cleanup twice within a single +Dart runtime could not detect this failure. + +## Current behavior + +`app/web/flutter_bootstrap.js` uses Flutter's documented +[`onEntrypointLoaded` callback](https://docs.flutter.dev/platform-integration/web/initialization#the-onentrypointloaded-callback), +which runs on initial loading and each hot restart. Its JavaScript state survives +a Dart restart. The second entrypoint invocation reloads the document instead of +initializing another Dart runtime alongside stale browser listeners. + +This retains the current URL and browser storage, including the offline library. +It does not clear databases or other tabs. Normal widget disposal still closes +the app's data services; no Dart cleanup closure is retained across restarts. + +Refresh the browser once after installing this change so the page loads the new +bootstrap. Subsequent terminal restarts refresh automatically. Flutter's debugger +may log `Cannot find context with specified id` during reconnection to the new +document; this is separate from the previous Dart startup failure and did not +prevent the app from rendering in validation. + +## Verification + +- Reproduced stale-runtime errors and a blank first restart in an isolated Chrome + session before the change. +- Created a physical book in an offline library, then performed three consecutive + terminal hot restarts. Each returned to `/library/books`, rendered the saved + book, and preserved a browser-storage marker without a second restart. +- Normal hot reload retained the same browser document and rendered library. +- Four bootstrap tests passed, covering cold startup, repeated restarts, a fresh + document, and a restart during initialization. +- 37 relevant PowerSync, auth, and composition tests passed; one case was skipped + by the existing test configuration. Flutter analysis and the + production web build passed. + +Run the bootstrap tests from `app/`: + +```sh +node --test test/web/flutter_bootstrap_test.cjs +```