- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
226 lines
8.8 KiB
Swift
226 lines
8.8 KiB
Swift
import Foundation
|
||
|
||
final class RDEPUBChapterSummaryDiskCache {
|
||
private let cacheDirectory: URL
|
||
private let fileManager = FileManager.default
|
||
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
|
||
|
||
init(cacheDirectory: URL) {
|
||
self.cacheDirectory = cacheDirectory
|
||
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||
}
|
||
|
||
// MARK: - 写入(异步)
|
||
|
||
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||
queue.async {
|
||
self.writeImmediately(summary: summary, for: key)
|
||
}
|
||
}
|
||
|
||
/// 同步写入:用于后台整书元数据构建完成前,确保摘要文件已经真实落盘。
|
||
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||
queue.sync {
|
||
self.writeImmediately(summary: summary, for: key)
|
||
}
|
||
}
|
||
|
||
/// 等待此前已排队的异步写入全部落盘。
|
||
func flushPendingWrites() {
|
||
queue.sync { }
|
||
}
|
||
|
||
// MARK: - 读取(同步,因为 loadChapter 已在串行队列上)
|
||
|
||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||
let fileURL = self.fileURL(for: key)
|
||
let data: Data
|
||
do {
|
||
data = try Data(contentsOf: fileURL)
|
||
} catch {
|
||
let nsError = error as NSError
|
||
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
|
||
// 文件不存在属于正常缓存未命中,不报错
|
||
} else {
|
||
#if DEBUG
|
||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||
#endif
|
||
}
|
||
return nil
|
||
}
|
||
do {
|
||
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||
} catch {
|
||
#if DEBUG
|
||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||
#endif
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap
|
||
|
||
/// 批量读取指定缓存键列表的摘要,返回 spineIndex → summary 映射。
|
||
/// 同步方法,应在后台线程调用。
|
||
/// 由调用方负责构建完整的缓存键列表(含正确的 contentHash)。
|
||
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
|
||
summaries: [Int: RDEPUBChapterSummary],
|
||
mapBuilder: RDEPUBBookPageMap.Builder
|
||
) {
|
||
var summaries: [Int: RDEPUBChapterSummary] = [:]
|
||
var mapBuilder = RDEPUBBookPageMap.Builder()
|
||
|
||
for item in keys {
|
||
if let summary = read(for: item.key) {
|
||
summaries[item.spineIndex] = summary
|
||
mapBuilder.add(
|
||
spineIndex: item.spineIndex,
|
||
href: item.href,
|
||
title: item.title,
|
||
pageCount: summary.pageCount,
|
||
fragmentOffsets: summary.fragmentOffsets
|
||
)
|
||
}
|
||
}
|
||
return (summaries, mapBuilder)
|
||
}
|
||
|
||
/// 检查指定缓存键列表是否全部有对应的磁盘摘要。
|
||
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||
for key in keys {
|
||
if read(for: key) == nil {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
/// 清空所有缓存文件
|
||
func removeAll() {
|
||
removeFiles(matching: { _ in true })
|
||
}
|
||
|
||
/// 清空指定书籍的所有缓存文件
|
||
func removeAll(forBookID bookID: String) {
|
||
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
|
||
removeFiles { $0.hasPrefix(bookPrefix + "__") }
|
||
}
|
||
|
||
/// 清空指定渲染签名下的所有缓存文件
|
||
func removeAll(forRenderSignature renderSignature: String) {
|
||
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
|
||
removeFiles { $0.contains(renderPrefix) }
|
||
}
|
||
|
||
/// 缓存统计信息
|
||
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
|
||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
|
||
return (0, 0)
|
||
}
|
||
var count = 0
|
||
var totalBytes: Int64 = 0
|
||
for fileURL in files where fileURL.pathExtension == "json" {
|
||
count += 1
|
||
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||
totalBytes += Int64(size)
|
||
}
|
||
}
|
||
return (count, totalBytes)
|
||
}
|
||
|
||
// MARK: - key -> 文件路径
|
||
|
||
/// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue
|
||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
|
||
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
|
||
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||
let digest = rawKey.sha256Hex
|
||
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
|
||
}
|
||
|
||
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||
let fileURL = self.fileURL(for: key)
|
||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||
do {
|
||
let data = try JSONEncoder().encode(summary)
|
||
try data.write(to: tmpURL)
|
||
if fileManager.fileExists(atPath: fileURL.path) {
|
||
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
|
||
} else {
|
||
try fileManager.moveItem(at: tmpURL, to: fileURL)
|
||
}
|
||
} catch {
|
||
#if DEBUG
|
||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||
#endif
|
||
try? fileManager.removeItem(at: tmpURL)
|
||
}
|
||
}
|
||
|
||
private func removeFiles(matching predicate: (String) -> Bool) {
|
||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
|
||
try? fileManager.removeItem(at: fileURL)
|
||
}
|
||
}
|
||
|
||
private static func cacheNamespacePrefix(for rawValue: String) -> String {
|
||
rawValue.sha256Hex.prefix(12).lowercased()
|
||
}
|
||
}
|
||
|
||
struct RDEPUBChapterSummary: Codable {
|
||
let pageRanges: [RangeData]
|
||
let pageCount: Int
|
||
let fragmentOffsets: [String: Int]
|
||
let renderSignature: String
|
||
let schemaVersion: Int
|
||
let chapterContentHash: String
|
||
let pageMetadataList: [PageMetadataSummary]
|
||
|
||
static let currentSchemaVersion = 6
|
||
|
||
struct RangeData: Codable {
|
||
let location: Int
|
||
let length: Int
|
||
var nsRange: NSRange { NSRange(location: location, length: length) }
|
||
}
|
||
|
||
struct PageMetadataSummary: Codable {
|
||
let breakReason: String
|
||
let attachmentRanges: [RangeData]
|
||
let attachmentKinds: [String]
|
||
let blockKinds: [String]
|
||
let semanticHints: [String]
|
||
let attachmentPlacements: [String]
|
||
let trailingFragmentID: String?
|
||
|
||
func toPageMetadata() -> RDEPUBTextPageMetadata {
|
||
RDEPUBTextPageMetadata(
|
||
breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit,
|
||
blockRange: nil,
|
||
attachmentRanges: attachmentRanges.map { $0.nsRange },
|
||
attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) },
|
||
blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) },
|
||
semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) },
|
||
attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) },
|
||
trailingFragmentID: trailingFragmentID,
|
||
diagnostics: []
|
||
)
|
||
}
|
||
|
||
static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary {
|
||
PageMetadataSummary(
|
||
breakReason: metadata.breakReason.rawValue,
|
||
attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) },
|
||
attachmentKinds: metadata.attachmentKinds.map { $0.rawValue },
|
||
blockKinds: metadata.blockKinds.map { $0.rawValue },
|
||
semanticHints: metadata.semanticHints.map { $0.rawValue },
|
||
attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue },
|
||
trailingFragmentID: metadata.trailingFragmentID
|
||
)
|
||
}
|
||
}
|
||
}
|