From 8c0766a9fef52a2ece5740696eae1dc57f760317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 12 Jun 2024 15:42:52 +0200 Subject: [PATCH 01/12] Revert "Improve URL equality (#453)" This reverts commit 91bc1592394cf81e6e2c4c5d04db7cc892a7201a. --- .../Toolkit/URL/Absolute URL/FileURL.swift | 10 --- .../Toolkit/URL/Absolute URL/HTTPURL.swift | 16 ---- .../URL/Absolute URL/UnknownAbsoluteURL.swift | 20 ----- Sources/Shared/Toolkit/URL/RelativeURL.swift | 12 --- .../URL/Absolute URL/FileURLTests.swift | 56 ++---------- .../URL/Absolute URL/HTTPURLTests.swift | 85 +------------------ .../UnknownAbsoluteURLTests.swift | 81 +----------------- .../Toolkit/URL/RelativeURLTests.swift | 31 +++---- 8 files changed, 29 insertions(+), 282 deletions(-) diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift index 0dd0f6200..bd892b4d3 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift @@ -66,16 +66,6 @@ public struct FileURL: AbsoluteURL, Hashable, Sendable { public func isDirectory() throws -> Bool { 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 - } } public extension URLConvertible { diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift index 18273e042..76257bea8 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift @@ -35,22 +35,6 @@ 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 - } } public extension URLConvertible { diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift index d23175d7c..ab5b3b727 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift @@ -23,24 +23,4 @@ struct UnknownAbsoluteURL: AbsoluteURL, Hashable { let url: URL 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 - } } diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index e37354d45..646488ecd 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -96,18 +96,6 @@ public struct RelativeURL: URLProtocol, Hashable { .removingPrefix("/") ) } - - 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 - } } /// Implements `URLConvertible`. diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift index af38a1842..5547b48c1 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift @@ -10,60 +10,22 @@ import XCTest class FileURLTests: XCTestCase { func testEquality() { - // Paths must be equal. XCTAssertEqual( FileURL(string: "file:///foo/bar")!, - FileURL(string: "file:///foo/bar") + FileURL(string: "file:///foo/bar")! ) - XCTAssertNotEqual( - FileURL(string: "file:///foo/baz")!, - FileURL(string: "file:///foo/bar") - ) - - // Paths is compared percent and entity-decoded. - XCTAssertEqual( - FileURL(string: "file:///c%27est%20valide")!, - FileURL(string: "file:///c%27est%20valide") - ) - XCTAssertEqual( - FileURL(string: "file:///c'est%20valide")!, - FileURL(string: "file:///c%27est%20valide") - ) - - // Authority must be equal. + // Fragments are ignored. XCTAssertEqual( - FileURL(string: "file://user:password@host/foo")!, - FileURL(string: "file://user:password@host/foo") + FileURL(string: "file:///foo/bar")!, + FileURL(string: "file:///foo/bar#fragment")! ) XCTAssertNotEqual( - FileURL(string: "file://foo"), - FileURL(string: "file://host/foo") - ) - - // Query parameters are ignored. - XCTAssertEqual( - FileURL(string: "file:///foo/bar?b=b&a=a")!, - FileURL(string: "file:///foo/bar?a=a&b=b") - ) - XCTAssertEqual( - FileURL(string: "file:///foo/bar?b=b")!, - FileURL(string: "file:///foo/bar?a=a") - ) - - // Scheme is case insensitive. - XCTAssertEqual( - FileURL(string: "FILE:///foo")!, - FileURL(string: "file:///foo") - ) - - // Fragment is ignored. - XCTAssertEqual( - FileURL(string: "file:///foo")!, - FileURL(string: "file:///foo#fragment") + FileURL(string: "file:///foo/bar")!, + FileURL(string: "file:///foo/baz")! ) - XCTAssertEqual( - FileURL(string: "file:///foo#other")!, - FileURL(string: "file:///foo#fragment") + XCTAssertNotEqual( + FileURL(string: "file:///foo/bar")!, + FileURL(string: "file:///foo/bar/")! ) } diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift index 3fc21dfd3..869eed1aa 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift @@ -10,90 +10,13 @@ import XCTest class HTTPURLTests: XCTestCase { func testEquality() { - // Paths must be equal. XCTAssertEqual( - HTTPURL(string: "http://example.com/foo/bar")!, - HTTPURL(string: "http://example.com/foo/bar") + HTTPURL(string: "http://domain.com")!, + HTTPURL(string: "http://domain.com")! ) XCTAssertNotEqual( - HTTPURL(string: "http://example.com/foo/baz")!, - HTTPURL(string: "http://example.com/foo/bar") - ) - - // Paths is compared percent and entity-decoded. - XCTAssertEqual( - HTTPURL(string: "http://example.com/c%27est%20valide")!, - HTTPURL(string: "http://example.com/c%27est%20valide") - ) - XCTAssertEqual( - HTTPURL(string: "http://example.com/c'est%20valide")!, - HTTPURL(string: "http://example.com/c%27est%20valide") - ) - - // Authority must be equal. - XCTAssertEqual( - HTTPURL(string: "http://example.com/foo")!, - HTTPURL(string: "http://example.com/foo") - ) - XCTAssertNotEqual( - HTTPURL(string: "http://example.com:80/foo")!, - HTTPURL(string: "http://example.com/foo") - ) - XCTAssertNotEqual( - HTTPURL(string: "http://example.com:80/foo")!, - HTTPURL(string: "http://example.com:443/foo") - ) - XCTAssertNotEqual( - HTTPURL(string: "http://example.com:80/foo")!, - HTTPURL(string: "http://example.com/foo") - ) - XCTAssertNotEqual( - HTTPURL(string: "http://domain.com/foo")!, - HTTPURL(string: "http://example.com/foo") - ) - XCTAssertNotEqual( - HTTPURL(string: "http://user:password@example.com/foo")!, - HTTPURL(string: "http://example.com/foo") - ) - XCTAssertNotEqual( - HTTPURL(string: "http://user:password@example.com/foo")!, - HTTPURL(string: "http://other:password@example.com/foo") - ) - - // Order of query parameters is important. - XCTAssertNotEqual( - HTTPURL(string: "http://example.com/foo/bar?b=b&a=a")!, - HTTPURL(string: "http://example.com/foo/bar?a=a&b=b") - ) - - // Content of parameters is important. - XCTAssertEqual( - HTTPURL(string: "http://example.com/foo/bar?a=a&b=b")!, - HTTPURL(string: "http://example.com/foo/bar?a=a&b=b") - ) - XCTAssertNotEqual( - HTTPURL(string: "http://example.com/foo/bar?b=b")!, - HTTPURL(string: "http://example.com/foo/bar?a=a") - ) - - // Scheme is case insensitive. - XCTAssertEqual( - HTTPURL(string: "HTTP://example.com/foo")!, - HTTPURL(string: "http://example.com/foo") - ) - XCTAssertNotEqual( - HTTPURL(string: "https://example.com/foo")!, - HTTPURL(string: "http://example.com/foo") - ) - - // Fragment is relevant. - XCTAssertEqual( - HTTPURL(string: "http://example.com/foo#fragment")!, - HTTPURL(string: "http://example.com/foo#fragment") - ) - XCTAssertNotEqual( - HTTPURL(string: "http://example.com/foo#other")!, - HTTPURL(string: "http://example.com/foo#fragment") + HTTPURL(string: "http://domain.com")!, + HTTPURL(string: "http://domain.com#fragment")! ) } diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift index 3c81e2278..3ca1f5989 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift @@ -10,86 +10,13 @@ import XCTest class UnknownAbsoluteURLTests: XCTestCase { func testEquality() { - // Paths must be equal. XCTAssertEqual( - UnknownAbsoluteURL(string: "opds://example.com/foo/bar")!, - UnknownAbsoluteURL(string: "opds://example.com/foo/bar") + UnknownAbsoluteURL(string: "opds://domain.com")!, + UnknownAbsoluteURL(string: "opds://domain.com")! ) XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://example.com/foo/baz")!, - UnknownAbsoluteURL(string: "opds://example.com/foo/bar") - ) - - // Paths is compared percent and entity-decoded. - XCTAssertEqual( - UnknownAbsoluteURL(string: "opds://example.com/c%27est%20valide")!, - UnknownAbsoluteURL(string: "opds://example.com/c%27est%20valide") - ) - XCTAssertEqual( - UnknownAbsoluteURL(string: "opds://example.com/c'est%20valide")!, - UnknownAbsoluteURL(string: "opds://example.com/c%27est%20valide") - ) - - // Authority must be equal. - XCTAssertEqual( - UnknownAbsoluteURL(string: "opds://example.com/foo")!, - UnknownAbsoluteURL(string: "opds://example.com/foo") - ) - XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://example.com:80/foo")!, - UnknownAbsoluteURL(string: "opds://example.com/foo") - ) - XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://example.com:80/foo")!, - UnknownAbsoluteURL(string: "opds://example.com:443/foo") - ) - XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://example.com:80/foo")!, - UnknownAbsoluteURL(string: "opds://example.com/foo") - ) - XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://domain.com/foo")!, - UnknownAbsoluteURL(string: "opds://example.com/foo") - ) - XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://user:password@example.com/foo")!, - UnknownAbsoluteURL(string: "opds://example.com/foo") - ) - XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://user:password@example.com/foo")!, - UnknownAbsoluteURL(string: "opds://other:password@example.com/foo") - ) - - // Order of query parameters is important. - XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://example.com/foo/bar?b=b&a=a")!, - UnknownAbsoluteURL(string: "opds://example.com/foo/bar?a=a&b=b") - ) - - // Content of parameters is important. - XCTAssertEqual( - UnknownAbsoluteURL(string: "opds://example.com/foo/bar?a=a&b=b")!, - UnknownAbsoluteURL(string: "opds://example.com/foo/bar?a=a&b=b") - ) - XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://example.com/foo/bar?b=b")!, - UnknownAbsoluteURL(string: "opds://example.com/foo/bar?a=a") - ) - - // Scheme is case insensitive. - XCTAssertEqual( - UnknownAbsoluteURL(string: "OPDS://example.com/foo")!, - UnknownAbsoluteURL(string: "opds://example.com/foo") - ) - - // Fragment is relevant. - XCTAssertEqual( - UnknownAbsoluteURL(string: "opds://example.com/foo#fragment")!, - UnknownAbsoluteURL(string: "opds://example.com/foo#fragment") - ) - XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://example.com/foo#other")!, - UnknownAbsoluteURL(string: "opds://example.com/foo#fragment") + UnknownAbsoluteURL(string: "opds://domain.com")!, + UnknownAbsoluteURL(string: "opds://domain.com#fragment")! ) } diff --git a/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift b/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift index 7c4ada1af..aae6e7923 100644 --- a/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift @@ -12,25 +12,18 @@ import XCTest class RelativeURLTests: XCTestCase { func testEquality() { - // Paths must be equal. - XCTAssertEqual(RelativeURL(string: "foo/bar")!, RelativeURL(string: "foo/bar")) - XCTAssertNotEqual(RelativeURL(string: "foo/bar")!, RelativeURL(string: "foo/bar/")) - XCTAssertNotEqual(RelativeURL(string: "foo/baz")!, RelativeURL(string: "foo/bar")) - - // Paths is compared percent and entity-decoded. - XCTAssertEqual(RelativeURL(string: "c%27est%20valide")!, RelativeURL(string: "c%27est%20valide")) - XCTAssertEqual(RelativeURL(string: "c'est%20valide")!, RelativeURL(string: "c%27est%20valide")) - - // Order of query parameters is important. - XCTAssertNotEqual(RelativeURL(string: "foo/bar?b=b&a=a")!, RelativeURL(string: "foo/bar?a=a&b=b")) - - // Content of parameters is important. - XCTAssertEqual(RelativeURL(string: "foo/bar?a=a&b=b")!, RelativeURL(string: "foo/bar?a=a&b=b")) - XCTAssertNotEqual(RelativeURL(string: "foo/bar?b=b")!, RelativeURL(string: "foo/bar?a=a")) - - // Fragment is relevant. - XCTAssertEqual(RelativeURL(string: "foo/bar#fragment")!, RelativeURL(string: "foo/bar#fragment")) - XCTAssertNotEqual(RelativeURL(string: "foo/bar#other")!, RelativeURL(string: "foo/bar#fragment")) + XCTAssertEqual( + RelativeURL(string: "dir/file")!, + RelativeURL(string: "dir/file")! + ) + XCTAssertNotEqual( + RelativeURL(string: "dir/file/")!, + RelativeURL(string: "dir/file")! + ) + XCTAssertNotEqual( + RelativeURL(string: "dir")!, + RelativeURL(string: "dir/file")! + ) } // MARK: - URLProtocol From 2e1a4648d58b39b7e4234c5c58946444d5adf547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 12 Jun 2024 17:23:53 +0200 Subject: [PATCH 02/12] Normalize URLs --- Sources/Shared/Publication/Link.swift | 2 +- Sources/Shared/Publication/Publication.swift | 31 ++++++++++++ Sources/Shared/Toolkit/URL/URLProtocol.swift | 9 ++++ .../SharedTests/Toolkit/URL/AnyURLTests.swift | 49 +++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/Sources/Shared/Publication/Link.swift b/Sources/Shared/Publication/Link.swift index 227eb3e57..3c0dcbdf3 100644 --- a/Sources/Shared/Publication/Link.swift +++ b/Sources/Shared/Publication/Link.swift @@ -178,7 +178,7 @@ public struct Link: JSONEquatable, Hashable, Sendable { guard let url = AnyURL(string: href) else { throw LinkError.invalidHREF(href) } - return url + return url.normalized } /// Returns the URL represented by this link's HREF, resolved to the given diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index 44ccd7547..001a8e3e8 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -136,6 +136,37 @@ public class Publication: Loggable { /// Sets the URL where this `Publication`'s RWPM manifest is served. @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 { + guard let href = AnyURL(string: locator.href) else { + return 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(href)?.normalized, + let newHREF = try? link(withHREF: relativeHREF.string)?.url() + { + locator.href = newHREF.string + } + + } else { // Packaged publication + locator.href = href.normalized.string.removingPrefix("/") + } + + return locator + } + /// Represents a Readium Web Publication Profile a `Publication` can conform to. /// diff --git a/Sources/Shared/Toolkit/URL/URLProtocol.swift b/Sources/Shared/Toolkit/URL/URLProtocol.swift index c95299bd5..ad7ba8419 100644 --- a/Sources/Shared/Toolkit/URL/URLProtocol.swift +++ b/Sources/Shared/Toolkit/URL/URLProtocol.swift @@ -23,6 +23,15 @@ public extension URLProtocol { } self.init(url: url) } + + /// 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 + }!.standardized)! + } /// Returns the string representation for this URL. var string: String { url.absoluteString } diff --git a/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift b/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift index bb69bb9ac..1767196ce 100644 --- a/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift @@ -147,4 +147,53 @@ class AnyURLTests: XCTestCase { XCTAssertEqual(base.relativize(AnyURL(string: "/foo/quz/baz")!)!.string, "quz/baz") XCTAssertNil(base.relativize(AnyURL(string: "/quz/baz")!)) } + + func testNormalized() { + // Scheme is lower case. + XCTAssertEqual( + AnyURL(string: "HTTP://example.com")!.normalized.string, + "http://example.com" + ) + + // Path is percent-decoded. + XCTAssertEqual( + AnyURL(string: "HTTP://example.com/c%27est%20valide")!.normalized.string, + "http://example.com/c'est%20valide" + ) + XCTAssertEqual( + AnyURL(string: "c%27est%20valide")!.normalized.string, + "c'est%20valide" + ) + + // Relative paths are resolved. + XCTAssertEqual( + AnyURL(string: "http://example.com/foo/./bar/../baz")!.normalized.string, + "http://example.com/foo/baz" + ) + XCTAssertEqual( + AnyURL(string: "foo/./bar/../baz")!.normalized.string, + "foo/baz" + ) + XCTAssertEqual( + AnyURL(string: "foo/./bar/../../../../../baz")!.normalized.string, + "baz" + ) + + // Trailing slash is kept. + XCTAssertEqual( + AnyURL(string: "http://example.com/foo/")!.normalized.string, + "http://example.com/foo/" + ) + XCTAssertEqual( + AnyURL(string: "foo/")!.normalized.string, + "foo/" + ) + + // The other components are left as-is. + XCTAssertEqual( + AnyURL(string: "http://user:password@example.com:443/foo?b=b&a=a#fragment")!.normalized.string, + "http://user:password@example.com:443/foo?b=b&a=a#fragment" + ) + + } } From 1cae3ab831cadc4b60513d29bdc22f386e793ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 13 Jun 2024 10:46:58 +0200 Subject: [PATCH 03/12] Normalize locators --- .../Navigator/Audiobook/AudioNavigator.swift | 2 + .../CBZ/CBZNavigatorViewController.swift | 2 + .../EPUB/EPUBNavigatorViewController.swift | 8 +- .../PDF/PDFNavigatorViewController.swift | 2 + Sources/Shared/Publication/Publication.swift | 14 +-- .../Publication/PublicationTests.swift | 99 ++++++++++++++++++- 6 files changed, 117 insertions(+), 10 deletions(-) diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 7bf8d207a..e6cbe00f2 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -382,6 +382,8 @@ open class AudioNavigator: Navigator, Configurable, AudioSessionUser, Loggable { @discardableResult public func go(to locator: Locator, animated: Bool = false, completion: @escaping () -> Void = {}) -> Bool { + let locator = publication.normalizeLocator(locator) + guard let newResourceIndex = publication.readingOrder.firstIndex(withHREF: locator.href) else { return false } diff --git a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift index 6ccca29d2..20ba0262e 100644 --- a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift +++ b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift @@ -209,6 +209,8 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab } public func go(to locator: Locator, animated: Bool, completion: @escaping () -> Void) -> Bool { + let locator = publication.normalizeLocator(locator) + guard let index = publication.readingOrder.firstIndex(withHREF: locator.href) else { return false } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 94c669400..9cda07707 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -726,6 +726,8 @@ 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), on(.jump(locator)) @@ -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 diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index e120da6ec..18eafc93b 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 } diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index 001a8e3e8..e014a4778 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -80,7 +80,7 @@ public class Publication: Loggable { public var baseURL: HTTPURL? { links.first(withRel: .`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. @@ -145,7 +145,7 @@ public class Publication: Loggable { /// we still need to support the locators created with the old absolute /// HREFs. public func normalizeLocator(_ locator: Locator) -> Locator { - guard let href = AnyURL(string: locator.href) else { + guard var href = AnyURL(string: locator.href) else { return locator } @@ -153,13 +153,13 @@ public class Publication: Loggable { if let baseURL = baseURL { // Remote publication // Check that the locator HREF relative to `baseURL` exists in the manifest. - if - let relativeHREF = baseURL.relativize(href)?.normalized, - let newHREF = try? link(withHREF: relativeHREF.string)?.url() - { - locator.href = newHREF.string + if let relativeHREF = baseURL.relativize(href) { + href = (try? link(withHREF: relativeHREF.string)?.url()) + ?? relativeHREF.anyURL } + locator.href = href.normalized.string + } else { // Packaged publication locator.href = href.normalized.string.removingPrefix("/") } diff --git a/Tests/SharedTests/Publication/PublicationTests.swift b/Tests/SharedTests/Publication/PublicationTests.swift index ec86cf0aa..75e52c006 100644 --- a/Tests/SharedTests/Publication/PublicationTests.swift +++ b/Tests/SharedTests/Publication/PublicationTests.swift @@ -73,7 +73,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 +90,7 @@ class PublicationTests: XCTestCase { makePublication(links: [ Link(href: "http://host/manifest.json", rel: .`self`), ]).baseURL?.string, - "http://host/manifest.json" + "http://host/" ) } @@ -309,6 +309,101 @@ 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", type: "text/html"), + Link(href: "bar/c'est%20valide.html", type: "text/html"), + ] + ) + ) + + // Passthrough for invalid locators. + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "invalid", type: "text/html") + ), + Locator(href: "invalid", type: "text/html") + ) + + // Absolute URLs relative to self are made relative. + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "https://example.com/foo/chap1.html", type: "text/html") + ), + Locator(href: "chap1.html", type: "text/html") + ) + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "https://other.com/chap1.html", type: "text/html") + ), + Locator(href: "https://other.com/chap1.html", type: "text/html") + ) + + // The HREF is normalized + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "https://example.com/foo/bar/c%27est%20valide.html", type: "text/html") + ), + Locator(href: "bar/c'est%20valide.html", type: "text/html") + ) + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "bar/c%27est%20valide.html", type: "text/html") + ), + Locator(href: "bar/c'est%20valide.html", type: "text/html") + ) + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "https://other.com/c%27est%20valide.html", type: "text/html") + ), + Locator(href: "https://other.com/c'est%20valide.html", type: "text/html") + ) + } + + func testNormalizeLocatorPackagedPublication() { + let publication = Publication( + manifest: Manifest( + readingOrder: [ + Link(href: "foo/chap1.html", type: "text/html"), + Link(href: "bar/c'est%20valide.html", type: "text/html"), + ] + ) + ) + + // Passthrough for invalid locators. + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "invalid", type: "text/html") + ), + Locator(href: "invalid", type: "text/html") + ) + + // Leading slashes are removed + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "foo/chap1.html", type: "text/html") + ), + Locator(href: "foo/chap1.html", type: "text/html") + ) + + // The HREF is normalized + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "bar/c%27est%20valide.html", type: "text/html") + ), + Locator(href: "bar/c'est%20valide.html", type: "text/html") + ) + XCTAssertEqual( + publication.normalizeLocator( + Locator(href: "c%27est%20valide.html", type: "text/html") + ), + Locator(href: "c'est%20valide.html", type: "text/html") + ) + } } private struct TestService: PublicationService { From 71f4dfde42f385b35337dd7f2052b788036a73da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 13 Jun 2024 12:03:24 +0200 Subject: [PATCH 04/12] Various refactoring --- Sources/Shared/Fetcher/ArchiveFetcher.swift | 4 +- Sources/Shared/Fetcher/FileFetcher.swift | 4 +- Sources/Shared/Fetcher/HTTPFetcher.swift | 2 +- .../Shared/Fetcher/Resource/Resource.swift | 2 +- .../Resource/TransformingResource.swift | 2 +- Sources/Shared/OPDS/Feed.swift | 2 +- .../Shared/Publication/HREFNormalizer.swift | 4 +- Sources/Shared/Publication/Link.swift | 83 ++++++------- Sources/Shared/Publication/Locator.swift | 115 ++++++++++++------ Sources/Shared/Publication/Manifest.swift | 53 ++++---- Sources/Shared/Publication/Publication.swift | 48 +++++--- .../HTMLResourceContentIterator.swift | 11 +- .../PublicationContentIterator.swift | 2 +- .../Services/Cover/CoverService.swift | 2 +- .../Cover/GeneratedCoverService.swift | 2 +- .../Locator/DefaultLocatorService.swift | 14 +-- .../PerResourcePositionsService.swift | 10 +- .../Services/Positions/PositionsService.swift | 6 +- Sources/Shared/Toolkit/HTTP/HTTPRequest.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPServer.swift | 4 +- .../Toolkit/Media Type/MediaTypeSniffer.swift | 4 +- .../Shared/Toolkit/PDF/PDFOutlineNode.swift | 2 +- Sources/Shared/Toolkit/URL/URLProtocol.swift | 2 +- Tests/SharedTests/Extensions.swift | 14 +++ .../HTMLResourceContentIteratorTests.swift | 2 +- 25 files changed, 233 insertions(+), 163 deletions(-) create mode 100644 Tests/SharedTests/Extensions.swift 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..4e12c5a59 100644 --- a/Sources/Shared/Fetcher/FileFetcher.swift +++ b/Sources/Shared/Fetcher/FileFetcher.swift @@ -23,7 +23,7 @@ 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 { return FileResource(link: link, file: url) @@ -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/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/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 3c0dcbdf3..242497af0 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.normalized + return AnyURL(string: href)!.normalized } /// 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,71 @@ 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? { + first { $0.url() == href.anyURL } } /// 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? { + firstIndex { $0.url() == href.anyURL } } /// 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 +352,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..4445c5915 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..482d7d65b 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() == href { 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 e014a4778..e16d4bb4e 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)?.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) } @@ -145,25 +160,24 @@ public class Publication: Loggable { /// we still need to support the locators created with the old absolute /// HREFs. public func normalizeLocator(_ locator: Locator) -> Locator { - guard var href = AnyURL(string: locator.href) else { - return 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(href) { - href = (try? link(withHREF: relativeHREF.string)?.url()) + if let relativeHREF = baseURL.relativize(locator.href) { + locator.href = linkWithHREF(relativeHREF)?.url() ?? relativeHREF.anyURL } - locator.href = href.normalized.string + locator.href = locator.href.normalized } else { // Packaged publication - locator.href = href.normalized.string.removingPrefix("/") + if let href = AnyURL(string: locator.href.string.removingPrefix("/")) { + locator.href = href + } } + locator.href = locator.href.normalized return locator } 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/URLProtocol.swift b/Sources/Shared/Toolkit/URL/URLProtocol.swift index ad7ba8419..2249d7bd1 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) diff --git a/Tests/SharedTests/Extensions.swift b/Tests/SharedTests/Extensions.swift new file mode 100644 index 000000000..314b46e9e --- /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 + +public extension Locator { + init(href: String, type: MediaType) { + self.init(href: AnyURL(string: href)!, type: type) + } +} diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift index 09752ae7f..55cc694ee 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift @@ -9,7 +9,7 @@ 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 locator = Locator(href: "dir/res.xhtml", mediaType: .xhtml) private let html = """ From 62c4b25e9928196b194e3ce895ebaf038d3efdf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 13 Jun 2024 14:14:16 +0200 Subject: [PATCH 05/12] Fix tests --- Sources/Shared/Publication/Link.swift | 2 +- Sources/Shared/Toolkit/URL/URLProtocol.swift | 33 ++++- Tests/SharedTests/Extensions.swift | 4 +- .../Fetcher/ArchiveFetcherTests.swift | 16 +-- .../Fetcher/FileFetcherTests.swift | 8 +- .../OPDS/Properties+OPDSTests.swift | 2 +- .../Publication/LinkArrayTests.swift | 128 +++++++++--------- Tests/SharedTests/Publication/LinkTests.swift | 35 ++--- .../Publication/LocatorTests.swift | 34 ++--- .../Publication/ManifestTests.swift | 32 ++--- .../Publication/PublicationTests.swift | 125 +++++++++-------- .../ContentProtectionServiceTests.swift | 2 +- .../HTMLResourceContentIteratorTests.swift | 12 +- .../Cover/GeneratedCoverServiceTests.swift | 4 +- .../Locator/DefaultLocatorServiceTests.swift | 72 +++++----- .../PerResourcePositionsServiceTests.swift | 24 ++-- .../Positions/PositionsServiceTests.swift | 44 +++--- .../PublicationServicesBuilderTests.swift | 2 +- .../Toolkit/Extensions/URLTests.swift | 2 +- .../SharedTests/Toolkit/URL/AnyURLTests.swift | 5 +- .../Parser/Audio/AudioParserTests.swift | 2 +- .../Services/AudioLocatorServiceTests.swift | 44 +++--- .../Parser/EPUB/OPFParserTests.swift | 12 +- .../Services/EPUBPositionsServiceTests.swift | 72 +++++----- .../Parser/Image/ImageParserTests.swift | 2 +- 25 files changed, 365 insertions(+), 353 deletions(-) diff --git a/Sources/Shared/Publication/Link.swift b/Sources/Shared/Publication/Link.swift index 242497af0..2296b313f 100644 --- a/Sources/Shared/Publication/Link.swift +++ b/Sources/Shared/Publication/Link.swift @@ -167,7 +167,7 @@ public struct Link: JSONEquatable, Hashable, Sendable { if href.isEmpty { href = "#" } - return AnyURL(string: href)!.normalized + return (AnyURL(string: href) ?? AnyURL(legacyHREF: href))!.normalized } /// Returns the URL represented by this link's HREF, resolved to the given diff --git a/Sources/Shared/Toolkit/URL/URLProtocol.swift b/Sources/Shared/Toolkit/URL/URLProtocol.swift index 2249d7bd1..acf241899 100644 --- a/Sources/Shared/Toolkit/URL/URLProtocol.swift +++ b/Sources/Shared/Toolkit/URL/URLProtocol.swift @@ -29,8 +29,8 @@ public extension URLProtocol { var normalized: Self { Self(url: url.copy { $0.scheme = $0.scheme?.lowercased() - $0.path = path - }!.standardized)! + $0.path = path.normalizedPath + }!)! } /// Returns the string representation for this URL. @@ -135,3 +135,32 @@ 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: "/") + } +} diff --git a/Tests/SharedTests/Extensions.swift b/Tests/SharedTests/Extensions.swift index 314b46e9e..425ae510d 100644 --- a/Tests/SharedTests/Extensions.swift +++ b/Tests/SharedTests/Extensions.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared public extension Locator { - init(href: String, type: MediaType) { - self.init(href: AnyURL(string: href)!, type: type) + 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..49a120dd7 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 75e52c006..38df4a2b0 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() { @@ -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( @@ -315,8 +312,8 @@ class PublicationTests: XCTestCase { manifest: Manifest( links: [Link(href: "https://example.com/foo/manifest.json", rels: [.`self`])], readingOrder: [ - Link(href: "chap1.html", type: "text/html"), - Link(href: "bar/c'est%20valide.html", type: "text/html"), + Link(href: "chap1.html", mediaType: .html), + Link(href: "bar/c'est%20valide.html", mediaType: .html), ] ) ) @@ -324,43 +321,43 @@ class PublicationTests: XCTestCase { // Passthrough for invalid locators. XCTAssertEqual( publication.normalizeLocator( - Locator(href: "invalid", type: "text/html") + Locator(href: "invalid", mediaType: .html) ), - Locator(href: "invalid", type: "text/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", type: "text/html") + Locator(href: "https://example.com/foo/chap1.html", mediaType: .html) ), - Locator(href: "chap1.html", type: "text/html") + Locator(href: "chap1.html", mediaType: .html) ) XCTAssertEqual( publication.normalizeLocator( - Locator(href: "https://other.com/chap1.html", type: "text/html") + Locator(href: "https://other.com/chap1.html", mediaType: .html) ), - Locator(href: "https://other.com/chap1.html", type: "text/html") + Locator(href: "https://other.com/chap1.html", mediaType: .html) ) // The HREF is normalized XCTAssertEqual( publication.normalizeLocator( - Locator(href: "https://example.com/foo/bar/c%27est%20valide.html", type: "text/html") + Locator(href: "https://example.com/foo/bar/c%27est%20valide.html", mediaType: .html) ), - Locator(href: "bar/c'est%20valide.html", type: "text/html") + Locator(href: "bar/c'est%20valide.html", mediaType: .html) ) XCTAssertEqual( publication.normalizeLocator( - Locator(href: "bar/c%27est%20valide.html", type: "text/html") + Locator(href: "bar/c%27est%20valide.html", mediaType: .html) ), - Locator(href: "bar/c'est%20valide.html", type: "text/html") + Locator(href: "bar/c'est%20valide.html", mediaType: .html) ) XCTAssertEqual( publication.normalizeLocator( - Locator(href: "https://other.com/c%27est%20valide.html", type: "text/html") + Locator(href: "https://other.com/c%27est%20valide.html", mediaType: .html) ), - Locator(href: "https://other.com/c'est%20valide.html", type: "text/html") + Locator(href: "https://other.com/c'est%20valide.html", mediaType: .html) ) } @@ -368,8 +365,8 @@ class PublicationTests: XCTestCase { let publication = Publication( manifest: Manifest( readingOrder: [ - Link(href: "foo/chap1.html", type: "text/html"), - Link(href: "bar/c'est%20valide.html", type: "text/html"), + Link(href: "foo/chap1.html", mediaType: .html), + Link(href: "bar/c'est%20valide.html", mediaType: .html), ] ) ) @@ -377,31 +374,31 @@ class PublicationTests: XCTestCase { // Passthrough for invalid locators. XCTAssertEqual( publication.normalizeLocator( - Locator(href: "invalid", type: "text/html") + Locator(href: "invalid", mediaType: .html) ), - Locator(href: "invalid", type: "text/html") + Locator(href: "invalid", mediaType: .html) ) // Leading slashes are removed XCTAssertEqual( publication.normalizeLocator( - Locator(href: "foo/chap1.html", type: "text/html") + Locator(href: "foo/chap1.html", mediaType: .html) ), - Locator(href: "foo/chap1.html", type: "text/html") + Locator(href: "foo/chap1.html", mediaType: .html) ) // The HREF is normalized XCTAssertEqual( publication.normalizeLocator( - Locator(href: "bar/c%27est%20valide.html", type: "text/html") + Locator(href: "bar/c%27est%20valide.html", mediaType: .html) ), - Locator(href: "bar/c'est%20valide.html", type: "text/html") + Locator(href: "bar/c'est%20valide.html", mediaType: .html) ) XCTAssertEqual( publication.normalizeLocator( - Locator(href: "c%27est%20valide.html", type: "text/html") + Locator(href: "c%27est%20valide.html", mediaType: .html) ), - Locator(href: "c'est%20valide.html", type: "text/html") + Locator(href: "c'est%20valide.html", mediaType: .html) ) } } 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 55cc694ee..a61d3f5b7 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift @@ -8,7 +8,7 @@ import XCTest class HTMLResourceContentIteratorTest: XCTestCase { - private let link = Link(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 {