diff --git a/TestApp/Integrations/Carthage/project+lcp.yml b/TestApp/Integrations/Carthage/project+lcp.yml index ef9eac672..f72e78abf 100644 --- a/TestApp/Integrations/Carthage/project+lcp.yml +++ b/TestApp/Integrations/Carthage/project+lcp.yml @@ -15,8 +15,8 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" - sources: + deploymentTarget: "16.0" + sources: - path: Sources excludes: - Resources/Fonts diff --git a/TestApp/Integrations/Carthage/project.yml b/TestApp/Integrations/Carthage/project.yml index 24a543164..0e33f1e38 100644 --- a/TestApp/Integrations/Carthage/project.yml +++ b/TestApp/Integrations/Carthage/project.yml @@ -15,7 +15,7 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" + deploymentTarget: "16.0" sources: - path: Sources excludes: diff --git a/TestApp/Integrations/CocoaPods/Podfile b/TestApp/Integrations/CocoaPods/Podfile index 6d02b2e2f..ccb1dc0ab 100644 --- a/TestApp/Integrations/CocoaPods/Podfile +++ b/TestApp/Integrations/CocoaPods/Podfile @@ -1,4 +1,4 @@ -platform :ios, '14.0' +platform :ios, '16.0' target 'TestApp' do # Comment the next line if you don't want to use dynamic frameworks @@ -23,7 +23,7 @@ end post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' config.build_settings['ENABLE_BITCODE'] = 'NO' end end diff --git a/TestApp/Integrations/CocoaPods/Podfile+lcp b/TestApp/Integrations/CocoaPods/Podfile+lcp index 31186eded..fe80b872c 100644 --- a/TestApp/Integrations/CocoaPods/Podfile+lcp +++ b/TestApp/Integrations/CocoaPods/Podfile+lcp @@ -1,4 +1,4 @@ -platform :ios, '14.0' +platform :ios, '16.0' target 'TestApp' do # Comment the next line if you don't want to use dynamic frameworks @@ -26,7 +26,7 @@ end post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' config.build_settings['ENABLE_BITCODE'] = 'NO' end end diff --git a/TestApp/Integrations/CocoaPods/project+lcp.yml b/TestApp/Integrations/CocoaPods/project+lcp.yml index 741934ed2..42c682ac8 100644 --- a/TestApp/Integrations/CocoaPods/project+lcp.yml +++ b/TestApp/Integrations/CocoaPods/project+lcp.yml @@ -5,7 +5,7 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" + deploymentTarget: "16.0" sources: - path: Sources excludes: diff --git a/TestApp/Integrations/CocoaPods/project.yml b/TestApp/Integrations/CocoaPods/project.yml index 578dd1ed9..86ef1d095 100644 --- a/TestApp/Integrations/CocoaPods/project.yml +++ b/TestApp/Integrations/CocoaPods/project.yml @@ -5,7 +5,7 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" + deploymentTarget: "16.0" sources: - path: Sources excludes: diff --git a/TestApp/Integrations/Local/project+lcp.yml b/TestApp/Integrations/Local/project+lcp.yml index ba2c0558d..cd70cab50 100644 --- a/TestApp/Integrations/Local/project+lcp.yml +++ b/TestApp/Integrations/Local/project+lcp.yml @@ -31,7 +31,7 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" + deploymentTarget: "16.0" sources: - path: Sources excludes: diff --git a/TestApp/Integrations/Local/project.yml b/TestApp/Integrations/Local/project.yml index 8ef863399..50ed51bdc 100644 --- a/TestApp/Integrations/Local/project.yml +++ b/TestApp/Integrations/Local/project.yml @@ -29,8 +29,8 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" - sources: + deploymentTarget: "16.0" + sources: - path: Sources excludes: - Resources/Fonts diff --git a/TestApp/Integrations/SPM/project+lcp.yml b/TestApp/Integrations/SPM/project+lcp.yml index 6a6e7d29a..a0c92db70 100644 --- a/TestApp/Integrations/SPM/project+lcp.yml +++ b/TestApp/Integrations/SPM/project+lcp.yml @@ -23,7 +23,7 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" + deploymentTarget: "16.0" sources: - path: Sources excludes: diff --git a/TestApp/Integrations/SPM/project.yml b/TestApp/Integrations/SPM/project.yml index d7dff76e1..dfe71fd83 100644 --- a/TestApp/Integrations/SPM/project.yml +++ b/TestApp/Integrations/SPM/project.yml @@ -21,8 +21,8 @@ targets: TestApp: type: application platform: iOS - deploymentTarget: "14.0" - sources: + deploymentTarget: "16.0" + sources: - path: Sources excludes: - Resources/Fonts diff --git a/TestApp/README.md b/TestApp/README.md index 4970e3d36..76d36ee89 100644 --- a/TestApp/README.md +++ b/TestApp/README.md @@ -2,7 +2,7 @@ This sample application demonstrates how to integrate the Readium Swift toolkit in your own reading app. Stable versions are [published on TestFlight](https://testflight.apple.com/join/lYEMEfBr). -:warning: The Readium toolkit itself supports down to iOS 11, but the Test App requires iOS 14 and Xcode 13.2. +:warning: The Readium toolkit itself supports down to iOS 13, but the Test App requires iOS 16 and Xcode 13.2. ## Features diff --git a/TestApp/Sources/About/Views/About.swift b/TestApp/Sources/About/Views/About.swift new file mode 100644 index 000000000..bbdb43c2f --- /dev/null +++ b/TestApp/Sources/About/Views/About.swift @@ -0,0 +1,40 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import SwiftUI + +struct About: View { + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Version") + .font(.title2) + HStack(spacing: 10) { + Text("Version").frame(width: 170.0, alignment: .leading) + Text("2.3.0") + } + HStack(spacing: 10) { + Text("Build").frame(width: 170.0, alignment: .leading) + Text("1") + } + Text("Copyright").font(.title2) + Link("© 2022 European Digital Reading Lab", + destination: URL(string: "https://www.edrlab.org/")!) + Link("[BSD-3 License]", + destination: URL(string: "https://opensource.org/licenses/BSD-3-Clause")!) + Text("Acknowledgements").font(.title2) + Text("R2 Reader wouldn't have been developed without the financial help of the French State.") + Image("rf") + } + .padding() + .navigationTitle("About") + } +} + +struct About_Previews: PreviewProvider { + static var previews: some View { + About() + } +} diff --git a/TestApp/Sources/App/AppModule.swift b/TestApp/Sources/App/AppModule.swift index 5a6e0401f..b4534e74a 100644 --- a/TestApp/Sources/App/AppModule.swift +++ b/TestApp/Sources/App/AppModule.swift @@ -28,7 +28,7 @@ final class AppModule { init() throws { let file = Paths.library.appendingPath("database.db", isDirectory: false) - let db = try Database(file: file.url) + let db = try Database(file: file.url, migrations: [InitialMigration()]) print("Created database at \(file.path)") let books = BookRepository(db: db) diff --git a/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift b/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift new file mode 100644 index 000000000..78e239223 --- /dev/null +++ b/TestApp/Sources/Bookshelf/Views/AddBookSheet.swift @@ -0,0 +1,48 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import SwiftUI + +struct AddBookSheet: View { + @Environment(\.dismiss) private var dismiss + var action: (String) -> Void + + @State var url: String = "" + + var body: some View { + NavigationStack { + Form { + TextField("URL", text: $url) + .keyboardType(.URL) + .autocapitalization(.none) + } + .navigationTitle("Add a Book") + .toolbar(content: toolbarContent) + } + } + + @ToolbarContentBuilder + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button(.cancel) { + dismiss() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(.save) { + action(url) + dismiss() + } + .disabled(url.isEmpty) + } + } +} + +// struct AddBookSheet_Previews: PreviewProvider { +// static var previews: some View { +// AddBookSheet(showingSheet: true) +// } +// } diff --git a/TestApp/Sources/Bookshelf/Views/Bookshelf.swift b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift new file mode 100644 index 000000000..f535749fd --- /dev/null +++ b/TestApp/Sources/Bookshelf/Views/Bookshelf.swift @@ -0,0 +1,62 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +struct Bookshelf: View { + let bookRepository: BookRepository + let reader: (Book) -> Reader + + @State private var showingSheet = false + @State private var books: [Book] = [] + + var body: some View { + NavigationStack { + VStack { + // TODO: figure out what the best column layout is for phones and tablets + let columns: [GridItem] = [GridItem(.adaptive(minimum: Constant.bookCoverWidth + Constant.adaptiveGridDelta))] + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(books, id: \.self) { book in + NavigationLink(value: book) { + BookCover(title: book.title, authors: book.authors, url: book.cover?.url) + } + .buttonStyle(.plain) + } + } + // TODO: handle error + .onReceive(bookRepository.all() + .replaceError(with: []) + ) { books in + self.books = books + } + } + } + .navigationTitle("Bookshelf") + .navigationDestination(for: Book.self) { book in + reader(book) + } + .toolbar(content: toolbarContent) + } + .sheet(isPresented: $showingSheet) { + AddBookSheet { url in + // TODO: validate the URL and import the book + } + } + } +} + +extension Bookshelf { + @ToolbarContentBuilder + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button(.add, action: { + showingSheet = true + }) + } + } +} diff --git a/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift new file mode 100644 index 000000000..282a3db33 --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/AddFeedSheet.swift @@ -0,0 +1,55 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import SwiftUI + +/// Sheet to add a new OPDS catalog via a URL +struct AddFeedSheet: View { + typealias ActionCallback = ((title: String, url: String)) -> Void + + @Environment(\.dismiss) private var dismiss + var action: ActionCallback + + @State var title: String = "" + @State var url: String = "" + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Feed Title", text: $title) + TextField("URL", text: $url) + .keyboardType(.URL) + .autocapitalization(.none) + } + } + .navigationBarTitle("Add an OPDS Feed") + .toolbar(content: toolbarContent) + } + } + + @ToolbarContentBuilder + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button(.cancel) { + dismiss() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(.save) { + action((title: title, url: url)) + dismiss() + } + .disabled(title.isEmpty || url.isEmpty) + } + } +} + +// struct AddFeedSheet_Previews: PreviewProvider { +// static var previews: some View { +// AddFeedSheet() +// } +// } diff --git a/TestApp/Sources/Catalogs/Views/CatalogFeed.swift b/TestApp/Sources/Catalogs/Views/CatalogFeed.swift new file mode 100644 index 000000000..4532145d7 --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/CatalogFeed.swift @@ -0,0 +1,88 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumOPDS +import ReadiumShared +import SwiftUI + +/// Screen of an actual catalog feed, second to x number in the stack since it can keep going to another catalog +struct CatalogFeed: View { + var catalog: Catalog + @State private var parseData: ParseData? + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + if let feed = parseData?.feed { + if !feed.navigation.isEmpty { + ForEach(feed.navigation, id: \.self) { link in + let navigationLink = Catalog(title: link.title ?? "Catalog", url: link.href) + /// We don't need to define the navigationDestination for this again because it will use the one in CatalogList + NavigationLink(value: navigationLink) { + ListRowItem(title: link.title!) + } + } + Divider().frame(height: 50) + } + + // TODO: This probably needs its own file + if !feed.publications.isEmpty { + let columns: [GridItem] = [GridItem(.adaptive(minimum: 150 + 8))] + LazyVGrid(columns: columns) { + ForEach(feed.publications) { publication in + let authors = publication.metadata.authors + .map(\.name) + .joined(separator: ", ") + NavigationLink(value: OPDSPublication(from: publication)) { + BookCover( + title: publication.metadata.title ?? "", + authors: authors, + url: publication.images.first + .flatMap { URL(string: $0.href) } + ) + } + .buttonStyle(.plain) + } + } + Divider().frame(height: 50) + } + + if !feed.groups.isEmpty { + ForEach(feed.groups as [ReadiumShared.Group]) { group in + CatalogGroup(group: group) + .padding([.bottom], 25) + } + } + } + } + } + .padding() + .navigationTitle(catalog.title) + .navigationBarTitleDisplayMode(.inline) + .task { + if parseData == nil { + await parseFeed() + } + } + } +} + +extension CatalogFeed { + func parseFeed() async { + if let url = URL(string: catalog.url) { + OPDSParser.parseURL(url: url) { data, _ in + self.parseData = data + } + } + } +} + +struct CatalogDetail_Previews: PreviewProvider { + static var previews: some View { + let catalog = Catalog(title: "Test", url: "https://www.test.com") + CatalogFeed(catalog: catalog) + } +} diff --git a/TestApp/Sources/Catalogs/Views/CatalogGroup.swift b/TestApp/Sources/Catalogs/Views/CatalogGroup.swift new file mode 100644 index 000000000..abf11a7ad --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/CatalogGroup.swift @@ -0,0 +1,60 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +struct CatalogGroup: View { + var group: ReadiumShared.Group + + var body: some View { + VStack(alignment: .leading) { + let rows = [GridItem(.flexible(), alignment: .top)] + HStack { + Text(group.metadata.title).font(.title3) + if !group.links.isEmpty { + let navigationLink = Catalog(title: group.metadata.title, url: group.links.first!.href) + NavigationLink(value: navigationLink) { + ListRowItem(title: "See All").frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + if !group.publications.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + LazyHGrid(rows: rows, spacing: 30) { + ForEach(group.publications) { publication in + let authors = publication.metadata.authors + .map(\.name) + .joined(separator: ", ") + NavigationLink(value: OPDSPublication(from: publication)) { + // FIXME: Ideally the title and author should not be truncated + BookCover( + title: publication.metadata.title ?? "", + authors: authors, + url: publication.images.first + .map { URL(string: $0.href)! } + ) + } + .buttonStyle(.plain) + } + } + } + } + ForEach(group.navigation, id: \.self) { navigation in + let navigationLink = Catalog(title: navigation.title ?? "Catalog", url: navigation.href) + NavigationLink(value: navigationLink) { + ListRowItem(title: navigation.title!) + } + } + } + } +} + +// struct CatalogGroup_Previews: PreviewProvider { +// static var previews: some View { +// CatalogGroup() +// } +// } diff --git a/TestApp/Sources/Catalogs/Views/CatalogList.swift b/TestApp/Sources/Catalogs/Views/CatalogList.swift new file mode 100644 index 000000000..3ef32e73e --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/CatalogList.swift @@ -0,0 +1,103 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumOPDS +import SwiftUI + +/// Screen of list of catalog feeds, first in the stack +struct CatalogList: View { + let catalogRepository: CatalogRepository + let catalogFeed: (Catalog) -> CatalogFeed + let publicationDetail: (OPDSPublication) -> PublicationDetail + + @State private var showingSheet = false + @State private var showingAlert = false + @State private var catalogs: [Catalog] = [] + + var body: some View { + NavigationStack { + VStack { + List { + ForEach(catalogs, id: \.id) { catalog in + /// Use the `value` argument for navigationDestination to use. + /// In this case, it is a catalog of type `Catalog`. See below navigationDestination comment. + NavigationLink(value: catalog) { + ListRowItem(title: catalog.title) + } + } + .onDelete { offsets in + let catalogIds = offsets.map { catalogs[$0].id! } + Task { + try await deleteCatalogs(ids: catalogIds) + } + } + } + .onReceive(catalogRepository.all() + .replaceError(with: nil)) + { catalogsOrNil in + if let catalogs = catalogsOrNil { + self.catalogs = catalogs + } else { + print("Error fetching catalogs") + } + } + .listStyle(DefaultListStyle()) + } + .navigationTitle("Catalogs") + /// We define the different destinations here, which are applicable to everywhere in the stack. + /// Use the `for` argument to pass the type of data. This should match what is being passed in NavigationLink. + /// In the first case below, it is of type `Catalog`, the same as NavigationLink above. + .navigationDestination(for: Catalog.self) { catalog in + catalogFeed(catalog) + } + .navigationDestination(for: OPDSPublication.self) { opdsPublication in + publicationDetail(opdsPublication) + } + .toolbar(content: toolbarContent) + } + .sheet(isPresented: $showingSheet) { + AddFeedSheet { title, url in + Task { + try await addCatalog(title: title, url: url) + } + } + } + .alert("Error", isPresented: $showingAlert, actions: { + Button("OK", role: .cancel, action: {}) + }, message: { + Text("Feed is not valid, please try again.") + }) + } + + @ToolbarContentBuilder + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button(.add, action: { + showingSheet = true + }) + } + } +} + +extension CatalogList { + func addCatalog(title: String, url: String) async throws { + do { + guard let catalogURL = URL(string: url) else { + showingAlert = true + return + } + OPDSParser.parseURL(url: catalogURL) { _, _ in } + var savedCatalog = Catalog(title: title, url: url) + try await catalogRepository.save(&savedCatalog) + } catch { + showingAlert = true + } + } + + func deleteCatalogs(ids: [Catalog.Id]) async throws { + try? await catalogRepository.delete(ids: ids) + } +} diff --git a/TestApp/Sources/Catalogs/Views/PublicationDetail.swift b/TestApp/Sources/Catalogs/Views/PublicationDetail.swift new file mode 100644 index 000000000..2bc932eaf --- /dev/null +++ b/TestApp/Sources/Catalogs/Views/PublicationDetail.swift @@ -0,0 +1,56 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +/// Screen of the publication detail, last in the stack +struct PublicationDetail: View { + @State var opdsPublication: OPDSPublication + + var body: some View { + let authors = opdsPublication.authors + .map(\.name) + .joined(separator: ", ") + ScrollView { + VStack { + AsyncImage( + url: opdsPublication.images.first + .map { URL(string: $0.href)! }, + content: { $0 + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 225, height: 330) + }, + placeholder: { ProgressView() } + ) + Text(opdsPublication.title ?? "").font(.title) + Text(authors).font(.title3) + .padding([.top], 5) + Text(opdsPublication.description ?? "") + .padding([.top, .bottom], 20) + .frame(alignment: .leading) + } + } + .padding() + .toolbar(content: toolbarContent) + } + + @ToolbarContentBuilder + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button(.download) { + // TODO: download the publication + } + } + } +} + +// struct PublicationDetail_Previews: PreviewProvider { +// static var previews: some View { +// PublicationDetail() +// } +// } diff --git a/TestApp/Sources/Common/Publication.swift b/TestApp/Sources/Common/Publication.swift index ba84aa244..06fad18da 100644 --- a/TestApp/Sources/Common/Publication.swift +++ b/TestApp/Sources/Common/Publication.swift @@ -17,3 +17,19 @@ extension Publication { } } } + +struct OPDSPublication: Hashable { + let title: String? + let authors: [Contributor] + let images: [Link] + let description: String? + let baseURL: HTTPURL? + + init(from publication: Publication) { + title = publication.metadata.title + authors = publication.metadata.authors + images = publication.images + description = publication.metadata.description + baseURL = publication.baseURL + } +} diff --git a/TestApp/Sources/Common/Toolkit/Extensions/ReadiumShared.swift b/TestApp/Sources/Common/Toolkit/Extensions/ReadiumShared.swift new file mode 100644 index 000000000..0ce46151c --- /dev/null +++ b/TestApp/Sources/Common/Toolkit/Extensions/ReadiumShared.swift @@ -0,0 +1,10 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared + +extension ReadiumShared.Publication: Identifiable {} +extension ReadiumShared.Group: Identifiable {} diff --git a/TestApp/Sources/Container.swift b/TestApp/Sources/Container.swift new file mode 100644 index 000000000..3a3fa3c11 --- /dev/null +++ b/TestApp/Sources/Container.swift @@ -0,0 +1,123 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumAdapterGCDWebServer +import ReadiumNavigator +import ReadiumShared +import ReadiumStreamer +import UIKit + +class Container { + private let db: Database + + init() throws { + db = try Database( + file: Paths.library.appendingPath("database.db", isDirectory: false).url, + migrations: [InitialMigration()] + ) + } + + // Bookshelf + + private lazy var bookRepository = BookRepository(db: db) + + func bookshelf() -> Bookshelf { + Bookshelf(bookRepository: bookRepository, reader: reader(with:)) + } + + // Reader + + lazy var readerService: ReaderService = { + var drmLibraryServices = [DRMLibraryService]() + #if LCP + drmLibraryServices.append(LCPLibraryService()) + #endif + + return ReaderService( + bookmarks: BookmarkRepository(db: db), + highlights: HighlightRepository(db: db), + makeReaderVCFunc: makeReaderVCFunc, + drmLibraryServices: drmLibraryServices, + streamer: Streamer( + contentProtections: drmLibraryServices.compactMap(\.contentProtection) + ), + httpClient: DefaultHTTPClient() + ) + }() + + func reader(with book: Book) -> Reader { + let viewModel = ReaderViewModel(book: book, readerService: readerService) + return Reader(viewModel: viewModel) + } + + // Catalogs + + private lazy var catalogRepository = CatalogRepository(db: db) + + func catalogs() -> CatalogList { + CatalogList( + catalogRepository: catalogRepository, + catalogFeed: catalogFeed(with:), + publicationDetail: publicationDetail(with:) + ) + } + + func catalogFeed(with catalog: Catalog) -> CatalogFeed { + CatalogFeed(catalog: catalog) + } + + func publicationDetail(with opdsPublication: OPDSPublication) -> PublicationDetail { + PublicationDetail(opdsPublication: opdsPublication) + } + + // About + + func about() -> About { + About() + } +} + +extension Container { + //TODO I don't know if this is the best spot for this code. I duplicated it in ReaderService where it might be better served. + func makeReaderVCFunc(for publication: Publication, book: Book, delegate: NavigatorDelegate) -> ReaderViewControllerType { + let locator = book.locator + let httpServer = GCDHTTPServer.shared + + do { + if publication.conforms(to: .pdf) { + let navigator = try PDFNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? PDFNavigatorDelegate + return navigator + } + + if publication.conforms(to: .epub) || publication.readingOrder.allAreHTML { + guard publication.metadata.identifier != nil else { + fatalError("ReaderError.epubNotValid") + } + + let navigator = try EPUBNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? EPUBNavigatorDelegate + return navigator + } + + if publication.conforms(to: .divina) { + let navigator = try CBZNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? CBZNavigatorDelegate + return navigator + } + } catch { + fatalError("Failed: \(error)") + } + return StubNavigatorViewController() + } + + private class StubNavigatorViewController: UIViewController, Navigator { + var publication: ReadiumShared.Publication + + var currentLocation: Locator? + } +} diff --git a/TestApp/Sources/Data/Book.swift b/TestApp/Sources/Data/Book.swift index c48f17118..dbb26b5da 100644 --- a/TestApp/Sources/Data/Book.swift +++ b/TestApp/Sources/Data/Book.swift @@ -9,7 +9,7 @@ import Foundation import GRDB import ReadiumShared -struct Book: Codable { +struct Book: Codable, Hashable, Identifiable { struct Id: EntityId { let rawValue: Int64 } let id: Id? diff --git a/TestApp/Sources/Data/Catalog.swift b/TestApp/Sources/Data/Catalog.swift new file mode 100644 index 000000000..6f192b3e5 --- /dev/null +++ b/TestApp/Sources/Data/Catalog.swift @@ -0,0 +1,61 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Combine +import Foundation +import GRDB + +/// Represents an OPDS catalog. +struct Catalog: Codable, Hashable, Identifiable { + struct Id: EntityId { let rawValue: Int64 } + + var id: Id? + var title: String + var url: String + var created: Date + + init(id: Id? = nil, title: String, url: String, created: Date = Date()) { + self.id = id + self.title = title + self.url = url + self.created = created + } +} + +extension Catalog: TableRecord, FetchableRecord, PersistableRecord { + enum Columns: String, ColumnExpression { + case id, title, url, created + } +} + +final class CatalogRepository { + private let db: Database + + init(db: Database) { + self.db = db + } + + /// Get all saved OPDS catalogs + func all() -> AnyPublisher<[Catalog]?, Error> { + db.observe { + try Catalog.order(Catalog.Columns.title).fetchAll($0) + } + } + + /// Save an OPDS catalog + func save(_ catalog: inout Catalog) async throws { + catalog = try await db.write { [catalog] db in + try catalog.saved(db) + } + } + + /// Delete an OPDS catalog + func delete(ids: [Catalog.Id]) async throws { + try await db.write { db in + try Catalog.deleteAll(db, ids: ids) + } + } +} diff --git a/TestApp/Sources/Data/Database.swift b/TestApp/Sources/Data/Database.swift index 6a7bca42d..39f7b2ac3 100644 --- a/TestApp/Sources/Data/Database.swift +++ b/TestApp/Sources/Data/Database.swift @@ -8,56 +8,45 @@ import Combine import Foundation import GRDB import ReadiumShared +import SwiftUI + +/// Database migration to be performed when updating the app. +protocol DatabaseMigration { + /// Schema version for this migration. + var version: Int { get } + + /// Applies the migration. + func run(on db: GRDB.Database) throws +} final class Database { - convenience init(file: URL) throws { - try self.init(writer: DatabaseQueue(path: file.path)) + convenience init(file: URL, migrations: [DatabaseMigration]) throws { + try self.init(writer: DatabaseQueue(path: file.path), migrations: migrations) } private let writer: DatabaseWriter - private init(writer: DatabaseWriter = DatabaseQueue()) throws { + private init(writer: DatabaseWriter = DatabaseQueue(), migrations: [DatabaseMigration]) throws { self.writer = writer - var migrator = DatabaseMigrator() - migrator.registerMigration("initial") { db in - try db.create(table: "book") { t in - t.autoIncrementedPrimaryKey("id") - t.column("identifier", .text) - t.column("title", .text).notNull() - t.column("authors", .text) - t.column("type", .text).notNull() - t.column("path", .text).notNull() - t.column("coverPath", .text) - t.column("locator", .text) - t.column("progression", .integer).notNull().defaults(to: 0) - t.column("created", .datetime).notNull() - t.column("preferencesJSON", .text) - } - - try db.create(table: "bookmark") { t in - t.autoIncrementedPrimaryKey("id") - t.column("bookId", .integer).references("book", onDelete: .cascade).notNull() - t.column("locator", .text) - t.column("progression", .double).notNull() - t.column("created", .datetime).notNull() - } + try run(migrations) + } - try db.create(table: "highlight") { t in - t.autoIncrementedPrimaryKey("id") - t.column("bookId", .integer).references("book", onDelete: .cascade).notNull() - t.column("locator", .text) - t.column("progression", .double).notNull() - t.column("color", .integer).notNull() - t.column("created", .datetime).notNull() - } + /// Runs the database migrations on `Database` initialization. + private func run(_ migrations: [DatabaseMigration]) throws { + try writer.write { db in + let currentVersion = try Int64.fetchOne(db, sql: "PRAGMA user_version") ?? 0 - // create an index to make sorting by progression faster - try db.create(index: "index_highlight_progression", on: "highlight", columns: ["bookId", "progression"], ifNotExists: true) - try db.create(index: "index_bookmark_progression", on: "bookmark", columns: ["bookId", "progression"], ifNotExists: true) + try migrations + .filter { $0.version > currentVersion } + .sorted { $0.version < $1.version } + .forEach { try run($0, on: db) } } + } - try migrator.migrate(writer) + private func run(_ migration: DatabaseMigration, on db: GRDB.Database) throws { + try migration.run(on: db) + try db.execute(sql: "PRAGMA user_version = \(migration.version)") } func read(_ query: @escaping (GRDB.Database) throws -> T) async throws -> T { diff --git a/TestApp/Sources/Data/Migrations/01 Initial.swift b/TestApp/Sources/Data/Migrations/01 Initial.swift new file mode 100644 index 000000000..1b4c72427 --- /dev/null +++ b/TestApp/Sources/Data/Migrations/01 Initial.swift @@ -0,0 +1,73 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import GRDB + +/// This database migration will create the SQL schema and insert some initial data. +struct InitialMigration: DatabaseMigration { + let version = 1 + + func run(on db: GRDB.Database) throws { + try createSchema(on: db) + try bootstrapData(on: db) + } + + private func createSchema(on db: GRDB.Database) throws { + try db.create(table: "book", ifNotExists: true) { t in + t.autoIncrementedPrimaryKey("id") + t.column("identifier", .text) + t.column("title", .text).notNull() + t.column("authors", .text) + t.column("type", .text).notNull() + t.column("path", .text).notNull() + t.column("coverPath", .text) + t.column("locator", .text) + t.column("progression", .integer).notNull().defaults(to: 0) + t.column("created", .datetime).notNull() + t.column("preferencesJSON", .text) + } + + try db.create(table: "bookmark", ifNotExists: true) { t in + t.autoIncrementedPrimaryKey("id") + t.column("bookId", .integer).references("book", onDelete: .cascade).notNull() + t.column("locator", .text) + t.column("progression", .double).notNull() + t.column("created", .datetime).notNull() + } + + try db.create(table: "highlight", ifNotExists: true) { t in + t.autoIncrementedPrimaryKey("id") + t.column("bookId", .integer).references("book", onDelete: .cascade).notNull() + t.column("locator", .text) + t.column("progression", .double).notNull() + t.column("color", .integer).notNull() + t.column("created", .datetime).notNull() + } + + // create an index to make sorting by progression faster + try db.create(index: "index_highlight_progression", on: "highlight", columns: ["bookId", "progression"], ifNotExists: true) + try db.create(index: "index_bookmark_progression", on: "bookmark", columns: ["bookId", "progression"], ifNotExists: true) + + try db.create(table: "catalog", ifNotExists: true) { t in + t.autoIncrementedPrimaryKey("id") + t.column("title", .text) + t.column("url", .text).notNull() + t.column("created", .datetime).notNull() + } + } + + private func bootstrapData(on db: GRDB.Database) throws { + let catalogs = [ + Catalog(title: "OPDS 2.0 Test Catalog", url: "https://test.opds.io/2.0/home.json"), + Catalog(title: "Open Textbooks Catalog", url: "http://open.minitex.org/textbooks"), + ] + + for catalog in catalogs { + try catalog.save(db) + } + } +} diff --git a/TestApp/Sources/Reader/Views/NewReaderViewController.swift b/TestApp/Sources/Reader/Views/NewReaderViewController.swift new file mode 100644 index 000000000..f618a058b --- /dev/null +++ b/TestApp/Sources/Reader/Views/NewReaderViewController.swift @@ -0,0 +1,18 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import SwiftUI + +struct NewReaderViewController: UIViewControllerRepresentable { + let makeReaderVCFunc: () -> UIViewController + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + func makeUIViewController(context: Context) -> UIViewController { + makeReaderVCFunc() + } +} diff --git a/TestApp/Sources/Reader/Views/Reader.swift b/TestApp/Sources/Reader/Views/Reader.swift new file mode 100644 index 000000000..ae359ee95 --- /dev/null +++ b/TestApp/Sources/Reader/Views/Reader.swift @@ -0,0 +1,27 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import SwiftUI + +struct Reader: View { + @State private var isFullScreen = true + @ObservedObject var viewModel: ReaderViewModel + + var body: some View { + NewReaderViewController(makeReaderVCFunc: viewModel.makeReaderVCFunc) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .toolbar(.hidden, for: .tabBar) + .toolbar(isFullScreen ? .hidden : .visible, for: .navigationBar) + .statusBar(hidden: isFullScreen) + .onTapGesture { + withAnimation { + isFullScreen.toggle() + } + } + .edgesIgnoringSafeArea(.all) + } +} diff --git a/TestApp/Sources/Reader/Views/ReaderService.swift b/TestApp/Sources/Reader/Views/ReaderService.swift new file mode 100644 index 000000000..a8669dc25 --- /dev/null +++ b/TestApp/Sources/Reader/Views/ReaderService.swift @@ -0,0 +1,142 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumAdapterGCDWebServer +import ReadiumNavigator +import ReadiumShared +import ReadiumStreamer +import UIKit + +typealias ReaderViewControllerType = Navigator & UIViewController + +class ReaderService { + let bookmarks: BookmarkRepository + let highlights: HighlightRepository + let makeReaderVCFunc: (Publication, Book, NavigatorDelegate) -> ReaderViewControllerType + let drmLibraryServices: [DRMLibraryService] + let streamer: Streamer + let httpClient: HTTPClient + + init(bookmarks: BookmarkRepository, + highlights: HighlightRepository, + makeReaderVCFunc: @escaping (Publication, Book, NavigatorDelegate) -> ReaderViewControllerType, + drmLibraryServices: [DRMLibraryService], + streamer: Streamer, + httpClient: HTTPClient) + { + self.bookmarks = bookmarks + self.highlights = highlights + self.makeReaderVCFunc = makeReaderVCFunc + self.drmLibraryServices = drmLibraryServices + self.streamer = streamer + self.httpClient = httpClient + } + + func openBook(_ book: Book, sender: UIViewController) async throws -> Publication { + let (pub, _) = try await openPublication(at: book.url(), allowUserInteraction: true, sender: sender) + try checkIsReadable(publication: pub) + return pub + } + + /// Opens the Readium 2 Publication at the given `url`. + private func openPublication(at url: FileURL, allowUserInteraction: Bool, sender: UIViewController?) async throws -> (Publication, MediaType) { + let asset = FileAsset(file: url) + guard let mediaType = asset.mediaType() else { + throw LibraryError.openFailed(Publication.OpeningError.unsupportedFormat) + } + + return try await withCheckedThrowingContinuation { cont in + streamer.open(asset: asset, allowUserInteraction: allowUserInteraction, sender: sender) { result in + switch result { + case let .success(publication): + cont.resume(returning: (publication, mediaType)) + case let .failure(error): + cont.resume(throwing: LibraryError.openFailed(error)) + case .cancelled: + cont.resume(throwing: LibraryError.cancelled) + } + } + } + } + + /// Checks if the publication is not still locked by a DRM. + private func checkIsReadable(publication: Publication) throws { + guard !publication.isRestricted else { + if let error = publication.protectionError { + throw LibraryError.openFailed(error) + } else { + throw LibraryError.cancelled + } + } + } + + func makeReaderVCFunc(for publication: Publication, book: Book, delegate: NavigatorDelegate) -> ReaderViewControllerType { + let locator = book.locator + let httpServer = GCDHTTPServer.shared + + do { + if publication.conforms(to: .pdf) { + let navigator = try PDFNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? PDFNavigatorDelegate + return navigator + } + + if publication.conforms(to: .epub) || publication.readingOrder.allAreHTML { + guard publication.metadata.identifier != nil else { + fatalError("ReaderError.epubNotValid") + } + + let navigator = try EPUBNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? EPUBNavigatorDelegate + return navigator + } + + if publication.conforms(to: .divina) { + let navigator = try CBZNavigatorViewController(publication: publication, initialLocation: locator, httpServer: httpServer) + navigator.delegate = delegate as? CBZNavigatorDelegate + return navigator + } + } catch { + fatalError("Failed: \(error)") + } + return StubNavigatorViewController(coder: NSCoder())! + } + + private class StubNavigatorViewController: UIViewController, Navigator { + var publication: ReadiumShared.Publication + + var currentLocation: Locator? + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } +} + +private extension Book { + func url() throws -> FileURL { + guard let url = AnyURL(string: path) else { + throw LibraryError.bookNotFound + } + + switch url { + case let .absolute(url): + guard let url = url.fileURL else { + throw LibraryError.bookNotFound + } + return url + + case let .relative(relativeURL): + // Path relative to Documents/. + guard let url = Paths.documents.resolve(relativeURL) else { + throw LibraryError.bookNotFound + } + return url + } + } +} diff --git a/TestApp/Sources/Reader/Views/ReaderViewModel.swift b/TestApp/Sources/Reader/Views/ReaderViewModel.swift new file mode 100644 index 000000000..fa6afc06a --- /dev/null +++ b/TestApp/Sources/Reader/Views/ReaderViewModel.swift @@ -0,0 +1,66 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumNavigator +import ReadiumShared +import WebKit + +class ReaderViewModel: ObservableObject { + let book: Book + // TODO: do we need publication in the VM? + let publication: Publication? + let readerService: ReaderService + + @Published var positionLabelText: String = "" + @Published var navigator: Navigator! + + private var bookId: Book.Id { + book.id! + } + + init(book: Book, readerService: ReaderService) { + self.book = book + self.readerService = readerService + } + + func makeReaderVCFunc() -> UIViewController { + // Where best to get the publication from the book via openBook, which is async. Here? + let result = readerService.makeReaderVCFunc(publication, book, self) + navigator = result + // TODO: become a delegate of a specific Format implementation + return result + } +} + +extension ReaderViewModel: PDFNavigatorDelegate, EPUBNavigatorDelegate, CBZNavigatorDelegate {} + +extension ReaderViewModel: NavigatorDelegate { + func navigator(_ navigator: any ReadiumNavigator.Navigator, didFailToLoadResourceAt href: ReadiumShared.RelativeURL, withError error: ReadiumShared.ResourceError) {} + + func navigator(_ navigator: Navigator, presentError error: NavigatorError) {} + + func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { + positionLabelText = { + if let position = locator.locations.position { + return "\(position) / \(publication.positions.count)" + } else if let progression = locator.locations.totalProgression { + return "\(progression)%" + } else { + return "" + } + }() + } +} + +extension ReaderViewModel: VisualNavigatorDelegate { + func navigator(_ navigator: VisualNavigator, didTapAt point: CGPoint) { + // clear a current search highlight + if let decorator = self.navigator as? DecorableNavigator { + decorator.apply(decorations: [], in: "search") + } + } +} diff --git a/TestApp/Sources/TestApp.swift b/TestApp/Sources/TestApp.swift new file mode 100644 index 000000000..aff4bca12 --- /dev/null +++ b/TestApp/Sources/TestApp.swift @@ -0,0 +1,33 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import GRDB +import SwiftUI + +// @main +/// The main function and serves as the app's entry +struct TestApp: App { + let container = try! Container() + + var body: some Scene { + WindowGroup { + TabView { + container.bookshelf() + .tabItem { + Label("Bookshelf", systemImage: "books.vertical.fill") + } + container.catalogs() + .tabItem { + Label("Catalogs", systemImage: "magazine.fill") + } + container.about() + .tabItem { + Label("About", systemImage: "info.circle.fill") + } + } + } + } +} diff --git a/TestApp/Sources/Views/BookCover.swift b/TestApp/Sources/Views/BookCover.swift new file mode 100644 index 000000000..a74f0dc62 --- /dev/null +++ b/TestApp/Sources/Views/BookCover.swift @@ -0,0 +1,73 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import SwiftUI + +struct BookCover: View { + var title: String + var authors: String? + var url: URL? + + var body: some View { + VStack { + cover + .frame(width: Constant.bookCoverWidth, height: Constant.bookCoverHeight, alignment: .bottom) + labels + .frame(width: Constant.bookCoverWidth, alignment: .topLeading) + } + } + + @ViewBuilder + private var cover: some View { + if url != nil { + AsyncImage( + url: url, + content: { $0 + .resizable() + .aspectRatio(contentMode: .fit) + .shadow(radius: 2) + }, + placeholder: { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + ) + } else { + Image(systemName: "book.closed") + .resizable() + .aspectRatio(contentMode: .fit) + } + } + + @ViewBuilder + private var labels: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .lineLimit(1) + + // Hack to reserve space for two lines of text. + // See https://sarunw.com/posts/how-to-force-two-lines-of-text-in-swiftui/ + Text((authors ?? "") + "\n") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } +} + +struct BookCover_Previews: PreviewProvider { + static var previews: some View { + let book = Book(title: "Test Title", authors: "Test Author", type: "application/epub+zip", path: "/test/path/") + BookCover(title: book.title, authors: book.authors) + } +} + +enum Constant { + static let bookCoverWidth: Double = 130 + static let bookCoverHeight: Double = 200 + static let adaptiveGridDelta: Double = 8 +} diff --git a/TestApp/Sources/Views/Button.swift b/TestApp/Sources/Views/Button.swift new file mode 100644 index 000000000..ac69fb3a3 --- /dev/null +++ b/TestApp/Sources/Views/Button.swift @@ -0,0 +1,32 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import SwiftUI + +enum ButtonKind { + case add + case cancel + case save + case download +} + +@ViewBuilder +func Button(_ kind: ButtonKind, action: @escaping () -> Void) -> some View { + switch kind { + case .add: + Button(action: action) { + Label("Add", systemImage: "plus") + } + case .cancel: + Button("Cancel", action: action) + case .save: + Button("Save", action: action) + case .download: + Button(action: action) { + Label("Download", systemImage: "icloud.and.arrow.down") + } + } +} diff --git a/TestApp/Sources/Views/ListRowItem.swift b/TestApp/Sources/Views/ListRowItem.swift new file mode 100644 index 000000000..ed70ba974 --- /dev/null +++ b/TestApp/Sources/Views/ListRowItem.swift @@ -0,0 +1,24 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import SwiftUI + +struct ListRowItem: View { + var action: () -> Void = {} + var title: String + + var body: some View { + Text(title) + .font(.title3) + .padding(.vertical, 8) + } +} + +struct ListRowItem_Previews: PreviewProvider { + static var previews: some View { + ListRowItem(title: "Test") + } +}