351 lines
11 KiB
Swift
351 lines
11 KiB
Swift
import Foundation
|
||
|
||
public struct RDEPUBSelection: Codable, Equatable {
|
||
/// 书籍标识符
|
||
public var bookIdentifier: String?
|
||
/// 选择发生的位置
|
||
public var location: RDEPUBLocation
|
||
/// 选中的文本内容
|
||
public var text: String
|
||
/// DOM Range 信息(JSON 序列化字符串,用于精确重建选区)
|
||
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 || rangeInfo?.isEmpty != false
|
||
}
|
||
}
|
||
|
||
/// 高亮样式类型
|
||
/// - highlight: 背景色高亮
|
||
/// - underline: 下划线标记
|
||
public enum RDEPUBHighlightStyle: String, Codable {
|
||
case highlight
|
||
case underline
|
||
}
|
||
|
||
/// 统一标注类型,对标 WXRead 的 WRBookmark 语义分层。
|
||
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
|
||
case bookmark
|
||
case highlight
|
||
case underline
|
||
}
|
||
|
||
/// 标注菜单操作类型,用于用户长按选中文本后的操作选项
|
||
public enum RDEPUBAnnotationMenuAction: Equatable {
|
||
case copy
|
||
case highlight
|
||
case annotate
|
||
}
|
||
|
||
/// 文本偏移范围信息,用于文本 EPUB 的高亮精确定位
|
||
/// 使用字符偏移量而非 DOM Range,适用于 DTCoreText 渲染的纯文本内容
|
||
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||
/// 类型标识符,固定为 "text-offset"
|
||
public var kind: String
|
||
/// 资源路径
|
||
public var href: String
|
||
/// 起始字符偏移量
|
||
public var start: Int
|
||
/// 结束字符偏移量
|
||
public var end: Int
|
||
|
||
/// 初始化文本偏移范围
|
||
/// - Parameters:
|
||
/// - href: 资源路径
|
||
/// - start: 起始字符偏移量
|
||
/// - end: 结束字符偏移量
|
||
public init(href: String, start: Int, end: Int) {
|
||
self.kind = "text-offset"
|
||
self.href = href
|
||
self.start = start
|
||
self.end = end
|
||
}
|
||
|
||
/// 转换为 NSRange(用于 NSAttributedString 操作)
|
||
public var nsRange: NSRange? {
|
||
guard end > start else { return nil }
|
||
return NSRange(location: start, length: end - start)
|
||
}
|
||
|
||
/// 序列化为 JSON 字符串
|
||
public func jsonString() -> String? {
|
||
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||
return String(data: data, encoding: .utf8)
|
||
}
|
||
|
||
/// 从 JSON 字符串反序列化,校验 kind 和范围有效性
|
||
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
|
||
/// 范围信息(Web EPUB 为 DOM Range JSON,文本 EPUB 为字符偏移 JSON)
|
||
public var rangeInfo: String?
|
||
/// 高亮样式(背景色高亮或下划线)
|
||
public var style: RDEPUBHighlightStyle
|
||
/// 高亮颜色(CSS 颜色值,如 "#F8E16C")
|
||
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
|
||
}
|
||
|
||
/// 编码键,用于 Codable 序列化/反序列化
|
||
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 chapterTitle: String?
|
||
/// 用户备注
|
||
public var note: String?
|
||
/// 创建时间
|
||
public var createdAt: Date
|
||
|
||
public init(
|
||
id: String = UUID().uuidString,
|
||
bookIdentifier: String? = nil,
|
||
location: RDEPUBLocation,
|
||
chapterTitle: String? = nil,
|
||
note: String? = nil,
|
||
createdAt: Date = Date()
|
||
) {
|
||
self.id = id
|
||
self.bookIdentifier = bookIdentifier
|
||
self.location = location
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 统一标注模型,对标 WXRead 的 WRBookmark。
|
||
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: nil,
|
||
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,
|
||
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
|
||
)
|
||
}
|
||
}
|
||
|
||
|
||
private extension String {
|
||
var nilIfEmpty: String? {
|
||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return trimmed.isEmpty ? nil : trimmed
|
||
}
|
||
}
|