ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift
shenlei d15f20b097 feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 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>
2026-06-24 17:47:24 +08:00

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
)
}
}
}