diff --git a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift index 3a36e51e2..4925b275c 100644 --- a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift +++ b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift @@ -138,7 +138,7 @@ public class GCDHTTPServer: HTTPServer, Loggable { let pathWithoutAnchor = url.removingQuery().removingFragment() for (endpoint, handler) in handlers { - if endpoint == pathWithoutAnchor { + if endpoint.isEquivalentTo(pathWithoutAnchor) { let request = HTTPServerRequest(url: url, href: nil) let resource = handler.onRequest(request) completion( diff --git a/Sources/Adapters/GCDWebServer/ResourceResponse.swift b/Sources/Adapters/GCDWebServer/ResourceResponse.swift index 241ca0cf0..522f7e216 100644 --- a/Sources/Adapters/GCDWebServer/ResourceResponse.swift +++ b/Sources/Adapters/GCDWebServer/ResourceResponse.swift @@ -77,7 +77,7 @@ class ResourceResponse: ReadiumGCDWebServerFileResponse, Loggable { super.init() - contentType = resource.link.type ?? "" + contentType = resource.link.mediaType?.string ?? "" // Disable HTTP caching for publication resources, because it poses a security threat for protected // publications. diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 7bf8d207a..7b9f249fd 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -339,8 +339,8 @@ open class AudioNavigator: Navigator, Configurable, AudioSessionUser, Loggable { } return Locator( - href: link.href, - type: link.type ?? "audio/*", + href: link.url(), + mediaType: link.mediaType ?? MediaType("audio/*")!, title: link.title, locations: Locator.Locations( fragments: ["t=\(time)"], @@ -382,7 +382,9 @@ open class AudioNavigator: Navigator, Configurable, AudioSessionUser, Loggable { @discardableResult public func go(to locator: Locator, animated: Bool = false, completion: @escaping () -> Void = {}) -> Bool { - guard let newResourceIndex = publication.readingOrder.firstIndex(withHREF: locator.href) else { + let locator = publication.normalizeLocator(locator) + + guard let newResourceIndex = publication.readingOrder.firstIndexWithHREF(locator.href) else { return false } let link = publication.readingOrder[newResourceIndex] diff --git a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift index d778a9e2d..bda80fbe7 100644 --- a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift +++ b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift @@ -12,8 +12,6 @@ import ReadiumShared /// /// Useful for local resources or when you need to customize the way HTTP requests are sent. final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { - private typealias HREF = String - public enum AssetError: LocalizedError { case invalidHREF(String) @@ -35,10 +33,8 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { /// Creates a new `AVURLAsset` to serve the given `link`. func makeAsset(for link: Link) throws -> AVURLAsset { - guard - let originalURL = try? link.url(relativeTo: publication.baseURL), - var components = URLComponents(url: originalURL.url, resolvingAgainstBaseURL: true) - else { + let originalURL = link.url(relativeTo: publication.baseURL) + guard var components = URLComponents(url: originalURL.url, resolvingAgainstBaseURL: true) else { throw AssetError.invalidHREF(link.href) } @@ -55,10 +51,11 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { // MARK: - Resource Management - private var resources: [HREF: Resource] = [:] + private var resources: [AnyURL: Resource] = [:] - private func resource(forHREF href: HREF) -> Resource { - if let res = resources[href] { + private func resource(forHREF href: T) -> Resource { + let href = href.anyURL + if let res = resources[equivalent: href] { return res } @@ -72,10 +69,11 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { private typealias CancellableRequest = (request: AVAssetResourceLoadingRequest, cancellable: Cancellable) /// List of on-going loading requests. - private var requests: [HREF: [CancellableRequest]] = [:] + private var requests: [AnyURL: [CancellableRequest]] = [:] /// Adds a new loading request. - private func registerRequest(_ request: AVAssetResourceLoadingRequest, cancellable: Cancellable, for href: HREF) { + private func registerRequest(_ request: AVAssetResourceLoadingRequest, cancellable: Cancellable, for href: T) { + let href = href.anyURL var reqs: [CancellableRequest] = requests[href] ?? [] reqs.append((request, cancellable)) requests[href] = reqs @@ -129,7 +127,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { private func fillInfo(_ infoRequest: AVAssetResourceLoadingContentInformationRequest, of request: AVAssetResourceLoadingRequest, using resource: Resource) { infoRequest.isByteRangeAccessSupported = true - infoRequest.contentType = resource.link.mediaType.uti + infoRequest.contentType = resource.link.mediaType?.uti if case let .success(length) = resource.length { infoRequest.contentLength = Int64(length) } @@ -153,7 +151,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { } ) - registerRequest(request, cancellable: cancellable, for: resource.link.href) + registerRequest(request, cancellable: cancellable, for: resource.link.url()) } func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) { @@ -164,7 +162,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { private let schemePrefix = "readium" extension URL { - var audioHREF: String? { + var audioHREF: AnyURL? { guard let url = absoluteURL, url.scheme.rawValue.hasPrefix(schemePrefix) == true else { return nil } @@ -173,6 +171,6 @@ extension URL { // * readium:relative/file.mp3 // * readiumfile:///directory/local-file.mp3 // * readiumhttp(s)://domain.com/external-file.mp3 - return url.string.removingPrefix(schemePrefix).removingPrefix(":") + return AnyURL(string: url.string.removingPrefix(schemePrefix).removingPrefix(":")) } } diff --git a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift index 6ccca29d2..724e6a8a2 100644 --- a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift +++ b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift @@ -87,7 +87,7 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab self.publicationEndpoint = publicationEndpoint initialIndex = { - guard let initialLocation = initialLocation, let initialIndex = publication.readingOrder.firstIndex(withHREF: initialLocation.href) else { + guard let initialLocation = initialLocation, let initialIndex = publication.readingOrder.firstIndexWithHREF(initialLocation.href) else { return 0 } return initialIndex @@ -180,13 +180,10 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab } private func imageViewController(at index: Int) -> ImageViewController? { - guard - publication.readingOrder.indices.contains(index), - let url = try? publication.readingOrder[index].url(relativeTo: publicationBaseURL) - else { + guard publication.readingOrder.indices.contains(index) else { return nil } - + let url = publication.readingOrder[index].url(relativeTo: publicationBaseURL) return ImageViewController(index: index, url: url.url) } @@ -209,14 +206,16 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab } public func go(to locator: Locator, animated: Bool, completion: @escaping () -> Void) -> Bool { - guard let index = publication.readingOrder.firstIndex(withHREF: locator.href) else { + let locator = publication.normalizeLocator(locator) + + guard let index = publication.readingOrder.firstIndexWithHREF(locator.href) else { return false } return goToResourceAtIndex(index, animated: animated, isJump: true, completion: completion) } public func go(to link: Link, animated: Bool, completion: @escaping () -> Void) -> Bool { - guard let index = publication.readingOrder.firstIndex(withHREF: link.href) else { + guard let index = publication.readingOrder.firstIndexWithHREF(link.url()) else { return false } return goToResourceAtIndex(index, animated: animated, isJump: true, completion: completion) diff --git a/Sources/Navigator/Decorator/DiffableDecoration.swift b/Sources/Navigator/Decorator/DiffableDecoration.swift index 531a53eb2..84a572439 100644 --- a/Sources/Navigator/Decorator/DiffableDecoration.swift +++ b/Sources/Navigator/Decorator/DiffableDecoration.swift @@ -20,10 +20,10 @@ enum DecorationChange { } extension Array where Element == DiffableDecoration { - func changesByHREF(from source: [DiffableDecoration]) -> [String: [DecorationChange]] { + func changesByHREF(from source: [DiffableDecoration]) -> [AnyURL: [DecorationChange]] { let changeset = StagedChangeset(source: source, target: self) - var changes: [String: [DecorationChange]] = [:] + var changes: [AnyURL: [DecorationChange]] = [:] func register(_ change: DecorationChange, at locator: Locator) { var resourceChanges: [DecorationChange] = changes[locator.href] ?? [] diff --git a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift index 646b4c031..c8c437950 100644 --- a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift @@ -103,8 +103,8 @@ final class EPUBFixedSpreadView: EPUBSpreadView { goToCompletions.complete() } - override func evaluateScript(_ script: String, inHREF href: String?, completion: ((Result) -> Void)?) { - let href = href ?? "" + override func evaluateScript(_ script: String, inHREF href: AnyURL? = nil, completion: ((Result) -> Void)? = nil) { + let href = href?.string ?? "" let script = "spread.eval('\(href)', `\(script.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "`", with: "\\`"))`);" super.evaluateScript(script, completion: completion) } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 94c669400..8fc6cc8ce 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -431,13 +431,13 @@ open class EPUBNavigatorViewController: UIViewController, } /// Mapping between reading order hrefs and the table of contents title. - private lazy var tableOfContentsTitleByHref: [String: String] = { - func fulfill(linkList: [Link]) -> [String: String] { - var result = [String: String]() + private lazy var tableOfContentsTitleByHref: [AnyURL: String] = { + func fulfill(linkList: [Link]) -> [AnyURL: String] { + var result = [AnyURL: String]() for link in linkList { if let title = link.title { - result[link.href] = title + result[link.url()] = title } let subResult = fulfill(linkList: link.children) result.merge(subResult) { current, _ -> String in @@ -565,7 +565,7 @@ open class EPUBNavigatorViewController: UIViewController, return nil } - return readingOrder.firstIndex(withHREF: spreads[currentSpreadIndex].left.href) + return readingOrder.firstIndexWithHREF(spreads[currentSpreadIndex].left.url()) } private let reloadSpreadsCompletions = CompletionList() @@ -615,7 +615,7 @@ open class EPUBNavigatorViewController: UIViewController, ) let initialIndex: Int = { - if let href = locator?.href, let foundIndex = self.spreads.firstIndex(withHref: href) { + if let href = locator?.href, let foundIndex = self.spreads.firstIndexWithHREF(href) { return foundIndex } else { return 0 @@ -633,10 +633,10 @@ open class EPUBNavigatorViewController: UIViewController, } } - private func loadedSpreadView(forHREF href: String) -> EPUBSpreadView? { + private func loadedSpreadViewForHREF(_ href: T) -> EPUBSpreadView? { paginationView.loadedViews .compactMap { _, view in view as? EPUBSpreadView } - .first { $0.spread.links.first(withHREF: href) != nil } + .first { $0.spread.links.firstWithHREF(href) != nil } } // MARK: - Navigator @@ -671,21 +671,21 @@ open class EPUBNavigatorViewController: UIViewController, } let link = spreadView.focusedResource ?? spreadView.spread.leading - let href = link.href + let href = link.url() let progression = min(max(spreadView.progression(in: href), 0.0), 1.0) if // The positions are not always available, for example a Readium // WebPub doesn't have any unless a Publication Positions Web // Service is provided - let index = readingOrder.firstIndex(withHREF: href), + let index = readingOrder.firstIndexWithHREF(href), let positionList = positionsByReadingOrder.getOrNil(index), positionList.count > 0 { // Gets the current locator from the positionList, and fill its missing data. let positionIndex = Int(ceil(progression * Double(positionList.count - 1))) return positionList[positionIndex].copy( - title: tableOfContentsTitleByHref[href], + title: tableOfContentsTitleByHref[equivalent: href], locations: { $0.progression = progression } ) } else { @@ -726,8 +726,10 @@ open class EPUBNavigatorViewController: UIViewController, } public func go(to locator: Locator, animated: Bool, completion: @escaping () -> Void) -> Bool { + let locator = publication.normalizeLocator(locator) + guard - let spreadIndex = spreads.firstIndex(withHref: locator.href), + let spreadIndex = spreads.firstIndexWithHREF(locator.href), on(.jump(locator)) else { return false @@ -796,7 +798,11 @@ open class EPUBNavigatorViewController: UIViewController, public func apply(decorations: [Decoration], in group: String) { let source = self.decorations[group] ?? [] - let target = decorations.map { DiffableDecoration(decoration: $0) } + let target = decorations.map { d in + var d = d + d.locator = publication.normalizeLocator(d.locator) + return DiffableDecoration(decoration: d) + } self.decorations[group] = target @@ -815,7 +821,7 @@ open class EPUBNavigatorViewController: UIViewController, guard let script = changes.javascript(forGroup: group, styles: config.decorationTemplates) else { continue } - loadedSpreadView(forHREF: href)?.evaluateScript(script, inHREF: href) + loadedSpreadViewForHREF(href)?.evaluateScript(script, inHREF: href) } } } @@ -922,7 +928,7 @@ extension EPUBNavigatorViewController: EPUBNavigatorViewModelDelegate { for (_, view) in paginationView.loadedViews { guard let view = view as? EPUBSpreadView, - view.spread.links.first(withHREF: href) != nil + view.spread.links.firstWithHREF(href) != nil else { continue } @@ -966,10 +972,10 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { spreadView.evaluateScript("(function() {\n\(script)\n})();") { _ in for link in spreadView.spread.links { - let href = link.href + let href = link.url() for (group, decorations) in self.decorations { let decorations = decorations - .filter { $0.decoration.locator.href == href } + .filter { $0.decoration.locator.href.isEquivalentTo(href) } .map { DecorationChange.add($0.decoration) } guard let script = decorations.javascript(forGroup: group, styles: self.config.decorationTemplates) else { @@ -1018,7 +1024,10 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { } func spreadView(_ spreadView: EPUBSpreadView, didTapOnInternalLink href: String, clickEvent: ClickEvent?) { - guard var link = publication.link(withHREF: href) else { + guard + let url = AnyURL(string: href), + var link = publication.linkWithHREF(url) + else { log(.warning, "Cannot find link with HREF: \(href)") return } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 312a339b5..e0644b80f 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -17,7 +17,7 @@ protocol EPUBNavigatorViewModelDelegate: AnyObject { enum EPUBScriptScope { case currentResource case loadedResources - case resource(href: String) + case resource(href: AnyURL) } final class EPUBNavigatorViewModel: Loggable { @@ -173,8 +173,8 @@ final class EPUBNavigatorViewModel: Loggable { } } - func url(to link: Link) -> AnyURL? { - try? link.url(relativeTo: publicationBaseURL) + func url(to link: Link) -> AnyURL { + link.url(relativeTo: publicationBaseURL) } private func serveFile(at file: FileURL, baseEndpoint: HTTPServerEndpoint) throws -> HTTPURL { @@ -351,7 +351,7 @@ final class EPUBNavigatorViewModel: Loggable { func injectReadiumCSS(in resource: Resource) -> Resource { let link = resource.link guard - link.mediaType.isHTML, + link.mediaType?.isHTML == true, publication.metadata.presentation.layout(of: link) == .reflowable else { return resource diff --git a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index 5643fbf3a..775973e0b 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -77,10 +77,7 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { return } let link = spread.leading - guard let url = viewModel.url(to: link) else { - log(.error, "Can't get URL for link \(link.href)") - return - } + let url = viewModel.url(to: link) webView.load(URLRequest(url: url.url)) } @@ -172,8 +169,11 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { // MARK: - Location and progression - override func progression(in href: String) -> Double { - guard spread.leading.href == href, let progression = progression else { + override func progression(in href: T) -> Double where T: URLConvertible { + guard + spread.leading.url().isEquivalentTo(href), + let progression = progression + else { return 0 } return progression @@ -261,7 +261,7 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { } private func go(to locator: Locator, completion: @escaping (Bool) -> Void) { - guard ["", "#"].contains(locator.href) || spread.contains(href: locator.href) else { + guard ["", "#"].contains(locator.href.string) || spread.contains(href: locator.href) else { log(.warning, "The locator's href is not in the spread") completion(false) return diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index 4c003d2da..5a7386cc7 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -58,15 +58,15 @@ struct EPUBSpread: Loggable { } /// Returns whether the spread contains a resource with the given href. - func contains(href: String) -> Bool { - links.first(withHREF: href) != nil + func contains(href: T) -> Bool { + links.firstWithHREF(href) != nil } /// Return the number of positions (as in `Publication.positionList`) contained in the spread. func positionCount(in readingOrder: [Link], positionsByReadingOrder: [[Locator]]) -> Int { links .map { - if let index = readingOrder.firstIndex(withHREF: $0.href) { + if let index = readingOrder.firstIndexWithHREF($0.url()) { return positionsByReadingOrder[index].count } else { return 0 @@ -83,14 +83,10 @@ struct EPUBSpread: Loggable { /// - page [left|center|right]: (optional) Page position of the linked resource in the spread. func json(forBaseURL baseURL: HTTPURL) -> [[String: Any]] { func makeLinkJSON(_ link: Link, page: Presentation.Page? = nil) -> [String: Any]? { - guard let url = try? link.url(relativeTo: baseURL) else { - return nil - } - let page = page ?? link.properties.page ?? readingProgression.leadingPage return [ "link": link.json, - "url": url.string, + "url": link.url(relativeTo: baseURL).string, "page": page.rawValue, ] } @@ -204,9 +200,10 @@ struct EPUBSpread: Loggable { extension Array where Element == EPUBSpread { /// Returns the index of the first spread containing a resource with the given `href`. - func firstIndex(withHref href: String) -> Int? { - firstIndex { spread in - spread.links.contains { $0.href == href } + func firstIndexWithHREF(_ href: T) -> Int? { + let href = href.anyURL.normalized + return firstIndex { spread in + spread.links.contains { $0.url().normalized.string == href.string } } } } diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index 4eded5e87..64fa717be 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -148,7 +148,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { } /// Evaluates the given JavaScript into the resource's HTML page. - func evaluateScript(_ script: String, inHREF href: String? = nil, completion: ((Result) -> Void)? = nil) { + func evaluateScript(_ script: String, inHREF href: AnyURL? = nil, completion: ((Result) -> Void)? = nil) { log(.debug, "Evaluate script: \(script)") webView.evaluateJavaScript(script) { res, error in if let error = error { @@ -257,7 +257,8 @@ class EPUBSpreadView: UIView, Loggable, PageView { guard let selection = body as? [String: Any], - let href = selection["href"] as? String, + let hrefString = selection["href"] as? String, + let href = AnyURL(string: hrefString), let text = try? Locator.Text(json: selection["text"]), var frame = CGRect(json: selection["rect"]) else { @@ -267,7 +268,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { return } - focusedResource = spread.links.first(withHREF: href) + focusedResource = spread.links.firstWithHREF(href) frame.origin = convertPointToNavigatorSpace(frame.origin) delegate?.spreadView(self, selectionDidChange: text, frame: frame) } @@ -281,7 +282,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { // MARK: - Location and progression. /// Current progression in the resource with given href. - func progression(in href: String) -> Double { + func progression(in href: T) -> Double { // To be overridden in subclasses if the resource supports a progression. 0 } @@ -313,7 +314,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { do { let resource = self.spread.leading let locator = try Locator(json: result.get())? - .copy(href: resource.href, type: resource.type ?? MediaType.xhtml.string) + .copy(href: resource.url(), mediaType: resource.mediaType ?? .xhtml) completion(locator) } catch { self.log(.error, error) diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index e120da6ec..78b100241 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -357,6 +357,8 @@ open class PDFNavigatorViewController: UIViewController, VisualNavigator, Select @discardableResult private func go(to locator: Locator, isJump: Bool, completion: @escaping () -> Void = {}) -> Bool { + let locator = publication.normalizeLocator(locator) + guard let link = findLink(at: locator) else { return false } @@ -373,7 +375,7 @@ open class PDFNavigatorViewController: UIViewController, VisualNavigator, Select if isPDFFile { return publication.readingOrder.first } else { - return publication.readingOrder.first(withHREF: locator.href) + return publication.readingOrder.firstWithHREF(locator.href) } } @@ -395,10 +397,8 @@ open class PDFNavigatorViewController: UIViewController, VisualNavigator, Select } if currentResourceIndex != index { - guard - let url = try? link.url(relativeTo: publicationBaseURL), - let document = PDFDocument(url: url.url) - else { + let url = link.url(relativeTo: publicationBaseURL) + guard let document = PDFDocument(url: url.url) else { log(.error, "Can't open PDF document at \(link)") return false } @@ -453,7 +453,7 @@ open class PDFNavigatorViewController: UIViewController, VisualNavigator, Select if publication.readingOrder.count > 1, - let index = publication.readingOrder.firstIndex(withHREF: locator.href), + let index = publication.readingOrder.firstIndexWithHREF(locator.href), let firstPosition = publication.positionsByReadingOrder[index].first?.locations.position { position = position - firstPosition + 1 diff --git a/Sources/OPDS/OPDS1Parser.swift b/Sources/OPDS/OPDS1Parser.swift index 2fa31eca8..35db096e1 100644 --- a/Sources/OPDS/OPDS1Parser.swift +++ b/Sources/OPDS/OPDS1Parser.swift @@ -156,7 +156,7 @@ public class OPDS1Parser: Loggable { let newLink = Link( href: absoluteHref, - type: link.attr("type"), + mediaType: link.attr("type").flatMap { MediaType($0) }, title: entry.firstChild(tag: "title")?.stringValue, rel: link.attr("rel").map { LinkRelation($0) }, properties: .init(properties) @@ -196,7 +196,7 @@ public class OPDS1Parser: Loggable { let newLink = Link( href: absoluteHref, - type: link.attributes["type"], + mediaType: link.attributes["type"].flatMap { MediaType($0) }, title: link.attributes["title"], rels: rels, properties: .init(properties) @@ -228,7 +228,7 @@ public class OPDS1Parser: Loggable { /// Fetch an Open Search template from an OPDS feed. /// - parameter feed: The OPDS feed public static func fetchOpenSearchTemplate(feed: Feed, completion: @escaping (String?, Error?) -> Void) { - guard let openSearchHref = feed.links.first(withRel: .search)?.href, + guard let openSearchHref = feed.links.firstWithRel(.search)?.href, let openSearchURL = URL(string: openSearchHref) else { completion(nil, OPDSParserOpenSearchHelperError.searchLinkNotFound) @@ -256,8 +256,8 @@ public class OPDS1Parser: Loggable { // We match by mimetype and profile; if that fails, by mimetype; and if that fails, the first url is returned var typeAndProfileMatch: Fuzi.XMLElement? = nil var typeMatch: Fuzi.XMLElement? = nil - if let selfMimeType = feed.links.first(withRel: .self)?.type { - let selfMimeParams = parseMimeType(mimeTypeString: selfMimeType) + if let selfMimeType = feed.links.firstWithRel(.self)?.mediaType { + let selfMimeParams = parseMimeType(mimeTypeString: selfMimeType.string) for url in urls { guard let urlMimeType = url.attributes["type"] else { continue @@ -365,7 +365,7 @@ public class OPDS1Parser: Loggable { let link = Link( href: absoluteHref, - type: linkElement.attributes["type"], + mediaType: linkElement.attributes["type"].flatMap { MediaType($0) }, title: linkElement.attributes["title"], rel: linkElement.attributes["rel"].map { LinkRelation($0) }, properties: .init(properties) diff --git a/Sources/Shared/Fetcher/ArchiveFetcher.swift b/Sources/Shared/Fetcher/ArchiveFetcher.swift index c3db2642d..df7afedb8 100644 --- a/Sources/Shared/Fetcher/ArchiveFetcher.swift +++ b/Sources/Shared/Fetcher/ArchiveFetcher.swift @@ -21,14 +21,14 @@ public final class ArchiveFetcher: Fetcher, Loggable { } return Link( href: url.string, - type: MediaType.of(fileExtension: url.pathExtension)?.string, + mediaType: MediaType.of(fileExtension: url.pathExtension), properties: Properties(entry.linkProperties) ) } public func get(_ link: Link) -> Resource { guard - let path = try? link.url().relativeURL?.path, + let path = link.url().relativeURL?.path, let entry = findEntry(at: path), let reader = archive.readEntry(at: entry.path) else { diff --git a/Sources/Shared/Fetcher/FileFetcher.swift b/Sources/Shared/Fetcher/FileFetcher.swift index fc0d7b5cb..40eba4f50 100644 --- a/Sources/Shared/Fetcher/FileFetcher.swift +++ b/Sources/Shared/Fetcher/FileFetcher.swift @@ -23,9 +23,9 @@ public final class FileFetcher: Fetcher, Loggable { } public func get(_ link: Link) -> Resource { - if let linkHREF = try? link.url().relativeURL { + if let linkHREF = link.url().relativeURL { for (href, url) in paths { - if linkHREF == href { + if linkHREF.isEquivalentTo(href) { return FileResource(link: link, file: url) } else if let relativeHREF = href.relativize(linkHREF)?.path { @@ -67,7 +67,7 @@ public final class FileFetcher: Fetcher, Loggable { let subPath = url.standardizedFileURL.path.removingPrefix(path.path) return Link( href: href.appendingPath(subPath, isDirectory: false).string, - type: FileURL(url: url).flatMap { MediaType.of($0)?.string } + mediaType: FileURL(url: url).flatMap { MediaType.of($0) } ) } } diff --git a/Sources/Shared/Fetcher/HTTPFetcher.swift b/Sources/Shared/Fetcher/HTTPFetcher.swift index 0410c5117..45050682e 100644 --- a/Sources/Shared/Fetcher/HTTPFetcher.swift +++ b/Sources/Shared/Fetcher/HTTPFetcher.swift @@ -21,7 +21,7 @@ public final class HTTPFetcher: Fetcher, Loggable { public let links: [Link] = [] public func get(_ link: Link) -> Resource { - guard let url = try? link.url(relativeTo: baseURL).httpURL else { + guard let url = link.url(relativeTo: baseURL).httpURL else { log(.error, "Not a valid HTTP URL: \(link.href)") return FailureResource(link: link, error: .badRequest(HTTPError(kind: .malformedRequest(url: link.href)))) } diff --git a/Sources/Shared/Fetcher/Resource/Resource.swift b/Sources/Shared/Fetcher/Resource/Resource.swift index a2599035d..fad95576b 100644 --- a/Sources/Shared/Fetcher/Resource/Resource.swift +++ b/Sources/Shared/Fetcher/Resource/Resource.swift @@ -100,7 +100,7 @@ public extension Resource { /// falls back on UTF-8. func readAsString(encoding: String.Encoding? = nil) -> ResourceResult { read().map { - let encoding = encoding ?? link.mediaType.encoding ?? .utf8 + let encoding = encoding ?? link.mediaType?.encoding ?? .utf8 return String(data: $0, encoding: encoding) ?? "" } } diff --git a/Sources/Shared/Fetcher/Resource/ResourceContentExtractor.swift b/Sources/Shared/Fetcher/Resource/ResourceContentExtractor.swift index efa7c32a4..cfa2c8418 100644 --- a/Sources/Shared/Fetcher/Resource/ResourceContentExtractor.swift +++ b/Sources/Shared/Fetcher/Resource/ResourceContentExtractor.swift @@ -37,7 +37,7 @@ public class _DefaultResourceContentExtractorFactory: _ResourceContentExtractorF public func makeExtractor(for resource: Resource) -> _ResourceContentExtractor? { switch resource.link.mediaType { - case .html, .xhtml: + case MediaType.html, MediaType.xhtml: return _HTMLResourceContentExtractor() default: return nil diff --git a/Sources/Shared/Fetcher/Resource/TransformingResource.swift b/Sources/Shared/Fetcher/Resource/TransformingResource.swift index 55c1d2fb1..c71cab42a 100644 --- a/Sources/Shared/Fetcher/Resource/TransformingResource.swift +++ b/Sources/Shared/Fetcher/Resource/TransformingResource.swift @@ -51,7 +51,7 @@ public extension Resource { } func mapAsString(encoding: String.Encoding? = nil, transform: @escaping (String) -> String) -> Resource { - let encoding = encoding ?? link.mediaType.encoding ?? .utf8 + let encoding = encoding ?? link.mediaType?.encoding ?? .utf8 return TransformingResource(self) { $0.map { data in let string = String(data: data, encoding: encoding) ?? "" diff --git a/Sources/Shared/OPDS/Feed.swift b/Sources/Shared/OPDS/Feed.swift index a694c9907..06daac0fb 100644 --- a/Sources/Shared/OPDS/Feed.swift +++ b/Sources/Shared/OPDS/Feed.swift @@ -22,6 +22,6 @@ public class Feed { /// /// - Returns: The HREF value of the search link internal func getSearchLinkHref() -> String? { - links.first(withRel: .search)?.href + links.firstWithRel(.search)?.href } } diff --git a/Sources/Shared/Publication/Asset/FileAsset.swift b/Sources/Shared/Publication/Asset/FileAsset.swift index 9bdc249be..a7afb26ee 100644 --- a/Sources/Shared/Publication/Asset/FileAsset.swift +++ b/Sources/Shared/Publication/Asset/FileAsset.swift @@ -7,7 +7,7 @@ import Foundation /// Represents a publication stored as a file on the local file system. -public final class FileAsset: PublicationAsset, Loggable, Sendable { +public final class FileAsset: PublicationAsset, Loggable { /// File URL on the file system. public let file: FileURL diff --git a/Sources/Shared/Publication/HREFNormalizer.swift b/Sources/Shared/Publication/HREFNormalizer.swift index d1ffad3ec..64700cbc4 100644 --- a/Sources/Shared/Publication/HREFNormalizer.swift +++ b/Sources/Shared/Publication/HREFNormalizer.swift @@ -9,7 +9,7 @@ import Foundation public extension Manifest { /// Resolves the HREFs in the ``Manifest`` to the link with `rel="self"`. mutating func normalizeHREFsToSelf() throws { - guard let base = try link(withRel: .self)?.url() else { + guard let base = linkWithRel(.self)?.url() else { return } @@ -45,6 +45,6 @@ private struct HREFNormalizer: ManifestTransformer, Loggable { return } - link.href = try link.url(relativeTo: baseURL).string + link.href = link.url(relativeTo: baseURL).string } } diff --git a/Sources/Shared/Publication/Link.swift b/Sources/Shared/Publication/Link.swift index 227eb3e57..9111c84c3 100644 --- a/Sources/Shared/Publication/Link.swift +++ b/Sources/Shared/Publication/Link.swift @@ -19,8 +19,8 @@ public struct Link: JSONEquatable, Hashable, Sendable { /// Note: a String because templates are lost with URL. public var href: String // URI - /// MIME type of the linked resource. - public var type: String? + /// Media type of the linked resource. + public var mediaType: MediaType? /// Indicates that a URI template is used in href. public var templated: Bool @@ -57,7 +57,7 @@ public struct Link: JSONEquatable, Hashable, Sendable { public init( href: String, - type: String? = nil, + mediaType: MediaType? = nil, templated: Bool = false, title: String? = nil, rels: [LinkRelation] = [], @@ -77,7 +77,7 @@ public struct Link: JSONEquatable, Hashable, Sendable { rels.append(rel) } self.href = href - self.type = type + self.mediaType = mediaType self.templated = templated self.title = title self.rels = rels @@ -117,7 +117,7 @@ public struct Link: JSONEquatable, Hashable, Sendable { self.init( href: href, - type: jsonObject["type"] as? String, + mediaType: (jsonObject["type"] as? String).flatMap { MediaType($0) }, templated: templated, title: jsonObject["title"] as? String, rels: .init(json: jsonObject["rel"]), @@ -135,7 +135,7 @@ public struct Link: JSONEquatable, Hashable, Sendable { public var json: JSONDictionary.Wrapped { makeJSON([ "href": href, - "type": encodeIfNotNil(type), + "type": encodeIfNotNil(mediaType?.string), "templated": templated, "title": encodeIfNotNil(title), "rel": encodeIfNotEmpty(rels.json), @@ -150,15 +150,8 @@ public struct Link: JSONEquatable, Hashable, Sendable { ]) } - /// Media type of the linked resource. - public var mediaType: MediaType { - MediaType.of( - mediaType: type, - fileExtension: href - .components(separatedBy: ".") - .last - ) ?? .binary - } + @available(*, unavailable, renamed: "mediaType") + public var type: String? { mediaType?.string } /// Returns the URL represented by this link's HREF. /// @@ -166,7 +159,7 @@ public struct Link: JSONEquatable, Hashable, Sendable { /// according to RFC 6570. public func url( parameters: [String: LosslessStringConvertible] = [:] - ) throws -> AnyURL { + ) -> AnyURL { var href = href if templated { href = URITemplate(href).expand(with: parameters) @@ -174,11 +167,7 @@ public struct Link: JSONEquatable, Hashable, Sendable { if href.isEmpty { href = "#" } - - guard let url = AnyURL(string: href) else { - throw LinkError.invalidHREF(href) - } - return url + return (AnyURL(string: href) ?? AnyURL(legacyHREF: href))! } /// Returns the URL represented by this link's HREF, resolved to the given @@ -189,8 +178,8 @@ public struct Link: JSONEquatable, Hashable, Sendable { public func url( relativeTo baseURL: T?, parameters: [String: LosslessStringConvertible] = [:] - ) throws -> AnyURL { - let url = try url(parameters: parameters) + ) -> AnyURL { + let url = url(parameters: parameters) return baseURL?.anyURL.resolve(url) ?? url } @@ -221,7 +210,7 @@ public struct Link: JSONEquatable, Hashable, Sendable { /// Makes a copy of the `Link`, after modifying some of its properties. public func copy( href: String? = nil, - type: String?? = nil, + mediaType: MediaType?? = nil, templated: Bool? = nil, title: String?? = nil, rels: [LinkRelation]? = nil, @@ -236,7 +225,7 @@ public struct Link: JSONEquatable, Hashable, Sendable { ) -> Link { Link( href: href ?? self.href, - type: type ?? self.type, + mediaType: mediaType ?? self.mediaType, templated: templated ?? self.templated, title: title ?? self.title, rels: rels ?? self.rels, @@ -286,71 +275,73 @@ public extension Array where Element == Link { } /// Finds the first link with the given relation. - func first(withRel rel: LinkRelation) -> Link? { + func firstWithRel(_ rel: LinkRelation) -> Link? { first { $0.rels.contains(rel) } } /// Finds all the links with the given relation. - func filter(byRel rel: LinkRelation) -> [Link] { + func filterByRel(_ rel: LinkRelation) -> [Link] { filter { $0.rels.contains(rel) } } /// Finds the first link matching the given HREF. - func first(withHREF href: String) -> Link? { - first { $0.href == href } + func firstWithHREF(_ href: T) -> Link? { + let href = href.anyURL.normalized.string + return first { $0.url().normalized.string == href } } /// Finds the index of the first link matching the given HREF. - func firstIndex(withHREF href: String) -> Int? { - firstIndex { $0.href == href } + func firstIndexWithHREF(_ href: T) -> Int? { + let href = href.anyURL.normalized.string + return firstIndex { $0.url().normalized.string == href } } /// Finds the first link matching the given media type. - func first(withMediaType mediaType: MediaType) -> Link? { - first { mediaType.matches($0.type) } + func firstWithMediaType(_ mediaType: MediaType) -> Link? { + first { mediaType.matches($0.mediaType) } } /// Finds all the links matching the given media type. - func filter(byMediaType mediaType: MediaType) -> [Link] { - filter { mediaType.matches($0.type) } + func filterByMediaType(_ mediaType: MediaType) -> [Link] { + filter { mediaType.matches($0.mediaType) } } /// Finds all the links matching any of the given media types. - func filter(byMediaTypes mediaTypes: [MediaType]) -> [Link] { + func filterByMediaTypes(_ mediaTypes: [MediaType]) -> [Link] { filter { link in mediaTypes.contains { mediaType in - mediaType.matches(link.type) + mediaType.matches(link.mediaType) } } } /// Returns whether all the resources in the collection are bitmaps. var allAreBitmap: Bool { - allSatisfy(\.mediaType.isBitmap) + allSatisfy { $0.mediaType?.isBitmap == true } } /// Returns whether all the resources in the collection are audio clips. var allAreAudio: Bool { - allSatisfy(\.mediaType.isAudio) + allSatisfy { $0.mediaType?.isAudio == true } } /// Returns whether all the resources in the collection are video clips. var allAreVideo: Bool { - allSatisfy(\.mediaType.isVideo) + allSatisfy { $0.mediaType?.isVideo == true } } /// Returns whether all the resources in the collection are HTML documents. var allAreHTML: Bool { - allSatisfy(\.mediaType.isHTML) + allSatisfy { $0.mediaType?.isHTML == true } } /// Returns whether all the resources in the collection are matching the given media type. - func all(matchMediaType mediaType: MediaType) -> Bool { + func allMatchingMediaType(_ mediaType: MediaType) -> Bool { allSatisfy { mediaType.matches($0.mediaType) } } /// Returns whether all the resources in the collection are matching any of the given media types. - func all(matchMediaTypes mediaTypes: [MediaType]) -> Bool { + func allMatchingMediaTypes(_ mediaTypes: [MediaType]) -> Bool { allSatisfy { link in mediaTypes.contains { mediaType in mediaType.matches(link.mediaType) @@ -363,13 +354,13 @@ public extension Array where Element == Link { firstIndex { ($0.properties.otherProperties[otherProperty] as? T) == matching } } - @available(*, unavailable, renamed: "first(withHREF:)") + @available(*, unavailable, renamed: "firstWithHREF") func first(withHref href: String) -> Link? { - first(withHREF: href) + fatalError() } - @available(*, unavailable, renamed: "firstIndex(withHREF:)") + @available(*, unavailable, renamed: "firstIndexWithHREF") func firstIndex(withHref href: String) -> Int? { - firstIndex(withHREF: href) + fatalError() } } diff --git a/Sources/Shared/Publication/Locator.swift b/Sources/Shared/Publication/Locator.swift index 713dd9e86..8effd408b 100644 --- a/Sources/Shared/Publication/Locator.swift +++ b/Sources/Shared/Publication/Locator.swift @@ -10,10 +10,10 @@ import ReadiumInternal /// https://github.com/readium/architecture/tree/master/locators public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { /// The URI of the resource that the Locator Object points to. - public var href: String // URI + public var href: AnyURL /// The media type of the resource that the Locator Object points to. - public var type: String + public var mediaType: MediaType /// The title of the chapter or section which is more relevant in the context of this locator. public var title: String? @@ -24,36 +24,35 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { /// Textual context of the locator. public var text: Text - public init(href: String, type: String, title: String? = nil, locations: Locations = .init(), text: Text = .init()) { - self.href = href - self.type = type + @available(*, unavailable, renamed: "mediaType") + public var type: String { mediaType.string } + + public init(href: T, mediaType: MediaType, title: String? = nil, locations: Locations = .init(), text: Text = .init()) { + self.href = href.anyURL + self.mediaType = mediaType self.title = title self.locations = locations self.text = text } public init?(json: Any?, warnings: WarningLogger? = nil) throws { - if json == nil { - return nil - } - guard let jsonObject = json as? JSONDictionary.Wrapped, - let href = jsonObject["href"] as? String, - let type = jsonObject["type"] as? String - else { - warnings?.log("`href` and `type` required", model: Self.self, source: json) - throw JSONError.parsing(Self.self) - } - - try self.init( - href: href, - type: type, - title: jsonObject["title"] as? String, - locations: Locations(json: jsonObject["locations"], warnings: warnings), - text: Text(json: jsonObject["text"], warnings: warnings) - ) + try self.init(json: json, warnings: warnings, legacyHREF: false) } public init?(jsonString: String, warnings: WarningLogger? = nil) throws { + try self.init(jsonString: jsonString, warnings: warnings, legacyHREF: false) + } + + /// Creates a ``Locator`` from its legacy JSON representation. + /// + /// Only use this API when you are upgrading to Readium 3.x and migrating + /// the ``Locator`` objects stored in your database. See the migration guide + /// for more information. + public init?(legacyJSONString: String, warnings: WarningLogger? = nil) throws { + try self.init(jsonString: legacyJSONString, warnings: warnings, legacyHREF: true) + } + + private init?(jsonString: String, warnings: WarningLogger?, legacyHREF: Bool) throws { let json: Any do { json = try JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!) @@ -62,21 +61,38 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { throw JSONError.parsing(Self.self) } - try self.init(json: json, warnings: warnings) + try self.init(json: json, warnings: warnings, legacyHREF: legacyHREF) } - /// Creates a ``Locator`` from its legacy JSON representation. - /// - /// Only use this API when you are upgrading to Readium 3.x and migrating - /// the ``Locator`` objects stored in your database. See the migration guide - /// for more information. - public init?(legacyJSONString: String, warnings: WarningLogger? = nil) throws { - try self.init(jsonString: legacyJSONString, warnings: warnings) - - guard let url = AnyURL(legacyHREF: href) else { + private init?(json: Any?, warnings: WarningLogger?, legacyHREF: Bool) throws { + if json == nil { return nil } - href = url.string + guard let jsonObject = json as? JSONDictionary.Wrapped, + let hrefString = jsonObject["href"] as? String, + let typeString = jsonObject["type"] as? String + else { + warnings?.log("`href` and `type` required", model: Self.self, source: json) + throw JSONError.parsing(Self.self) + } + + guard let type = MediaType(typeString) else { + warnings?.log("`type` is not a valid media type", model: Self.self, source: json) + throw JSONError.parsing(Self.self) + } + + guard let href = legacyHREF ? AnyURL(legacyHREF: hrefString) : AnyURL(string: hrefString) else { + warnings?.log("`href` is not a valid URL", model: Self.self, source: json) + throw JSONError.parsing(Self.self) + } + + try self.init( + href: href, + mediaType: type, + title: jsonObject["title"] as? String, + locations: Locations(json: jsonObject["locations"], warnings: warnings), + text: Text(json: jsonObject["text"], warnings: warnings) + ) } @available(*, unavailable, message: "This may create an incorrect `Locator` if the link `type` is missing. Use `publication.locate(Link)` instead.") @@ -84,8 +100,8 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { public var json: JSONDictionary.Wrapped { makeJSON([ - "href": href, - "type": type, + "href": href.string, + "type": mediaType.string, "title": encodeIfNotNil(title), "locations": encodeIfNotEmpty(locations.json), "text": encodeIfNotEmpty(text.json), @@ -101,20 +117,43 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { } /// Makes a copy of the `Locator`, after modifying some of its components. - public func copy(href: String? = nil, type: String? = nil, title: String?? = nil, locations transformLocations: ((inout Locations) -> Void)? = nil, text transformText: ((inout Text) -> Void)? = nil) -> Locator { + public func copy( + href: AnyURL? = nil, + mediaType: MediaType? = nil, + title: String?? = nil, + locations transformLocations: ((inout Locations) -> Void)? = nil, + text transformText: ((inout Text) -> Void)? = nil + ) -> Locator { var locations = locations var text = text transformLocations?(&locations) transformText?(&text) return Locator( href: href ?? self.href, - type: type ?? self.type, + mediaType: mediaType ?? self.mediaType, title: title ?? self.title, locations: locations, text: text ) } + /// Makes a copy of the `Locator`, after modifying some of its components. + public func copy( + href: T?, + mediaType: MediaType? = nil, + title: String?? = nil, + locations: ((inout Locations) -> Void)? = nil, + text: ((inout Text) -> Void)? = nil + ) -> Locator { + copy( + href: href?.anyURL, + mediaType: mediaType, + title: title, + locations: locations, + text: text + ) + } + /// One or more alternative expressions of the location. /// https://github.com/readium/architecture/tree/master/models/locators#the-location-object /// diff --git a/Sources/Shared/Publication/Manifest.swift b/Sources/Shared/Publication/Manifest.swift index c9f00c02d..8821eccb9 100644 --- a/Sources/Shared/Publication/Manifest.swift +++ b/Sources/Shared/Publication/Manifest.swift @@ -77,9 +77,9 @@ public struct Manifest: JSONEquatable, Hashable, Sendable { // `readingOrder` used to be `spine`, so we parse `spine` as a fallback. readingOrder = [Link](json: json.pop("readingOrder") ?? json.pop("spine"), warnings: warnings) - .filter { $0.type != nil } + .filter { $0.mediaType != nil } resources = [Link](json: json.pop("resources"), warnings: warnings) - .filter { $0.type != nil } + .filter { $0.mediaType != nil } // Parses sub-collections from remaining JSON properties. subcollections = PublicationCollection.makeCollections(json: json.json, warnings: warnings) @@ -112,7 +112,7 @@ public struct Manifest: JSONEquatable, Hashable, Sendable { // it could be a regular Web Publication. return readingOrder.allAreHTML && metadata.conformsTo.contains(.epub) case .pdf: - return readingOrder.all(matchMediaType: .pdf) + return readingOrder.allMatchingMediaType(.pdf) default: break } @@ -121,13 +121,13 @@ public struct Manifest: JSONEquatable, Hashable, Sendable { } /// Finds the first Link having the given `href` in the manifest's links. - public func link(withHREF href: String) -> Link? { - func deepFind(in linkLists: [Link]...) -> Link? { + public func linkWithHREF(_ href: T) -> Link? { + func deepFind(href: AnyURL, in linkLists: [[Link]]) -> Link? { for links in linkLists { for link in links { - if link.href == href { + if link.url().normalized.string == href.string { return link - } else if let child = deepFind(in: link.alternates, link.children) { + } else if let child = deepFind(href: href, in: [link.alternates, link.children]) { return child } } @@ -136,29 +136,38 @@ public struct Manifest: JSONEquatable, Hashable, Sendable { return nil } - var link = deepFind(in: readingOrder, resources, links) - if - link == nil, - let shortHREF = href.components(separatedBy: .init(charactersIn: "#?")).first, - shortHREF != href - { - // Tries again, but without the anchor and query parameters. - link = self.link(withHREF: shortHREF) - } + let href = href.anyURL.normalized + let links = [readingOrder, resources, links] - return link + return deepFind(href: href, in: links) + ?? deepFind(href: href.removingQuery().removingFragment(), in: links) } /// Finds the first link with the given relation in the manifest's links. - public func link(withRel rel: LinkRelation) -> Link? { - readingOrder.first(withRel: rel) - ?? resources.first(withRel: rel) - ?? links.first(withRel: rel) + public func linkWithRel(_ rel: LinkRelation) -> Link? { + readingOrder.firstWithRel(rel) + ?? resources.firstWithRel(rel) + ?? links.firstWithRel(rel) } /// Finds all the links with the given relation in the manifest's links. + public func linksWithRel(_ rel: LinkRelation) -> [Link] { + (readingOrder + resources + links).filterByRel(rel) + } + + @available(*, unavailable, renamed: "linkWithHREF") + public func link(withHREF href: String) -> Link? { + fatalError() + } + + @available(*, unavailable, renamed: "linkWithRel") + public func link(withRel rel: LinkRelation) -> Link? { + fatalError() + } + + @available(*, unavailable, renamed: "linksWithRel") public func links(withRel rel: LinkRelation) -> [Link] { - (readingOrder + resources + links).filter(byRel: rel) + fatalError() } /// Makes a copy of the `Manifest`, after modifying some of its properties. diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index 44ccd7547..a1f4234f7 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -78,24 +78,39 @@ public class Publication: Loggable { /// /// e.g. https://provider.com/pub1293/manifest.json gives https://provider.com/pub1293/ public var baseURL: HTTPURL? { - links.first(withRel: .`self`) + links.firstWithRel(.`self`) .takeIf { !$0.templated } - .flatMap { HTTPURL(string: $0.href) } + .flatMap { HTTPURL(string: $0.href)?.removingLastPathSegment() } } /// Finds the first Link having the given `href` in the publication's links. - public func link(withHREF href: String) -> Link? { - manifest.link(withHREF: href) + public func linkWithHREF(_ href: T) -> Link? { + manifest.linkWithHREF(href) } /// Finds the first link with the given relation in the publication's links. - public func link(withRel rel: LinkRelation) -> Link? { - manifest.link(withRel: rel) + public func linkWithRel(_ rel: LinkRelation) -> Link? { + manifest.linkWithRel(rel) } /// Finds all the links with the given relation in the publication's links. + public func linksWithRel(_ rel: LinkRelation) -> [Link] { + manifest.linksWithRel(rel) + } + + @available(*, unavailable, renamed: "linkWithHREF") + public func link(withHREF href: String) -> Link? { + fatalError() + } + + @available(*, unavailable, renamed: "linkWithRel") + public func link(withRel rel: LinkRelation) -> Link? { + fatalError() + } + + @available(*, unavailable, renamed: "linksWithRel") public func links(withRel rel: LinkRelation) -> [Link] { - manifest.links(withRel: rel) + fatalError() } /// Returns the resource targeted by the given `link`. @@ -107,10 +122,10 @@ public class Publication: Loggable { } /// Returns the resource targeted by the given `href`. - public func get(_ href: String) -> Resource { - var link = link(withHREF: href) ?? Link(href: href) + public func get(_ href: T) -> Resource { + var link = linkWithHREF(href) ?? Link(href: href.anyURL.string) // Uses the original href to keep the query parameters - link.href = href + link.href = href.anyURL.string link.templated = false return get(link) } @@ -137,6 +152,32 @@ public class Publication: Loggable { @available(*, unavailable, message: "Not used anymore") public func setSelfLink(href: String?) { fatalError() } + /// Historically, we used to have "absolute" HREFs in the manifest: + /// - starting with a `/` for packaged publications. + /// - resolved to the `self` link for remote publications. + /// + /// We removed the normalization and now use relative HREFs everywhere, but + /// we still need to support the locators created with the old absolute + /// HREFs. + public func normalizeLocator(_ locator: Locator) -> Locator { + var locator = locator + + if let baseURL = baseURL { // Remote publication + // Check that the locator HREF relative to `baseURL` exists in the manifest. + if let relativeHREF = baseURL.relativize(locator.href) { + locator.href = linkWithHREF(relativeHREF)?.url() + ?? relativeHREF.anyURL + } + + } else { // Packaged publication + if let href = AnyURL(string: locator.href.string.removingPrefix("/")) { + locator.href = href + } + } + + return locator + } + /// Represents a Readium Web Publication Profile a `Publication` can conform to. /// /// For a list of supported profiles, see the registry: diff --git a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift index 594916f28..c08be69ea 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift @@ -28,7 +28,7 @@ public class HTMLResourceContentIterator: ContentIterator { resource: Resource, locator: Locator ) -> ContentIterator? { - guard resource.link.mediaType.isHTML else { + guard resource.link.mediaType?.isHTML == true else { return nil } @@ -158,7 +158,7 @@ public class HTMLResourceContentIterator: ContentIterator { init(baseLocator: Locator, startElement: Element?, beforeMaxLength: Int) { self.baseLocator = baseLocator - baseHREF = AnyURL(string: baseLocator.href) + baseHREF = baseLocator.href self.startElement = startElement self.beforeMaxLength = beforeMaxLength } @@ -257,7 +257,12 @@ public class HTMLResourceContentIterator: ContentIterator { let sources = try node.select("source") .compactMap { source in try source.srcRelativeToHREF(baseHREF).map { href in - try Link(href: href.string, type: source.attr("type").takeUnlessBlank()) + try Link( + href: href.string, + mediaType: source.attr("type") + .takeUnlessBlank() + .flatMap { MediaType($0) } + ) } } diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift index 85f1faeac..df839fd2e 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift @@ -86,7 +86,7 @@ public class PublicationContentIterator: ContentIterator, Loggable { /// Returns the first iterator starting at `startLocator` or the beginning of the publication. private func initialIterator() -> IndexedIterator? { - let index = startLocator.flatMap { publication.readingOrder.firstIndex(withHREF: $0.href) } ?? 0 + let index = startLocator.flatMap { publication.readingOrder.firstIndexWithHREF($0.href) } ?? 0 let location = startLocator.orProgression(0.0) return loadIterator(at: index, location: location) diff --git a/Sources/Shared/Publication/Services/Cover/CoverService.swift b/Sources/Shared/Publication/Services/Cover/CoverService.swift index 0de756aa3..376581faf 100644 --- a/Sources/Shared/Publication/Services/Cover/CoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/CoverService.swift @@ -63,7 +63,7 @@ public extension Publication { /// Extracts the first valid cover from the manifest links with `cover` relation. private func coverFromManifest() -> UIImage? { - for link in links(withRel: .cover) { + for link in linksWithRel(.cover) { if let cover = try? get(link).read().map(UIImage.init).get() { return cover } diff --git a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift index d3b9a4432..b9143b202 100644 --- a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift @@ -26,7 +26,7 @@ public final class GeneratedCoverService: CoverService { private let coverLink = Link( href: "/~readium/cover", - type: "image/png", + mediaType: .png, rel: .cover ) diff --git a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift index 8202b9e29..0e3684503 100644 --- a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift @@ -24,7 +24,7 @@ open class DefaultLocatorService: LocatorService, Loggable { return nil } - if publication.link(withHREF: locator.href) != nil { + if publication.linkWithHREF(locator.href) != nil { return locator } @@ -39,20 +39,20 @@ open class DefaultLocatorService: LocatorService, Loggable { } open func locate(_ link: Link) -> Locator? { - let components = link.href.split(separator: "#", maxSplits: 1).map(String.init) - let href = components.first ?? link.href - let fragment = components.getOrNil(1) + let originalHREF = link.url() + let fragment = originalHREF.fragment + let href = originalHREF.removingFragment() guard - let resourceLink = publication()?.link(withHREF: href), - let type = resourceLink.type + let resourceLink = publication()?.linkWithHREF(href), + let type = resourceLink.mediaType else { return nil } return Locator( href: href, - type: type, + mediaType: type, title: resourceLink.title ?? link.title, locations: Locator.Locations( fragments: Array(ofNotNil: fragment), diff --git a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift index ebb9e3f00..992b219d7 100644 --- a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift @@ -12,9 +12,9 @@ public final class PerResourcePositionsService: PositionsService { private let readingOrder: [Link] /// Media type that will be used as a fallback if the `Link` doesn't specify any. - private let fallbackMediaType: String + private let fallbackMediaType: MediaType - init(readingOrder: [Link], fallbackMediaType: String) { + init(readingOrder: [Link], fallbackMediaType: MediaType) { self.readingOrder = readingOrder self.fallbackMediaType = fallbackMediaType } @@ -24,8 +24,8 @@ public final class PerResourcePositionsService: PositionsService { public lazy var positionsByReadingOrder: [[Locator]] = readingOrder.enumerated().map { index, link in [ Locator( - href: link.href, - type: link.type ?? fallbackMediaType, + href: link.url(), + mediaType: link.mediaType ?? fallbackMediaType, title: link.title, locations: Locator.Locations( totalProgression: Double(index) / Double(pageCount), @@ -35,7 +35,7 @@ public final class PerResourcePositionsService: PositionsService { ] } - public static func makeFactory(fallbackMediaType: String) -> (PublicationServiceContext) -> PerResourcePositionsService { + public static func makeFactory(fallbackMediaType: MediaType) -> (PublicationServiceContext) -> PerResourcePositionsService { { context in PerResourcePositionsService(readingOrder: context.manifest.readingOrder, fallbackMediaType: fallbackMediaType) } diff --git a/Sources/Shared/Publication/Services/Positions/PositionsService.swift b/Sources/Shared/Publication/Services/Positions/PositionsService.swift index a92edb74a..c11f5ea49 100644 --- a/Sources/Shared/Publication/Services/Positions/PositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PositionsService.swift @@ -25,7 +25,7 @@ public extension PositionsService { private let positionsLink = Link( href: "/~readium/positions", - type: MediaType.readiumPositions.string + mediaType: MediaType.readiumPositions ) public extension PositionsService { @@ -63,7 +63,7 @@ public extension Publication { } let positionsByResource = Dictionary(grouping: positionsFromManifest(), by: { $0.href }) - return readingOrder.map { positionsByResource[$0.href] ?? [] } + return readingOrder.map { positionsByResource[$0.url()] ?? [] } } /// List of all the positions in the publication. @@ -74,7 +74,7 @@ public extension Publication { /// Fetches the positions from a web service declared in the manifest, if there's one. private func positionsFromManifest() -> [Locator] { - links.first(withMediaType: .readiumPositions) + links.firstWithMediaType(.readiumPositions) .map { get($0) }? .readAsJSON() .map { $0["positions"] } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift index 4f0165849..b5cfe8d88 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift @@ -180,7 +180,7 @@ extension String: HTTPRequestConvertible { extension Link: HTTPRequestConvertible { public func httpRequest() -> HTTPResult { - guard let url = try? url().httpURL else { + guard let url = url().httpURL else { return .failure(HTTPError(kind: .malformedRequest(url: href))) } return url.httpRequest() diff --git a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift index 6cd3fcb21..383bf1784 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift @@ -54,7 +54,7 @@ public extension HTTPServer { return FileResource( link: Link( href: request.url.string, - type: MediaType.of(file)?.string + mediaType: MediaType.of(file) ), file: file ) @@ -91,7 +91,7 @@ public extension HTTPServer { ) } - return publication.get(href.string) + return publication.get(href) } return try serve( diff --git a/Sources/Shared/Toolkit/Media Type/MediaTypeSniffer.swift b/Sources/Shared/Toolkit/Media Type/MediaTypeSniffer.swift index bb9df7485..c99fb88ca 100644 --- a/Sources/Shared/Toolkit/Media Type/MediaTypeSniffer.swift +++ b/Sources/Shared/Toolkit/Media Type/MediaTypeSniffer.swift @@ -146,7 +146,7 @@ public extension MediaType { return .opds2Publication } if let rwpm = context.contentAsRWPM { - if rwpm.link(withRel: .`self`)?.type == "application/opds+json" { + if rwpm.linkWithRel(.`self`)?.mediaType?.matches(.opds2) == true { return .opds2 } if rwpm.link(withRelMatching: { $0.hasPrefix("http://opds-spec.org/acquisition") }) != nil { @@ -238,7 +238,7 @@ public extension MediaType { if isLCPProtected, rwpm.conforms(to: .pdf) { return .lcpProtectedPDF } - if rwpm.link(withRel: .`self`)?.type == "application/webpub+json" { + if rwpm.linkWithRel(.`self`)?.mediaType?.matches(.readiumWebPubManifest) == true { return isManifest ? .readiumWebPubManifest : .readiumWebPub } } diff --git a/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift b/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift index a002da15e..1a75cd6c7 100644 --- a/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift +++ b/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift @@ -29,7 +29,7 @@ public struct PDFOutlineNode { public func link(withDocumentHREF documentHREF: String) -> Link { Link( href: "\(documentHREF)#page=\(pageNumber)", - type: MediaType.pdf.string, + mediaType: .pdf, title: title, children: children.links(withDocumentHREF: documentHREF) ) diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift index 0dd0f6200..d28d2ecac 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift @@ -67,14 +67,15 @@ public struct FileURL: AbsoluteURL, Hashable, Sendable { try (url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false } - public func hash(into hasher: inout Hasher) { - hasher.combine(path) - hasher.combine(url.user) - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.path == rhs.path - && lhs.url.user == rhs.url.user + /// Strict URL comparisons can be a source of bug, if the URLs are not + /// normalized. In most cases, you should compare using + /// `isEquivalent()`. + /// + /// To ignore this warning, compare `FileURL.string` instead of + /// `FileURL` itself. + @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") + public static func == (lhs: FileURL, rhs: FileURL) -> Bool { + lhs.string == rhs.string } } diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift index 18273e042..7ba9efffc 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift @@ -36,20 +36,15 @@ public struct HTTPURL: AbsoluteURL, Hashable, Sendable { return o } - public func hash(into hasher: inout Hasher) { - hasher.combine(origin) - hasher.combine(path) - hasher.combine(query) - hasher.combine(fragment) - hasher.combine(url.user) - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.origin == rhs.origin - && lhs.path == rhs.path - && lhs.query == rhs.query - && lhs.fragment == rhs.fragment - && lhs.url.user == rhs.url.user + /// Strict URL comparisons can be a source of bug, if the URLs are not + /// normalized. In most cases, you should compare using + /// `isEquivalent()`. + /// + /// To ignore this warning, compare `HTTPURL.string` instead of + /// `HTTPURL` itself. + @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") + public static func == (lhs: HTTPURL, rhs: HTTPURL) -> Bool { + lhs.string == rhs.string } } diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift index d23175d7c..cadc14e62 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift @@ -24,23 +24,14 @@ struct UnknownAbsoluteURL: AbsoluteURL, Hashable { let scheme: URLScheme let origin: String? = nil - public func hash(into hasher: inout Hasher) { - hasher.combine(scheme) - hasher.combine(host) - hasher.combine(url.port) - hasher.combine(path) - hasher.combine(query) - hasher.combine(fragment) - hasher.combine(url.user) - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.scheme == rhs.scheme - && lhs.host == rhs.host - && lhs.url.port == rhs.url.port - && lhs.path == rhs.path - && lhs.query == rhs.query - && lhs.fragment == rhs.fragment - && lhs.url.user == rhs.url.user + /// Strict URL comparisons can be a source of bug, if the URLs are not + /// normalized. In most cases, you should compare using + /// `isEquivalent()`. + /// + /// To ignore this warning, compare `UnknownAbsoluteURL.string` instead of + /// `UnknownAbsoluteURL` itself. + @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") + public static func == (lhs: UnknownAbsoluteURL, rhs: UnknownAbsoluteURL) -> Bool { + lhs.string == rhs.string } } diff --git a/Sources/Shared/Toolkit/URL/AnyURL.swift b/Sources/Shared/Toolkit/URL/AnyURL.swift index 9523d4f2b..301b7cd44 100644 --- a/Sources/Shared/Toolkit/URL/AnyURL.swift +++ b/Sources/Shared/Toolkit/URL/AnyURL.swift @@ -114,6 +114,13 @@ extension AnyURL: URLConvertible { /// Implements `Hashable` and `Equatable`. extension AnyURL: Hashable { + /// Strict URL comparisons can be a source of bug, if the URLs are not + /// normalized. In most cases, you should compare using + /// `isEquivalent()`. + /// + /// To ignore this warning, compare `AnyURL.string` instead of + /// `AnyURL` itself. + @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") public static func == (lhs: AnyURL, rhs: AnyURL) -> Bool { lhs.string == rhs.string } diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index e37354d45..694da7f98 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -97,16 +97,15 @@ public struct RelativeURL: URLProtocol, Hashable { ) } - public func hash(into hasher: inout Hasher) { - hasher.combine(path) - hasher.combine(query) - hasher.combine(fragment) - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.path == rhs.path - && lhs.query == rhs.query - && lhs.fragment == rhs.fragment + /// Strict URL comparisons can be a source of bug, if the URLs are not + /// normalized. In most cases, you should compare using + /// `isEquivalent()`. + /// + /// To ignore this warning, compare `RelativeURL.string` instead of + /// `RelativeURL` itself. + @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") + public static func == (lhs: RelativeURL, rhs: RelativeURL) -> Bool { + lhs.string == rhs.string } } diff --git a/Sources/Shared/Toolkit/URL/URLProtocol.swift b/Sources/Shared/Toolkit/URL/URLProtocol.swift index c95299bd5..bebec5e84 100644 --- a/Sources/Shared/Toolkit/URL/URLProtocol.swift +++ b/Sources/Shared/Toolkit/URL/URLProtocol.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumInternal /// A type that can represent a URL. -public protocol URLProtocol: CustomStringConvertible, URLConvertible { +public protocol URLProtocol: URLConvertible, Sendable, CustomStringConvertible { /// Creates a new instance of this type from a Foundation ``URL``. init?(url: URL) @@ -27,6 +27,20 @@ public extension URLProtocol { /// Returns the string representation for this URL. var string: String { url.absoluteString } + /// Normalizes the URL using a subset of the RFC-3986 rules. + /// https://datatracker.ietf.org/doc/html/rfc3986#section-6 + var normalized: Self { + Self(url: url.copy { + $0.scheme = $0.scheme?.lowercased() + $0.path = path.normalizedPath + }!)! + } + + /// Returns whether the two URLs are equivalent after normalization. + func isEquivalentTo(_ url: T) -> Bool { + normalized.string == url.anyURL.normalized.string + } + /// Decoded path segments identifying a location. var path: String { // We can't use `url.path`, see https://openradar.appspot.com/28357201 @@ -126,3 +140,43 @@ public extension URLProtocol { public extension URLProtocol { var description: String { string } } + +private extension String { + var normalizedPath: String { + guard !isEmpty else { + return "" + } + + var segments = [String]() + let pathComponents = split(separator: "/", omittingEmptySubsequences: false) + + for component in pathComponents { + let segment = String(component) + if segment == ".." { + if !segments.isEmpty { + // Remove last added directory + segments.removeLast() + } else { + // Add ".." to the beginning + segments.append(segment) + } + } else if segment != "." { + segments.append(segment) + } + } + + return segments.joined(separator: "/") + } +} + +public extension Dictionary where Key: URLProtocol { + /// Returns the value of the first key matching `key` after normalization. + subscript(equivalent key: T) -> Value? { + for (k, v) in self { + if k.isEquivalentTo(key) { + return v + } + } + return nil + } +} diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index 7e7573763..8fcb8f7ac 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -24,7 +24,7 @@ public final class AudioParser: PublicationParser { } let defaultReadingOrder = fetcher.links - .filter { !ignores($0) && $0.mediaType.isAudio } + .filter { !ignores($0) && $0.mediaType?.isAudio == true } .sorted { $0.href.localizedStandardCompare($1.href) == .orderedAscending } guard !defaultReadingOrder.isEmpty else { @@ -59,7 +59,7 @@ public final class AudioParser: PublicationParser { // Checks if the fetcher contains only bitmap-based resources. return !fetcher.links.isEmpty - && fetcher.links.allSatisfy { ignores($0) || $0.mediaType.isAudio } + && fetcher.links.allSatisfy { ignores($0) || $0.mediaType?.isAudio == true } } private func ignores(_ link: Link) -> Bool { diff --git a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift index f70830567..855bf19db 100644 --- a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift +++ b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift @@ -39,8 +39,8 @@ final class AudioLocatorService: DefaultLocatorService { let positionInResource = positionInPublication - resourcePosition return Locator( - href: link.href, - type: link.type ?? MediaType.binary.string, + href: link.url(), + mediaType: link.mediaType ?? .binary, locations: .init( fragments: ["t=\(Int(positionInResource))"], progression: link.duration.map { duration in diff --git a/Sources/Streamer/Parser/EPUB/EPUBParser.swift b/Sources/Streamer/Parser/EPUB/EPUBParser.swift index db0a8b729..31804fb1a 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBParser.swift @@ -102,7 +102,7 @@ public final class EPUBParser: PublicationParser { private func parseNavigationDocument(in fetcher: Fetcher, links: [Link]) -> [String: [PublicationCollection]] { // Get the link in the readingOrder pointing to the Navigation Document. guard - let navLink = links.first(withRel: .contents), + let navLink = links.firstWithRel(.contents), let navURI = RelativeURL(string: navLink.href), let navDocumentData = try? fetcher.readData(at: navURI) else { @@ -137,7 +137,7 @@ public final class EPUBParser: PublicationParser { private func parseNCXDocument(in fetcher: Fetcher, links: [Link]) -> [String: [PublicationCollection]] { // Get the link in the readingOrder pointing to the NCX document. guard - let ncxLink = links.first(withMediaType: .ncx), + let ncxLink = links.firstWithMediaType(.ncx), let ncxURI = RelativeURL(string: ncxLink.href), let ncxDocumentData = try? fetcher.readData(at: ncxURI) else { diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 5b45fcb51..b6f53972b 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -174,7 +174,7 @@ final class OPFParser: Loggable { var properties = parseStringProperties(stringProperties) - if let encryption = encryptions[href]?.json, !encryption.isEmpty { + if let encryption = encryptions[equivalent: href]?.json, !encryption.isEmpty { properties["encrypted"] = encryption } @@ -186,7 +186,7 @@ final class OPFParser: Loggable { return Link( href: href.string, - type: type, + mediaType: type.flatMap { MediaType($0) }, rels: rels, properties: Properties(properties) ) diff --git a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBHTMLInjector.swift b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBHTMLInjector.swift index 11e8c0590..00a8dc766 100644 --- a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBHTMLInjector.swift +++ b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBHTMLInjector.swift @@ -25,7 +25,7 @@ final class EPUBHTMLInjector { // Will be empty when the new Settings API is in use. !userProperties.properties.isEmpty, // We only transform HTML resources. - resource.link.mediaType.isHTML + resource.link.mediaType?.isHTML == true else { return resource } diff --git a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift index eeb4abe52..1d1801549 100644 --- a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift +++ b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift @@ -134,8 +134,8 @@ public final class EPUBPositionsService: PositionsService { private func makeLocator(for link: Link, progression: Double, position: Int) -> Locator { Locator( - href: link.href, - type: link.type ?? MediaType.html.string, + href: link.url(), + mediaType: link.mediaType ?? .html, title: link.title, locations: .init( progression: progression, diff --git a/Sources/Streamer/Parser/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index f98e6d2bc..9bcf3c0c6 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -20,7 +20,7 @@ public final class ImageParser: PublicationParser { } var readingOrder = fetcher.links - .filter { !ignores($0) && $0.mediaType.isBitmap } + .filter { !ignores($0) && $0.mediaType?.isBitmap == true } .sorted { $0.href.localizedStandardCompare($1.href) == .orderedAscending } guard !readingOrder.isEmpty else { @@ -41,7 +41,7 @@ public final class ImageParser: PublicationParser { ), fetcher: fetcher, servicesBuilder: .init( - positions: PerResourcePositionsService.makeFactory(fallbackMediaType: "image/*") + positions: PerResourcePositionsService.makeFactory(fallbackMediaType: MediaType("image/*")!) ) ) } @@ -53,7 +53,7 @@ public final class ImageParser: PublicationParser { // Checks if the fetcher contains only bitmap-based resources. return !fetcher.links.isEmpty - && fetcher.links.allSatisfy { ignores($0) || $0.mediaType.isBitmap } + && fetcher.links.allSatisfy { ignores($0) || $0.mediaType?.isBitmap == true } } private func ignores(_ link: Link) -> Bool { diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index 35a488915..cc02beb2f 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -36,7 +36,7 @@ public final class PDFParser: PublicationParser, Loggable { return nil } - let readingOrder = fetcher.links.filter(byMediaType: .pdf) + let readingOrder = fetcher.links.filterByMediaType(.pdf) guard let firstLink = readingOrder.first else { throw PDFDocumentError.openFailed } diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift index c3472da8c..3fa3e71cc 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift @@ -50,8 +50,8 @@ final class LCPDFPositionsService: PositionsService, PDFPublicationService, Logg let progression = Double(position - 1) / Double(pageCount) let totalProgression = Double(startPosition + position - 1) / Double(totalPageCount) return Locator( - href: link.href, - type: link.type ?? MediaType.pdf.string, + href: link.url(), + mediaType: link.mediaType ?? .pdf, locations: .init( fragments: ["page=\(position)"], progression: progression, diff --git a/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift b/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift index a81a40621..f4abdf82d 100644 --- a/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift @@ -18,8 +18,8 @@ final class PDFPositionsService: PositionsService { (1 ... pageCount).map { position in let progression = Double(position - 1) / Double(pageCount) return Locator( - href: link.href, - type: link.type ?? MediaType.pdf.string, + href: link.url(), + mediaType: link.mediaType ?? .pdf, locations: .init( fragments: ["page=\(position)"], progression: progression, diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index 148795265..448665a50 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -59,7 +59,7 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { // used to read the manifest file. We use an `HTTPFetcher` instead to serve the remote // resources. if !isPackage { - let baseURL = try manifest.link(withRel: .`self`)?.url().httpURL + let baseURL = manifest.linkWithRel(.`self`)?.url().httpURL fetcher = HTTPFetcher(client: httpClient, baseURL: baseURL) } @@ -74,7 +74,7 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { // Checks the requirements from the spec, see. https://readium.org/lcp-specs/drafts/lcpdf guard !manifest.readingOrder.isEmpty, - manifest.readingOrder.all(matchMediaType: .pdf) + manifest.readingOrder.allMatchingMediaType(.pdf) else { throw Error.invalidManifest } @@ -89,7 +89,7 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { $0.setPositionsServiceFactory(EPUBPositionsService.makeFactory(reflowableStrategy: epubReflowablePositionsStrategy)) } else if manifest.conforms(to: .divina) { - $0.setPositionsServiceFactory(PerResourcePositionsService.makeFactory(fallbackMediaType: "image/*")) + $0.setPositionsServiceFactory(PerResourcePositionsService.makeFactory(fallbackMediaType: MediaType("image/*")!)) } else if manifest.conforms(to: .audiobook) { $0.setLocatorServiceFactory(AudioLocatorService.makeFactory()) diff --git a/Sources/Streamer/Toolkit/Extensions/Fetcher.swift b/Sources/Streamer/Toolkit/Extensions/Fetcher.swift index 3360cf744..3c88b7cc6 100644 --- a/Sources/Streamer/Toolkit/Extensions/Fetcher.swift +++ b/Sources/Streamer/Toolkit/Extensions/Fetcher.swift @@ -36,8 +36,8 @@ extension Fetcher { guard !ignoring(link) else { continue } + let components = link.url().pathSegments guard - let components = try? link.url().pathSegments, components.count > 1, title == nil || title == components.first else { diff --git a/TestApp/Sources/App/AppModule.swift b/TestApp/Sources/App/AppModule.swift index db3875b6e..84467aa43 100644 --- a/TestApp/Sources/App/AppModule.swift +++ b/TestApp/Sources/App/AppModule.swift @@ -86,7 +86,7 @@ extension AppModule: ReaderModuleDelegate {} extension AppModule: OPDSModuleDelegate { func opdsDownloadPublication(_ publication: Publication?, at link: Link, sender: UIViewController) async throws -> Book { - guard let url = try link.url(relativeTo: publication?.baseURL).absoluteURL else { + guard let url = link.url(relativeTo: publication?.baseURL).absoluteURL else { throw OPDSError.invalidURL(link.href) } return try await library.importPublication(from: url, sender: sender) diff --git a/TestApp/Sources/Common/Publication.swift b/TestApp/Sources/Common/Publication.swift index 4f9aebd3e..58bd30770 100644 --- a/TestApp/Sources/Common/Publication.swift +++ b/TestApp/Sources/Common/Publication.swift @@ -12,8 +12,8 @@ extension Publication { /// Finds all the downloadable links for this publication. var downloadLinks: [Link] { links.filter { - DocumentTypes.main.supportsMediaType($0.type) - || DocumentTypes.main.supportsFileExtension(try? $0.url().url.pathExtension) + ($0.mediaType.map { DocumentTypes.main.supportsMediaType($0.string) } == true) + || DocumentTypes.main.supportsFileExtension($0.url().pathExtension) } } } diff --git a/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift b/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift index 7d646e0fb..e6720eafb 100644 --- a/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift +++ b/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift @@ -89,7 +89,7 @@ extension OPDSGroupTableViewCell: UICollectionViewDataSource { .joined(separator: ", ") ) - let coverURL: URL? = try? publication.link(withRel: .cover)?.url(relativeTo: publication.baseURL).url + let coverURL: URL? = publication.linkWithRel(.cover)?.url(relativeTo: publication.baseURL).url ?? publication.images.first.flatMap { URL(string: $0.href) } if let coverURL = coverURL { diff --git a/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift b/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift index cef161a58..9b5cbc53b 100644 --- a/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift +++ b/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift @@ -64,7 +64,7 @@ extension OPDSPublicationTableViewCell: UICollectionViewDataSource { .joined(separator: ", ") ) - let coverURL: URL? = try? publication.link(withRel: .cover)?.url(relativeTo: publication.baseURL).url + let coverURL: URL? = publication.linkWithRel(.cover)?.url(relativeTo: publication.baseURL).url ?? publication.images.first.flatMap { URL(string: $0.href) } if let coverURL = coverURL { diff --git a/TestApp/Sources/OPDS/OPDSRootTableViewController.swift b/TestApp/Sources/OPDS/OPDSRootTableViewController.swift index f70f3c83c..027da8027 100644 --- a/TestApp/Sources/OPDS/OPDSRootTableViewController.swift +++ b/TestApp/Sources/OPDS/OPDSRootTableViewController.swift @@ -152,7 +152,7 @@ class OPDSRootTableViewController: UITableViewController { } func findNextPageURL(feed: Feed) -> URL? { - guard let href = feed.links.first(withRel: .next)?.href else { + guard let href = feed.links.firstWithRel(.next)?.href else { return nil } return URL(string: href) diff --git a/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift b/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift index a1f74766c..dbaa665a9 100644 --- a/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift +++ b/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift @@ -9,18 +9,18 @@ import XCTest class PublicationMediaLoaderTests: XCTestCase { func testURLToHREF() { - XCTAssertEqual(URL(string: "readium:relative/file.mp3")!.audioHREF, "relative/file.mp3") - XCTAssertEqual(URL(string: "readium:/absolute/file.mp3")!.audioHREF, "/absolute/file.mp3") - XCTAssertEqual(URL(string: "readiumfile:///directory/file.mp3")!.audioHREF, "file:///directory/file.mp3") - XCTAssertEqual(URL(string: "readiumhttp:///domain.com/file.mp3")!.audioHREF, "http:///domain.com/file.mp3") - XCTAssertEqual(URL(string: "readiumhttps:///domain.com/file.mp3")!.audioHREF, "https:///domain.com/file.mp3") + XCTAssertEqual(URL(string: "readium:relative/file.mp3")!.audioHREF!.string, "relative/file.mp3") + XCTAssertEqual(URL(string: "readium:/absolute/file.mp3")!.audioHREF!.string, "/absolute/file.mp3") + XCTAssertEqual(URL(string: "readiumfile:///directory/file.mp3")!.audioHREF!.string, "file:///directory/file.mp3") + XCTAssertEqual(URL(string: "readiumhttp:///domain.com/file.mp3")!.audioHREF!.string, "http:///domain.com/file.mp3") + XCTAssertEqual(URL(string: "readiumhttps:///domain.com/file.mp3")!.audioHREF!.string, "https:///domain.com/file.mp3") // Encoded characters - XCTAssertEqual(URL(string: "readium:relative/a%20file.mp3")!.audioHREF, "relative/a%20file.mp3") - XCTAssertEqual(URL(string: "readium:/absolute/a%20file.mp3")!.audioHREF, "/absolute/a%20file.mp3") - XCTAssertEqual(URL(string: "readiumfile:///directory/a%20file.mp3")!.audioHREF, "file:///directory/a%20file.mp3") - XCTAssertEqual(URL(string: "readiumhttp:///domain.com/a%20file.mp3")!.audioHREF, "http:///domain.com/a%20file.mp3") - XCTAssertEqual(URL(string: "readiumhttps:///domain.com/a%20file.mp3")!.audioHREF, "https:///domain.com/a%20file.mp3") + XCTAssertEqual(URL(string: "readium:relative/a%20file.mp3")!.audioHREF!.string, "relative/a%20file.mp3") + XCTAssertEqual(URL(string: "readium:/absolute/a%20file.mp3")!.audioHREF!.string, "/absolute/a%20file.mp3") + XCTAssertEqual(URL(string: "readiumfile:///directory/a%20file.mp3")!.audioHREF!.string, "file:///directory/a%20file.mp3") + XCTAssertEqual(URL(string: "readiumhttp:///domain.com/a%20file.mp3")!.audioHREF!.string, "http:///domain.com/a%20file.mp3") + XCTAssertEqual(URL(string: "readiumhttps:///domain.com/a%20file.mp3")!.audioHREF!.string, "https:///domain.com/a%20file.mp3") // Ignores if the r2 prefix is missing. XCTAssertNil(URL(string: "relative/file.mp3")!.audioHREF) diff --git a/Tests/OPDSTests/readium_opds1_1_test.swift b/Tests/OPDSTests/readium_opds1_1_test.swift index 0f9715233..4e56a5998 100644 --- a/Tests/OPDSTests/readium_opds1_1_test.swift +++ b/Tests/OPDSTests/readium_opds1_1_test.swift @@ -53,7 +53,7 @@ class readium_opds1_1_test: XCTestCase { func testLinks() { XCTAssertEqual(feed.links.count, 4) XCTAssertEqual(feed.links[0].rels, ["related"]) - XCTAssertEqual(feed.links[1].type, "application/atom+xml;profile=opds-catalog;kind=acquisition") + XCTAssertEqual(feed.links[1].mediaType, MediaType("application/atom+xml;profile=opds-catalog;kind=acquisition")!) XCTAssertEqual(feed.links[2].href, "http://test.com/opds-catalogs/root.xml") // TODO: add more tests... } diff --git a/Tests/SharedTests/Extensions.swift b/Tests/SharedTests/Extensions.swift new file mode 100644 index 000000000..682dd4186 --- /dev/null +++ b/Tests/SharedTests/Extensions.swift @@ -0,0 +1,14 @@ +// +// 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 ReadiumShared + +extension Locator { + init(href: String, mediaType: MediaType, title: String? = nil, locations: Locations = .init(), text: Text = .init()) { + self.init(href: AnyURL(string: href)!, mediaType: mediaType, title: title, locations: locations, text: text) + } +} diff --git a/Tests/SharedTests/Fetcher/ArchiveFetcherTests.swift b/Tests/SharedTests/Fetcher/ArchiveFetcherTests.swift index a404682aa..a00f746fa 100644 --- a/Tests/SharedTests/Fetcher/ArchiveFetcherTests.swift +++ b/Tests/SharedTests/Fetcher/ArchiveFetcherTests.swift @@ -21,17 +21,17 @@ class ArchiveFetcherTests: XCTestCase { fetcher.links, [ ("mimetype", nil, 20, false), - ("EPUB/cover.xhtml", "text/html", 259, true), - ("EPUB/css/epub.css", "text/css", 595, true), - ("EPUB/css/nav.css", "text/css", 306, true), - ("EPUB/images/cover.png", "image/png", 35809, true), - ("EPUB/nav.xhtml", "text/html", 2293, true), + ("EPUB/cover.xhtml", .html, 259, true), + ("EPUB/css/epub.css", .css, 595, true), + ("EPUB/css/nav.css", .css, 306, true), + ("EPUB/images/cover.png", .png, 35809, true), + ("EPUB/nav.xhtml", .html, 2293, true), ("EPUB/package.opf", nil, 773, true), - ("EPUB/s04.xhtml", "text/html", 118_269, true), + ("EPUB/s04.xhtml", .html, 118_269, true), ("EPUB/toc.ncx", nil, 1697, true), - ("META-INF/container.xml", "application/xml", 176, true), + ("META-INF/container.xml", .xml, 176, true), ].map { href, type, entryLength, isCompressed in - Link(href: href, type: type, properties: .init([ + Link(href: href, mediaType: type, properties: .init([ "https://readium.org/webpub-manifest/properties#archive": [ "entryLength": entryLength, "isEntryCompressed": isCompressed, diff --git a/Tests/SharedTests/Fetcher/FileFetcherTests.swift b/Tests/SharedTests/Fetcher/FileFetcherTests.swift index 848d2351d..89c9b6d2a 100644 --- a/Tests/SharedTests/Fetcher/FileFetcherTests.swift +++ b/Tests/SharedTests/Fetcher/FileFetcherTests.swift @@ -20,10 +20,10 @@ class FileFetcherTests: XCTestCase { func testLinks() { XCTAssertEqual(fetcher.links, [ - Link(href: "dir_href/subdirectory/hello.mp3", type: "audio/mpeg"), - Link(href: "dir_href/subdirectory/text2.txt", type: "text/plain"), - Link(href: "dir_href/text1.txt", type: "text/plain"), - Link(href: "file_href", type: "text/plain"), + Link(href: "dir_href/subdirectory/hello.mp3", mediaType: .mp3), + Link(href: "dir_href/subdirectory/text2.txt", mediaType: .text), + Link(href: "dir_href/text1.txt", mediaType: .text), + Link(href: "file_href", mediaType: .text), ]) } diff --git a/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift b/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift index 7f44b8d15..1156e2b34 100644 --- a/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift +++ b/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift @@ -103,7 +103,7 @@ class PropertiesOPDSTests: XCTestCase { ]]) XCTAssertEqual(sut.authenticate, Link( href: "https://example.com/authentication.json", - type: "application/opds-authentication+json" + mediaType: .opdsAuthentication )) } diff --git a/Tests/SharedTests/Publication/LinkArrayTests.swift b/Tests/SharedTests/Publication/LinkArrayTests.swift index 233012c30..13d0a1896 100644 --- a/Tests/SharedTests/Publication/LinkArrayTests.swift +++ b/Tests/SharedTests/Publication/LinkArrayTests.swift @@ -16,13 +16,13 @@ class LinkArrayTests: XCTestCase { Link(href: "l3", rel: "test"), ] - XCTAssertEqual(links.first(withRel: "test")?.href, "l2") + XCTAssertEqual(links.firstWithRel("test")?.href, "l2") } /// Finds the first `Link` with given `rel` when none is found. func testFirstWithRelNotFound() { let links = [Link(href: "l1", rel: "other")] - XCTAssertNil(links.first(withRel: "strawberry")) + XCTAssertNil(links.firstWithRel("strawberry")) } /// Finds all the `Link` with given `rel`. @@ -34,7 +34,7 @@ class LinkArrayTests: XCTestCase { ] XCTAssertEqual( - links.filter(byRel: "test"), + links.filterByRel("test"), [ Link(href: "l2", rels: ["test", "other"]), Link(href: "l3", rel: "test"), @@ -45,7 +45,7 @@ class LinkArrayTests: XCTestCase { /// Finds all the `Link` with given `rel` when none is found. func testFilterByRelNotFound() { let links = [Link(href: "l1", rel: "other")] - XCTAssertEqual(links.filter(byRel: "strawberry").count, 0) + XCTAssertEqual(links.filterByRel("strawberry").count, 0) } /// Finds the first `Link` with given `href`. @@ -56,13 +56,13 @@ class LinkArrayTests: XCTestCase { Link(href: "l2", rel: "test"), ] - XCTAssertEqual(links.first(withHREF: "l2"), Link(href: "l2")) + XCTAssertEqual(links.firstWithHREF(AnyURL(string: "l2")!), Link(href: "l2")) } /// Finds the first `Link` with given `href` when none is found. func testFirstWithHREFNotFound() { let links = [Link(href: "l1")] - XCTAssertNil(links.first(withHREF: "unknown")) + XCTAssertNil(links.firstWithHREF(AnyURL(string: "unknown")!)) } /// Finds the index of the first `Link` with given `href`. @@ -73,53 +73,53 @@ class LinkArrayTests: XCTestCase { Link(href: "l2", rel: "test"), ] - XCTAssertEqual(links.firstIndex(withHREF: "l2"), 1) + XCTAssertEqual(links.firstIndexWithHREF(AnyURL(string: "l2")!), 1) } /// Finds the index of the first `Link` with given `href` when none is found. func testFirstIndexWithHREFNotFound() { let links = [Link(href: "l1")] - XCTAssertNil(links.firstIndex(withHREF: "unknown")) + XCTAssertNil(links.firstIndexWithHREF(AnyURL(string: "unknown")!)) } /// Finds the first `Link` with a `type` matching the given `mediaType`. func testFirstWithMediaType() { let links = [ - Link(href: "l1", type: "text/css"), - Link(href: "l2", type: "text/html"), - Link(href: "l3", type: "text/html"), + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: .html), + Link(href: "l3", mediaType: .html), ] - XCTAssertEqual(links.first(withMediaType: .html)?.href, "l2") + XCTAssertEqual(links.firstWithMediaType(.html)?.href, "l2") } /// Finds the first `Link` with a `type` matching the given `mediaType`, even if the `type` has /// extra parameters. func testFirstWithMediaTypeWithExtraParameter() { let links = [ - Link(href: "l1", type: "text/html;charset=utf-8"), + Link(href: "l1", mediaType: MediaType("text/html;charset=utf-8")!), ] - XCTAssertEqual(links.first(withMediaType: .html)?.href, "l1") + XCTAssertEqual(links.firstWithMediaType(.html)?.href, "l1") } /// Finds the first `Link` with a `type` matching the given `mediaType`. func testFirstWithMediaTypeNotFound() { - let links = [Link(href: "l1", type: "text/css")] - XCTAssertNil(links.first(withMediaType: .html)) + let links = [Link(href: "l1", mediaType: .css)] + XCTAssertNil(links.firstWithMediaType(.html)) } /// Finds all the `Link` with a `type` matching the given `mediaType`. func testFilterByMediaType() { let links = [ - Link(href: "l1", type: "text/css"), - Link(href: "l2", type: "text/html"), - Link(href: "l3", type: "text/html"), + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: .html), + Link(href: "l3", mediaType: .html), ] - XCTAssertEqual(links.filter(byMediaType: .html), [ - Link(href: "l2", type: "text/html"), - Link(href: "l3", type: "text/html"), + XCTAssertEqual(links.filterByMediaType(.html), [ + Link(href: "l2", mediaType: .html), + Link(href: "l3", mediaType: .html), ]) } @@ -127,42 +127,42 @@ class LinkArrayTests: XCTestCase { /// extra parameters. func testFilterByMediaTypeWithExtraParameter() { let links = [ - Link(href: "l1", type: "text/css"), - Link(href: "l2", type: "text/html"), - Link(href: "l1", type: "text/html;charset=utf-8"), + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: .html), + Link(href: "l1", mediaType: MediaType("text/html;charset=utf-8")!), ] - XCTAssertEqual(links.filter(byMediaType: .html), [ - Link(href: "l2", type: "text/html"), - Link(href: "l1", type: "text/html;charset=utf-8"), + XCTAssertEqual(links.filterByMediaType(.html), [ + Link(href: "l2", mediaType: .html), + Link(href: "l1", mediaType: MediaType("text/html;charset=utf-8")!), ]) } /// Finds all the `Link` with a `type` matching the given `mediaType`, when none is found. func testFilterByMediaTypeNotFound() { - let links = [Link(href: "l1", type: "text/css")] - XCTAssertEqual(links.filter(byMediaType: .html).count, 0) + let links = [Link(href: "l1", mediaType: .css)] + XCTAssertEqual(links.filterByMediaType(.html).count, 0) } /// Finds all the `Link` with a `type` matching any of the given `mediaTypes`. func testFilterByMediaTypes() { let links = [ - Link(href: "l1", type: "text/css"), - Link(href: "l2", type: "text/html;charset=utf-8"), - Link(href: "l3", type: "application/xml"), + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: MediaType("text/html;charset=utf-8")!), + Link(href: "l3", mediaType: .xml), ] - XCTAssertEqual(links.filter(byMediaTypes: [.html, .xml]), [ - Link(href: "l2", type: "text/html;charset=utf-8"), - Link(href: "l3", type: "application/xml"), + XCTAssertEqual(links.filterByMediaTypes([.html, .xml]), [ + Link(href: "l2", mediaType: MediaType("text/html;charset=utf-8")!), + Link(href: "l3", mediaType: .xml), ]) } /// Checks if all the links are bitmaps. func testAllAreBitmap() { let links = [ - Link(href: "l1", type: "image/png"), - Link(href: "l2", type: "image/gif"), + Link(href: "l1", mediaType: .png), + Link(href: "l2", mediaType: .gif), ] XCTAssertTrue(links.allAreBitmap) @@ -171,8 +171,8 @@ class LinkArrayTests: XCTestCase { /// Checks if all the links are bitmaps, when it's not the case. func testAllAreBitmapFalse() { let links = [ - Link(href: "l1", type: "image/png"), - Link(href: "l2", type: "text/css"), + Link(href: "l1", mediaType: .png), + Link(href: "l2", mediaType: .css), ] XCTAssertFalse(links.allAreBitmap) @@ -181,8 +181,8 @@ class LinkArrayTests: XCTestCase { /// Checks if all the links are audio clips. func testAllAreAudio() { let links = [ - Link(href: "l1", type: "audio/mpeg"), - Link(href: "l2", type: "audio/aac"), + Link(href: "l1", mediaType: .mp3), + Link(href: "l2", mediaType: .aac), ] XCTAssertTrue(links.allAreAudio) @@ -191,8 +191,8 @@ class LinkArrayTests: XCTestCase { /// Checks if all the links are audio clips, when it's not the case. func testAllAreAudioFalse() { let links = [ - Link(href: "l1", type: "audio/mpeg"), - Link(href: "l2", type: "text/css"), + Link(href: "l1", mediaType: .mp3), + Link(href: "l2", mediaType: .css), ] XCTAssertFalse(links.allAreAudio) @@ -201,8 +201,8 @@ class LinkArrayTests: XCTestCase { /// Checks if all the links are video clips. func testAllAreVideo() { let links = [ - Link(href: "l1", type: "video/mp4"), - Link(href: "l2", type: "video/webm"), + Link(href: "l1", mediaType: MediaType("video/mp4")!), + Link(href: "l2", mediaType: .webmVideo), ] XCTAssertTrue(links.allAreVideo) @@ -211,8 +211,8 @@ class LinkArrayTests: XCTestCase { /// Checks if all the links are video clips, when it's not the case. func testAllAreVideoFalse() { let links = [ - Link(href: "l1", type: "video/mp4"), - Link(href: "l2", type: "text/css"), + Link(href: "l1", mediaType: .mp4), + Link(href: "l2", mediaType: .css), ] XCTAssertFalse(links.allAreVideo) @@ -221,8 +221,8 @@ class LinkArrayTests: XCTestCase { /// Checks if all the links are HTML documents. func testAllAreHTML() { let links = [ - Link(href: "l1", type: "text/html"), - Link(href: "l2", type: "application/xhtml+xml"), + Link(href: "l1", mediaType: .html), + Link(href: "l2", mediaType: .xhtml), ] XCTAssertTrue(links.allAreHTML) @@ -231,8 +231,8 @@ class LinkArrayTests: XCTestCase { /// Checks if all the links are HTML documents, when it's not the case. func testAllAreHTMLFalse() { let links = [ - Link(href: "l1", type: "text/html"), - Link(href: "l2", type: "text/css"), + Link(href: "l1", mediaType: .html), + Link(href: "l2", mediaType: .css), ] XCTAssertFalse(links.allAreHTML) @@ -241,40 +241,40 @@ class LinkArrayTests: XCTestCase { /// Checks if all the links match the given media type. func testAllMatchesMediaType() { let links = [ - Link(href: "l1", type: "text/css"), - Link(href: "l2", type: "text/css;charset=utf-8"), + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: MediaType("text/css;charset=utf-8")!), ] - XCTAssertTrue(links.all(matchMediaType: .css)) + XCTAssertTrue(links.allMatchingMediaType(.css)) } /// Checks if all the links match the given media type when it's not the case. func testAllMatchesMediaTypeFalse() { let links = [ - Link(href: "l1", type: "text/css"), - Link(href: "l2", type: "text/plain"), + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: .text), ] - XCTAssertFalse(links.all(matchMediaType: .css)) + XCTAssertFalse(links.allMatchingMediaType(.css)) } /// Checks if all the links match any of the given media types. func testAllMatchesMediaTypes() { let links = [ - Link(href: "l1", type: "text/html"), - Link(href: "l2", type: "application/xml"), + Link(href: "l1", mediaType: .html), + Link(href: "l2", mediaType: .xml), ] - XCTAssertTrue(links.all(matchMediaTypes: [.html, .xml])) + XCTAssertTrue(links.allMatchingMediaTypes([.html, .xml])) } /// Checks if all the links match any of the given media types, when it's not the case. func testAllMatchesMediaTypesFalse() { let links = [ - Link(href: "l1", type: "text/css"), - Link(href: "l2", type: "text/html"), + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: .html), ] - XCTAssertFalse(links.all(matchMediaTypes: [.html, .xml])) + XCTAssertFalse(links.allMatchingMediaTypes([.html, .xml])) } } diff --git a/Tests/SharedTests/Publication/LinkTests.swift b/Tests/SharedTests/Publication/LinkTests.swift index 78694b528..361c88e42 100644 --- a/Tests/SharedTests/Publication/LinkTests.swift +++ b/Tests/SharedTests/Publication/LinkTests.swift @@ -10,7 +10,7 @@ import XCTest class LinkTests: XCTestCase { let fullLink = Link( href: "http://href", - type: "application/pdf", + mediaType: .pdf, templated: true, title: "Link Title", rels: [.publication, .cover], @@ -73,7 +73,7 @@ class LinkTests: XCTestCase { func testParseInvalidHREFWithDecodedPathInJSON() throws { let link = try Link(json: ["href": "01_Note de l editeur audio.mp3"]) XCTAssertEqual(link, Link(href: "01_Note%20de%20l%20editeur%20audio.mp3")) - XCTAssertEqual(try link.url(), AnyURL(string: "01_Note%20de%20l%20editeur%20audio.mp3")) + XCTAssertEqual(link.url(), AnyURL(string: "01_Note%20de%20l%20editeur%20audio.mp3")) } func testParseJSONRelAsSingleString() { @@ -222,56 +222,43 @@ class LinkTests: XCTestCase { ) } - func testUnknownMediaType() { - XCTAssertEqual(Link(href: "file").mediaType, .binary) - } - - func testMediaTypeFromType() { - XCTAssertEqual(Link(href: "file", type: "application/epub+zip").mediaType, .epub) - XCTAssertEqual(Link(href: "file", type: "application/pdf").mediaType, .pdf) - } - - func testMediaTypeFromExtension() { - XCTAssertEqual(Link(href: "file.epub").mediaType, .epub) - XCTAssertEqual(Link(href: "file.pdf").mediaType, .pdf) - } - func testURLRelativeToBaseURL() throws { XCTAssertEqual( - try Link(href: "folder/file.html").url(relativeTo: AnyURL(string: "http://host/")!), + Link(href: "folder/file.html").url(relativeTo: AnyURL(string: "http://host/")!), AnyURL(string: "http://host/folder/file.html")! ) } func testURLRelativeToBaseURLWithRootPrefix() throws { XCTAssertEqual( - try Link(href: "file.html").url(relativeTo: AnyURL(string: "http://host/folder/")!), + Link(href: "file.html").url(relativeTo: AnyURL(string: "http://host/folder/")!), AnyURL(string: "http://host/folder/file.html")! ) } func testURLRelativeToNil() throws { XCTAssertEqual( - try Link(href: "http://example.com/folder/file.html").url(), + Link(href: "http://example.com/folder/file.html").url(), AnyURL(string: "http://example.com/folder/file.html")! ) XCTAssertEqual( - try Link(href: "folder/file.html").url(), + Link(href: "folder/file.html").url(), AnyURL(string: "folder/file.html")! ) } func testURLWithAbsoluteHREF() throws { XCTAssertEqual( - try Link(href: "http://test.com/folder/file.html").url(relativeTo: AnyURL(string: "http://host/")!), + Link(href: "http://test.com/folder/file.html").url(relativeTo: AnyURL(string: "http://host/")!), AnyURL(string: "http://test.com/folder/file.html")! ) } func testURLWithInvalidHREF() { - XCTAssertThrowsError(try Link(href: "01_Note de l editeur audio.mp3").url()) { error in - XCTAssertEqual(LinkError.invalidHREF("01_Note de l editeur audio.mp3"), error as? LinkError) - } + XCTAssertEqual( + Link(href: "01_Note de l editeur audio.mp3").url(), + AnyURL(string: "01_Note%20de%20l%20editeur%20audio.mp3") + ) } func testTemplateParameters() { diff --git a/Tests/SharedTests/Publication/LocatorTests.swift b/Tests/SharedTests/Publication/LocatorTests.swift index 1327a544a..0556e28eb 100644 --- a/Tests/SharedTests/Publication/LocatorTests.swift +++ b/Tests/SharedTests/Publication/LocatorTests.swift @@ -16,7 +16,7 @@ class LocatorTests: XCTestCase { ]), Locator( href: "http://locator", - type: "text/html" + mediaType: .html ) ) } @@ -36,7 +36,7 @@ class LocatorTests: XCTestCase { ] as [String: Any]), Locator( href: "http://locator", - type: "text/html", + mediaType: .html, title: "My Locator", locations: .init(position: 42), text: .init(highlight: "Excerpt") @@ -59,8 +59,8 @@ class LocatorTests: XCTestCase { ["href": "loc2", "type": "text/html"], ]), [ - Locator(href: "loc1", type: "text/html"), - Locator(href: "loc2", type: "text/html"), + Locator(href: "loc1", mediaType: .html), + Locator(href: "loc2", mediaType: .html), ] ) } @@ -73,7 +73,7 @@ class LocatorTests: XCTestCase { AssertJSONEqual( Locator( href: "http://locator", - type: "text/html" + mediaType: .html ).json, [ "href": "http://locator", @@ -86,7 +86,7 @@ class LocatorTests: XCTestCase { AssertJSONEqual( Locator( href: "http://locator", - type: "text/html", + mediaType: .html, title: "My Locator", locations: .init(position: 42), text: .init(highlight: "Excerpt") @@ -108,8 +108,8 @@ class LocatorTests: XCTestCase { func testGetJSONArray() { AssertJSONEqual( [ - Locator(href: "loc1", type: "text/html"), - Locator(href: "loc2", type: "text/html"), + Locator(href: "loc1", mediaType: .html), + Locator(href: "loc2", mediaType: .html), ].json, [ ["href": "loc1", "type": "text/html"], @@ -121,7 +121,7 @@ class LocatorTests: XCTestCase { func testCopy() { let locator = Locator( href: "http://locator", - type: "text/html", + mediaType: .html, title: "My Locator", locations: .init(position: 42), text: .init(highlight: "Excerpt") @@ -517,13 +517,13 @@ class LocatorCollectionTests: XCTestCase { ] ), links: [ - Link(href: "/978-1503222687/search?query=apple", type: "application/vnd.readium.locators+json", rel: "self"), - Link(href: "/978-1503222687/search?query=apple&page=2", type: "application/vnd.readium.locators+json", rel: "next"), + Link(href: "/978-1503222687/search?query=apple", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "self"), + Link(href: "/978-1503222687/search?query=apple&page=2", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "next"), ], locators: [ Locator( href: "/978-1503222687/chap7.html", - type: "application/xhtml+xml", + mediaType: .xhtml, locations: Locator.Locations( fragments: [":~:text=riddle,-yet%3F'"], progression: 0.43 @@ -536,7 +536,7 @@ class LocatorCollectionTests: XCTestCase { ), Locator( href: "/978-1503222687/chap7.html", - type: "application/xhtml+xml", + mediaType: .xhtml, locations: Locator.Locations( fragments: [":~:text=in%20asking-,riddles"], progression: 0.47 @@ -590,13 +590,13 @@ class LocatorCollectionTests: XCTestCase { ] ), links: [ - Link(href: "/978-1503222687/search?query=apple", type: "application/vnd.readium.locators+json", rel: "self"), - Link(href: "/978-1503222687/search?query=apple&page=2", type: "application/vnd.readium.locators+json", rel: "next"), + Link(href: "/978-1503222687/search?query=apple", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "self"), + Link(href: "/978-1503222687/search?query=apple&page=2", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "next"), ], locators: [ Locator( href: "/978-1503222687/chap7.html", - type: "application/xhtml+xml", + mediaType: .xhtml, locations: Locator.Locations( fragments: [":~:text=riddle,-yet%3F'"], progression: 0.43 @@ -609,7 +609,7 @@ class LocatorCollectionTests: XCTestCase { ), Locator( href: "/978-1503222687/chap7.html", - type: "application/xhtml+xml", + mediaType: .xhtml, locations: Locator.Locations( fragments: [":~:text=in%20asking-,riddles"], progression: 0.47 diff --git a/Tests/SharedTests/Publication/ManifestTests.swift b/Tests/SharedTests/Publication/ManifestTests.swift index 64540391b..7253efd3f 100644 --- a/Tests/SharedTests/Publication/ManifestTests.swift +++ b/Tests/SharedTests/Publication/ManifestTests.swift @@ -24,7 +24,7 @@ class ManifestTests: XCTestCase { Manifest( metadata: Metadata(title: "Title"), links: [Link(href: "manifest.json", rels: [.self])], - readingOrder: [Link(href: "chap1.html", type: "text/html")] + readingOrder: [Link(href: "chap1.html", mediaType: .html)] ) ) } @@ -57,8 +57,8 @@ class ManifestTests: XCTestCase { context: ["https://readium.org/webpub-manifest/context.jsonld"], metadata: Metadata(title: "Title"), links: [Link(href: "manifest.json", rels: [.self])], - readingOrder: [Link(href: "chap1.html", type: "text/html")], - resources: [Link(href: "image.png", type: "image/png")], + readingOrder: [Link(href: "chap1.html", mediaType: .html)], + resources: [Link(href: "image.png", mediaType: .png)], tableOfContents: [Link(href: "cover.html"), Link(href: "chap1.html")], subcollections: ["sub": [PublicationCollection(links: [Link(href: "sublink")])]] ) @@ -81,7 +81,7 @@ class ManifestTests: XCTestCase { context: ["context1", "context2"], metadata: Metadata(title: "Title"), links: [Link(href: "manifest.json", rels: [.self])], - readingOrder: [Link(href: "chap1.html", type: "text/html")] + readingOrder: [Link(href: "chap1.html", mediaType: .html)] ) ) } @@ -116,7 +116,7 @@ class ManifestTests: XCTestCase { Manifest( metadata: Metadata(title: "Title"), links: [Link(href: "manifest.json", rels: [.self])], - readingOrder: [Link(href: "chap1.html", type: "text/html")] + readingOrder: [Link(href: "chap1.html", mediaType: .html)] ) ) } @@ -138,7 +138,7 @@ class ManifestTests: XCTestCase { links: [ Link(href: "manifest.json", rels: [.self]), ], - readingOrder: [Link(href: "chap1.html", type: "text/html")] + readingOrder: [Link(href: "chap1.html", mediaType: .html)] ) ) } @@ -163,8 +163,8 @@ class ManifestTests: XCTestCase { links: [ Link(href: "manifest.json", rels: [.self]), ], - readingOrder: [Link(href: "chap1.html", type: "text/html")], - resources: [Link(href: "withtype", type: "text/html")] + readingOrder: [Link(href: "chap1.html", mediaType: .html)], + resources: [Link(href: "withtype", mediaType: .html)] ) ) } @@ -174,7 +174,7 @@ class ManifestTests: XCTestCase { Manifest( metadata: Metadata(title: "Title"), links: [Link(href: "manifest.json", rels: [.self])], - readingOrder: [Link(href: "chap1.html", type: "text/html")] + readingOrder: [Link(href: "chap1.html", mediaType: .html)] ).json, [ "metadata": ["title": "Title", "readingProgression": "auto"], @@ -194,8 +194,8 @@ class ManifestTests: XCTestCase { context: ["https://readium.org/webpub-manifest/context.jsonld"], metadata: Metadata(title: "Title"), links: [Link(href: "manifest.json", rels: [.self])], - readingOrder: [Link(href: "chap1.html", type: "text/html")], - resources: [Link(href: "image.png", type: "image/png")], + readingOrder: [Link(href: "chap1.html", mediaType: .html)], + resources: [Link(href: "image.png", mediaType: .png)], tableOfContents: [Link(href: "cover.html"), Link(href: "chap1.html")], subcollections: ["sub": [PublicationCollection(links: [Link(href: "sublink")])]] ).json, @@ -229,7 +229,7 @@ class ManifestTests: XCTestCase { makeManifest(readingOrder: [ Link(href: "l1"), Link(href: "l2", rel: "rel1"), - ]).link(withRel: "rel1")?.href, + ]).linkWithRel("rel1")?.href, "l2" ) } @@ -239,7 +239,7 @@ class ManifestTests: XCTestCase { makeManifest(links: [ Link(href: "l1"), Link(href: "l2", rel: "rel1"), - ]).link(withRel: "rel1")?.href, + ]).linkWithRel("rel1")?.href, "l2" ) } @@ -249,7 +249,7 @@ class ManifestTests: XCTestCase { makeManifest(resources: [ Link(href: "l1"), Link(href: "l2", rel: "rel1"), - ]).link(withRel: "rel1")?.href, + ]).linkWithRel("rel1")?.href, "l2" ) } @@ -271,7 +271,7 @@ class ManifestTests: XCTestCase { ]), Link(href: "l6", rel: "rel1"), ] - ).links(withRel: "rel1"), + ).linksWithRel("rel1"), [ Link(href: "l4", rel: "rel1"), Link(href: "l6", rel: "rel1"), @@ -285,7 +285,7 @@ class ManifestTests: XCTestCase { makeManifest(resources: [ Link(href: "l1"), Link(href: "l2"), - ]).links(withRel: "rel1"), + ]).linksWithRel("rel1"), [] ) } diff --git a/Tests/SharedTests/Publication/PublicationTests.swift b/Tests/SharedTests/Publication/PublicationTests.swift index ec86cf0aa..8efae0693 100644 --- a/Tests/SharedTests/Publication/PublicationTests.swift +++ b/Tests/SharedTests/Publication/PublicationTests.swift @@ -14,7 +14,7 @@ class PublicationTests: XCTestCase { manifest: Manifest( metadata: Metadata(title: "Title"), links: [Link(href: "manifest.json", rels: [.`self`])], - readingOrder: [Link(href: "chap1.html", type: "text/html")] + readingOrder: [Link(href: "chap1.html", mediaType: .html)] ) ).jsonManifest, serializeJSONString([ @@ -30,42 +30,39 @@ class PublicationTests: XCTestCase { } func testConformsToProfile() { - func makePub(_ readingOrder: [String], conformsTo: [Publication.Profile] = []) -> Publication { + func makePub(_ readingOrder: [Link], conformsTo: [Publication.Profile] = []) -> Publication { Publication(manifest: Manifest( - metadata: Metadata( - conformsTo: conformsTo, - title: "" - ), - readingOrder: readingOrder.map { Link(href: $0) } + metadata: Metadata(conformsTo: conformsTo), + readingOrder: readingOrder )) } // An empty reading order doesn't conform to anything. XCTAssertFalse(makePub([], conformsTo: [.epub]).conforms(to: .epub)) - XCTAssertTrue(makePub(["c1.mp3", "c2.aac"]).conforms(to: .audiobook)) - XCTAssertTrue(makePub(["c1.jpg", "c2.png"]).conforms(to: .divina)) - XCTAssertTrue(makePub(["c1.pdf", "c2.pdf"]).conforms(to: .pdf)) + XCTAssertTrue(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)]).conforms(to: .audiobook)) + XCTAssertTrue(makePub([Link(href: "c1.jpg", mediaType: .jpeg), Link(href: "c2.png", mediaType: .png)]).conforms(to: .divina)) + XCTAssertTrue(makePub([Link(href: "c1.pdf", mediaType: .pdf), Link(href: "c2.pdf", mediaType: .pdf)]).conforms(to: .pdf)) // Mixed media types disable implicit conformance. - XCTAssertFalse(makePub(["c1.mp3", "c2.jpg"]).conforms(to: .audiobook)) - XCTAssertFalse(makePub(["c1.mp3", "c2.jpg"]).conforms(to: .divina)) + XCTAssertFalse(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.jpg", mediaType: .jpeg)]).conforms(to: .audiobook)) + XCTAssertFalse(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.jpg", mediaType: .jpeg)]).conforms(to: .divina)) // XHTML could be EPUB or a Web Publication, so we require an explicit EPUB profile. - XCTAssertFalse(makePub(["c1.xhtml", "c2.xhtml"]).conforms(to: .epub)) - XCTAssertFalse(makePub(["c1.html", "c2.html"]).conforms(to: .epub)) - XCTAssertTrue(makePub(["c1.xhtml", "c2.xhtml"], conformsTo: [.epub]).conforms(to: .epub)) - XCTAssertTrue(makePub(["c1.html", "c2.html"], conformsTo: [.epub]).conforms(to: .epub)) + XCTAssertFalse(makePub([Link(href: "c1.xhtml", mediaType: .xhtml), Link(href: "c2.xhtml", mediaType: .xhtml)]).conforms(to: .epub)) + XCTAssertFalse(makePub([Link(href: "c1.html", mediaType: .html), Link(href: "c2.html", mediaType: .html)]).conforms(to: .epub)) + XCTAssertTrue(makePub([Link(href: "c1.xhtml", mediaType: .xhtml), Link(href: "c2.xhtml", mediaType: .xhtml)], conformsTo: [.epub]).conforms(to: .epub)) + XCTAssertTrue(makePub([Link(href: "c1.html", mediaType: .html), Link(href: "c2.html", mediaType: .html)], conformsTo: [.epub]).conforms(to: .epub)) // Implicit conformance always take precedence over explicit profiles. - XCTAssertTrue(makePub(["c1.mp3", "c2.aac"]).conforms(to: .audiobook)) - XCTAssertTrue(makePub(["c1.mp3", "c2.aac"], conformsTo: [.divina]).conforms(to: .audiobook)) - XCTAssertFalse(makePub(["c1.mp3", "c2.aac"], conformsTo: [.divina]).conforms(to: .divina)) + XCTAssertTrue(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)]).conforms(to: .audiobook)) + XCTAssertTrue(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)], conformsTo: [.divina]).conforms(to: .audiobook)) + XCTAssertFalse(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)], conformsTo: [.divina]).conforms(to: .divina)) // Unknown profile let profile = Publication.Profile("http://extension") - XCTAssertFalse(makePub(["file"]).conforms(to: profile)) - XCTAssertTrue(makePub(["file"], conformsTo: [profile]).conforms(to: profile)) + XCTAssertFalse(makePub([Link(href: "file", mediaType: .text)]).conforms(to: profile)) + XCTAssertTrue(makePub([Link(href: "file", mediaType: .text)], conformsTo: [profile]).conforms(to: profile)) } func testBaseURL() { @@ -73,7 +70,7 @@ class PublicationTests: XCTestCase { makePublication(links: [ Link(href: "http://host/folder/manifest.json", rel: .`self`), ]).baseURL?.string, - "http://host/folder/manifest.json" + "http://host/folder/" ) } @@ -90,7 +87,7 @@ class PublicationTests: XCTestCase { makePublication(links: [ Link(href: "http://host/manifest.json", rel: .`self`), ]).baseURL?.string, - "http://host/manifest.json" + "http://host/" ) } @@ -99,7 +96,7 @@ class PublicationTests: XCTestCase { makePublication(readingOrder: [ Link(href: "l1"), Link(href: "l2"), - ]).link(withHREF: "l2")?.href, + ]).linkWithHREF(AnyURL(string: "l2")!)?.href, "l2" ) } @@ -109,7 +106,7 @@ class PublicationTests: XCTestCase { makePublication(links: [ Link(href: "l1"), Link(href: "l2"), - ]).link(withHREF: "l2")?.href, + ]).linkWithHREF(AnyURL(string: "l2")!)?.href, "l2" ) } @@ -119,7 +116,7 @@ class PublicationTests: XCTestCase { makePublication(resources: [ Link(href: "l1"), Link(href: "l2"), - ]).link(withHREF: "l2")?.href, + ]).linkWithHREF(AnyURL(string: "l2")!)?.href, "l2" ) } @@ -132,7 +129,7 @@ class PublicationTests: XCTestCase { Link(href: "l3"), ]), ]), - ]).link(withHREF: "l3")?.href, + ]).linkWithHREF(AnyURL(string: "l3")!)?.href, "l3" ) } @@ -145,7 +142,7 @@ class PublicationTests: XCTestCase { Link(href: "l3"), ]), ]), - ]).link(withHREF: "l3")?.href, + ]).linkWithHREF(AnyURL(string: "l3")!)?.href, "l3" ) } @@ -156,8 +153,8 @@ class PublicationTests: XCTestCase { Link(href: "l2"), ]) - XCTAssertEqual(publication.link(withHREF: "l1?q=a")?.href, "l1?q=a") - XCTAssertEqual(publication.link(withHREF: "l2?q=b")?.href, "l2") + XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l1?q=a")!)?.href, "l1?q=a") + XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l2?q=b")!)?.href, "l2") } func testLinkWithHREFIgnoresAnchor() { @@ -166,8 +163,8 @@ class PublicationTests: XCTestCase { Link(href: "l2"), ]) - XCTAssertEqual(publication.link(withHREF: "l1#a")?.href, "l1#a") - XCTAssertEqual(publication.link(withHREF: "l2#b")?.href, "l2") + XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l1#a")!)?.href, "l1#a") + XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l2#b")!)?.href, "l2") } func testLinkWithRelInReadingOrder() { @@ -175,7 +172,7 @@ class PublicationTests: XCTestCase { makePublication(readingOrder: [ Link(href: "l1"), Link(href: "l2", rel: "rel1"), - ]).link(withRel: "rel1")?.href, + ]).linkWithRel("rel1")?.href, "l2" ) } @@ -185,7 +182,7 @@ class PublicationTests: XCTestCase { makePublication(links: [ Link(href: "l1"), Link(href: "l2", rel: "rel1"), - ]).link(withRel: "rel1")?.href, + ]).linkWithRel("rel1")?.href, "l2" ) } @@ -195,7 +192,7 @@ class PublicationTests: XCTestCase { makePublication(resources: [ Link(href: "l1"), Link(href: "l2", rel: "rel1"), - ]).link(withRel: "rel1")?.href, + ]).linkWithRel("rel1")?.href, "l2" ) } @@ -217,7 +214,7 @@ class PublicationTests: XCTestCase { ]), Link(href: "l6", rel: "rel1"), ] - ).links(withRel: "rel1"), + ).linksWithRel("rel1"), [ Link(href: "l4", rel: "rel1"), Link(href: "l6", rel: "rel1"), @@ -231,14 +228,14 @@ class PublicationTests: XCTestCase { makePublication(resources: [ Link(href: "l1"), Link(href: "l2"), - ]).links(withRel: "rel1"), + ]).linksWithRel("rel1"), [] ) } /// `Publication.get()` delegates to the `Fetcher`. func testGetDelegatesToFetcher() { - let link = Link(href: "test", type: "text/html") + let link = Link(href: "test", mediaType: .html) let publication = makePublication( links: [link], fetcher: ProxyFetcher { @@ -255,7 +252,7 @@ class PublicationTests: XCTestCase { /// Services take precedence over the Fetcher in `Publication.get()`. func testGetServicesTakePrecedence() { - let link = Link(href: "test", type: "text/html") + let link = Link(href: "test", mediaType: .html) let publication = makePublication( links: [link], fetcher: ProxyFetcher { @@ -275,7 +272,7 @@ class PublicationTests: XCTestCase { /// `Publication.get(String)` keeps the query parameters and automatically untemplate the `Link`. func testGetKeepsQueryParameters() { - let link = Link(href: "test", type: "text/html", templated: true) + let link = Link(href: "test", mediaType: .html, templated: true) var requestedLink: Link? let publication = makePublication( links: [link], @@ -285,9 +282,9 @@ class PublicationTests: XCTestCase { } ) - _ = publication.get("test?query=param") + _ = publication.get(AnyURL(string: "test?query=param")!) - XCTAssertEqual(requestedLink, Link(href: "test?query=param", type: "text/html", templated: false)) + XCTAssertEqual(requestedLink, Link(href: "test?query=param", mediaType: .html, templated: false)) } private func makePublication( @@ -309,6 +306,67 @@ class PublicationTests: XCTestCase { servicesBuilder: services ) } + + func testNormalizeLocatorRemotePublication() { + let publication = Publication( + manifest: Manifest( + links: [Link(href: "https://example.com/foo/manifest.json", rels: [.`self`])], + readingOrder: [ + Link(href: "chap1.html", mediaType: .html), + Link(href: "bar/c'est%20valide.html", mediaType: .html), + ] + ) + ) + + // Passthrough for invalid locators. + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "invalid", mediaType: .html) + ), + Locator(href: "invalid", mediaType: .html) + ) + + // Absolute URLs relative to self are made relative. + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "https://example.com/foo/chap1.html", mediaType: .html) + ), + Locator(href: "chap1.html", mediaType: .html) + ) + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "https://other.com/chap1.html", mediaType: .html) + ), + Locator(href: "https://other.com/chap1.html", mediaType: .html) + ) + } + + func testNormalizeLocatorPackagedPublication() { + let publication = Publication( + manifest: Manifest( + readingOrder: [ + Link(href: "foo/chap1.html", mediaType: .html), + Link(href: "bar/c'est%20valide.html", mediaType: .html), + ] + ) + ) + + // Passthrough for invalid locators. + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "invalid", mediaType: .html) + ), + Locator(href: "invalid", mediaType: .html) + ) + + // Leading slashes are removed + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "foo/chap1.html", mediaType: .html) + ), + Locator(href: "foo/chap1.html", mediaType: .html) + ) + } } private struct TestService: PublicationService { diff --git a/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift b/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift index 4467a464c..7009eb1ec 100644 --- a/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift @@ -78,7 +78,7 @@ class ContentProtectionServiceTests: XCTestCase { manifest: Manifest( metadata: Metadata(title: ""), readingOrder: [ - Link(href: "chap1", type: "text/html"), + Link(href: "chap1", mediaType: .html), ] ), servicesBuilder: PublicationServicesBuilder(contentProtection: service) diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift index 09752ae7f..a61d3f5b7 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift @@ -8,8 +8,8 @@ import XCTest class HTMLResourceContentIteratorTest: XCTestCase { - private let link = Link(href: "dir/res.xhtml", type: "application/xhtml+xml") - private let locator = Locator(href: "dir/res.xhtml", type: "application/xhtml+xml") + private let link = Link(href: "dir/res.xhtml", mediaType: .xhtml) + private let locator = Locator(href: "dir/res.xhtml", mediaType: .xhtml) private let html = """ @@ -336,7 +336,7 @@ class HTMLResourceContentIteratorTest: XCTestCase {