-
Notifications
You must be signed in to change notification settings - Fork 113
/
Locator.swift
405 lines (348 loc) · 14.7 KB
/
Locator.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//
// 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 ReadiumInternal
/// https://github.com/readium/architecture/tree/master/locators
public struct Locator: Hashable, CustomStringConvertible, Loggable {
/// The URI of the resource that the Locator Object points to.
public let href: String // URI
/// The media type of the resource that the Locator Object points to.
public let type: String
/// The title of the chapter or section which is more relevant in the context of this locator.
public let title: String?
/// One or more alternative expressions of the location.
public let locations: Locations
/// Textual context of the locator.
public let text: Text
public init(href: String, type: String, title: String? = nil, locations: Locations = .init(), text: Text = .init()) {
self.href = href
self.type = type
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? [String: Any],
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)
)
}
public init?(jsonString: String, warnings: WarningLogger? = nil) throws {
let json: Any
do {
json = try JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!)
} catch {
warnings?.log("Invalid Locator object: \(error)", model: Self.self)
throw JSONError.parsing(Self.self)
}
try self.init(json: json, warnings: warnings)
}
@available(*, deprecated, message: "This may create an incorrect `Locator` if the link `type` is missing. Use `publication.locate(Link)` instead.")
public init(link: Link) {
let components = link.href.split(separator: "#", maxSplits: 1).map(String.init)
let fragments = (components.count > 1) ? [String(components[1])] : []
self.init(
href: components.first ?? link.href,
type: link.type ?? "",
title: link.title,
locations: Locations(fragments: fragments)
)
}
public var json: [String: Any] {
makeJSON([
"href": href,
"type": type,
"title": encodeIfNotNil(title),
"locations": encodeIfNotEmpty(locations.json),
"text": encodeIfNotEmpty(text.json),
])
}
public var jsonString: String? {
serializeJSONString(json)
}
public var description: String {
jsonString ?? "{}"
}
/// 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 {
var locations = locations
var text = text
transformLocations?(&locations)
transformText?(&text)
return Locator(
href: href ?? self.href,
type: type ?? self.type,
title: title ?? self.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
///
/// Properties are mutable for convenience when making a copy, but the `locations` property
/// is immutable in `Locator`, for safety.
public struct Locations: Hashable, Loggable, WarningLogger {
/// Contains one or more fragment in the resource referenced by the `Locator`.
public var fragments: [String]
/// Progression in the resource expressed as a percentage (between 0 and 1).
public var progression: Double?
/// Progression in the publication expressed as a percentage (between 0 and 1).
public var totalProgression: Double?
/// An index in the publication (>= 1).
public var position: Int?
/// Additional locations for extensions.
public var otherLocations: [String: Any] {
get { otherLocationsJSON.json }
set { otherLocationsJSON = JSONDictionary(newValue) ?? JSONDictionary() }
}
// Trick to keep the struct equatable despite [String: Any]
private var otherLocationsJSON: JSONDictionary
public init(fragments: [String] = [], progression: Double? = nil, totalProgression: Double? = nil, position: Int? = nil, otherLocations: [String: Any] = [:]) {
self.fragments = fragments
self.progression = progression
self.totalProgression = totalProgression
self.position = position
otherLocationsJSON = JSONDictionary(otherLocations) ?? JSONDictionary()
}
public init(json: Any?, warnings: WarningLogger? = nil) throws {
if json == nil {
self.init()
return
}
guard var jsonObject = JSONDictionary(json) else {
warnings?.log("Invalid Locations object", model: Self.self, source: json)
throw JSONError.parsing(Self.self)
}
var fragments = (jsonObject.pop("fragments") as? [String]) ?? []
if let fragment = jsonObject.pop("fragment") as? String {
fragments.append(fragment)
}
self.init(
fragments: fragments,
progression: jsonObject.pop("progression") as? Double,
totalProgression: jsonObject.pop("totalProgression") as? Double,
position: jsonObject.pop("position") as? Int,
otherLocations: jsonObject.json
)
}
public init(jsonString: String, warnings: WarningLogger? = nil) {
do {
let json = try JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!)
try self.init(json: json, warnings: warnings)
} catch {
warnings?.log("Invalid Locations object: \(error)", model: Self.self)
self.init()
}
}
public var isEmpty: Bool { json.isEmpty }
public var json: [String: Any] {
makeJSON([
"fragments": encodeIfNotEmpty(fragments),
"progression": encodeIfNotNil(progression),
"totalProgression": encodeIfNotNil(totalProgression),
"position": encodeIfNotNil(position),
], additional: otherLocations)
}
public var jsonString: String? { serializeJSONString(json) }
/// Syntactic sugar to access the `otherLocations` values by subscripting `Locations` directly.
/// locations["cssSelector"] == locations.otherLocations["cssSelector"]
public subscript(key: String) -> Any? { otherLocations[key] }
@available(*, unavailable, renamed: "init(jsonString:)")
public init(fromString: String) {
fatalError()
}
@available(*, unavailable, renamed: "jsonString")
public func toString() -> String? {
fatalError()
}
@available(*, unavailable, message: "Use `fragments.first` instead")
public var fragment: String? { fragments.first }
}
public struct Text: Hashable, Loggable {
public var after: String?
public var before: String?
public var highlight: String?
public init(after: String? = nil, before: String? = nil, highlight: String? = nil) {
self.after = after
self.before = before
self.highlight = highlight
}
public init(json: Any?, warnings: WarningLogger? = nil) throws {
if json == nil {
self.init()
return
}
guard let jsonObject = json as? [String: Any] else {
warnings?.log("Invalid Text object", model: Self.self, source: json)
throw JSONError.parsing(Self.self)
}
self.init(
after: jsonObject["after"] as? String,
before: jsonObject["before"] as? String,
highlight: jsonObject["highlight"] as? String
)
}
public init(jsonString: String, warnings: WarningLogger? = nil) {
do {
let json = try JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!)
try self.init(json: json, warnings: warnings)
} catch {
warnings?.log("Invalid Text object", model: Self.self)
self.init()
}
}
public var json: [String: Any] {
makeJSON([
"after": encodeIfNotNil(after),
"before": encodeIfNotNil(before),
"highlight": encodeIfNotNil(highlight),
])
}
public var jsonString: String? { serializeJSONString(json) }
/// Returns a copy of this text after sanitizing its content for user display.
public func sanitized() -> Locator.Text {
Locator.Text(
after: after?.coalescingWhitespaces().removingSuffix(" "),
before: before?.coalescingWhitespaces().removingPrefix(" "),
highlight: highlight?.coalescingWhitespaces()
)
}
/// Returns a copy of this text after highlighting a sub-range in the `highlight` property.
///
/// The bounds of the range must be valid indices of the `highlight` property.
public subscript(range: Range<String.Index>) -> Text {
guard
let highlight = highlight,
!highlight.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
preconditionFailure("highlight is nil")
}
let range = range
.clamped(to: highlight.startIndex ..< highlight.endIndex)
var before = before ?? ""
var after = after ?? ""
let newHighlight = highlight[range]
before = before + highlight[..<range.lowerBound]
after = highlight[range.upperBound...] + after
return Locator.Text(
after: Optional(after).takeIf { !$0.isEmpty },
before: Optional(before).takeIf { !$0.isEmpty },
highlight: String(newHighlight)
)
}
@available(*, unavailable, renamed: "init(jsonString:)")
public init(fromString: String) {
fatalError()
}
@available(*, unavailable, renamed: "jsonString")
public func toString() -> String? {
fatalError()
}
}
}
public extension Array where Element == Locator {
/// Parses multiple JSON locators into an array of `Locator`.
init(json: Any?, warnings: WarningLogger? = nil) {
self.init()
guard let json = json as? [Any] else {
return
}
let links = json.compactMap { try? Locator(json: $0, warnings: warnings) }
append(contentsOf: links)
}
var json: [[String: Any]] {
map(\.json)
}
}
/// Represents a sequential list of `Locator` objects.
///
/// For example, a search result or a list of positions.
///
/// **WARNING:** This API is experimental and may change or be removed in a future release without
/// notice. Use with caution.
public struct _LocatorCollection: Hashable {
public let metadata: Metadata
public let links: [Link]
public let locators: [Locator]
public init(metadata: Metadata = Metadata(), links: [Link] = [], locators: [Locator] = []) {
self.metadata = metadata
self.links = links
self.locators = locators
}
public init?(json: Any?, warnings: WarningLogger? = nil) {
if json == nil {
return nil
}
guard let jsonObject = json as? [String: Any] else {
warnings?.log("Not a JSON object", model: Self.self, source: json)
return nil
}
self.init(
metadata: Metadata(json: jsonObject["metadata"], warnings: warnings),
links: [Link](json: jsonObject["links"]),
locators: [Locator](json: jsonObject["locators"])
)
}
public var json: [String: Any] {
makeJSON([
"metadata": encodeIfNotEmpty(metadata.json),
"links": encodeIfNotEmpty(links.json),
"locators": locators.json,
])
}
/// Holds the metadata of a `LocatorCollection`.
public struct Metadata: Hashable {
public let localizedTitle: LocalizedString?
public var title: String? { localizedTitle?.string }
/// Indicates the total number of locators in the collection.
public let numberOfItems: Int?
/// Additional properties for extensions.
public var otherMetadata: [String: Any] { otherMetadataJSON.json }
// Trick to keep the struct equatable despite [String: Any]
private let otherMetadataJSON: JSONDictionary
public init(
title: LocalizedStringConvertible? = nil,
numberOfItems: Int? = nil,
otherMetadata: [String: Any] = [:]
) {
localizedTitle = title?.localizedString
self.numberOfItems = numberOfItems
otherMetadataJSON = JSONDictionary(otherMetadata) ?? JSONDictionary()
}
public init(json: Any?, warnings: WarningLogger? = nil) {
if var json = JSONDictionary(json) {
localizedTitle = try? LocalizedString(json: json.pop("title"), warnings: warnings)
numberOfItems = parsePositive(json.pop("numberOfItems"))
otherMetadataJSON = json
} else {
warnings?.log("Not a JSON object", model: Self.self, source: json)
localizedTitle = nil
numberOfItems = nil
otherMetadataJSON = JSONDictionary()
}
}
public var json: [String: Any] {
makeJSON([
"title": encodeIfNotNil(localizedTitle?.json),
"numberOfItems": encodeIfNotNil(numberOfItems),
], additional: otherMetadata)
}
}
}