import Foundation import UIKit public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.RDEpubReader.highlight") public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.RDEpubReader.underline") public struct RDEPUBSelection: Codable, Equatable { public var bookIdentifier: String? public var location: RDEPUBLocation public var text: String public var rangeInfo: String? public var createdAt: Date public init( bookIdentifier: String? = nil, location: RDEPUBLocation, text: String, rangeInfo: String? = nil, createdAt: Date = Date() ) { self.bookIdentifier = bookIdentifier self.location = location self.text = text self.rangeInfo = rangeInfo?.nilIfEmpty self.createdAt = createdAt } public var isEmpty: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } public enum RDEPUBHighlightStyle: String, Codable { case highlight case underline } public enum RDEPUBAnnotationKind: String, Codable, Equatable { case bookmark case highlight case underline } public enum RDEPUBAnnotationMenuAction: Equatable { case copy case highlight case annotate } public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable { public var kind: String public var href: String public var start: Int public var end: Int public init(href: String, start: Int, end: Int) { self.kind = "text-offset" self.href = href self.start = start self.end = end } public var nsRange: NSRange? { guard end > start else { return nil } return NSRange(location: start, length: end - start) } public func jsonString() -> String? { guard let data = try? JSONEncoder().encode(self) else { return nil } return String(data: data, encoding: .utf8) } public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? { guard let string, let data = string.data(using: .utf8), let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data), rangeInfo.kind == "text-offset", rangeInfo.end > rangeInfo.start else { return nil } return rangeInfo } } public struct RDEPUBHighlight: Codable, Equatable { public var id: String public var bookIdentifier: String? public var location: RDEPUBLocation public var text: String public var rangeInfo: String? public var style: RDEPUBHighlightStyle public var color: String public var note: String? public var createdAt: Date public init( id: String = UUID().uuidString, bookIdentifier: String? = nil, location: RDEPUBLocation, text: String, rangeInfo: String? = nil, style: RDEPUBHighlightStyle = .highlight, color: String = "#F8E16C", note: String? = nil, createdAt: Date = Date() ) { self.id = id self.bookIdentifier = bookIdentifier self.location = location self.text = text self.rangeInfo = rangeInfo?.nilIfEmpty self.style = style self.color = color self.note = note?.nilIfEmpty self.createdAt = createdAt } public var hasNote: Bool { note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } public var uiColor: UIColor { UIColor(rdHexString: color, alpha: 0.35) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35) } private enum CodingKeys: String, CodingKey { case id case bookIdentifier case location case text case rangeInfo case style case color case note case createdAt } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier) location = try container.decode(RDEPUBLocation.self, forKey: .location) text = try container.decode(String.self, forKey: .text) rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style), let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) { style = decodedStyle } else { style = .highlight } color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C" note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier) try container.encode(location, forKey: .location) try container.encode(text, forKey: .text) try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo) try container.encode(style, forKey: .style) try container.encode(color, forKey: .color) try container.encodeIfPresent(note, forKey: .note) try container.encode(createdAt, forKey: .createdAt) } public var annotation: RDEPUBAnnotation { RDEPUBAnnotation(highlight: self) } } public struct RDEPUBBookmark: Codable, Equatable { public var id: String public var bookIdentifier: String? public var location: RDEPUBLocation public var rangeInfo: String? public var chapterTitle: String? public var note: String? public var createdAt: Date public init( id: String = UUID().uuidString, bookIdentifier: String? = nil, location: RDEPUBLocation, rangeInfo: String? = nil, chapterTitle: String? = nil, note: String? = nil, createdAt: Date = Date() ) { self.id = id self.bookIdentifier = bookIdentifier self.location = location self.rangeInfo = rangeInfo?.nilIfEmpty self.chapterTitle = chapterTitle?.nilIfEmpty self.note = note?.nilIfEmpty self.createdAt = createdAt } public var hasNote: Bool { note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } public var annotation: RDEPUBAnnotation { RDEPUBAnnotation(bookmark: self) } } public struct RDEPUBAnnotation: Codable, Equatable { public var id: String public var bookIdentifier: String? public var kind: RDEPUBAnnotationKind public var location: RDEPUBLocation public var text: String? public var rangeInfo: String? public var color: String? public var chapterTitle: String? public var note: String? public var createdAt: Date public init( id: String = UUID().uuidString, bookIdentifier: String? = nil, kind: RDEPUBAnnotationKind, location: RDEPUBLocation, text: String? = nil, rangeInfo: String? = nil, color: String? = nil, chapterTitle: String? = nil, note: String? = nil, createdAt: Date = Date() ) { self.id = id self.bookIdentifier = bookIdentifier self.kind = kind self.location = location self.text = text?.nilIfEmpty self.rangeInfo = rangeInfo?.nilIfEmpty self.color = color?.nilIfEmpty self.chapterTitle = chapterTitle?.nilIfEmpty self.note = note?.nilIfEmpty self.createdAt = createdAt } public init(highlight: RDEPUBHighlight) { self.init( id: highlight.id, bookIdentifier: highlight.bookIdentifier, kind: highlight.style == .underline ? .underline : .highlight, location: highlight.location, text: highlight.text, rangeInfo: highlight.rangeInfo, color: highlight.color, chapterTitle: nil, note: highlight.note, createdAt: highlight.createdAt ) } public init(bookmark: RDEPUBBookmark) { self.init( id: bookmark.id, bookIdentifier: bookmark.bookIdentifier, kind: .bookmark, location: bookmark.location, text: nil, rangeInfo: bookmark.rangeInfo, color: nil, chapterTitle: bookmark.chapterTitle, note: bookmark.note, createdAt: bookmark.createdAt ) } public var bookmark: RDEPUBBookmark? { guard kind == .bookmark else { return nil } return RDEPUBBookmark( id: id, bookIdentifier: bookIdentifier, location: location, rangeInfo: rangeInfo, chapterTitle: chapterTitle, note: note, createdAt: createdAt ) } public var highlight: RDEPUBHighlight? { guard kind == .highlight || kind == .underline, let text else { return nil } return RDEPUBHighlight( id: id, bookIdentifier: bookIdentifier, location: location, text: text, rangeInfo: rangeInfo, style: kind == .underline ? .underline : .highlight, color: color ?? "#F8E16C", note: note, createdAt: createdAt ) } }