- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip) - 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示 - 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配 - 新增 RDEPUBSelectionLoupeView 选区放大镜 - 新增 MetadataParseWorker/CancellationController 元数据解析取消机制 - 重构 PresentationRuntime/PaginationCoordinator 精简职责 - 优化 ChapterLoader/WarmupOrchestrator 异步章节加载 - CFI 模块微调与 NoteModels 更新 - 清理冗余文档,更新架构/UML/业务逻辑文档 Co-Authored-By: Claude <noreply@anthropic.com>
229 lines
7.8 KiB
Swift
229 lines
7.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)
|
|
}
|
|
|
|
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 { }
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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.rd_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.rd_sha256Hex.prefix(12).lowercased()
|
|
}
|
|
}
|
|
|
|
struct RDEPUBChapterSummary: Codable {
|
|
|
|
let pageRanges: [RangeData]
|
|
|
|
let pageCount: Int
|
|
|
|
let fragmentOffsets: [String: Int]
|
|
|
|
let cfiMap: RDEPUBCFIMap?
|
|
|
|
let renderSignature: String
|
|
|
|
let schemaVersion: Int
|
|
|
|
let chapterContentHash: String
|
|
|
|
let pageMetadataList: [PageMetadataSummary]
|
|
|
|
static let currentSchemaVersion = 9
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
}
|