--- phase: 08-pagination-quality-cache-performance plan: 01 type: execute wave: 1 depends_on: [] files_modified: - Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift autonomous: true requirements: - QUAL-01 must_haves: truths: - "Same bookID + fontSize + lineHeightMultiple + contentInsets produces same cache key" - "Cache hit returns stored RDEPUBTextBook without calling build()" - "Cache miss falls through to build and stores result" - "Schema version bump invalidates all cached entries" - "Changing any layout parameter produces a different cache key" artifacts: - path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift" provides: "Disk-persistent RDEPUBTextBook cache with SHA256 key generation" contains: "class RDEPUBTextBookCache" min_lines: 100 key_links: - from: "RDEPUBTextBookCache.swift" to: "Library/Caches/RDEPUBTextBookCache/" via: "FileManager.default.urls(for: .cachesDirectory)" pattern: "cachesDirectory" --- Create a disk-persistent cache for RDEPUBTextBook objects keyed by layout parameters (bookID + fontSize + lineHeightMultiple + contentInsets + pageSize), using NSKeyedArchiver for serialization and SHA256 for cache key hashing. This eliminates redundant full pagination of the same book under identical layout configuration. Purpose: QUAL-01 requires that the same viewport and typography configuration does not trigger redundant full pagination. The cache stores complete RDEPUBTextBook objects to disk so repeated opens with the same parameters skip the entire build pipeline. Output: A new `RDEPUBTextBookCache.swift` file providing load/save/invalidate operations. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/08-pagination-quality-cache-performance/08-CONTEXT.md @.planning/phases/08-pagination-quality-cache-performance/08-RESEARCH.md @.planning/phases/08-pagination-quality-cache-performance/08-PATTERNS.md @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift @Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift @Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift From RDEPUBTextBookBuilder.swift: ```swift public struct RDEPUBTextBook: Equatable { public var chapters: [RDEPUBTextChapter] public var pages: [RDEPUBTextPage] public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) } public struct RDEPUBTextChapter: Equatable { public var chapterIndex: Int public var spineIndex: Int public var href: String public var title: String public var attributedContent: NSAttributedString public var fragmentOffsets: [String: Int] public var pageBreakReasons: [RDEPUBTextPageBreakReason] public var pages: [RDEPUBTextPage] } public struct RDEPUBTextPage: Equatable { public var absolutePageIndex: Int public var chapterIndex: Int public var spineIndex: Int public var href: String public var chapterTitle: String public var pageIndexInChapter: Int public var totalPagesInChapter: Int public var content: NSAttributedString public var contentRange: NSRange public var pageStartOffset: Int public var pageEndOffset: Int public var metadata: RDEPUBTextPageMetadata } ``` From RDEPUBReadingModels.swift lines 183-215: ```swift public struct RDEPUBTextPageMetadata: Codable, Equatable { public var breakReason: RDEPUBTextPageBreakReason public var blockRange: NSRange? public var attachmentRanges: [NSRange] public var attachmentKinds: [RDEPUBTextAttachmentKind] public var blockKinds: [RDEPUBTextBlockKind] public var semanticHints: [RDEPUBTextSemanticHint] public var attachmentPlacements: [RDEPUBTextAttachmentPlacement] public var trailingFragmentID: String? public var diagnostics: [String] } ``` ```swift // Cache directory pattern: let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? .appendingPathComponent("RDEPUBTextBookCache", isDirectory: true) ?? FileManager.default.temporaryDirectory.appendingPathComponent("RDEPUBTextBookCache", isDirectory: true) try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) ``` ```swift // NSAttributedString supports NSCoding — serialize via NSKeyedArchiver let data = try NSKeyedArchiver.archivedData(withRootObject: object, requiringSecureCoding: false) let object = try NSKeyedUnarchiver.unarchivedObject(ofClass: SomeClass.self, from: data) ``` Task 1: Create RDEPUBTextBookCache class with cache key generation and disk I/O Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift Create a new file `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift` implementing the complete cache system. **RDEPUBTextBookCache class** (per D-01): 1. **Cache key generation** using CryptoKit SHA256: - Key inputs: `bookID: String`, `fontSize: CGFloat`, `lineHeightMultiple: CGFloat`, `contentInsets: UIEdgeInsets`, `pageSize: CGSize`, `schemaVersion: Int` - Format: `SHA256("\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_v\(schemaVersion)")` - Output: hex-encoded string + ".cache" extension for use as filename - Reference WXRead pattern: `WRChapterPageCount.currentCacheKeyWithBookId:` encodes bookId + fontSize + lineSpacing + pageWidth + pageHeight 2. **Thread safety** using a serial DispatchQueue: - `private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)` - All read/write operations wrapped in `queue.sync { }` 3. **Storage directory**: `Library/Caches/RDEPUBTextBookCache/` using `FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)`, with fallback to `temporaryDirectory`. Create directory with `createDirectory(at:withIntermediateDirectories:)` on init. 4. **Serialization strategy** using NSKeyedArchiver: - Since RDEPUBTextBook is NOT NSCoding-conformant, create NSCoding wrapper classes for serialization: - `RDEPUBTextBookArchive: NSObject, NSCoding` — stores array of chapter archives - `RDEPUBTextChapterArchive: NSObject, NSCoding` — stores attributedContent (via NSKeyedArchiver), page data arrays - `RDEPUBTextPageArchive: NSObject, NSCoding` — stores all RDEPUBTextPage fields - `RDEPUBTextPageMetadataArchive: NSObject, NSCoding` — stores RDEPUBTextPageMetadata fields - For NSRange fields: encode as `{location: Int, length: Int}` pairs - For enums: encode as rawValue strings - Wrap all NSCoding classes as `private` or `internal` (not public API) 5. **Public API**: - `public init(subdirectory: String = "RDEPUBTextBookCache")` — sets up cache directory - `public func cacheKey(bookID: String, fontSize: CGFloat, lineHeightMultiple: CGFloat, contentInsets: UIEdgeInsets, pageSize: CGSize) -> String` — returns SHA256 hash filename - `public func load(key: String) -> RDEPUBTextBook?` — deserializes from disk, returns nil on miss or error - `public func save(_ book: RDEPUBTextBook, key: String)` — serializes to disk - `public func invalidateAll()` — deletes all files in cache directory - `public var schemaVersion: Int` — default 1, bump when pagination logic changes to force invalidation 6. **Error handling**: All file I/O wrapped in do/catch. Failures logged via `print("[Cache] ...")` pattern and return nil/false (no throws in public API to avoid breaking callers). 7. **Console logging pattern** (follows existing `[EPUB]` style): - `[Cache] save key=... chapters=N pages=N` on save - `[Cache] load HIT key=...` on hit - `[Cache] load MISS key=...` on miss - `[Cache] invalidateAll` on clear xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5 - File `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift` exists and contains `public final class RDEPUBTextBookCache` - Cache key method produces deterministic SHA256 hex string from layout parameters - Same inputs always produce identical key (deterministic) - Different fontSize values produce different keys - `schemaVersion` is a public property defaulting to 1 - `load(key:)` returns `RDEPUBTextBook?` (not throwing) - `save(_:key:)` accepts `RDEPUBTextBook` (not throwing) - `invalidateAll()` method exists - Serial dispatch queue used for thread safety (`queue.sync`) - Cache directory is under `Library/Caches/RDEPUBTextBookCache/` - NSCoding wrapper classes exist for RDEPUBTextBook/Chapter/Page/Metadata serialization - NSRange fields encoded as location+length integer pairs - Build succeeds without compiler errors RDEPUBTextBookCache.swift is a complete, compilable file implementing SHA256 cache key generation, NSKeyedArchiver serialization with NSCoding wrappers, serial-queue thread safety, and load/save/invalidateAll API. Build succeeds. ## Trust Boundaries | Boundary | Description | |----------|-------------| | disk-cache → memory | Cached RDEPUBTextBook loaded from disk into memory; tampered cache files could inject malicious attributed strings | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation | |-----------|----------|-----------|-------------|------------| | T-08-01 | Tampering | RDEPUBTextBookCache disk files | accept | Cache stored in app sandbox Library/Caches; no external access; low-value target | | T-08-02 | Information Disclosure | RDEPUBTextBookCache disk files | accept | No PII in cache; book content already accessible via app bundle | | T-08-SC | Tampering | npm/pip/cargo installs | mitigate | No external packages installed in this phase | - `xcodebuild build -scheme ReadViewDemo` succeeds - `RDEPUBTextBookCache.swift` contains public class with cacheKey/load/save/invalidateAll - SHA256 key generation uses CryptoKit - NSCoding wrapper classes handle NSAttributedString and NSRange serialization - Cache directory created under Library/Caches - New file `RDEPUBTextBookCache.swift` exists with complete implementation - Cache key is deterministic: same inputs -> same key - NSKeyedArchiver serialization produces valid .cache files - Build succeeds with no warnings related to new code Create `.planning/phases/08-pagination-quality-cache-performance/08-01-SUMMARY.md` when done