import Foundation public final class RDEPUBParser { public internal(set) var metadata = RDEPUBMetadata() public internal(set) var manifest: [String: RDEPUBManifestItem] = [:] public internal(set) var spine: [RDEPUBSpineItem] = [] public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = [] public internal(set) var extractionRootURL: URL? public internal(set) var opfURL: URL? public var opfDirectoryURL: URL? { opfURL?.deletingLastPathComponent() } public init() {} // MARK: - Cache Management /// The base directory used for EPUB extraction caches. public static var extractionCacheBaseDirectory: URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? .appendingPathComponent("ssreaderview-epub", isDirectory: true) ?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true) } /// Removes all EPUB extraction caches from disk. /// Call this when the host app wants to free disk space, e.g. on didReceiveMemoryWarning /// or during cleanup when the reader is no longer needed. public static func cleanExtractionCache() throws { let fileManager = FileManager.default let baseURL = extractionCacheBaseDirectory guard fileManager.fileExists(atPath: baseURL.path) else { return } try fileManager.removeItem(at: baseURL) } /// Evicts extraction caches until total disk usage is below the given threshold, /// removing least-recently-accessed directories first. /// - Parameter maxBytes: Maximum total bytes for all extraction caches. Defaults to 500 MB. public static func evictExtractionCache(maxBytes: UInt64 = 500 * 1024 * 1024) throws { let fileManager = FileManager.default let baseURL = extractionCacheBaseDirectory guard fileManager.fileExists(atPath: baseURL.path) else { return } let contents = try fileManager.contentsOfDirectory( at: baseURL, includingPropertiesForKeys: [.contentAccessDateKey, .isDirectoryKey], options: .skipsHiddenFiles ) var entries: [(url: URL, accessDate: Date, size: UInt64)] = [] var totalSize: UInt64 = 0 for directoryURL in contents { guard let resourceValues = try? directoryURL.resourceValues(forKeys: [.isDirectoryKey, .contentAccessDateKey]), resourceValues.isDirectory == true else { continue } let directorySize = directoryURL.directorySize let accessDate = resourceValues.contentAccessDate ?? Date.distantPast entries.append((url: directoryURL, accessDate: accessDate, size: directorySize)) totalSize += directorySize } guard totalSize > maxBytes else { return } entries.sort { $0.accessDate < $1.accessDate } for entry in entries { guard totalSize > maxBytes else { break } try? fileManager.removeItem(at: entry.url) totalSize -= entry.size } } public func makePublication() -> RDEPUBPublication { RDEPUBPublication(parser: self) } public func parse(epubURL: URL) throws { reset() let extractionURL = try extractArchiveIfNeeded(epubURL: epubURL) extractionRootURL = extractionURL let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml") guard FileManager.default.fileExists(atPath: containerURL.path) else { throw RDEPUBParserError.missingContainerXML } let rootFilePath = try parseContainerRootFile(at: containerURL) let packageURL = extractionURL.appendingPathComponent(rootFilePath) guard FileManager.default.fileExists(atPath: packageURL.path) else { throw RDEPUBParserError.invalidRootFilePath(rootFilePath) } try parseOPF(at: packageURL) } public func parseOPF(at opfURL: URL) throws { resetPublicationState() guard let parser = XMLParser(contentsOf: opfURL) else { throw RDEPUBParserError.invalidXML(opfURL) } let delegate = OPFPackageParserDelegate() parser.shouldProcessNamespaces = false parser.delegate = delegate guard parser.parse() else { throw parser.parserError ?? RDEPUBParserError.invalidXML(opfURL) } let packageDocument = delegate.packageDocument() self.opfURL = opfURL self.metadata = packageDocument.metadata self.manifest = Dictionary(uniqueKeysWithValues: packageDocument.manifest.map { ($0.id, $0) }) self.spine = try buildSpine(from: packageDocument, opfURL: opfURL) self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL) } public func parseTOC() -> [EPUBTableOfContentsItem] { tableOfContents } public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] { parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent()) } } // MARK: - URL Directory Size Extension private extension URL { var directorySize: UInt64 { let fileManager = FileManager.default guard let enumerator = fileManager.enumerator(at: self, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles], errorHandler: nil) else { return 0 } var totalSize: UInt64 = 0 for case let fileURL as URL in enumerator { guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), let fileSize = resourceValues.fileSize else { continue } totalSize += UInt64(fileSize) } return totalSize } }