diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift index 59bfcc5..7a25144 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -402,6 +402,42 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin let _ = await self.timebasedNavigator?.seekRelative(byOffsetSeconds: seekOffset) result(nil) } + case "searchInPublication": + guard let publication = getCurrentPublication(), + let query = call.arguments as? String + else { + result( + FlutterError( + code: "InvalidArgument", + message: "No publication open or invalid parameters to searchInPublication", + details: nil)) + return + } + Task { + do { + let searchResults = await publication.searchInContentForQuery(query) + switch searchResults { + case .failure(let err): + throw err + case .success(let searchResultsCols): + let fallbackTitle = searchResultsCols.first?.metadata.title ?? publication.metadata.title ?? "Unknown chapter" + // TODO: Should we try to find physical page-numbers for the results? + let results = searchResultsCols.flatMap { $0.locators.map { l in TextSearchResult(locator: l, chapterTitle: l.title ?? fallbackTitle, pageNumbers: nil) } } + let searchResultsJson = try results.map { try $0.toJsonString() } + await MainActor.run { + result(searchResultsJson) + } + } + } catch { + await MainActor.run { + result( + FlutterError( + code: "SearchError", + message: "Failed to perform search with query: \(query)", + details: error.localizedDescription)) + } + } + } default: result(FlutterMethodNotImplemented) diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift index 0edea3d..90b2d32 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift @@ -37,6 +37,7 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele private let _view: UIView private let readiumViewController: EPUBNavigatorViewController private var hasSentReady = false + private let publication: Publication var publicationIdentifier: String? @@ -63,6 +64,7 @@ public class ReadiumReaderView: NSObject, FlutterPlatformView, EPUBNavigatorDele let creationParams = args as! Dictionary let publication = FlutterReadiumPlugin.instance!.getCurrentPublication()! + self.publication = publication let preferencesMap = creationParams["preferences"] as? Dictionary? let defaultPreferences = preferencesMap == nil ? nil : EPUBPreferences.init(fromMap: preferencesMap!!) diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/TextSearchResult.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/TextSearchResult.swift new file mode 100644 index 0000000..d41a731 --- /dev/null +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/TextSearchResult.swift @@ -0,0 +1,29 @@ +import ReadiumShared + +struct TextSearchResult { + let locator: Locator + let chapterTitle: String? + let pageNumbers: [String]? + + public init(locator: Locator, chapterTitle: String? = nil, pageNumbers: [String]? = nil) { + self.locator = locator + self.chapterTitle = chapterTitle + self.pageNumbers = pageNumbers + } + + func toJson() -> [String: Any?] { + let map: [String: Any?] = [ + "locator": locator.jsonString, + "chapterTitle": chapterTitle, + "pageNumbers": pageNumbers?.joined(separator: ","), + ] + + return map + } + + func toJsonString(pretty: Bool = false) throws -> String? { + let options: JSONSerialization.WritingOptions = pretty ? [.prettyPrinted] : [] + let data = try JSONSerialization.data(withJSONObject: toJson(), options: options) + return String(data: data, encoding: .utf8) + } +} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift index 72e2552..050371b 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift @@ -74,28 +74,22 @@ extension Publication { } } - func searchInContentForQuery(_ query: String) async -> [LocatorCollection] { + func searchInContentForQuery(_ query: String) async -> Result<[LocatorCollection]> { guard let searchService: SearchService = findService(SearchService.self) else { Log.readium.warn("No SearchService available") - return [] + return Result.failure(SearchError.publicationNotSearchable) } var collections: [LocatorCollection] = [] switch await searchService.search(query: query, options: .init()) { case .failure(let err): - switch err { - case .badQuery(let queryErr): - Log.readium.error("Search failed, bad query: \(queryErr)") - case .reading(let readErr): - Log.readium.error("Search failed, reading error: \(readErr)") - case .publicationNotSearchable: - Log.readium.error("Search failed, publication is not searchable") - } + Log.readium.error("Search in publication content failed: \(err)") + return Result.failure(err) case .success(let iterator): _ = await iterator.forEach { collection in collections.append(collection) } } - return collections + return .success(collections) } /** diff --git a/flutter_readium/lib/flutter_readium.dart b/flutter_readium/lib/flutter_readium.dart index 2b0bf20..04c34f1 100644 --- a/flutter_readium/lib/flutter_readium.dart +++ b/flutter_readium/lib/flutter_readium.dart @@ -134,4 +134,7 @@ class FlutterReadium { return goByLink(pageLink, pub); } + + Future> searchInPublication(String searchKey) async => + _platform.searchInPublication(searchKey); } diff --git a/flutter_readium/test/flutter_readium_test.dart b/flutter_readium/test/flutter_readium_test.dart index 2467e54..825b981 100644 --- a/flutter_readium/test/flutter_readium_test.dart +++ b/flutter_readium/test/flutter_readium_test.dart @@ -192,6 +192,12 @@ class MockFlutterReadiumPlatform with MockPlatformInterfaceMixin implements Flut // TODO: implement audioSeekBy throw UnimplementedError(); } + + @override + Future> searchInPublication(String searchKey) { + // TODO: implement searchInPublication + throw UnimplementedError(); + } } void main() { diff --git a/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart b/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart index cf3ef53..e284c16 100644 --- a/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart +++ b/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart @@ -140,6 +140,10 @@ abstract class FlutterReadiumPlatform extends PlatformInterface { Future audioSeekBy(Duration offset) => throw UnimplementedError('seekInAudio() has not been implemented'); // AUDIOBOOK API - END + Future> searchInPublication(final String searchKey) { + throw UnimplementedError('searchInPublication() has not been implemented'); + } + // State stream for reader status changes Stream get onReaderStatusChanged { throw UnimplementedError('onReaderStatus stream has not been implemented.'); diff --git a/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart b/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart index 142748f..42d720c 100644 --- a/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart +++ b/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart @@ -193,4 +193,28 @@ class MethodChannelFlutterReadium extends FlutterReadiumPlatform { @override Future audioSeekBy(Duration offset) => methodChannel.invokeMethod('audioSeekBy', offset.inSeconds); + + @override + Future> searchInPublication(String searchKey) async { + final resultString = await methodChannel.invokeMethod('searchInPublication', searchKey); + + if (resultString == null || resultString.isEmpty) { + return []; + } + + try { + final decoded = json.decode(resultString); + if (decoded is List) { + final results = decoded + .map((e) => TextSearchResult.fromJson(e as Map?)) + .whereType() + .toList(); + + return results; + } + return []; + } catch (e) { + throw Exception('Failed to parse search results: $e'); + } + } } diff --git a/flutter_readium_platform_interface/lib/src/shared/publication/dto/index.dart b/flutter_readium_platform_interface/lib/src/shared/publication/dto/index.dart new file mode 100644 index 0000000..92a880c --- /dev/null +++ b/flutter_readium_platform_interface/lib/src/shared/publication/dto/index.dart @@ -0,0 +1 @@ +export 'text_search_result.dart'; diff --git a/flutter_readium_platform_interface/lib/src/shared/publication/dto/text_search_result.dart b/flutter_readium_platform_interface/lib/src/shared/publication/dto/text_search_result.dart new file mode 100644 index 0000000..abccf60 --- /dev/null +++ b/flutter_readium_platform_interface/lib/src/shared/publication/dto/text_search_result.dart @@ -0,0 +1,45 @@ +// NOTE: This is a Nota type +import 'package:equatable/equatable.dart'; + +import '../../../utils/jsonable.dart'; +import '../index.dart'; + +class TextSearchResult with EquatableMixin implements JSONable { + const TextSearchResult({required this.locator, this.chapterTitle, this.pageNumbers}); + + final Locator locator; + final String? chapterTitle; + final List? pageNumbers; + + factory TextSearchResult.fromJson(Map? json) { + if (json == null) { + throw ArgumentError('json cannot be null'); + } + + final jsonObject = Map.of(json); + + return TextSearchResult( + locator: Locator.fromJson(jsonObject['locator'] as Map)!, + chapterTitle: jsonObject['chapterTitle'] as String?, + pageNumbers: (jsonObject['pageNumbers'] as List?)?.map((e) => e as String).toList(), + ); + } + + @override + Map toJson() => {} + ..put('locator', locator.toJson()) + ..putOpt('chapterTitle', chapterTitle) + ..putIterableIfNotEmpty('pageNumbers', pageNumbers); + + @override + List get props => [locator, chapterTitle, pageNumbers]; + + @override + String toString() => + 'TextSearchResult{locator: $locator, chapterTitle: $chapterTitle, ' + 'pageNumbers: $pageNumbers}'; +} + +extension TextSearchResultExtension on TextSearchResult { + LocatorText? get text => locator.text; +} diff --git a/flutter_readium_platform_interface/lib/src/shared/publication/index.dart b/flutter_readium_platform_interface/lib/src/shared/publication/index.dart index cecc3e2..ee0f71f 100644 --- a/flutter_readium_platform_interface/lib/src/shared/publication/index.dart +++ b/flutter_readium_platform_interface/lib/src/shared/publication/index.dart @@ -1,4 +1,5 @@ export 'drm.dart'; +export 'dto/index.dart'; export 'encryption.dart'; export 'epub/index.dart'; export 'format.dart'; @@ -7,6 +8,7 @@ export 'link.dart'; export 'link_list_extension.dart'; export 'localized_string.dart'; export 'locator.dart'; +export 'locator_collection.dart'; export 'metadata/index.dart'; export 'opds/index.dart'; export 'positions_list.dart'; diff --git a/flutter_readium_platform_interface/lib/src/shared/publication/locator_collection.dart b/flutter_readium_platform_interface/lib/src/shared/publication/locator_collection.dart new file mode 100644 index 0000000..766e0f0 --- /dev/null +++ b/flutter_readium_platform_interface/lib/src/shared/publication/locator_collection.dart @@ -0,0 +1,161 @@ +// Copyright (c) 2021 Mantano. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE.Iridium file. + +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +import '../../utils/additional_properties.dart'; +import '../../utils/jsonable.dart'; +import 'link.dart'; +import 'locator.dart'; + +/// Represents a sequential list of [Locator] objects. +/// +/// For example, a search result or a list of positions. +@immutable +class LocatorCollection with EquatableMixin implements JSONable { + const LocatorCollection({ + this.metadata = const LocatorCollectionMetadata(), + this.links = const [], + this.locators = const [], + }); + + final LocatorCollectionMetadata metadata; + final List links; + final List locators; + + static LocatorCollection? fromJson(Map? json) { + if (json == null) { + return null; + } + + final jsonObject = Map.of(json); + + final metadata = LocatorCollectionMetadata.fromJson(jsonObject['metadata'] as Map?); + + final linksJson = jsonObject['links'] as List?; + final links = linksJson?.map((e) => Link.fromJson(e as Map?)).whereType().toList() ?? []; + + final locatorsJson = jsonObject['locators'] as List?; + final locators = + locatorsJson?.map((e) => Locator.fromJson(e as Map?)).whereType().toList() ?? []; + + return LocatorCollection(metadata: metadata, links: links, locators: locators); + } + + @override + Map toJson() { + final json = {}; + + final metadataJson = metadata.toJson(); + if (metadataJson.isNotEmpty) { + json['metadata'] = metadataJson; + } + + if (links.isNotEmpty) { + json['links'] = links.map((e) => e.toJson()).toList(); + } + + json['locators'] = locators.map((e) => e.toJson()).toList(); + + return json; + } + + LocatorCollection copyWith({LocatorCollectionMetadata? metadata, List? links, List? locators}) => + LocatorCollection( + metadata: metadata ?? this.metadata, + links: links ?? this.links, + locators: locators ?? this.locators, + ); + + @override + List get props => [metadata, links, locators]; + + @override + String toString() => 'LocatorCollection{metadata: $metadata, links: $links, locators: $locators}'; +} + +/// Holds the metadata of a [LocatorCollection]. +@immutable +class LocatorCollectionMetadata extends AdditionalProperties with EquatableMixin implements JSONable { + const LocatorCollectionMetadata({this.localizedTitle, this.numberOfItems, super.additionalProperties}); + + /// The localized title. Can be a simple string or a map of language codes to strings. + final dynamic localizedTitle; + + /// Indicates the total number of locators in the collection. + final int? numberOfItems; + + /// Returns the title as a simple string. + String? get title { + if (localizedTitle == null) { + return null; + } + if (localizedTitle is String) { + return localizedTitle as String; + } + if (localizedTitle is Map) { + final map = localizedTitle as Map; + // Return the first available value or the 'en' value if available + if (map.containsKey('en')) { + return map['en'] as String?; + } + return map.values.firstOrNull as String?; + } + return null; + } + + static LocatorCollectionMetadata fromJson(Map? json) { + if (json == null) { + return const LocatorCollectionMetadata(); + } + + final jsonObject = Map.of(json); + + final localizedTitle = jsonObject.remove('title'); + final numberOfItems = jsonObject.remove('numberOfItems') as int?; + + // Validate numberOfItems is positive + final validNumberOfItems = (numberOfItems != null && numberOfItems > 0) ? numberOfItems : null; + + return LocatorCollectionMetadata( + localizedTitle: localizedTitle, + numberOfItems: validNumberOfItems, + additionalProperties: jsonObject, + ); + } + + @override + Map toJson() { + final json = Map.of(additionalProperties); + + if (localizedTitle != null) { + json['title'] = localizedTitle; + } + + if (numberOfItems != null) { + json['numberOfItems'] = numberOfItems; + } + + return json; + } + + LocatorCollectionMetadata copyWith({ + dynamic localizedTitle, + int? numberOfItems, + Map? additionalProperties, + }) => LocatorCollectionMetadata( + localizedTitle: localizedTitle ?? this.localizedTitle, + numberOfItems: numberOfItems ?? this.numberOfItems, + additionalProperties: additionalProperties ?? this.additionalProperties, + ); + + @override + List get props => [localizedTitle, numberOfItems, additionalProperties]; + + @override + String toString() => + 'LocatorCollectionMetadata{title: $title, numberOfItems: $numberOfItems, ' + 'otherMetadata: $additionalProperties}'; +}