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