diff --git a/.claude/tdd-guardian/config.json b/.claude/tdd-guardian/config.json index 7d8b40b..6efce7c 100644 --- a/.claude/tdd-guardian/config.json +++ b/.claude/tdd-guardian/config.json @@ -1,6 +1,6 @@ { "enabled": true, - "testCommand": "xcodebuild test -project vreader.xcodeproj -scheme vreader -destination 'platform=iOS Simulator,name=iPhone 16' -quiet", + "testCommand": "DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild test -project vreader.xcodeproj -scheme vreader -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:vreaderTests -quiet", "coverageCommand": "", "coverageSummaryPath": "", "mutationCommand": "", diff --git a/.claude/tdd-guardian/state.json b/.claude/tdd-guardian/state.json index d66bc66..4f05ab8 100644 --- a/.claude/tdd-guardian/state.json +++ b/.claude/tdd-guardian/state.json @@ -1 +1 @@ -{"last_gate_passed_at": "2026-03-11T12:00:00Z", "tests_passed": 1508, "coverage_passed": true, "last_head_sha": "ea2fb68e7a90bfd2a28149d1924e968151bb8909"} +{"last_gate_passed_at": "2026-03-22T02:48:19Z", "tests_passed": 3167, "coverage_passed": true, "last_head_sha": "cbee04292cfa0465bd8e408e8dcd62376eec740e"} diff --git a/AGENTS.md b/AGENTS.md index 13f582f..f16b311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ Shared instructions for all AI agents (Claude, Codex, etc.). - You are an AI assistant working on the project. +- **Read `docs/architecture.md` before making any code changes. Update it when adding new layers, patterns, services, or changing how components communicate.** - Use English unless another language is requested. - Follow the working agreement: - Run `git status -sb` at session start. @@ -21,7 +22,8 @@ Shared instructions for all AI agents (Claude, Codex, etc.). - Write a failing test (RED), implement minimally (GREEN), refactor (REFACTOR). - Coverage thresholds are enforced — `ut` fails if coverage drops. - Exceptions: CSS-only, docs, config. See `.claude/rules/10-tdd.md` for full scope. - - Run ut for gates. + - Run `xcodebuild test -only-testing:vreaderTests` for unit test gates. Skip UI tests during development. + - Default simulator: **iPhone 17 Pro** (Dynamic Island — catches safe area bugs). - **Task workflow** (three files, one flow): - `docs/tasks.md` — **inbox**. User writes free-form descriptions. Agent triages (classify only, do not fix or implement during triage). See `docs/tasks.md` for classification rules, deduplication, and triage record format. - `docs/bugs.md` — **bug tracker**. Something implemented but broken. Follow the bug fix workflow defined in `docs/bugs.md` (Understand → RED → GREEN → REFACTOR → Verify → Track). diff --git a/README.md b/README.md index 909430c..891689d 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,83 @@ # VReader -An iOS reader app for EPUB, PDF, TXT, and Markdown files — built with Swift 6, SwiftUI, and SwiftData. +**Built entirely by AI — coded, tested, and debugged by AI agents. Human-directed.** + +An iOS reader for EPUB, PDF, TXT, and Markdown — built entirely by AI coding agents, with Swift 6, SwiftUI, and SwiftData. ## About -VReader is a modern reading app designed for iPhone and iPad. It provides a unified reading experience across multiple document formats with features like reading position persistence, bookmarks, highlights, full-text search, and reading time tracking. Documents sync across devices via iCloud. +VReader is a modern reading app designed for iPhone and iPad, built entirely by AI coding agents (Claude Code + Codex CLI) with human direction on requirements and testing. It provides a dual-mode reading experience (native UIKit + unified TextKit 2 reflow) across multiple document formats with annotations, full-text search, AI assistant, TTS, book source scraping, and WebDAV backup. ## Features -- **Multi-format support** — Read EPUB, PDF, TXT, and Markdown files in a single app -- **Reading position persistence** — Automatically saves and restores your scroll position per book, surviving app backgrounding, kills, and relaunches -- **CJK & encoding support** — Automatic encoding detection for GBK, Big5, Shift-JIS, EUC-KR, and other non-UTF-8 files -- **Large file performance** — Chunked rendering (UITableView) for TXT files over 500K characters; no glyph storage blowup -- **Bookmarks & highlights** — Save your place and annotate passages with color-coded highlights and notes -- **EPUB/PDF annotation** — Text selection + highlight/note actions in EPUB (CSS Highlight API) and PDF (PDFAnnotation). Persists across sessions via unified AnnotationAnchor schema -- **Full-text search** — Search across your entire library with SQLite FTS5 and CJK-aware tokenization. Highlights match at destination -- **Reading progress bar** — Draggable scrubber in all 4 formats: continuous for TXT/MD, page-based for PDF, chapter-based for EPUB -- **Table of contents** — Auto-generated for Markdown (heading extraction), built-in for EPUB/PDF -- **AI assistant** — Summarize sections, multi-turn chat with book context, bilingual translation (9 languages), general AI chat. OpenAI-compatible API -- **Reading time tracking** — Automatic session tracking with per-book statistics and reading speed calculations -- **Reader settings** — Configurable font size, font family, line spacing, letter spacing, and theme -- **Library management** — Grid/list view with persistent preferences, book info sheet, share, context menu -- **Import from anywhere** — Open files via Share Sheet, Files app, or direct download +### Reading +- **Multi-format** — EPUB, PDF, TXT, Markdown in a single app +- **Dual-mode engine** — Native (UIKit bridges) + Unified (TextKit 2 reflow) rendering +- **Reading position** — Auto-saves scroll position, survives app kills and relaunches +- **CJK encoding** — Auto-detect GBK, Big5, Shift-JIS, EUC-KR (8KB sample-based) +- **Large file support** — Chunked UITableView for TXT files >500K characters +- **Paginated mode** — CSS columns (EPUB), TextKit containers (TXT/MD), PDFKit pages +- **Page turn animations** — Slide, cover-flip, or instant +- **Auto page turning** — Timer-based advancement with configurable interval +- **Configurable tap zones** — Left/center/right zones mapped to customizable actions + +### Annotations +- **Bookmarks, highlights, notes** — Full CRUD for all formats (TXT/MD/PDF/EPUB) +- **EPUB highlights** — CSS Highlight API with JS bridge + buffered delivery +- **PDF highlights** — PDFAnnotation-based with selection detection +- **TXT/MD highlights** — NSAttributedString with persistent rendering +- **Export/import** — Markdown + JSON export, VReader JSON round-trip import + +### Search & Navigation +- **Full-text search** — SQLite FTS5 with CJK tokenization, persistent index +- **Reading progress bar** — Draggable scrubber (continuous, page-based, chapter-based) +- **Table of contents** — EPUB nav/NCX, PDF outline, TXT auto-detection (25 Legado rules), MD headings +- **Dictionary** — System dictionary lookup + AI translation on text selection + +### AI +- **Summarization** — Section and chapter summaries via OpenAI-compatible API +- **Chat** — Multi-turn conversation with book context +- **Translation** — Bilingual view (9 languages) +- **General chat** — AI chat without book context + +### Library +- **Grid/list view** — Persistent sort order and view mode +- **Collections** — Tags, series, custom groups +- **Custom covers** — Set from photo library +- **Context menu** — Info, share, set cover, delete +- **OPDS catalog** — Browse and download from OPDS 1.2 feeds +- **Book sources** — Legado-compatible rule engine for web novel scraping + +### Text Processing +- **TTS** — System (AVSpeechSynthesizer) + cloud HTTP TTS with playback controls +- **TTS sentence highlight** — NLTokenizer-based sentence detection synced to speech position +- **TTS auto-scroll** — Text view follows speech position in real-time +- **Simp/Trad Chinese** — Toggle conversion via ICU (live re-apply without reloading) +- **Content replacement** — Regex rules for text cleanup (live re-apply via source text storage) +- **Reading time tracking** — Per-book session stats and speed calculations + +### Sync & Backup +- **iCloud Sync** (foundation) — CloudKit sync engine, record mapper (8 types), device identity, change tokens, durable tombstones, settings bridge (NSUbiquitousKeyValueStore) +- **WebDAV backup** — Archive to any WebDAV server (Nutstore compatible) +- **Per-book settings** — Font, theme, spacing overrides per book (JSON-persisted) +- **Theme backgrounds** — Custom background images via PhotosPicker with per-theme opacity ## Tech Stack | Component | Technology | | ----------- | -------------------------------------------------- | | UI | SwiftUI | -| Persistence | SwiftData + CloudKit | +| Persistence | SwiftData (SchemaV4) | | EPUB | WKWebView bridge with CSS theme injection + JS highlight API | | PDF | PDFKit + PDFAnnotation for highlights | | TXT | TextKit 1 (UITextView) + chunked UITableView | | Markdown | NSAttributedString rendering via MDParser | | Search | SQLite FTS5 with CJK tokenization | | AI | OpenAI-compatible API (summarize, chat, translate) | +| TTS | AVSpeechSynthesizer + HTTP cloud TTS | +| Backup | WebDAV client + iCloud Sync foundation (CloudKit) | | Encoding | ICU + heuristic detection (UTF-8/GBK/Big5/Shift-JIS) | | Concurrency | Swift 6 strict concurrency | -| Project gen | XcodeGen | ## Requirements @@ -59,27 +99,28 @@ Then select a simulator or device and run. ## Architecture +See [`docs/architecture.md`](docs/architecture.md) for the full architecture document. + ``` vreader/ -├── App/ # App entry point, configuration -├── Models/ # SwiftData models (Book, ReadingPosition, Bookmark, etc.) +├── App/ # App entry point, SwiftData schema init +├── Models/ # SwiftData models, DocumentFingerprint, Locator +├── ViewModels/ # Library and per-format reader view models ├── Views/ -│ ├── Reader/ # Reader views per format (EPUB, PDF, TXT, MD) -│ ├── Library/ # Library views, book info, context menu -│ ├── Settings/ # AI settings, preferences -│ └── AI/ # Chat view -├── ViewModels/ # Per-reader and per-feature view models +│ ├── Reader/ # Reader container, format bridges, chrome overlay +│ ├── Bookmarks/ # BookmarkListView, TOCListView +│ ├── Annotations/ # HighlightListView, AnnotationListView +│ └── Settings/ # ReaderSettingsPanel, AI/TTS/WebDAV settings ├── Services/ -│ ├── EPUB/ # EPUB parsing and rendering -│ ├── TXT/ # TXT service, chunker, attributed string builder -│ ├── MD/ # Markdown parser and renderer -│ ├── Search/ # FTS5 indexing, text extraction, tokenization -│ ├── AI/ # AI service, configuration, context extraction -│ ├── Sync/ # iCloud sync coordination -│ └── Locator/ # Reading position model (Readium-inspired) -└── Utils/ # Helpers, extensions, encoding detection -vreaderTests/ # Unit tests (2040+ test cases) -vreaderUITests/ # UI tests (XCTest) +│ ├── TXT/, EPUB/ # Format-specific parsing and loading +│ ├── Search/ # FTS5 indexing, text extraction +│ ├── AI/, TTS/ # AI service, TTS providers +│ ├── Backup/ # WebDAV client, BackupProvider +│ ├── Sync/ # iCloud sync engine, CloudKit mapper, tombstones +│ ├── TextMapping/ # Simp/Trad, replacement rules +│ └── Locator/ # Reading position (Readium-inspired) +vreaderTests/ # Unit tests (3200+ test cases) +vreaderUITests/ # UI tests (XCUITest) ``` ### Key Design Decisions @@ -92,7 +133,7 @@ vreaderUITests/ # UI tests (XCTest) ## AI-Powered Development -VReader is built using an AI-assisted coding workflow with multiple agents collaborating through structured processes. +All code, tests, bug fixes, and documentation are produced by AI coding agents. The human role is directing requirements, reporting bugs, and verifying on device. ### Tools @@ -129,6 +170,10 @@ Shared rules for all AI agents live in [`AGENTS.md`](AGENTS.md): - `CLAUDE.md` — Claude Code project instructions - `AGENTS.md` — Shared instructions for all AI coding agents +## Status + +Active development. See [features](docs/features.md) (38 done) and [bugs](docs/bugs.md) (87 fixed) for current state. + ## License -TBD +MIT diff --git a/dev-docs/plans/20260322-1600-icloud-backup.md b/dev-docs/plans/20260322-1600-icloud-backup.md new file mode 100644 index 0000000..03cc40a --- /dev/null +++ b/dev-docs/plans/20260322-1600-icloud-backup.md @@ -0,0 +1,487 @@ +# iCloud Sync — Implementation Plan (v2, revised) + +**Feature #10** | **Date**: 2026-03-22 | **Revised**: 2026-03-22 (post-Codex review) +**Based on**: `docs/archive/codex-plans/icloud-backup-design.md` (2026-03-14) + +--- + +## Revision Notes + +This is a **sync** feature, not a snapshot backup. The snapshot backup system (Feature #29, WebDAV) coexists as a separate, independent mechanism. This plan was revised after Codex architectural review to address: + +- Backup vs sync identity confusion (now explicitly "iCloud Sync") +- Missing sync engine foundation (new Phase 0) +- Missing bootstrap/enable flow +- Missing CK record types for BookSource + ReplacementRule +- Sequencing issues (tombstones before annotation sync, metadata before file sync) +- Underspecified deviceId, queue, subscriptions, change tokens +- Feasibility gaps in SyncConflictResolver (LWW, not field-merge) + +--- + +## 1. Outcomes + +1. Reading data syncs continuously across devices with the same Apple ID. +2. Settings sync automatically via NSUbiquitousKeyValueStore. +3. Reading positions sync near-real-time (LWW by updatedAt). +4. Annotations sync with tombstone-aware LWW conflict resolution. +5. Book files sync via iCloud Documents (download-on-demand). +6. Sync is opt-in (feature flag + user toggle) and gracefully degrades when iCloud is unavailable. +7. WebDAV snapshot backup (Feature #29) operates independently — both can be enabled. + +## 2. Constraints & Dependencies + +| Constraint | Detail | +|-----------|--------| +| Apple Developer account | Must enable iCloud + CloudKit capabilities | +| Container ID | `iCloud.com.vreader.app` | +| Entitlements | Required before any CloudKit/iCloud Documents API calls | +| Minimum iOS | 17.0 (matches project target) | +| Existing infrastructure | SyncConflictResolver (7 algorithms), FileAvailabilityStateMachine, TombstoneStore (in-memory), SyncService (stub), SyncStatusMonitor | +| WebDAV backup | Feature #29 is independent; both can be active. If user restores WebDAV snapshot while sync is on, local wins on next sync cycle (newest updatedAt). User warned before restore. | +| Privacy | CloudKit private database only — no public/shared data | +| Feature flag | `FeatureFlags.sync` guards all iCloud operations | +| Real device | CloudKit integration tests require physical device + Apple ID | + +## 3. Current Behavior + +- All data is local-only (SwiftData + UserDefaults + filesystem). +- WebDAV backup exists as concrete BackupProvider (ZIP archive with 7 JSON files). +- SyncService is an actor stub: feature-flag guard, in-memory file states, no CK wiring. +- SyncConflictResolver has 7 resolution algorithms returning `.useLocal`/`.useRemote` (not field-level merge — **downgraded to tombstone-aware LWW for highlights**). +- FileAvailabilityStateMachine has 6 states, event-driven, in-memory only. +- TombstoneStore has protocol + in-memory implementation (needs SwiftData backing). +- PreferenceStore has protocol + UserDefaults implementation (needs NSUKVS bridge). +- No entitlements file exists. No CloudKit container configured. +- `ReadingPosition.deviceId` is empty string — needs real device identifier. + +## 4. Target Rules + +### R1: Settings Sync +- **Trigger**: UserDefaults write to synced keys (sortOrder, viewMode, theme, AI toggle, schemaVersion) +- **Behavior**: Mirror to NSUKVS. On `NSUbiquitousKeyValueStoreDidChangeExternallyNotification`, update local UserDefaults. +- **Conflict**: Automatic LWW (Apple handles it). +- **Scope**: 5 keys, < 500 bytes total. +- **Exclusion**: Per-book settings NOT synced (too many keys, local preference). + +### R2: Reading Position Sync +- **Trigger**: Position save (onBackground, periodic flush, reader close) +- **Behavior**: Enqueue CK upsert for VRReadingPosition. On remote change, apply LWW. +- **Conflict**: `SyncConflictResolver.resolvePosition()` — newest updatedAt wins, lexicographic deviceId tie-break. +- **Throttle**: Max 1 push per 5 seconds per book. + +### R3: Annotation Sync +- **Trigger**: Highlight/bookmark/note create, update, delete +- **Behavior**: Enqueue CK upsert or soft-delete. On remote change, apply tombstone-aware LWW. +- **Conflict**: Per-type resolver (all use tombstone-aware LWW; highlight "field-level merge" downgraded to LWW per feasibility review). +- **Edge case**: Delete on A, edit on B → tombstone wins (delete bias). + +### R4: Reading Session Sync +- **Trigger**: Session close +- **Behavior**: Append-only CK record. Deduplicate by sessionId. Stats recomputed locally. + +### R5: Book Source & Rule Sync +- **Trigger**: BookSource or ContentReplacementRule create/update/delete +- **Behavior**: CK upsert. Dedup by sourceURL (BookSource) or ruleId (Rule). LWW by updatedAt. + +### R6: Library Metadata Sync +- **Trigger**: Book import, metadata edit (title, author, tags, isFavorite) +- **Behavior**: CK upsert for VRBook record. User-edited fields win over extracted. +- **Edge case**: Book imported on both devices independently (same fingerprintKey) → merge, no duplicate. + +### R7: Book File Sync +- **Trigger**: Book import +- **Behavior**: Copy to iCloud Documents. Other devices see metadata-only (`FileAvailability.metadataOnly`). +- **Edge case**: iCloud storage full → local copy preserved, error surfaced in sync status UI. +- **Edge case**: 200MB PDF on cellular → iOS manages download policy. + +### R8: Graceful Degradation +- **Trigger**: iCloud unavailable (signed out, quota full, network error) +- **Behavior**: All reads/writes work locally. Outbound queue persists to SwiftData. Resume on reconnection via change token catch-up. +- **Edge case**: User signs out of iCloud → sync paused, local data preserved, no data deleted. Re-sign-in resumes. + +### R9: Enable/Disable Flow +- **Enable (first time)**: Upload all local data in batches (50 records per CKModifyRecordsOperation). Fetch remote zone for any existing records (other device already syncing). Merge using conflict resolvers. Show progress in sync status UI. +- **Disable**: Stop sync, clear outbound queue, preserve local data. Remote data stays in CloudKit. +- **Reinstall**: On first launch with sync enabled, full pull from CloudKit → merge with empty local DB (effectively a restore). + +## 5. Decision Log + +| # | Decision | Options Considered | Rationale | +|---|----------|-------------------|-----------| +| D1 | CloudKit custom zone (not SwiftData+CloudKit auto) | SwiftData auto, CloudKit direct | SyncConflictResolver needs per-record control; auto is LWW-only | +| D2 | Hybrid approach (CK + NSUKVS + iCloud Docs) | Single-tech | Each data type has different size/frequency/conflict needs | +| D3 | JSON blob for Locator in CK records | Flattened fields | Avoids CK schema changes when Locator evolves | +| D4 | Soft-delete tombstones (isDeleted field) | Hard delete | Enables cross-device delete propagation | +| D5 | 4-phase implementation (0→3) | Big-bang | Phase 0 proves infrastructure; Phase 1 alone is shippable | +| D6 | ReadingStats NOT synced (recomputed) | Sync stats | Derived from sessions; recompute is safer | +| D7 | Highlight conflict resolution is LWW (not field-merge) | Field-level merge | Existing resolver returns useLocal/useRemote, not merged records. Field-merge adds complexity for minimal UX gain. | +| D8 | This is "iCloud Sync" not "iCloud Backup" | Combined backup+sync | Continuous sync is the primary UX; WebDAV handles snapshot backup | +| D9 | BookSource + ContentReplacementRule included | Exclude from V1 | Small records, high user value — worth the 2 extra CK record types | +| D10 | deviceId = UUID stored in Keychain | identifierForVendor, UserDefaults | Keychain survives app reinstall; identifierForVendor resets on reinstall | + +## 6. Closed Questions + +| # | Question | Answer | Rationale | +|---|----------|--------|-----------| +| Q1 | Per-book settings sync? | **No** | Too many keys, local preference, low UX impact | +| Q2 | Custom covers sync? | **No** | Regenerated from book file | +| Q3 | Theme backgrounds sync? | **No** | Cosmetic per-device preference | +| Q4 | Book source definitions sync? | **Yes** — VRBookSource CK record type | Small JSON, high user value (shared sources across devices) | +| Q5 | Content replacement rules sync? | **Yes** — VRReplacementRule CK record type | Small JSON, user-configured transforms | + +## 7. Work Items + +### Phase 0: Infrastructure Spike (Size: S — 1-2 days) + +#### WI-000: Day-1 Spike — Entitlements + CloudKit Smoke Test +- **Goal**: Prove CloudKit works end-to-end on a real device. Create entitlements, container, custom zone. Save and fetch one test record. Receive a push notification. +- **Acceptance**: On real device: (1) zone created, (2) record saved, (3) record fetched, (4) CKSubscription delivers push. Screenshot of CloudKit Dashboard showing zone + record. +- **Tests**: Manual device test (not automatable). Document results in spike report. +- **Touched areas**: `vreader.entitlements` (new), `vreader.xcodeproj` (capabilities), `Info.plist` (background modes: remote-notification) +- **Dependencies**: Apple Developer account +- **Risks**: Provisioning profile issues → this spike catches them early +- **Rollback**: Revert entitlements + +### Phase 1: Sync Engine + Settings + Positions (Size: L — 5-7 days) + +#### WI-001: Sync Engine Foundation +- **Goal**: Build the core sync pipeline: CloudKit client abstraction, durable outbound queue, change token store, fetch/apply pipeline, CK subscriptions. +- **Acceptance**: (1) Outbound queue persists across app restart. (2) Change token stored in UserDefaults. (3) Cold-start catch-up fetches all changes since last token. (4) Push subscription registered for VReaderData zone. +- **Tests (unit, mocked CK)**: + - `CloudKitClientTests.swift` — mock CK client: save, fetch, modify, subscribe + - `SyncOutboundQueueTests.swift` — enqueue, dequeue, persist, replay after restart, dedup by record ID + - `ChangeTokenStoreTests.swift` — save token, load token, nil on first use + - `SyncPipelineTests.swift` — fetch → resolve → apply pipeline with mock data +- **Touched areas**: + - `vreader/Services/Sync/CloudKitClient.swift` (new) — protocol + production impl + - `vreader/Services/Sync/SyncOutboundQueue.swift` (new) — SwiftData-backed queue + - `vreader/Services/Sync/ChangeTokenStore.swift` (new) — UserDefaults wrapper + - `vreader/Services/Sync/SyncPipeline.swift` (new) — fetch/resolve/apply orchestrator + - `SyncService.swift` — wire new dependencies +- **Dependencies**: WI-000 (entitlements proven) +- **Risks**: CKFetchRecordZoneChangesOperation API complexity → start with simplest zone fetch +- **Rollback**: Feature flag OFF disables everything + +#### WI-002: Device Identity +- **Goal**: Generate stable deviceId, persist in Keychain, populate ReadingPosition.deviceId +- **Acceptance**: deviceId survives app reinstall (Keychain persistence). New install generates new UUID. +- **Tests**: `DeviceIdentityTests.swift` — generate, persist, retrieve, different per Keychain reset +- **Touched areas**: `vreader/Services/Sync/DeviceIdentity.swift` (new), `PersistenceActor.swift` (populate deviceId on position save) +- **Dependencies**: None +- **Risks**: Keychain access on first launch before unlock → use kSecAttrAccessibleAfterFirstUnlock + +#### WI-003: CloudKit Record Mapper (8 types) +- **Goal**: Bidirectional mapping for all 8 CloudKit record types ↔ SwiftData models +- **Acceptance**: Round-trip test: model → CKRecord → model preserves all fields for all 8 types +- **Tests**: `CloudKitRecordMapperTests.swift` — round-trip for VRBook, VRReadingPosition, VRBookmark, VRHighlight, VRAnnotation, VRReadingSession, VRBookSource, VRReplacementRule. Nil handling. Unknown fields ignored (forward compat). +- **Touched areas**: `vreader/Services/Sync/CloudKitRecordMapper.swift` (new) +- **Dependencies**: None (pure mapping logic) +- **CloudKit Record Types** (8 total): + - `VRBook` — fingerprintKey (unique), title, author, format, fileByteCount, addedAt, tags, isFavorite, detectedEncoding, updatedAt + - `VRReadingPosition` — bookFingerprintKey, locatorJSON, updatedAt, deviceId + - `VRBookmark` — bookmarkId (unique), bookFingerprintKey, locatorJSON, title, createdAt, updatedAt, isDeleted + - `VRHighlight` — highlightId (unique), bookFingerprintKey, locatorJSON, selectedText, color, note, createdAt, updatedAt, isDeleted + - `VRAnnotation` — annotationId (unique), bookFingerprintKey, locatorJSON, content, createdAt, updatedAt, isDeleted + - `VRReadingSession` — sessionId (unique), bookFingerprintKey, startedAt, endedAt, durationSeconds, pagesRead, wordsRead, deviceId, isRecovered + - `VRBookSource` — sourceURL (unique), sourceName, sourceGroup, sourceType, enabled, searchURL, ruleSearchData, ruleBookInfoData, ruleTocData, ruleContentData, updatedAt, isDeleted + - `VRReplacementRule` — ruleId (unique), pattern, replacement, isRegex, scopeKey, enabled, order, label, createdAt, isDeleted + +#### WI-004: NSUbiquitousKeyValueStore Settings Bridge +- **Goal**: Mirror 5 settings keys to NSUKVS +- **Acceptance**: Change sortOrder on Device A → eventually appears on Device B +- **Tests (unit, mocked NSUKVS)**: + - `NSUKVSBridgeTests.swift` — write propagates to mock store, notification updates local, round-trip +- **Touched areas**: `PreferenceStore.swift`, `vreader/Services/Sync/NSUKVSBridge.swift` (new) +- **Dependencies**: WI-000 (entitlements) + +#### WI-005: Reading Position Sync +- **Goal**: Sync VRReadingPosition via sync pipeline with LWW +- **Acceptance**: Close book on A, open on B → resumes at saved position +- **Tests**: + - `PositionSyncTests.swift` — push enqueued on save, pull applies remote, conflict resolution (newer wins, deviceId tie-break), throttle (max 1/5s), offline queue replay +- **Touched areas**: `SyncPipeline.swift`, `PersistenceActor.swift`, reader ViewModels (trigger sync on position save) +- **Dependencies**: WI-001 (pipeline), WI-002 (deviceId), WI-003 (mapper) + +#### WI-006: Sync Status UI + Enable/Disable Flow +- **Goal**: Settings UI for sync toggle, status display, enable/disable flow +- **Acceptance**: (1) Toggle ON → initial upload starts, progress shown. (2) Toggle OFF → queue cleared, local data preserved. (3) "Last synced: X ago" or error displayed. +- **Tests**: `SyncStatusViewTests.swift` — all states rendered correctly +- **Touched areas**: `SettingsView.swift`, `SyncStatusMonitor.swift` +- **Dependencies**: WI-001 (something to sync), WI-004 or WI-005 +- **Enable flow detail**: + 1. User toggles "iCloud Sync" ON + 2. App checks `CKContainer.accountStatus()` — if not `.available`, show error + 3. Create zone if needed (idempotent) + 4. Register CK subscription for zone changes + 5. Fetch all remote records via change token (nil = full fetch) + 6. Merge remote with local using conflict resolvers + 7. Upload all local records in batches of 50 + 8. Store change token + 9. Show "Sync complete" or error count + +### Phase 2: Annotations + Sessions (Size: L — 5-8 days) + +#### WI-007: TombstoneStore SwiftData Backing +- **Goal**: Replace InMemoryTombstoneStore with durable SwiftData-backed store +- **Acceptance**: Tombstones survive app restart; purge removes entries > 30 days +- **Tests**: `DurableTombstoneStoreTests.swift` — persist, fetch by type, purge by age, concurrent access +- **Touched areas**: `TombstoneStore.swift`, SwiftData schema (SchemaV5: add Tombstone entity) +- **Tombstone entity**: + ```swift + @Model final class Tombstone { + @Attribute(.unique) var key: String // "{entityType}:{entityId}" + var entityType: String + var entityId: String + var deviceId: String + var deletedAt: Date + } + ``` +- **Migration**: SchemaV4 → SchemaV5: additive (new entity, no data migration) +- **Dependencies**: None +- **Risks**: Schema migration — test forward and backward + +#### WI-008: Annotation Sync (Highlights, Bookmarks, Notes) +- **Goal**: Tombstone-aware LWW sync for all 3 annotation types +- **Acceptance**: Create highlight on A → appears on B. Delete on A → removed on B (within one sync cycle). +- **Tests**: + - `AnnotationSyncTests.swift` — create propagation, update propagation, delete via tombstone, tombstone-vs-edit (delete wins), offline queue, batch partial failure recovery +- **Touched areas**: `SyncPipeline.swift`, `SyncService.swift`, `PersistenceActor.swift` +- **Dependencies**: WI-007 (durable tombstones), WI-003 (mapper), WI-001 (pipeline) + +#### WI-009: Reading Session Sync +- **Goal**: Append-only sync for reading sessions +- **Acceptance**: Session from A appears in B's stats after recompute +- **Tests**: `SessionSyncTests.swift` — append, dedup by sessionId, stats recompute triggered +- **Touched areas**: `SyncPipeline.swift`, `PersistenceActor+Stats.swift` +- **Dependencies**: WI-003 (mapper), WI-001 (pipeline) + +#### WI-010: Book Source & Replacement Rule Sync +- **Goal**: LWW sync for BookSource (by sourceURL) and ContentReplacementRule (by ruleId) +- **Acceptance**: Add source on A → available on B. Delete rule on A → removed on B. +- **Tests**: `SourceRuleSyncTests.swift` — create, update, delete, dedup +- **Touched areas**: `SyncPipeline.swift`, `CloudKitRecordMapper.swift` +- **Dependencies**: WI-003, WI-001 + +### Phase 3: Book Files + Library Metadata (Size: XL — 8-12 days) + +#### WI-011: Library Metadata Sync (CloudKit) +- **Goal**: Sync VRBook metadata records. User-edited fields win. +- **Acceptance**: Import book on A → metadata appears in B's library (no file yet, cloud icon shown). Edit title on A → updated on B. +- **Tests**: `LibraryMetadataSyncTests.swift` — create, user-edit wins, import provenance excluded, metadata-only Book lifecycle +- **Touched areas**: `SyncPipeline.swift`, `LibraryViewModel.swift`, `LibraryView.swift` (cloud icon for remote-only) +- **Dependencies**: WI-003, WI-001 +- **Metadata-only Book UX**: + - Cloud icon overlay on cover thumbnail + - Tap → starts download (WI-013), shows progress + - Context menu: Info (read-only), Delete (removes from library + CloudKit) + - Reader disabled until file downloaded + +#### WI-012: iCloud Documents Container + Upload +- **Goal**: Copy imported book files to iCloud Documents +- **Acceptance**: Import EPUB → file appears in `iCloud.com.vreader.app/Documents/Books/{fingerprintKey}/` +- **Tests**: `iCloudDocumentStoreTests.swift` — upload, manifest.json written, duplicate skipped, error on full storage +- **Touched areas**: Entitlements (iCloud Documents capability), `vreader/Services/Sync/iCloudDocumentStore.swift` (new), `BookImporter.swift` +- **Dependencies**: WI-000 (entitlements), WI-011 (metadata synced first) + +#### WI-013: Book File Download on Demand +- **Goal**: Download book file from iCloud when user opens metadata-only book +- **Acceptance**: Tap cloud book → download progress → opens reader +- **Tests**: `BookDownloadTests.swift` — state machine transitions (metadataOnly → downloading → available), progress reporting, failure → retry, corruption detection +- **Touched areas**: `LibraryView.swift`, `FileAvailabilityStateMachine.swift` (add persistence + progress), `iCloudDocumentStore.swift` +- **Dependencies**: WI-012, WI-011 +- **FileAvailabilityStateMachine expansion**: + - Add `downloadProgress: Double` property + - Persist per-book state to UserDefaults (small: just fingerprintKey → state enum) + - Add `NSFilePresenter` / `NSMetadataQuery` for ubiquitous item status tracking + +## 8. Data Model + +### CloudKit Record Types (8) +Zone: `VReaderData` in private database. + +| Record Type | Unique Key | Fields | +|-------------|-----------|--------| +| VRBook | fingerprintKey | title, author, format, fileByteCount, addedAt, tags, isFavorite, detectedEncoding, updatedAt | +| VRReadingPosition | bookFingerprintKey | locatorJSON, updatedAt, deviceId | +| VRBookmark | bookmarkId | bookFingerprintKey, locatorJSON, title, createdAt, updatedAt, isDeleted | +| VRHighlight | highlightId | bookFingerprintKey, locatorJSON, selectedText, color, note, createdAt, updatedAt, isDeleted | +| VRAnnotation | annotationId | bookFingerprintKey, locatorJSON, content, createdAt, updatedAt, isDeleted | +| VRReadingSession | sessionId | bookFingerprintKey, startedAt, endedAt, durationSeconds, pagesRead, wordsRead, deviceId, isRecovered | +| VRBookSource | sourceURL | sourceName, sourceGroup, sourceType, enabled, searchURL, ruleSearchData, ruleBookInfoData, ruleTocData, ruleContentData, updatedAt, isDeleted | +| VRReplacementRule | ruleId | pattern, replacement, isRegex, scopeKey, enabled, order, label, createdAt, isDeleted | + +### Schema Versioning +- `schemaVersion` stored in NSUKVS (key: `"schemaVersion"`, value: `"1"`) +- Each CK record carries `recordSchemaVersion` field (Int) +- Version rules: local == remote → normal; local > remote → process; local < remote → read-only mode + prompt update + +### New SwiftData Entity (Phase 2, SchemaV5) +```swift +@Model final class Tombstone { + @Attribute(.unique) var key: String // "{entityType}:{entityId}" + var entityType: String + var entityId: String + var deviceId: String + var deletedAt: Date +} +``` + +### Migration +- SchemaV4 → SchemaV5: Add Tombstone entity (additive, no data migration) +- Rollback: Remove Tombstone from schema (acceptable — tombstones are ephemeral) + +## 9. Sync Engine Architecture + +``` +┌─────────────────────────────────────────────┐ +│ App Layer │ +│ PersistenceActor PreferenceStore VMs │ +└──────────────┬──────────────────────────────┘ + │ (enqueue changes) +┌──────────────▼──────────────────────────────┐ +│ SyncPipeline (new, actor) │ +│ ┌─────────────┐ ┌──────────────────────┐ │ +│ │ Outbound │ │ Inbound │ │ +│ │ Queue │ │ (fetch → resolve → │ │ +│ │ (SwiftData) │ │ apply via Persist.) │ │ +│ └──────┬──────┘ └──────────┬───────────┘ │ +│ │ │ │ +│ ┌──────▼──────┐ ┌─────────▼────────────┐ │ +│ │ CloudKit │ │ ChangeTokenStore │ │ +│ │ Client │ │ (UserDefaults) │ │ +│ │ (protocol) │ └──────────────────────┘ │ +│ └──────┬──────┘ │ +│ │ SyncConflictResolver │ +│ │ TombstoneStore (SwiftData) │ +│ │ DeviceIdentity (Keychain) │ +└─────────┼───────────────────────────────────┘ + │ + ┌──────▼──────┐ ┌──────────┐ ┌──────────────────┐ + │ CloudKit │ │ NSUKVS │ │ iCloud Documents │ + │ (records) │ │ (prefs) │ │ (book files) │ + └─────────────┘ └──────────┘ └──────────────────┘ +``` + +### Sync Triggers +| Trigger | Direction | Mechanism | +|---------|-----------|-----------| +| Local change | Push | PersistenceActor → SyncPipeline.enqueue() | +| CK push notification | Pull | AppDelegate → SyncPipeline.fetchChanges() | +| App foreground | Pull | ScenePhase → SyncPipeline.catchUp() (change token) | +| App background | Push | Flush outbound queue | +| User enable | Both | Full initial sync (upload local + fetch remote) | + +### Change Token Flow +1. On fetch, CKFetchRecordZoneChangesOperation provides a `serverChangeToken` +2. Store in `ChangeTokenStore` (UserDefaults, key: `"ck_changeToken_VReaderData"`) +3. Next fetch uses stored token → incremental fetch +4. Token = nil → full zone fetch (first sync or token reset) + +## 10. Testing Procedures + +### Test Categories +| Category | Environment | Automatable | When | +|----------|-------------|-------------|------| +| Unit (mocked CK) | Simulator | Yes | Every WI | +| Local integration | Simulator + SwiftData | Yes | Every WI | +| Device smoke | Real device | Manual | WI-000, end of each phase | +| Cross-device | 2 real devices, same Apple ID | Manual | End of each phase | + +### Commands +```bash +# Unit tests +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ + xcodebuild test -only-testing:vreaderTests \ + -scheme vreader -destination 'platform=iOS Simulator,name=iPhone 17 Pro' +``` + +## 11. Manual Test Checklist + +### Phase 0 (Spike) +- [ ] Create zone on real device +- [ ] Save + fetch one CKRecord +- [ ] Receive CK push notification +- [ ] Verify CloudKit Dashboard shows data + +### Phase 1 +- [ ] Enable sync → initial upload completes (progress visible) +- [ ] Change sort order → appears on second device +- [ ] Read 50% of a book → close → open on second device → resumes +- [ ] Disable sync → local data preserved +- [ ] Sign out of iCloud → no crash, sync paused +- [ ] Re-enable sync → catches up from change token +- [ ] Kill app during sync → restart → queue replays + +### Phase 2 +- [ ] Add highlight on A → visible on B +- [ ] Delete bookmark on A → removed on B +- [ ] Edit note on A while offline → go online → syncs to B +- [ ] Delete highlight on A, edit same on B (offline) → delete wins +- [ ] Read 10 min → stats appear on second device +- [ ] Add book source on A → available on B +- [ ] Add replacement rule on A → applied on B + +### Phase 3 +- [ ] Import EPUB on A → appears in B's library with cloud icon +- [ ] Tap cloud book on B → download progress → opens reader +- [ ] Edit title on A → updated on B +- [ ] Delete book on A → removed from B +- [ ] Import 50MB PDF → upload completes without UI freeze +- [ ] iCloud storage full → error shown, local copy works + +## 12. Plan → Verify Handoff + +### Evidence per WI +| WI | Evidence | +|----|----------| +| 000 | Spike report: screenshots of Dashboard, push receipt log | +| 001 | Unit tests pass (mocked CK), queue persistence test | +| 002 | DeviceIdentity unit tests, Keychain round-trip | +| 003 | Round-trip tests for all 8 record types | +| 004 | Unit tests, mock NSUKVS propagation | +| 005 | Position sync unit tests + 2-device manual demo | +| 006 | UI screenshot, enable/disable flow manual test | +| 007 | Tombstone persistence tests pass | +| 008 | Annotation sync unit tests + 2-device demo | +| 009 | Session sync + stats recompute test | +| 010 | Source/rule sync test | +| 011 | Metadata sync + cloud icon UI | +| 012 | File appears in iCloud Documents | +| 013 | Download-on-demand opens book | + +### Fixtures +- Test EPUB, PDF, TXT files (existing in test bundle) +- `MockCloudKitClient` conforming to CloudKitClient protocol +- `MockNSUbiquitousKeyValueStore` for settings tests +- Two physical devices with same Apple ID for cross-device tests + +## 13. Priority & Estimates + +| Phase | WIs | Estimate | Value | Ship Gate | +|-------|-----|----------|-------|-----------| +| Phase 0 | WI-000 | S (1-2 days) | Proves feasibility | Spike report | +| Phase 1 | WI-001 → WI-006 | L (5-7 days) | High — settings + position sync | Beta (TestFlight) | +| Phase 2 | WI-007 → WI-010 | L (5-8 days) | High — annotation sync | Beta → GA after 2 weeks | +| Phase 3 | WI-011 → WI-013 | XL (8-12 days) | Medium — file sync | Deferred to V2.1 | + +**Recommended**: Phase 0 → Phase 1 → Phase 2. Phase 3 deferred. + +## 14. Rollout Plan + +- **Feature flag**: `FeatureFlags.sync` (default OFF) +- **Phase 0**: Developer only (spike) +- **Phase 1 ship**: TestFlight beta +- **Phase 2 ship**: All users after 2 weeks beta +- **Kill switch**: `FeatureFlags.sync = false` → all sync stops, local data preserved +- **Nuclear revert**: Delete CloudKit zone via Dashboard (loses remote data, local preserved) +- **WebDAV coexistence**: Both can be active. WebDAV restore while sync is on → warn user, local becomes source of truth on next sync cycle. + +## 15. Architecture Doc Update + +Before implementation starts, update `docs/architecture.md` with: +- New Sync layer section (SyncPipeline, CloudKitClient, ChangeTokenStore, OutboundQueue) +- iCloud Documents subsystem +- NSUKVSBridge in PreferenceStore +- DeviceIdentity service +- SchemaV5 Tombstone entity diff --git a/docs/2026-03-21-refactoring-plan.md b/docs/2026-03-21-refactoring-plan.md new file mode 100644 index 0000000..0bb2687 --- /dev/null +++ b/docs/2026-03-21-refactoring-plan.md @@ -0,0 +1,160 @@ +# Refactoring Plan + +Prerequisite for continuing bug fixes and feature implementation. + +## Problem + +Rapid feature development across 6 phases accumulated debt that causes recurring bugs: + +- Container views have 12-15 `@State` variables each — hard to trace state bugs +- TXT/MD share highlight/annotation logic via `ReaderNotificationModifier` but EPUB/PDF don't use it +- PersistenceActor extensions (1,035 lines) have partial test coverage — collections and sessions tested, highlights/bookmarks not +- Bridges (467-537 lines) each handle 3-5 concerns +- Reader ViewModel lifecycle (open/close/background/foreground) duplicated across 4 VMs + +## Phases + +### Phase R1 — Persistence & Integration Tests + +**Goal**: Safety net for subsequent refactors. + +**Scope**: + +- Test `PersistenceActor+Highlights.swift` (161 lines) — create, fetch, delete, deduplication +- Test `PersistenceActor+Bookmarks.swift` — create, fetch, delete, rename +- Integration tests: TXT highlight create → delete → visual verify +- Integration tests: EPUB page-load highlight restore → remove +- Integration tests: PDF annotation create → restore → delete + +**Note**: Collections and sessions already have tests (`CollectionPersistenceTests`, `SwiftDataSessionStoreTests`). Don't duplicate. + +**Acceptance**: Highlight/bookmark persistence > 80% coverage. Integration test suite for each format's highlight flow. + +**Effort**: Medium. + +### Phase R2 — Extend Notification Modifier to EPUB/PDF ✅ (revised) + +**Goal**: Unify common notification handling; keep format-specific handlers local. + +**Note**: `ReaderNotificationModifier` handles shared text-reader notifications (bookmark, navigate, highlight, annotation, highlightRemoved) for TXT/MD. EPUB/PDF have format-specific handlers that cannot be unified (JS injection, PDFAnnotation, spine navigation, page navigation). + +**What was done**: +- Moved `highlightRemoved` into modifier for TXT/MD +- EPUB/PDF keep format-specific `.onReceive` handlers (bookmark, navigate, page, highlightRemoved, textSelected) — these are **justified** because they need format-specific logic + +**Acceptance (revised)**: TXT/MD use modifier for all shared handlers. EPUB/PDF keep justified format-specific handlers. No unjustified duplication. + +**Effort**: Small (original scope was too broad). + +### Phase R3 — Shared Text Reader UI State ✅ + +**Goal**: Eliminate duplicate UI state between TXT and MD containers. + +**Scope — SHARED** (move to `TextReaderUIState`): + +- `scrollToOffset`, `highlightRange`, `highlightIsTemporary` +- `persistedHighlightRanges`, `pendingAnnotationInfo`, `annotationNoteText` +- Pagination state (pageNavigator, pagedCurrentPage) +- `refreshPersistedHighlights()` + +**Scope — KEEP SEPARATE** (format-specific): + +- TXT: chunking, chunk offsets, background attr-string building, large-file detection +- MD: pre-rendered attributed string, MD parser state +- Locator factories (TXT raw text vs MD rendered text) + +**Acceptance**: TXT and MD containers share one UI state object. No behavior change. + +**Effort**: Medium. + +### Phase R4a — Highlight Contract + Format Adapters ✅ + +**Goal**: Define a shared highlight protocol without merging implementations. + +**Scope**: + +- Define `HighlightRenderer` protocol: `apply(record)`, `remove(id)`, `restore(records)` +- TXT/MD adapter: wraps existing NSRange highlight logic +- EPUB adapter: wraps existing JS injection logic +- PDF adapter: wraps existing PDFAnnotation logic + **retain highlightId → [PDFAnnotation] mapping** (needed for delete — bug #87) +- Wire `readerHighlightRemoved` through all 4 containers via adapters + +**Acceptance**: Bug #87 (PDF delete) fixed. All formats respond to `readerHighlightRemoved`. + +**Effort**: Medium. + +### Phase R4b — Highlight Orchestration ✅ + +**Goal**: Single coordinator for highlight lifecycle. + +**Scope**: + +- `HighlightCoordinator` owns: create, delete, restore, refresh-after-import +- Calls format-specific `HighlightRenderer` adapter +- Handles persistence via `PersistenceActor+Highlights` +- Replaces per-container highlight logic + +**Acceptance**: Highlight bug fixes touch `HighlightCoordinator` + adapter, not container views. + +**Effort**: Medium. + +### Phase R5a — Container View Slimming ✅ + +**Goal**: Container views under 350 lines. + +**What was done**: +- All containers split into main file + extensions (`+Highlights`, `+Navigation`, `+Overlays`, `+Helpers`) +- `ReaderContainerView`: 619→321 lines (deferred setup stays inline, format hosts already extracted) +- `EPUBReaderContainerView`: 616→342 lines (highlight sheet + spine nav to extensions) +- `TXTReaderContainerView`: 515→338 lines (reduced by R3 shared state) + +**Acceptance**: All containers ≤350 lines ✓ + +### Phase R5b — Bridge Slimming ✅ + +**Goal**: Bridge files under 400 lines. + +**What was done**: +- `EPUBWebViewBridge`: 535→229 lines (JS constants extracted to `EPUBWebViewBridgeJS.swift`) +- `TXTTextViewBridge`: 467→234 lines (coordinator logic extracted to extensions) +- `TXTChunkedReaderBridge`: 537→387 lines (cache/restore logic extracted) + +**Acceptance**: All bridges ≤400 lines ✓ + +### Phase R6 — Shared Reader ViewModel Lifecycle ✅ + +**Goal**: Eliminate duplicated open/close/background/foreground logic across 4 VMs. + +**Scope**: + +- Extract shared lifecycle (session tracking, position save/restore, stats recompute) to `ReaderLifecycleBase` protocol or base class +- TXT/MD/EPUB/PDF ViewModels adopt it +- Format-specific open/close logic stays in each VM + +**Acceptance**: Adding a new lifecycle hook (e.g., iCloud sync on close) touches 1 file, not 4. + +**Effort**: Large. + +## Execution Order + +``` +R1 → R2 → R3 → R4a → R4b → R5a → R5b → R6 +``` + +R1 first (safety net). R2-R4 reduce complexity. R5 is mechanical cleanup. R6 is optional polish. + +## Rules + +- Zero behavior changes in any phase +- Unit tests must pass after every phase (`xcodebuild test -only-testing:vreaderTests`) +- Skip UI tests during refactoring — they test behavior, not structure, and add 10+ minutes per run +- Read `docs/architecture.md` before starting any phase +- Update `docs/architecture.md` after completing each phase + +## Testing Strategy + +- **Now**: Add geometry assertions to UI tests (free, catches layout regressions like bug #62) +- **After UI settles**: Add `swift-snapshot-testing` for reader chrome/layout baselines (16-24 snapshots) +- **Pre-release only**: Run UI tests on 2 simulators — iPhone 17 Pro (Dynamic Island) + iPhone SE (no notch) +- **Default simulator**: iPhone 17 Pro (Dynamic Island — catches safe area bugs like #73) + diff --git a/docs/2026-03-22-feature-fix-plan.md b/docs/2026-03-22-feature-fix-plan.md new file mode 100644 index 0000000..79988d1 --- /dev/null +++ b/docs/2026-03-22-feature-fix-plan.md @@ -0,0 +1,75 @@ +# Feature Fix Plan (Revised) + +All TODO features — prioritized by effort, dependency, and risk. + +## Phase 0 — Discovery (before any fixes) ✓ DONE + +Reproduce and write RED tests for uncertain bugs. Do NOT implement fixes yet. + +| Bug | Feature | Root Cause | RED Test | +|-----|---------|-----------|----------| +| #77 | #11 EPUB highlights | onInjectJS nil race + callback swap in restoreHighlightsOnLoad | EPUBHighlightRendererBug77Tests.swift (4 tests) | +| #82 | #21 Paged mode | updatePagination destroys navigator when attrText nil (race) | PagedModeBug82Tests.swift (4 tests) | +| #98 | #27/#28 Transforms | loadReplacementRules races text load; no re-apply on change; no source text | TransformsBug98Tests.swift (6 tests) | + +**Acceptance**: Each bug has a confirmed root cause and a failing test. ✓ + +## Phase 1 — Verify & Close (bugs already fixed, verify on device) ✓ DONE + +| # | Feature | Blocking Bug | Status | Result | +|---|---------|-------------|--------|--------| +| 13 | AI summarize | #92 FIXED | DONE | Device verified: non-UTF-8 TXT → AI summarize → real content | +| 18 | AI translate | #95 FIXED | DONE | Device verified: Select word → Translate → opens Translate tab | +| 24 | Book sources | #100, #101 FIXED | DONE | Device verified: Import JSON → sources visible → search works | + +**NOT ready to verify** (still have open bugs): +- #26 TTS: #96 fixed but #97 (bar overlap) still open → stays in Phase 2 +- #35 Export/import: #88 (imported highlights don't render) still open → stays in Phase 2 + +## Phase 2 — Bug Fixes (code exists, specific bugs) ✓ DONE + +### Quick fixes (already done before this session): +| Bug | Feature | Status | +|-----|---------|--------| +| #97 | #26 TTS bar overlap | FIXED | +| #85 | #34 Collections context menu | FIXED | +| #86 | #34 Tags in sidebar | FIXED | +| #84 | #37 Per-book settings | FIXED | + +### Moderate fixes (done this session): +| Bug | Feature | Fix Applied | +|-----|---------|-------------| +| #77 | #11 EPUB highlights | JS buffering in EPUBHighlightRenderer (deliverOrBuffer + didSet flush) | +| #98 | #27/#28 Transforms | sourceText storage + didSet on activeTransforms re-applies | +| #88 | #35 Import highlights | .readerHighlightsDidImport notification → coordinator.restoreAll() | +| #82 | #21 Paged mode | Split guard: preserve navigator when attrText nil in paged mode | +| #83 | #23 TXT TOC | Enabled 6 more rules (9,10,13,14,20,23) — 14/25 active | +| #31 | Auto page turn | Unblocked by #82 fix (no code change needed) | + +## Phase 3 — Device Verification (no known bugs, just needs testing) + +| # | Feature | Pass Criteria | +|---|---------|--------------| +| 5 | Search auto-dismiss | Search highlight clears on scroll, tap, or new search | +| 29 | WebDAV backup | Backup creates archive; restore recovers data. Test with real WebDAV server | +| 36 | OPDS catalog | Add catalog URL, browse, download book → appears in library | + +## Phase 4 — New Implementation ✓ DONE (4 of 5) + +| # | Feature | Implementation | Status | +|---|---------|---------------|--------| +| 25 | Tap zone settings UI | tapZoneSection in ReaderSettingsPanel, 3 Pickers wired to TapZoneStore | DONE | +| 32 | Theme bg image picker | PhotosPicker + opacity slider + remove button in ReaderSettingsPanel | DONE | +| 40 | TTS sentence highlight | TTSHighlightCoordinator: NLTokenizer → binary search → highlightRange | DONE | +| 41 | TTS auto-scroll | Same coordinator → scrollToOffset. TXT/MD only | DONE | +| 10 | iCloud backup | Architecture spike needed — deferred to future session | DEFERRED | + +## Rules + +- Read `docs/architecture.md` before each phase +- Unit tests only: `xcodebuild test -only-testing:vreaderTests -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` +- Discovery before fix for uncertain bugs (#77, #82, #98) +- Features must reach PLANNED before IN PROGRESS (Phase 4) +- Update `docs/architecture.md` after architectural changes +- Update `docs/manual-test-checklist.md` with verification items +- Codex audit after each phase diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9dff47a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,203 @@ +# VReader Architecture + +## Overview + +VReader is an iOS e-book reader built with SwiftUI + SwiftData. It supports TXT, EPUB, PDF, and MD formats with dual rendering modes (Native UIKit bridges + Unified TextKit 2 reflow). + +## System Diagram + +``` +┌──────────────────────────────────────────────────────┐ +│ VReaderApp │ +│ SwiftData SchemaV3 · PersistenceActor · BookImporter│ +└─────────────────────┬────────────────────────────────┘ + │ + ┌───────────┴───────────┐ + │ │ + ┌─────▼──────────┐ ┌──────▼──────────────────┐ + │ LibraryView │ │ ReaderContainerView │ + │ LibraryViewModel│ │ (format dispatcher) │ + │ PreferenceStore │ │ ReaderChromeBar (overlay)│ + └─────────────────┘ └──────┬───────────────────┘ + │ + ┌────────┬───────────┬───┴────┐ + │ │ │ │ + ┌───▼──┐ ┌──▼───┐ ┌───▼──┐ ┌──▼───┐ + │ TXT │ │ EPUB │ │ PDF │ │ MD │ + │Bridge│ │Bridge│ │Bridge│ │Bridge│ + └──────┘ └──────┘ └──────┘ └──────┘ + UITextView WKWebView PDFKit UITextView +``` + +## Layers + +### 1. App Layer (`vreader/App/`) +- `VReaderApp.swift` — SwiftData ModelContainer init, migration plan (V1→V2→V3), test seeding, error handling + +### 2. Library Layer (`vreader/Views/LibraryView.swift`, `vreader/ViewModels/LibraryViewModel.swift`) +- Grid/list view with sort (persisted via `PreferenceStore`) +- Context menu: Info, Share, Set Cover, Add to Collection, Delete +- Collections sidebar, OPDS catalog, AI chat entry points + +### 3. Reader Layer (`vreader/Views/Reader/`) + +#### Dispatcher +`ReaderContainerView.swift` routes to format-specific readers: +- If unified mode + format supports reflow → `UnifiedTextRenderer` +- Else → native format host (UIKit bridge) + +#### Chrome +`ReaderChromeBar.swift` — custom overlay toolbar (not system nav bar). Floats on top of content, no safe area impact. Buttons: back, search, bookmark, annotations, AI, TTS, settings. + +#### Format Hosts (`ReaderFormatHosts.swift`) +Each host owns its ViewModel lifecycle via `@State`: +- `TXTReaderHost` → `TXTReaderContainerView` → `TXTTextViewBridge` (small) or `TXTChunkedReaderBridge` (>500K UTF-16) +- `EPUBReaderHost` → `EPUBReaderContainerView` → `EPUBWebViewBridge` (WKWebView + JS injection) +- `PDFReaderHost` → `PDFReaderContainerView` → `PDFViewBridge` (PDFKit) +- `MDReaderHost` → `MDReaderContainerView` → reuses `TXTTextViewBridge` with NSAttributedString + +#### Unified Engine +`ReaderUnifiedCoordinator` loads text + applies transforms (replacement rules, simp/trad). `UnifiedTextRenderer` displays with TextKit 2 pagination or scroll. + +### 4. Coordinator Layer (`vreader/Views/Reader/`) + +| Coordinator | Responsibility | Setup Timing | +|-------------|---------------|--------------| +| `ReaderAICoordinator` | AI ViewModels, text loading, context extraction | On AI/TTS invoke | +| `ReaderSearchCoordinator` | Search service, indexing, FTS5 | Service+VM on reader open (`prepareService`), indexing on search open | +| `ReaderUnifiedCoordinator` | Unified renderer state, text transforms | On reader open (unified mode only) | + +### 5. Services Layer (`vreader/Services/`) + +| Service | Backing | Purpose | +|---------|---------|---------| +| `PersistenceActor` | SwiftData (actor-isolated) | All DB writes serialized | +| `SearchService` + `SearchIndexStore` | SQLite FTS5 | Full-text search with persistent index | +| `AIService` | OpenAI-compatible REST API | Summarize, translate, chat | +| `TTSService` | AVSpeechSynthesizer + HTTP | Read aloud with controls | +| `BookContentCache` | In-memory | Text cache for AI context loading (TXT/MD only) | +| `PreferenceStore` | UserDefaults | Sort order, view mode persistence | +| `CustomCoverStore` | JPEG files | Custom book cover images | +| `WebDAVClient` | HTTP | Backup/restore to WebDAV server | + +### 6. Data Layer (`vreader/Models/`) + +SwiftData SchemaV4 entities: +- `Book` (fingerprintKey unique) → `ReadingPosition`, `Highlight`, `Bookmark`, `AnnotationNote`, `BookCollection` +- `ReadingSession`, `ReadingStats` +- `BookSource`, `ContentReplacementRule` (added in SchemaV4) + +Key types: +- `DocumentFingerprint` — `{format}:{SHA256}:{byteCount}` deterministic identity +- `Locator` — universal position: href+progression (EPUB), page (PDF), UTF-16 offset (TXT/MD) +- `AnnotationAnchor` — format-agnostic location encoding for highlights/bookmarks + +## Notification Bus (`ReaderNotifications.swift`) + +All cross-component communication uses NotificationCenter: + +| Notification | Payload | Direction | +|-------------|---------|-----------| +| `.readerContentTapped` | nil | Bridge → Container (toggle chrome) | +| `.readerPositionDidChange` | `Locator` | Format container → ReaderContainerView → AI coordinator | +| `.readerNavigateToLocator` | `Locator` | Container → Format container | +| `.readerBookmarkRequested` | nil | Chrome → Format container | +| `.readerHighlightRequested` | `TextSelectionInfo` | Bridge → Container | +| `.readerHighlightRemoved` | UUID string | HighlightListVM → Container | +| `.readerDidClose` | fingerprintKey | ViewModel → LibraryView | +| `.readerAnnotationRequested` | `TextSelectionInfo` | Bridge → Container | +| `.readerDefineRequested` | `TextSelectionInfo` | Bridge → Container (dictionary) | +| `.readerTranslateRequested` | `TextSelectionInfo` | Bridge → Container (AI translate) | +| `.searchHighlightClear` | nil | SearchViewModel → Bridges | +| `.readerPreviousPage` | nil | TapZoneOverlay → Container | +| `.readerNextPage` | nil | TapZoneOverlay → Container | + +## Shared Reader UI State (Phase R3) + +`TextReaderUIState` (`@Observable`) holds UI state shared between TXT and MD containers: +- Highlight/annotation state: `scrollToOffset`, `highlightRange`, `persistedHighlightRanges`, `pendingAnnotationInfo`, `annotationNoteText` +- Pagination state: `pageNavigator`, `pagedCurrentPage`, `autoPageTurner` +- Reading progress: `readingProgress` +- Helper methods: `syncPagedState()`, `updatePagination()`, `updateAutoPageTurner()`, `refreshPersistedHighlights()` + +Conforms to `ReaderNotificationHandlerStateProtocol` so `ReaderNotificationModifier` mutates it directly. + +Format-specific state remains in each container: +- TXT: chunking, chunk offsets, attributed string building, large-file detection +- MD: rendered attributed string (from `MDReaderViewModel`) + +## Highlight System (Phase R4a/R4b) + +`HighlightRenderer` protocol defines format-agnostic visual operations: `apply(record:)`, `remove(id:)`, `restore(records:)`. + +| Adapter | Format | Mechanism | +|---------|--------|-----------| +| `TextHighlightRenderer` | TXT, MD | Mutates `TextReaderUIState.persistedHighlightRanges` | +| `EPUBHighlightRenderer` | EPUB | Generates CSS Highlight API JS via `onInjectJS` callback | +| `PDFHighlightRenderer` | PDF | Creates/removes `PDFAnnotation` objects; tracks `highlightId → [PDFAnnotation]` map | + +`HighlightCoordinator` orchestrates the highlight lifecycle: +- `create()` — persists via `HighlightPersisting`, then calls `renderer.apply()` +- `handleRemoval()` — calls `renderer.remove()`, re-fetches, calls `renderer.restore()` +- `restoreAll()` — fetches from persistence, calls `renderer.restore()` + +Each container creates its format-specific renderer and coordinator: +- TXT/MD: via `ReaderNotificationModifier` (handles `readerHighlightRequested` / `readerHighlightRemoved`) +- EPUB: coordinator for persistence, renderer for JS injection +- PDF: coordinator + renderer with annotation map (fixes bug #87: highlight deletion) + +## Key Design Patterns + +1. **Bridge** — UIKit views (UITextView, WKWebView, PDFView) wrapped in `UIViewRepresentable` with Coordinator for delegate/gesture handling +2. **Coordinator** — Complex multi-subsystem flows managed by dedicated coordinator objects (AI, Search, Unified, Highlight) +3. **Protocol injection** — `LibraryPersisting`, `BookImporting`, `PreferenceStoring`, `TTSProviderProtocol`, `HighlightRenderer` enable testing +4. **Actor isolation** — `PersistenceActor` serializes all SwiftData writes; `TXTService` is actor-isolated +5. **Deferred setup** — AI, search indexing, TOC building triggered on first use, not reader open +6. **Observer** — NotificationCenter decouples format-specific readers from chrome and coordinators +7. **Shared state extraction** — `TextReaderUIState` eliminates duplicated `@State` between TXT/MD containers (Phase R3) +8. **Format adapters** — `HighlightRenderer` protocol with per-format adapters decouples highlight lifecycle from rendering mechanism (Phase R4a) +9. **Extension splitting** — Container views and bridges decomposed into `+Highlights`, `+Navigation`, `+Overlays`, `+Helpers` extensions. Core wiring stays in the main file; subviews and action methods live in extensions (Phase R5a/R5b) +10. **Lifecycle composition** — `ReaderLifecycleHelper` owns session tracking, periodic flush, time display, and close/background/foreground sequences. Each format VM composes one instance and delegates shared lifecycle calls (Phase R6) + +## Performance Optimizations + +| Optimization | Target | +|-------------|--------| +| Sample-based encoding (8KB) | Fast TXT open for non-UTF-8 files | +| Chunked reader (UITableView) | Large TXT files (>500K UTF-16) | +| Deferred coordinator setup | Fast reader open (no AI/search/TOC upfront) | +| Persistent FTS5 index | Skip re-indexing on subsequent opens | +| Off-main-thread attributed string | Non-blocking TXT/MD rendering | +| PaginationCache | Avoid redundant TextKit layout passes | +| Non-contiguous layout | TextKit 1 performance for large documents | + +## File Organization + +``` +vreader/ +├── App/ # VReaderApp, ContentView +├── Models/ # SwiftData models, DocumentFingerprint, Locator +├── ViewModels/ # LibraryViewModel, format ViewModels +├── Views/ +│ ├── LibraryView.swift # Library grid/list +│ ├── Reader/ # Reader container, format containers, bridges +│ ├── Bookmarks/ # BookmarkListView, TOCListView +│ ├── Annotations/ # HighlightListView, AnnotationListView +│ └── Settings/ # SettingsView, ReaderSettingsPanel +├── Services/ +│ ├── PersistenceActor.swift +│ ├── TXT/ # TXTService, TXTFileLoader +│ ├── EPUB/ # EPUBParser, EPUBTypes +│ ├── Search/ # SearchService, FTS5, extractors +│ ├── AI/ # AIService, providers +│ ├── TTS/ # TTSService, providers +│ ├── Backup/ # WebDAV, BackupProvider +│ ├── Import/ # BookImporter +│ ├── Export/ # AnnotationExporter +│ ├── Locator/ # LocatorFactory, position resolution +│ ├── OPDS/ # OPDSClient, OPDSParser +│ ├── Sync/ # SyncService, SyncStatusMonitor +│ ├── Unified/ # PaginationCache, TextKit2 helpers +│ └── TextMapping/ # Transforms, offset mapping +└── (no Plugins/ directory — BookSource views are in Views/BookSource/) +``` diff --git a/docs/archive/bugs-history.md b/docs/archive/bugs-history.md index bc8eeb3..401f60d 100644 --- a/docs/archive/bugs-history.md +++ b/docs/archive/bugs-history.md @@ -217,7 +217,57 @@ Moved from `docs/bugs.md` to reduce file size. The Summary table in `docs/bugs.m - **Solution**: Added `persistedHighlights: [NSRange]` parameter to both `TXTTextViewBridge` and `TXTChunkedReaderBridge`. Container views (`TXTReaderContainerView`, `MDReaderContainerView`) fetch highlights via `persistence.fetchHighlights()` in their `.task` block after file open. Ranges are rendered via `HighlightingLayoutManager.drawBackground()` (bug #47 v12) — zero text storage mutation. - **Lessons**: Persisting data is only half the story — loading and rendering it on the display path must also be implemented. When adding a "save" feature, always plan the "load and display" counterpart. +### Bug #62 — Content shifts down when top bar reappears +- **Root cause**: `ReaderContainerView` toggled `.ignoresSafeArea(edges: isChromeVisible ? [] : [.top])` in the same `withAnimation` block as `isChromeVisible`. SwiftUI does not animate `ignoresSafeArea` changes — it applies them immediately. When chrome was shown, the safe area was restored at the START of the animation, snapping content down by ~88pt while the nav bar was still animating in. +- **Solution**: Introduced a separate `@State var isIgnoringTopSafeArea` that lags behind `isChromeVisible` when showing chrome. When HIDING: `isIgnoringTopSafeArea = true` immediately (no gap during nav bar exit). When SHOWING: nav bar animates in (0.2s), then `isIgnoringTopSafeArea = false` fires at 0.22s (after nav bar is fully visible). The safe area snap now occurs after the nav bar is in place, making it imperceptible. +- **Lessons**: SwiftUI's `ignoresSafeArea` is not an animatable property — layout state that depends on it must be managed separately if smooth transitions are needed. Use separate state variables when two values need different timing. + +### Bug #63 — Progress bar unresponsive in Native mode +- **Root cause**: `TapZoneModifier` placed `Color.clear.contentShape(Rectangle()).onTapGesture` in a ZStack ABOVE the entire native reader container (including `ReadingProgressBar`). The `Color.clear` with `contentShape(Rectangle())` makes the full screen area participate in hit-testing, capturing touch events before they reached the underlying SwiftUI `Slider`. The Slider's internal drag gesture never received the touch. +- **Solution**: Replaced the single full-screen clear overlay with a `VStack` containing: (1) a `Color.clear` tap detector for the reading area, and (2) a `Color.clear.allowsHitTesting(false)` spacer with `height: bottomInset` (default 100pt) at the bottom. The bottom zone passes all touches through to the progress bar Slider and bottom overlay. Added `bottomInset` parameter to both `TapZoneModifier` and `tapZoneOverlay(config:bottomInset:)` extension. +- **Lessons**: ZStack overlay views with `contentShape(Rectangle())` intercept all gestures in their frame — never place them above interactive controls. If a full-screen overlay is needed, always add a hit-testing exclusion zone for any interactive child controls. + ### Bug #56 — PDF crash after adding highlight and reopening - **Root cause**: `PDFAnnotationBridge.denormalizeRects()` did not validate input rects. If an `AnnotationAnchor` contained rects with NaN, infinity, or negative dimension values (from corrupt Codable decode or edge-case coordinate math), these were passed directly to `PDFAnnotation(bounds:forType:withProperties:)`. PDFKit forwards these to CoreGraphics, which logs "NaN passed to CoreGraphics API" errors and can crash on certain devices/OS versions. Additionally, `denormalizeRects` lacked the zero-dimension `pageBounds` guard that `normalizeRects` had, creating an asymmetry. - **Solution**: Added `isValidRect(_:)` private helper that checks `origin.x/y.isFinite`, `size.width/height.isFinite`, and `size.width/height >= 0` (using `size.width` not `width` because `CGRect.width` auto-normalizes negatives). Applied validation in two layers: (1) `denormalizeRects` now guards against zero-dimension pageBounds AND filters input/output rects via `isValidRect`; (2) `createHighlight` filters rects before passing to `PDFAnnotation`. Defense-in-depth prevents any invalid rect from reaching PDFKit. - **Lessons**: (1) `CGRect.width` always returns positive (it's the absolute value) — use `CGRect.size.width` to detect negative dimensions. (2) When a "normalize" function has a safety guard (zero pageBounds), the corresponding "denormalize" function must have the same guard for symmetry. (3) PDFKit does not validate bounds inputs — the caller must validate before creating annotations. + +### Bugs #65–69 — Stale UI test expectations (batch fix) +- **Root cause**: Five UI tests had stale expectations from before features were fully implemented: + - #65: Empty state text updated to include "Markdown" but test still had old string without it. + - #66: Annotation panel tabs replaced placeholder text ("...will appear here once the reader is fully wired") with real `ContentUnavailableView` descriptions, but tests still expected old strings. + - #67: `findFirstRow()` in `DeleteConfirmationTests` only searched `app.buttons` for `bookRow_*` identifiers. SwiftUI List items on iOS 26 may render as cells instead of buttons. + - #68: Dynamic Type tests at xxxLarge/AX5 used `.exists` (no wait) for toolbar buttons that need rendering time at large type sizes. + - #69: PDF reader placeholder replaced by real PDFKit implementation; test still looked for `pdfReaderPlaceholder` identifier that no longer exists. +- **Solution**: Updated all test strings to match production code. Broadened `findFirstRow()`/`bookRowCount()` to check both `app.buttons` and `app.cells`. Changed `.exists` to `waitForExistence(timeout: 5)`. Updated PDF tests to verify `pdfReaderContainer`. +- **Lessons**: (1) When production code evolves (placeholders → real implementation), UI tests must be updated in the same change. (2) SwiftUI element type in the accessibility tree can change across iOS versions — always query multiple element types for resilience. (3) Toolbar items at large Dynamic Type sizes need explicit waits, not synchronous `.exists` checks. + +### Bug #62 v2 — Content shifts down when top bar reappears +- **Root cause**: v1 fix used `isIgnoringTopSafeArea` with a 0.22s delay to toggle `.ignoresSafeArea(edges: .top)`, but `ignoresSafeArea` is not animatable — it's a discrete layout rule change that always causes an instant content jump regardless of timing. +- **Solution**: Always set `.ignoresSafeArea(edges: .top)` (constant, never toggled). The navigation bar now overlaps content like Apple Books / Kindle / KOReader. Content position is pixel-stable because the safe area participation never changes. Removed `isIgnoringTopSafeArea` state and `DispatchQueue.main.asyncAfter` hack. Simplified `toggleChrome()` to a single `isChromeVisible.toggle()` with animation. +- **Lessons**: (1) `ignoresSafeArea` is a layout rule, not a visual property — toggling it always causes a jump regardless of timing hacks. (2) Reader apps should use a constant full-screen layout with the toolbar as an overlay, not toggle safe area participation. (3) If a timing-based fix doesn't fully solve a layout problem, the approach is fundamentally wrong. + +### Bug #70 — Cannot scroll content in native mode — all formats +- **Root cause**: `TapZoneModifier` placed `Color.clear.contentShape(Rectangle()).onTapGesture` in a ZStack above the native reader content. In SwiftUI, the topmost hit-testable view "owns" touches — `contentShape(Rectangle())` made the overlay intercept ALL touch events, preventing scroll/drag gestures from ever reaching the underlying UIKit views (UITextView, WKWebView, PDFView). The touch wasn't re-routed even when the tap recognizer failed. +- **Solution**: Removed `.tapZoneOverlay()` from the native reader path in `ReaderContainerView`. All four native bridges already had their own `UITapGestureRecognizer` with `shouldRecognizeSimultaneously` (or JS click handler for EPUB) that posts `.readerContentTapped`. The overlay is now only used for the unified renderer (SwiftUI-native, no UIKit views underneath). +- **Lessons**: (1) Never place a SwiftUI `contentShape(Rectangle())` overlay in a ZStack above UIKit scroll views — it blocks ALL gestures, not just the ones it handles. (2) For UIKit-wrapped views, tap detection belongs inside the bridge using native `UITapGestureRecognizer` with `shouldRecognizeSimultaneously`. (3) SwiftUI's gesture routing is "top view wins" — failed gesture recognizers do NOT forward touches to views behind in a ZStack. + +### Bug #97 — TTS control bar overlaps bottom bar +- **Root cause**: TTSControlBar was rendered in ReaderContainerView's ZStack at the bottom, but format-specific containers also had their own bottom overlay (ReadingProgressBar + ReaderBottomOverlay) in a separate ZStack layer. When TTS was active and chrome was visible, both bars competed for the bottom position. +- **Solution**: Passed `ttsService` through format hosts to all 4 container views. Each container now hides its bottom overlay when `ttsService.state != .idle`. The TTS bar replaces the bottom overlay during playback. +- **Lessons**: (1) Overlapping overlays across ZStack layers need explicit coordination — SwiftUI won't auto-stack them. (2) Passing observable state down the view hierarchy is cleaner than notifications for simple boolean conditions. + +### Bug #85 — Cannot add books to collections +- **Root cause**: The library context menu (`bookContextMenu`) had Info, Share, Set Cover, and Delete actions but no collection management option. The persistence layer (`addBookToCollection`) was already implemented. +- **Solution**: Added "Add to Collection" submenu to `bookContextMenu` with a list of existing collections. Collections are loaded eagerly on library appear. Selecting a collection calls `PersistenceActor.addBookToCollection()`. +- **Lessons**: Context menus should expose all major entity operations — CRUD for relationships is as important as single-entity actions. + +### Bug #86 — Tags never shown in collection sidebar +- **Root cause**: `LibraryView` passed `allTags: []` and `allSeries: []` to `CollectionSidebar`. No methods existed to aggregate tags/series across all books. +- **Solution**: Added `fetchAllTags()` and `fetchAllSeriesNames()` to `PersistenceActor+Collections`. LibraryView now loads tags and series when opening the collections sidebar. +- **Lessons**: When adding aggregate UI (sidebar filters), the corresponding aggregate queries must exist in the persistence layer. + +### Bug #84 — Per-book settings affect all books instead of one +- **Root cause**: `PerBookSettingsStore.resolve()` existed and was tested, but `ReaderContainerView` never called it on book open. The settings panel saved per-book overrides to disk, but opening a book always loaded global `UserDefaults` settings. +- **Solution**: Added `applyResolvedSettings(_:)` to `ReaderSettingsStore` that maps `ResolvedSettings` fields back to store properties. Added `.task` in `ReaderContainerView` to load per-book settings and apply them on book open. +- **Lessons**: (1) Feature implementation is incomplete until the "load on open" path is wired — save-only is half the feature. (2) Adding a method to apply resolved settings back to the store closes the read/write symmetry gap. diff --git a/docs/archive/codex-plans/2026-03-15-v2-roadmap.md b/docs/archive/codex-plans/2026-03-15-v2-roadmap.md new file mode 100644 index 0000000..e6443d3 --- /dev/null +++ b/docs/archive/codex-plans/2026-03-15-v2-roadmap.md @@ -0,0 +1,366 @@ +# VReader V2 Roadmap + +**Date**: 2026-03-15 (revised after Codex review) +**Scope**: 18 features (#10, #21–#37) in 5 phases + 1 architectural foundation phase +**Baseline**: V1 shipped (17 DONE, #16 DEFERRED, #19 DUPLICATE; 2040+ tests, all audited) +**Reference**: `docs/codex-plans/legado-comparison.md` +**Estimated timeline**: 5–7 months full-time, 8–12 months part-time + +--- + +## Outcomes + +Transform VReader from a local document reader into a full-featured reading platform with pagination, web content, TTS, and cross-device sync — while preserving document fidelity and platform-native rendering. + +### Non-Goals (V2) + +- Full Legado compatibility (subset only in Phase D) +- App Store submission (personal use) + +### Dual-Mode Architecture + +VReader V2 introduces two rendering engines users can switch between. Both support scroll and paged layout. + +- **Native** — current per-format renderers (WKWebView, PDFKit, UITextView). Maximum format fidelity. Always available as fallback. + - Scroll: continuous scroll (current behavior, unchanged) + - Paged: CSS columns for EPUB, PDFKit pages for PDF, TextKit text containers for TXT/MD +- **Unified** — new TextKit 2 reflow engine. Pixel-identical rendering across TXT/MD/simple EPUB. Complex EPUBs fall back to Native. + - Scroll: continuous reflow scroll (like a single long page) + - Paged: paginated with page turn animations + +``` +Reader Settings + → Engine: Native | Unified + → Layout: Scroll | Paged + → Page Turn (Paged only): None | Slide | Cover +``` + +All 4 combinations are valid: + +| Engine | Layout | What happens | +|--------|--------|-------------| +| Native + Scroll | Current behavior (V1 default) | +| Native + Paged | Per-format pagination (CSS columns, PDFKit pages, TextKit containers) | +| Unified + Scroll | Reflow engine continuous scroll | +| Unified + Paged | Reflow engine paginated with page turns | + +**Unified scope**: TXT, MD, and simple EPUB chapters. Complex EPUBs (CSS/tables/math/SVG) fall back to Native. PDF always uses PDFKit (natively paginated). + +Both engines share where supported: themes, tap zones, progress bar, highlights, bookmarks, search. TTS available for reflowable text formats (TXT/MD/simple EPUB) via FormatCapabilities (WI-F02). Switching engines preserves reading position and annotations via canonical locator/anchor normalization (WI-F09). + +--- + +## Phase 0 — Architectural Foundation + +**Goal**: Shared abstractions + dual-mode infrastructure + performance fixes. Native mode unchanged, Unified mode scaffolded. + +| WI | Deliverable | Effort | Unblocks | +|----|-------------|--------|----------| +| WI-F01 | `ReaderLifecycleCoordinator` — extract open/close/background/session from 4 ViewModels | M | All phases (reduces regression risk) | +| WI-F02 | `FormatCapabilities` — per-format feature flags (pagination, TTS, text selection, highlights) | S | #21, #26, #27, #28 | +| WI-F03 | `ReflowableTextSource` — unified text segment provider for TXT/MD (optional EPUB) | M | #21, #26, #27, #28 | +| WI-F04 | `BackupProvider` protocol — shared abstraction for #10 (iCloud) and #29 (WebDAV) | S | #10, #29 | +| WI-F05 | Bug #60: Large TXT streaming open — defer FTS5 indexing to background, load visible chunks first, stream encoding detection (sample 8KB) | M | Large file UX | +| WI-F06 | Bug #61: Persistent FTS5 index — persist search index to disk, reuse across sessions via `BackgroundIndexingCoordinator` | M | Search performance | +| WI-F07 | `ReadingMode` enum (.native / .unified) + toggle in reader settings + `ReaderContainerView` dual dispatch | S | Phase B pagination | +| WI-F08 | TextKit 2 reflow engine spike (2–3 weeks) — prototype paginator for TXT, test with 15MB CJK. If TextKit 2 fails, fall back to Core Text | L | Phase B unified engine | +| WI-F09 | Cross-mode locator/anchor normalization — canonical position model that maps between Native offsets (UTF-16/CFI/page) and Unified page coordinates. Ensure highlights/bookmarks survive mode switch | M | Dual-mode data integrity | +| WI-F10 | Mode-switch persistence tests — verify position, highlights, bookmarks, and annotations round-trip across Native ↔ Unified switch for all 4 formats | S | Dual-mode data integrity | +| WI-F11 | `PageNavigator` protocol — shared page navigation contract (currentPage, totalPages, next/prev, jumpTo) used by both Unified engine and Native paged surfaces | S | Phase B pagination | + +**Checkpoint**: All 4 ViewModels delegate lifecycle to coordinator. Large TXT opens in <2s. ReadingMode toggle works (Unified shows placeholder). Mode-switch preserves position/highlights. Existing tests pass. + +--- + +## Phase A — Quick Wins + +**Goal**: High impact/effort ratio features. Ship fast, build momentum. + +| WI | Feature | Effort | Dependencies | +|----|---------|--------|-------------| +| WI-A01 | #22 Search match highlighting in result list | S | None | +| WI-A02 | #30 Custom book covers | S | None | +| WI-A03 | #25 Configurable tap zones | S | None | +| WI-A04 | #32 Reading theme backgrounds | S | None | +| WI-A05 | #37 Per-book reading settings | M | None | + +**Checkpoint**: 5 features shipped. Tap zones ready for Phase B pagination. + +--- + +## Phase B — Reader Core (Dual-Mode) + +**Goal**: Both engines support both scroll and paged layout. Plus TTS, dictionary, TOC. + +| WI | Feature | Effort | Dependencies | +|----|---------|--------|-------------| +| WI-B01 | #23 TXT TOC rules (Legado 25 patterns) | M | None (works in both engines) | +| WI-B02 | #33 Dictionary / define / translate-on-select (system UIReferenceLibraryViewController + AI translate) | L | None (works in both engines) | +| WI-B03 | #26 TTS read aloud (system AVSpeechSynthesizer, position tracking, pause/resume/speed) | M | WI-F02, WI-F03 | +| WI-B04 | #21 Unified — TXT reflow engine with scroll + pagination (TextKit 2 or Core Text based on F08 spike) | L | WI-F03, WI-F07, WI-F08, WI-F11 | +| WI-B05 | #21 Unified — MD reflow with scroll + pagination (same engine as B04, feed attributed text) | M | WI-B04 | +| WI-B06 | #21 Native — EPUB paged layout via CSS columns in WKWebView | M | WI-A03, WI-F11 | +| WI-B07 | #21 Unified — EPUB simple chapters (strip HTML → reflow engine scroll + paged) | L | WI-B04, WI-B12 | +| WI-B08 | #21 Native — TXT/MD paged layout via TextKit multiple NSTextContainers | M | WI-A03, WI-F11 | +| WI-B09 | #21 Native — PDF page navigation via tap zones (PDFKit already paginated) | S | WI-A03, WI-F11 | +| WI-B10 | #31 Auto page turning | S | WI-F11 (needs PageNavigator) | +| WI-B11 | #21 Page turn animations — shared `PageTurnDelegate` (none, slide, cover) above both engines | M | WI-B04, WI-B09 | +| WI-B12 | #21 EPUB "simple vs complex" classifier — detect CSS/tables/math/SVG/fixed-layout → route to Native or Unified | S | None | +| WI-B13 | #21 Pagination cache invalidation — recompute pages on font/theme/viewport change | M | WI-B04, WI-B08 | + +**Execution order**: +1. B01 + B02 + B12 in parallel (independent) +2. B03 (TTS) +3. B06 + B08 + B09 in parallel (Native paged — EPUB CSS columns + TXT/MD TextKit containers + PDF tap zones) +4. B04 (core Unified TXT engine) → B13 (cache invalidation) +5. B05 + B07 in parallel (Unified MD + EPUB text-mode) +6. B10 + B11 in parallel (auto page + animations) + +**Checkpoint**: TXT/MD/simple EPUB support scroll + paged in both Native and Unified. PDF paged via PDFKit. Complex EPUB paged via CSS columns (Native only). TTS works for TXT/MD. Dictionary lookup works. EPUB classifier routes to correct engine. + +--- + +## Phase C — Library & Organization + +**Goal**: Power user features for managing large libraries. + +| WI | Feature | Effort | Dependencies | +|----|---------|--------|-------------| +| WI-C01 | #34 Collections / tags / series | M | None | +| WI-C02 | #35 Export annotations (Markdown/JSON/PDF) | M | None | +| WI-C03 | #35 Import annotations (from other readers) | M | WI-C02 (export format defined first) | +| WI-C04 | #36 OPDS catalog support | M | None | + +**Checkpoint**: Users can organize books, export/import reading data, and browse OPDS catalogs. + +--- + +## Phase D — Web Content + +**Goal**: Book source scraping — the Legado killer feature. + +| WI | Feature | Effort | Dependencies | +|----|---------|--------|-------------| +| WI-D01 | #24 BookSource model + SwiftData schema + management UI | M | None | +| WI-D02 | #24 HTTP client + encoding detection + cookies/headers (moved earlier — affects MVP reliability) | M | None (HTTP layer is schema-independent; integrates with D01 output in D04) | +| WI-D03 | #24 SwiftSoup HTML parser + rule engine (CSS selectors, XPath, regex) | M | WI-D01 (schema) | +| WI-D04 | #24 Pipeline: search → book info → chapter list → content. One vetted source end-to-end | M | WI-D02, WI-D03 | +| WI-D05 | #24 Legado JSON import/export | M | WI-D01 (schema) | +| WI-D06 | #24 Chapter cache + offline reading | M | WI-D04 | +| WI-D07 | #24 Update detection + source sharing | M | WI-D04 (needs pipeline, not cache) | +| WI-D08 | #24 Optional spike: JSONPath + JS execution for advanced sources | L | WI-D04 (only if needed) | + +**Execution order**: +1. D01 + D02 in parallel (model/schema + HTTP client) +2. D03 + D05 in parallel (rule engine + JSON import — both need schema) +3. D04 (pipeline MVP) +4. D06 + D07 in parallel (cache + updates — both depend on D04, not on each other) +5. D08 only if needed (explicit spike, not mainline) + +**Checkpoint**: Users can import Legado sources, search web novels, read chapters offline. + +--- + +## Phase E — Sync & Text Processing + +**Goal**: Cross-device sync and text transformation features. + +| WI | Feature | Effort | Dependencies | +|----|---------|--------|-------------| +| WI-E01 | #29 WebDAV backup and restore | M | WI-F04 | +| WI-E02 | #10 iCloud backup and restore (CloudKit for metadata, iCloud Drive for book files) | L | WI-F04. Design doc: `docs/codex-plans/icloud-backup-design.md` | +| WI-E03 | Text-mapping layer (display text ↔ source text offset mapping) | M | WI-F03 | +| WI-E04 | #28 Simplified/Traditional Chinese conversion | S | WI-E03 | +| WI-E05 | #27 Content replacement rules | S | WI-E03 | +| WI-E06 | #26 TTS Phase 2 — HTTP TTS (cloud voices) | M | WI-B03 | + +**Execution order**: E01 + E02 independent (both use BackupProvider). E03 → E04 + E05 in parallel. E06 independent. + +**Checkpoint**: WebDAV and/or iCloud sync works. Text transforms don't break highlights/search. + +--- + +## Dependency Graph + +``` +Phase 0 (Foundation) + WI-F01 (lifecycle) ──────────────────────────────── all phases + WI-F02 (capabilities) ───── Phase B (TTS, pagination) + WI-F03 (text source) ────── Phase B (TTS, pagination) ── Phase E (text transforms) + WI-F04 (backup) ─────────── Phase E (WebDAV) + WI-F05 (large TXT open) ─── large file UX + WI-F06 (persistent index) ─ search performance + WI-F07 (reading mode) ───── Phase B (dual dispatch) + WI-F08 (TextKit 2 spike) ── Phase B (B04 unified engine) + WI-F09 (locator normalize) ─ dual-mode data integrity + WI-F10 (mode-switch tests) ─ dual-mode data integrity + WI-F11 (PageNavigator) ──── Phase B (all paged surfaces) + +Phase A (Quick Wins) — all independent + WI-A03 (tap zones) ─────── Phase B (B06, B08 prerequisite) + +Phase B (Reader Core) + WI-B01 (TXT TOC) ──────── independent + WI-B02 (dictionary) ────── independent + WI-B03 (TTS) ──────────── WI-F02 + WI-F03 + WI-B04 (Unified TXT) ──── WI-F03 + WI-F07 + WI-F08 + WI-F11 + WI-B05 (Unified MD) ───── WI-B04 + WI-B06 (Native EPUB CSS) ─ WI-A03 + WI-F11 + WI-B07 (Unified EPUB) ──── WI-B04 + WI-B12 (classifier) + WI-B08 (Native TXT/MD) ─── WI-A03 + WI-F11 + WI-B09 (Native PDF) ────── WI-A03 + WI-F11 + WI-B10 (auto page) ─────── WI-F11 + WI-B11 (animations) ────── WI-B04 + WI-B09 + WI-B12 (EPUB classifier) ─ independent + WI-B13 (page cache) ────── WI-B04 + WI-B08 + +Phase C (Library) — C01, C02, C04 independent. C03 depends on C02. + +Phase D (Web Content) — partially parallelizable + D01 + D02 parallel → D03 + D05 parallel → D04 → D06 + D07 parallel (both depend on D04) → D08 (optional spike) + +Phase E (Sync & Text) + WI-E01 (WebDAV) ────────── WI-F04 + WI-E02 (iCloud) ─────────── WI-F04 + WI-E03 (text mapping) ──── WI-F03 + WI-E04 (simp/trad) ─────── WI-E03 + WI-E05 (replacement) ───── WI-E03 + WI-E06 (HTTP TTS) ──────── WI-B03 +``` + +--- + +## Execution Strategy + +### Parallelization + +| Sprint | Work | Agents | +|--------|------|--------| +| 1 | Phase 0 part 1 (F01–F04, F07, F09–F11) — abstractions | 3–4 parallel | +| 2 | Phase 0 part 2 (F05, F06, F08) — performance + spike | 2–3 parallel | +| 3 | Phase A (A01–A05) — quick wins | 3–5 parallel | +| 4 | Phase B part 1 (B01, B02, B03, B12) — independent features | 3–4 parallel | +| 5 | Phase B part 2 (B06, B08, B09) — Native paged (EPUB CSS + TXT/MD TextKit + PDF) | 3 parallel | +| 6 | Phase B part 3 (B04) → B13 — Unified TXT engine + cache invalidation | sequential | +| 7 | Phase B part 4 (B05, B07) — Unified MD + EPUB text-mode | 2 parallel | +| 8 | Phase B part 5 (B10, B11) — auto page + animations | 2 parallel | +| 9 | Phase C (C01, C02, C04) + Phase D part 1 (D01, D02) | 3–4 parallel | +| 10 | Phase C (C03) + Phase D part 2 (D03, D05) | 2–3 parallel | +| 11 | Phase D part 3 (D04) → Phase D part 4 (D06, D07) | sequential → 2 parallel | +| 12a | Phase E part 1 (E01, E02, E03, E06) + Phase D part 5 (D08 optional) | 3–4 parallel | +| 12b | Phase E part 2 (E04, E05) — depends on E03 | 2 parallel | + +### TDD Enforcement + +**Every WI follows RED → GREEN → REFACTOR.** No exceptions (same as V1). + +1. **RED**: Write a failing test that describes the expected behavior before writing any implementation code. +2. **GREEN**: Write the minimum code to make the test pass. +3. **REFACTOR**: Clean up without changing behavior. Tests must still pass. + +**Per-WI test requirements:** + +| WI Category | Test Type | Examples | +|-------------|-----------|---------| +| Foundation protocols (F01-F04, F09, F11) | Unit tests for protocol contracts + integration tests with existing VMs | Lifecycle coordinator delegates correctly, capabilities report accurately, locator normalization round-trips | +| Performance fixes (F05, F06) | Benchmark tests + regression tests | Open time < 2s for 15MB TXT, index persists across sessions | +| Mode switch (F07, F10) | Integration tests | Position/highlights/bookmarks survive Native ↔ Unified switch | +| Engine spike (F08) | Prototype tests | Pagination correct for CJK, 15MB file, boundary conditions | +| Quick wins (A01-A05) | Unit + snapshot tests | Highlight appears in result row, cover image persists, tap zones fire correct actions | +| Pagination (B04-B09) | Unit tests for page calculation + integration tests for navigation | Page count correct for given text+font+viewport, next/prev navigate correctly, position persists | +| TTS (B03) | Unit tests for text extraction + mock speech | Correct text extracted, position tracks during speech, pause/resume works | +| Book source (D01-D08) | Unit tests for rule engine + integration tests with fixture HTML | CSS selector extracts correct elements, pipeline produces chapter list, cache round-trips | +| Backup (E01, E02) | Unit tests for provider + integration tests with mock server/store | Backup creates valid ZIP, restore recovers data, progress syncs | +| Text transforms (E03-E05) | Unit tests for mapping layer + offset preservation | Transform doesn't shift highlight offsets, search results still correct after transform | + +**Coverage gates:** Tests must pass before any commit. Coverage thresholds ratchet up (never decrease). Codex audit verifies test quality after each phase. + +**Anti-patterns enforced (from `.claude/rules/10-tdd.md`):** +- No `it("renders without crashing")` — tests must assert specific behavior +- No mocking everything — mock boundaries (APIs, filesystem), not logic +- No skipping edge cases — empty input, null, max values, CJK, concurrent access +- No snapshot tests for logic — use explicit assertions +- No code-first — if you write code before the test, you can't verify the test catches regressions + +### Commit Policy + +- **Commit after every WI** — each work item gets its own commit once tests pass. +- **Commit after audit** — audit fixes get a separate commit after Codex audit passes. +- No batching multiple WIs into one commit. No committing before tests pass. + +### Audit Schedule + +Codex audit after each phase completion (same as V1 roadmap). Audits verify both implementation correctness AND test quality (wiring-only tests are flagged). Audit fixes are committed separately after verification. + +### Risk Mitigation + +| Risk | Impact | Mitigation | +|------|--------|-----------| +| Cross-mode locator/annotation drift | **Critical** | WI-F09 canonical normalization + WI-F10 persistence tests. Highest priority in Phase 0 | +| TextKit 2 pagination bugs (CJK) | High | F08 spike validates before committing. Core Text fallback ready | +| EPUB CSS column pagination | High | B06 tries CSS columns first (simpler). B07 is text-mode fallback | +| EPUB simple vs complex misclassification | Medium | B12 classifier with conservative defaults (complex = anything with tables/math/SVG/fixed-layout) | +| Book source scope explosion | Medium | De-serialized D01-D08. JS execution is optional spike (D08), not mainline | +| Text-mapping layer complexity | Medium | Built in Phase E after pagination is stable; doesn't block earlier phases | +| Regression from lifecycle refactor | Medium | F01 has no behavior change; existing 2040+ tests must pass | +| Pagination cache stale after settings change | Medium | B13 explicitly handles font/theme/viewport invalidation | + +--- + +## Feature → WI Mapping + +### Foundation & Bug WIs (no user-facing feature) + +| WI | Purpose | Phase | +|----|---------|-------| +| F01 | ReaderLifecycleCoordinator | 0 | +| F02 | FormatCapabilities | 0 | +| F03 | ReflowableTextSource | 0 | +| F04 | BackupProvider | 0 | +| F05 | Bug #60 — Large TXT streaming open | 0 | +| F06 | Bug #61 — Persistent FTS5 index | 0 | +| F07 | ReadingMode toggle | 0 | +| F08 | TextKit 2 spike | 0 | +| F09 | Cross-mode locator normalization | 0 | +| F10 | Mode-switch persistence tests | 0 | +| F11 | PageNavigator protocol | 0 | +| E03 | Text-mapping layer | E | + +### Feature → WI Mapping + +| Feature # | Summary | WIs | Phase | +|-----------|---------|-----|-------| +| #21 | Paginated reading | B04 (Unified TXT), B05 (Unified MD), B06 (Native EPUB CSS), B07 (Unified EPUB text), B08 (Native TXT/MD TextKit), B09 (Native PDF), B11 (animations), B12 (EPUB classifier), B13 (cache) | B | +| #22 | Search result highlighting | A01 | A | +| #23 | TXT TOC rules | B01 | B | +| #24 | Book source scraping | D01–D08 | D | +| #25 | Configurable tap zones | A03 | A | +| #26 | TTS read aloud | B03 (system), E06 (HTTP) | B + E | +| #27 | Content replacement rules | E05 | E | +| #28 | Simp/Trad conversion | E04 | E | +| #29 | WebDAV backup | E01 | E | +| #10 | iCloud backup | E02 | E | +| #30 | Custom book covers | A02 | A | +| #31 | Auto page turning | B10 | B | +| #32 | Theme backgrounds | A04 | A | +| #33 | Dictionary / translate-on-select | B02 | B | +| #34 | Collections / tags / series | C01 | C | +| #35 | Export / import annotations | C02, C03 | C | +| #36 | OPDS catalog | C04 | C | +| #37 | Per-book reading settings | A05 | A | + +**Total**: 18 features + 12 foundation/bug WIs → 47 WIs (11 Phase 0 + 5 Phase A + 13 Phase B + 4 Phase C + 8 Phase D + 6 Phase E) across 6 phases. + +--- + +## Metrics + +| Metric | V1 (current) | V2 (target) | +|--------|-------------|-------------| +| Features shipped | 17 DONE (+1 deferred, +1 duplicate) | 35 DONE (+1 deferred, +1 duplicate) | +| Test count | 2040+ | 3500+ | +| Formats supported | 4 (TXT/MD/EPUB/PDF) | 4 + web sources | +| Reading modes | Scroll only (Native) | Native (scroll/paged) + Unified (scroll/paged) | +| Page turn animations | None | None / Slide / Cover | +| Backup options | None | WebDAV + iCloud | +| Content sources | Local files only | Local + OPDS + web scraping | diff --git a/docs/codex-plans/icloud-backup-design.md b/docs/archive/codex-plans/icloud-backup-design.md similarity index 100% rename from docs/codex-plans/icloud-backup-design.md rename to docs/archive/codex-plans/icloud-backup-design.md diff --git a/docs/archive/codex-plans/legado-comparison.md b/docs/archive/codex-plans/legado-comparison.md new file mode 100644 index 0000000..5a6e845 --- /dev/null +++ b/docs/archive/codex-plans/legado-comparison.md @@ -0,0 +1,413 @@ +# Legado vs VReader — Architecture Comparison & Adoption Plan + +**Date**: 2026-03-15 +**Purpose**: Reference document for architectural decisions. Informs features #21-#37. +**Source**: Legado (github.com/gedoor/legado, 44.8k stars, Kotlin/Android) + +--- + +## 1. Project Overview + +| | Legado | VReader | +| ----------------- | -------------------------------------------- | ---------------------------------------------------------- | +| **Platform** | Android (Kotlin) | iOS (Swift 6, SwiftUI) | +| **Stars** | 44.8k | — | +| **Core audience** | Chinese web novel readers | Local document readers | +| **Philosophy** | Content ingestion + unified reading pipeline | Document fidelity + platform-native rendering | +| **Rendering** | One custom Canvas engine for all formats | Native renderer per format (UITextView, WKWebView, PDFKit) | +| **Persistence** | Room (SQLite) | SwiftData + CloudKit | +| **Concurrency** | Kotlin coroutines | Swift 6 strict concurrency (actors) | +| **Testing** | Limited visible test surface | 2040+ tests, TDD enforced | + +--- + +## 2. Rendering Architecture + +### Legado: Single Engine + +``` +Any format → Text extraction → ChapterProvider (1053 lines) → TextPage[] → Canvas rendering +``` + +- EPUB: Parse ZIP → Jsoup strip HTML → plain text +- TXT: Encoding detect → regex TOC → byte-offset chapters +- PDF: MuPDF text extraction +- Web: HTTP fetch → rule engine → text extraction +- ALL feed into `ChapterProvider` which measures text with `TextPaint`, does CJK-aware line breaking (`ZhLayout`), and splits into pages + +**Pros**: Pagination, TTS, replacement rules, page-turn animations all attach ONCE +**Cons**: Loses EPUB CSS/layout, PDF native rendering, complex documents break + +### VReader: Multi-Engine + +``` +ReaderContainerView → format dispatch +├── TXT → UITextView (small) / UITableView chunked (large) +├── MD → UITextView with NSAttributedString +├── EPUB → WKWebView with CSS theme injection + JS bridges +└── PDF → PDFKit PDFView +``` + +**Pros**: Full document fidelity, native platform behaviors, accessibility +**Cons**: Every cross-format feature must be implemented 4x, no pagination + +--- + +## 3. Feature Comparison + +### Reading Features + +| Feature | Legado | VReader | Notes | +| ---------------------- | ------------------------------------------------------------ | ------------------- | ------------------------------------ | +| Pagination (pages) | Yes (all formats) | No (scroll only) | Legado's core advantage | +| Page turn animations | 6 modes (cover, simulation, slide, scroll, horizontal, none) | None | Via `PageDelegate` pattern | +| Auto page turning | Yes | No | Timed flip | +| Continuous scroll | Yes | Yes (default) | VReader's only mode | +| TTS read aloud | System + HTTP TTS | No | Legado tracks position during speech | +| Configurable tap zones | Yes (left/center/right → custom actions) | No | Toggle chrome only | +| Content replacement | Yes (regex rules, importable) | No | Text cleaning | +| Simp/Trad conversion | Yes | No | `ChineseConverter` | +| Reading progress bar | No (uses page number) | Yes (all 4 formats) | VReader ahead | + +### Annotation & Highlight + +| Feature | Legado | VReader | Notes | +| ---------------------------------- | --------------------- | ------------------------------------------------------------------------- | -------------------- | +| Text highlighting | Custom text rendering | CSS Highlight API (EPUB) + PDFAnnotation (PDF) + NSAttributedString (TXT) | Different approaches | +| Notes/annotations | Basic | Yes (unified AnnotationAnchor schema across formats) | VReader ahead | +| Search highlighting at destination | No | Yes (per-format, yellow highlight) | VReader ahead | +| Export annotations | No | Planned (#35) | — | + +### Content Sources + +| Feature | Legado | VReader | Notes | +| ----------------------- | ---------------------------------- | ------------------------ | ----------------------- | +| Book source scraping | Yes (core feature, 25+ rule types) | Planned (#24) | Legado's killer feature | +| OPDS catalog | No | Planned (#36) | Cleaner standard | +| Local file import | Yes (TXT, EPUB, PDF, UMD, MOBI) | Yes (TXT, EPUB, PDF, MD) | Similar | +| Web novel subscriptions | Yes | No | — | + +### Library & Organization + +| Feature | Legado | VReader | Notes | +| ------------------- | -------------------- | --------------------------- | ------------- | +| Custom book covers | Yes | Planned (#30) | — | +| Collections/tags | Bookshelves + groups | Planned (#34) | — | +| Reading statistics | Basic | Yes (sessions, time, speed) | VReader ahead | +| Library sort/filter | Yes | Yes (persistent prefs) | Similar | + +### AI Features + +| Feature | Legado | VReader | Notes | +| ---------------------- | ------ | --------------------------------- | -------------- | +| AI summarization | No | Yes | VReader unique | +| AI chat (book context) | No | Yes | VReader unique | +| AI translation | No | Yes (9 languages, bilingual view) | VReader unique | +| Dictionary/define | No | Planned (#33) | — | + +### Settings & Customization + +| Feature | Legado | VReader | Notes | +| ---------------------- | ---------------------------- | ------------------------- | ------------- | +| Font/size/spacing | Yes | Yes | Similar | +| Themes | Multiple + background images | Basic light/dark + colors | Legado richer | +| Per-book settings | Yes | Planned (#37) | — | +| Click zone config | Yes | Planned (#25) | — | +| Padding per-edge | Yes | No | — | +| Font weight adjustment | Yes | No | — | + +### Backup & Sync + +| Feature | Legado | VReader | Notes | +| --------------------- | ------------------------ | ----------------- | ------------------------ | +| WebDAV backup | Yes | Planned (#29) | Legado uses Nutstore/坚果云 | +| iCloud sync | N/A (Android) | Design only (#10) | — | +| Reading progress sync | Yes (WebDAV) | No | — | +| Source/rule sharing | Yes (JSON import/export) | No | — | + +--- + +## 4. What VReader SHOULD Adopt + +### High Priority Patterns + +#### 4.1 ReflowableTextSource Abstraction + +Legado's key insight: abstract text content from rendering. VReader should add a `ReflowableTextSource` protocol that yields ordered text segments with stable offsets. TXT, MD, and optionally EPUB-text-mode can conform to this. + +**Why**: Enables shared pagination, TTS, and replacement rules without 4x implementation. + +#### 4.2 Shared Reader Lifecycle Coordinator + +VReader has open/close/background/session logic duplicated across 4 ViewModels. Extract to a `ReaderLifecycleCoordinator` that all ViewModels delegate to. + +**Why**: Reduces duplication, prevents lifecycle bugs (historically the most common regression area). + +#### 4.3 Format Capability Flags + +Add a capability system so shared features can query what each format supports: + +```swift +protocol ReaderCapabilities { + var supportsPagination: Bool { get } + var supportsTextSelection: Bool { get } + var supportsTTS: Bool { get } + var supportsReplacementRules: Bool { get } +} +``` + +**Why**: Features like TTS can degrade gracefully per format instead of crashing. + +#### 4.4 TXT TOC Rule Engine (Feature #23) + +Directly adopt Legado's `txtTocRule.json` patterns. 25 battle-tested regex rules for Chinese, English, numbered headings, special symbols. Auto-detect best rule from 512KB sample. + +#### 4.5 Page Turn Delegate Pattern (Feature #21) + +Legado's `PageDelegate` abstract class with 6 subclasses is clean. Adopt the PATTERN (pluggable page turn strategy) but implement in SwiftUI/UIKit, not the Android Canvas code. + +#### 4.6 Book Source Rule Engine (Feature #24) + +Adopt Legado's `BookSource` JSON schema for compatibility with the massive existing source ecosystem. Implement the rule engine in Swift (SwiftSoup for HTML parsing). + +#### 4.7 Configurable Tap Zones (Feature #25) + +Simple action mapping: divide screen into zones, each maps to an action. Legado stores this as user preference. Quick win, prerequisite for page mode. + +### Medium Priority Patterns + +#### 4.8 Content Replacement Rules (Feature #27) + +Legado's regex-based text cleaning is useful for messy TXT files. But VReader must implement a text-mapping layer to avoid desyncing highlights/search/bookmarks. + +#### 4.9 WebDAV Backup (Feature #29) + +Simpler than iCloud, cross-platform, works with Nutstore. Adopt Legado's `AppWebDav` pattern: backup as ZIP, restore by overwriting, progress sync as small JSON files. + +#### 4.10 Reading Theme Backgrounds (Feature #32) + +Legado supports custom background images per theme. Straightforward to add in VReader. + +### Low Priority Patterns + +#### 4.11 Simp/Trad Conversion (Feature #28) + +Useful for CJK audience. Needs the same text-mapping layer as #27. + +#### 4.12 Auto Page Turning (Feature #31) + +Depends on pagination (#21). Timed page flip or auto-scroll. + +--- + +## 5. What VReader Should NOT Adopt + +### 5.1 Single Rendering Engine + +**Don't flatten EPUB to plain text.** Legado loses CSS layout, tables, math, SVG, links, and accessibility. VReader's WKWebView preserves all of this. The tradeoff is worth it for document-quality reading. + +### 5.2 Custom Canvas Text Rendering + +**Don't replace UITextView/WKWebView/PDFKit with custom drawing.** On iOS, fighting system frameworks is a net loss — you lose accessibility, text selection, Dynamic Type, VoiceOver, and system dictionary for free. + +### 5.3 Mega-Engine Pattern + +**Don't create one object that owns everything.** Legado's `ChapterProvider` is 1053 lines and growing. VReader's per-format separation is better for testability and maintenance. + +### 5.4 Book Source Scraping as Default Content Model + +**Don't make web scraping the primary UX.** VReader is a document reader with scraping as an advanced feature. The library should stay file-first, with web sources as an addition. + +### 5.5 Room-Style Raw SQL + +**Keep SwiftData.** VReader's actor-isolated persistence is safer than raw SQL for concurrent access patterns. + +--- + +## 6. Architecture Plan for Paginated Reading (Feature #21) + +### Recommended Approach: Hybrid + +Keep the multi-engine shell. Add pagination as a rendering MODE, not a replacement. + +``` +ReaderContainerView +├── TXT/MD +│ ├── ScrollMode (current UITextView/UITableView) +│ └── PageMode (new: TextKit multiple text containers) +├── EPUB +│ ├── ScrollMode (current WKWebView) +│ └── PageMode (CSS column-based OR Readium toolkit OR text-mode fallback) +├── PDF +│ └── PageMode (native — PDFKit already has pages) +└── PageTurnAnimationLayer (shared, above all renderers) +``` + +### Per-Format Strategy + +| Format | Pagination Method | Effort | Risk | +| --------------- | --------------------------------------------------------------------------------- | ------ | -------------------------- | +| **PDF** | Already paginated (PDFKit) | None | None | +| **TXT/MD** | TextKit `NSTextContainer` array — measure text, split into page-sized containers | M | Medium (TextKit 1 quirks) | +| **EPUB scroll** | CSS `column-width` + `overflow: hidden` in WKWebView — browser handles pagination | M | Medium (cross-browser CSS) | +| **EPUB text** | Extract text like Legado, use TXT/MD paginator | L | High (loses CSS fidelity) | + +### Page Turn Animations + +Shared layer above renderers: + +```swift +protocol PageTurnDelegate { + func animateForward(from: UIView, to: UIView) + func animateBackward(from: UIView, to: UIView) +} + +class SlidePageTurn: PageTurnDelegate { ... } +class CoverPageTurn: PageTurnDelegate { ... } +class NonePageTurn: PageTurnDelegate { ... } +// SimulationPageTurn deferred — complex, low priority +``` + +--- + +## 7. Architecture Plan for Book Source (Feature #24) + +### Phased Delivery + +| Phase | Scope | Effort | +| ----- | -------------------------------------------------------------------------------------------- | ------ | +| 1 | BookSource model + management UI + HTTP client + HTML parser (SwiftSoup) + one vetted source | M | +| 2 | Rule import/export (Legado JSON compatible) + chapter cache + offline reading | M | +| 3 | Encoding detection + cookies/headers + update detection + source sharing | M | +| 4 | Broader Legado compatibility + JS execution (if needed) | L | + +### Rule Engine Design + +```swift +protocol RuleEvaluator { + func evaluate(_ rule: String, in document: Document) -> [String] +} + +class CSSRuleEvaluator: RuleEvaluator { ... } // SwiftSoup CSS selectors +class XPathRuleEvaluator: RuleEvaluator { ... } // libxml2 XPath +class RegexRuleEvaluator: RuleEvaluator { ... } // NSRegularExpression +class JSONPathRuleEvaluator: RuleEvaluator { ... } // Custom or library +// JSRuleEvaluator deferred to Phase 4 +``` + +--- + +## 8. Shared Abstractions to Build + +### 8.1 ReflowableTextSource + +```swift +protocol ReflowableTextSource { + var totalLength: Int { get } + func text(in range: Range) -> String + func attributes(at offset: Int) -> TextAttributes +} +``` + +Conformers: `TXTTextSource`, `MDTextSource`, `EPUBTextSource` (optional) + +### 8.2 ReaderLifecycleCoordinator + +Extract from 4 ViewModels: open, close, background save, session tracking, position restore. + +### 8.3 FormatCapabilities + +```swift +struct FormatCapabilities: OptionSet { + static let pagination = FormatCapabilities(rawValue: 1 << 0) + static let textSelection = FormatCapabilities(rawValue: 1 << 1) + static let tts = FormatCapabilities(rawValue: 1 << 2) + static let replacementRules = FormatCapabilities(rawValue: 1 << 3) + static let highlights = FormatCapabilities(rawValue: 1 << 4) +} +``` + +### 8.4 BackupProvider + +```swift +protocol BackupProvider { + func backup(data: BackupData) async throws + func restore() async throws -> BackupData + func syncProgress(_ progress: ReadingProgress) async throws +} + +class WebDAVBackupProvider: BackupProvider { ... } +class ICloudBackupProvider: BackupProvider { ... } +class LocalExportProvider: BackupProvider { ... } +``` + +--- + +## 9. Implementation Priority + +See `docs/codex-plans/2026-03-15-v2-roadmap.md` for the full execution plan. + +**Summary**: 18 features → 47 WIs across 6 phases (Phase 0 foundation → A quick wins → B reader core → C library → D web content → E sync & text). + +**Dual-mode architecture**: Native (current renderers) + Unified (TextKit 2 reflow). Both support scroll + paged. Unified scoped to TXT/MD/simple EPUB. Complex EPUB falls back to Native. PDF always PDFKit. + +**Phase order**: +1. Phase 0: Foundation abstractions + performance fixes + TextKit 2 spike (11 WIs) +2. Phase A: Quick wins — #22, #25, #30, #32, #37 (5 WIs) +3. Phase B: Pagination + TTS + dictionary + TXT TOC (13 WIs) +4. Phase C: Collections + annotation export + OPDS (4 WIs) +5. Phase D: Book source scraping — Legado-compatible (8 WIs) +6. Phase E: WebDAV + iCloud backup + text transforms + HTTP TTS (6 WIs) + +--- + +## 10. Known Performance Issues (2026-03-15) + +Large TXT files (~15MB, ~7.5M CJK chars) have two performance problems: +- **Slow open** (bug #60): Encoding detection + full text load + FTS5 indexing all happen in series before UI shows content. Several seconds of spinner. +- **Slow search** (bug #61): FTS5 index built fresh on every open. `BackgroundIndexingCoordinator` exists but reader doesn't use it. Index not persisted. + +**These are I/O and indexing bottlenecks, not renderer problems.** The chunked UITableView renders fine once loaded. Fixes: persist FTS5 index, stream encoding detection (sample 8KB), defer indexing to background, load visible chunks first. + +**Architecture implication**: Codex confirmed these don't change the renderer decision. Option A (keep multi-engine + Phase 0 abstractions) remains correct. The performance fixes belong in Phase 0 alongside the lifecycle extraction. + +--- + +## 11. Architecture Decision Record (2026-03-15, revised) + +**Decision**: Dual-mode architecture — keep Native (multi-engine) AND add Unified (TextKit 2 reflow). User can switch between them. Both support scroll and paged layout. + +**Native mode**: WKWebView (EPUB), PDFKit (PDF), UITextView (TXT/MD). Unchanged from V1. + +**Unified mode**: TextKit 2 reflow engine for TXT, MD, and simple EPUB chapters. Pixel-identical rendering across these formats. Complex EPUBs fall back to Native. PDF stays on PDFKit. + +**Alternatives rejected**: +- Option B (Readium for EPUB+PDF): High migration, PDF not clearly better than PDFKit +- Option C (WKWebView for all text): Loses chunked TXT performance, high risk +- Option D (Readium for everything): Very high migration, delays V2 +- Option E (Full custom Canvas): Extreme effort, loses system features (selection, a11y, dictionary) + +**Rationale**: +1. Zero regression risk — Native mode is unchanged, always available as fallback +2. Unified gives pixel-identical reading for reflowable text without throwing away native fidelity +3. Large CJK TXT performance requires native chunked rendering in Native mode +4. PDFKit is already strong — no reason to replace it +5. Phase 0 abstractions (lifecycle coordinator, format capabilities, PageNavigator) shared by both modes + +**Critical safeguard**: WI-F09 (cross-mode locator normalization) + WI-F10 (mode-switch persistence tests) ensure highlights/bookmarks/position survive engine switching. + +**Future**: Readium EPUB spike if CSS-column pagination (B06) proves too difficult in WKWebView. + +--- + +## 12. Key Takeaways + +1. **Legado optimizes for web novels; VReader optimizes for documents.** Different audiences, different tradeoffs. +2. **Adopt Legado's PATTERNS, not its architecture.** The rule engine, TOC rules, page turn delegate, and tap zone concepts are portable. The single-rendering-engine is not. +3. **Dual-mode is the right answer.** Native (format fidelity) + Unified (pixel-identical reflow) gives users the best of both worlds. Neither engine alone is sufficient. +4. **Unified scope is limited.** TXT, MD, simple EPUB only. Complex EPUB stays on WKWebView. PDF stays on PDFKit. Don't try to unify everything. +5. **The biggest architectural gap is shared abstractions.** VReader needs ReflowableTextSource, ReaderLifecycleCoordinator, FormatCapabilities, and PageNavigator to scale. +6. **Cross-mode data integrity is the #1 risk.** Locator/anchor normalization (WI-F09) and mode-switch tests (WI-F10) must be built before any feature work. +7. **Book source scraping is an epic, not a feature.** Phase it carefully and aim for Legado JSON compatibility to leverage the existing source ecosystem. +8. **Large file performance is an I/O/indexing problem, not a renderer problem.** Fix with persistent FTS5 index, streaming load, and deferred indexing — not by changing renderers. + diff --git a/docs/archive/codex-plans/phase0-plan.md b/docs/archive/codex-plans/phase0-plan.md new file mode 100644 index 0000000..ffe5eb3 --- /dev/null +++ b/docs/archive/codex-plans/phase0-plan.md @@ -0,0 +1,46 @@ +# Phase 0 Implementation Plan (Codex-reviewed) + +**Date**: 2026-03-16 +**Status**: APPROVED after Codex review corrections +**Scope**: 11 WIs — architectural foundation + performance + dual-mode scaffold + +## Codex Review Corrections Applied + +1. F01 scoped to close/background/session — `open()` stays format-specific (different race guards per format) +2. F10 PDF tests changed to negative tests (prove PDF stays Native) +3. F05 narrowed: encoding sample + defer indexing. "Load visible chunks first" deferred to Phase B +4. F06 bumped to L — needs disk DB lifecycle, segmentBaseOffsets persistence, corruption recovery +5. F09 bumped to L — needs migration strategy for existing locators/anchors +6. F02 capabilities made context-aware: `capabilities(engine:contentComplexity:)` not just per-format +7. F05+F06 co-designed in same sprint — indexing deferral depends on persistent index design + +## Sprint Plan + +**Sprint 1** (6 WIs, parallel): +- F01 (lifecycle coordinator) — M +- F02 (capabilities) — S +- F03 (text source) — M +- F04 (backup protocol) — S +- F09 (locator normalization) — L +- F11 (page navigator) — S + +**Sprint 2** (5 WIs, after Sprint 1): +- F05 + F06 together (streaming open + persistent index) — L+L +- F07 (reading mode toggle) — S, depends on F02 +- F08 (TextKit 2 spike) — L, start early +- F10 (mode-switch tests) — S, depends on F09 + +## Effort Corrections + +| WI | Plan estimate | Codex-corrected | +|----|--------------|-----------------| +| F05 | M | **L** (needs protocol/VM changes for deferred indexing) | +| F06 | M | **L** (disk DB lifecycle + segmentBaseOffsets + corruption recovery) | +| F09 | M | **L** (needs migration plan for existing data) | + +## Implementation Rules + +- TDD: RED → GREEN → REFACTOR for every WI +- Commit after each WI passes tests +- No behavior change for existing features +- All 2040+ existing tests must pass after each WI diff --git a/docs/archive/codex-plans/phaseA-plan.md b/docs/archive/codex-plans/phaseA-plan.md new file mode 100644 index 0000000..f7341f3 --- /dev/null +++ b/docs/archive/codex-plans/phaseA-plan.md @@ -0,0 +1,171 @@ +# Phase A Implementation Plan (Retroactive) + +**Date**: 2026-03-17 +**Status**: RETROACTIVE — all 5 WIs implemented +**Scope**: 5 quick-win features — search highlighting, custom covers, tap zones, theme backgrounds, per-book settings + +## WI-A01: #22 Search Match Highlighting in Result List + +**What was built**: Pure function `HighlightedSnippet.highlight()` that applies bold AttributedString runs to query matches in search result snippets. + +**Files created/modified**: +- `vreader/Utils/HighlightedSnippet.swift` — Core logic (90 lines) +- `vreaderTests/Utils/HighlightedSnippetTests.swift` — 14 tests + +**Key decisions**: +- Case-insensitive matching via NSRegularExpression. +- Multi-word queries split by whitespace; each word highlighted independently. +- Regex special characters in query escaped for literal matching. +- FTS5 `...` tags stripped before highlighting. +- Returns plain AttributedString when query is empty or has no matches. + +**Tests (14)**: +- `emptyQuery_returnsPlainText`, `emptySnippet_returnsEmpty` +- `singleWordMatch_highlighted`, `caseInsensitiveMatch`, `fts5TagsStripped` +- `noMatch_returnsPlainText`, `multiWordQuery_highlightsBothWords` +- `multiWordQuery_worksWhenExactPhraseNotPresent` +- `multiWordQuery_duplicateWordHighlightsAllOccurrences` +- `singleWordQuery_stillWorks`, `whitespaceOnlyQuery_returnsPlainText` +- `multiWordQuery_withExtraSpaces_handledGracefully` +- `multiWordQuery_overlappingMatches_handled` +- `regexSpecialCharsInQuery_escaped` + +**Gaps**: None identified. Thorough edge case coverage. + +--- + +## WI-A02: #30 Custom Book Covers + +**What was built**: `CustomCoverStore` enum with static methods for saving, loading, removing custom cover images per fingerprint key. JPEG storage with resize. + +**Files created/modified**: +- `vreader/Services/CustomCoverStore.swift` — Store logic (139 lines) +- `vreaderTests/Services/CustomCoverStoreTests.swift` — 16 tests + +**Key decisions**: +- JPEG compression at quality 0.8, max 512x512 (no upscale). +- Fingerprint keys sanitized (colons, slashes replaced) for filesystem safety. +- All methods accept optional `baseDirectory` for testability. +- Enum with static methods — no instance state. +- Images stored at `/CustomCovers/.jpg`. + +**Tests (16)**: +- `coverPath_uniquePerBook`, `coverPath_sanitizesColons` +- `setCover_savesImageToDisk`, `setCover_replacesExisting` +- `setCover_resizesLargeImage`, `setCover_doesNotUpscaleSmallImage` +- `getCover_returnsNil_whenNoCover`, `getCover_returnsImage_whenCoverSet` +- `removeCover_deletesFile`, `removeCover_noOpWhenNoCover` +- `hasCover_falseWhenNoCover`, `hasCover_trueAfterSave`, `hasCover_falseAfterRemove` +- `emptyFingerprintKey_handledGracefully`, `fingerprintKey_withSlashes_sanitized` +- `coverPath_isUnderCustomCoversSubdirectory` + +**Gaps**: None. Pattern matches ThemeBackgroundStore cleanly. + +--- + +## WI-A03: #25 Configurable Tap Zones + +**What was built**: `TapZoneConfig` model (zone detection, action mapping), `TapZoneStore` (@Observable persistence), `TapZoneOverlay` (SwiftUI view modifier), `TapZoneDispatcher` (NotificationCenter dispatch). + +**Files created/modified**: +- `vreader/Models/TapZoneConfig.swift` — Model + store (100 lines) +- `vreader/Views/Reader/TapZoneOverlay.swift` — Overlay modifier + dispatcher (55 lines) +- `vreaderTests/Views/Reader/TapZoneTests.swift` — 24 tests + +**Key decisions**: +- Three horizontal zones: left/center/right at 33.33% each. +- Zone detection is a static pure function (`zone(atX:totalWidth:)`). +- Actions dispatched via NotificationCenter (`.readerContentTapped`, `.readerPreviousPage`, `.readerNextPage`). +- All types Codable + Sendable. TapZoneStore persists via UserDefaults. +- PDF wired via PDFPageNavigator (WI-B09). + +**Tests (24)**: +- Zone detection: `tapInLeftZone`, `tapInCenterZone`, `tapInRightZone`, `leftEdge`, `rightEdge`, `centerExact`, `leftBoundary`, `pastLeftBoundary`, `rightBoundary`, `pastRightBoundary`, `zeroWidth`, `negativeX`, `xExceedsWidth` +- Configuration: `defaultZones_leftPrevPage_centerToggle_rightNextPage`, `actionForZone`, `codableRoundTrip`, `defaultCodableRoundTrip`, `customMapping`, `allActionsAssignable` +- Raw values: `zoneRawValues`, `actionRawValues`, `actionAllCases` +- Store: `defaultConfig`, `persistsCustomConfig` + +**Gaps**: None. Accessibility labels on overlay view are good. + +--- + +## WI-A04: #32 Reading Theme Backgrounds + +**What was built**: `ThemeBackgroundStore` for saving/loading/removing background images per theme. `ThemeBackgroundView` SwiftUI component for rendering backgrounds in reader. + +**Files created/modified**: +- `vreader/Services/ThemeBackgroundStore.swift` — Store logic (48 lines) +- `vreader/Views/Reader/ThemeBackgroundView.swift` — SwiftUI view (24 lines) +- `vreaderTests/Services/ThemeBackgroundTests.swift` — 11 tests + +**Key decisions**: +- Max dimension 1024px (vs 512px for covers). JPEG quality 0.8. +- Stored at `/ThemeBackgrounds/.jpg`. +- Uses pixel dimensions for resize check (avoids scale mismatch). +- ThemeBackgroundView reloads on theme change and useCustomBackground toggle. +- Background opacity controlled by `settingsStore.backgroundOpacity`. + +**Tests (11)**: +- `saveBackground_savesImageToDisk`, `saveBackground_resizesLargeImage` +- `saveBackground_doesNotResizeSmallImage`, `loadBackground_returnsNil_whenNone` +- `loadBackground_returnsImage_whenSet`, `removeBackground_deletesFile` +- `removeBackground_doesNotThrow_whenNoFile`, `saveBackground_resizesHighScaleImage` +- `saveBackground_overwritesExisting`, `backgroundPath_uniquePerTheme` +- `backgroundPath_usesJPEGExtension` + +**Gaps**: ThemeBackgroundStore has compact formatting (single-line methods) — could benefit from formatting cleanup but is functional. + +--- + +## WI-A05: #37 Per-Book Reading Settings + +**What was built**: `PerBookSettingsOverride` model (all fields optional), `ResolvedSettings` (fully resolved), `PerBookSettingsStore` (JSON file persistence + resolution). + +**Files created/modified**: +- `vreader/Services/PerBookSettings.swift` — Model + store (127 lines) +- `vreaderTests/Services/PerBookSettingsTests.swift` — 15 tests + +**Key decisions**: +- All override fields Optional — nil means "inherit from global". +- Stored as JSON files keyed by fingerprint at `/.json`. +- Pure value type + enum namespace — no singletons. +- `resolve()` merges per-book overrides onto global ReaderSettingsStore values. +- File-based storage keeps per-book settings isolated from UserDefaults. +- Colons replaced with underscores in filenames. Empty keys mapped to `_empty_key`. + +**Tests (15)**: +- `perBookSettings_defaultsToNil`, `perBookSettings_savesAndRestores` +- `perBookSettings_differentBooks_independent`, `perBookSettings_deleteRemoves` +- `perBookSettings_codable_roundTrip`, `perBookSettings_codable_roundTrip_allNils` +- `resolvedSettings_usesPerBook_whenSet`, `resolvedSettings_usesGlobal_whenNoPerBook` +- `perBookSettings_partialOverride`, `resolvedSettings_allFieldsOverridden` +- `perBookSettings_emptyFingerprintKey`, `perBookSettings_deleteNonexistent_noError` +- `perBookSettings_directoryCreatedOnSave`, `perBookSettings_specialCharsInKey` +- `perBookSettings_overwriteExisting` + +**Gaps**: UI for editing per-book settings not yet built (ReaderSettingsPanel integration pending). The resolve() method requires @MainActor, which may need attention when called from background contexts. + +--- + +## Phase A Summary + +| WI | Tests | Lines (impl) | Lines (test) | Status | +|----|-------|-------------|-------------|--------| +| A01 | 14 | 90 | 160 | DONE | +| A02 | 16 | 139 | 206 | DONE | +| A03 | 24 | 155 | 95 | DONE | +| A04 | 11 | 72 | 105 | DONE | +| A05 | 15 | 127 | 222 | DONE | +| **Total** | **80** | **583** | **788** | **DONE** | + +## Integration Notes + +- All WIs are independent — no cross-WI dependencies. +- A03 (tap zones) is a prerequisite for Phase B pagination (B06, B08, B09). +- A05 (per-book settings) UI integration with ReaderSettingsPanel is pending. +- All implementations follow the enum-with-static-methods pattern for stores. +- All use optional `baseDirectory`/`baseURL` parameter for testability. + +## Manual Testing + +See `docs/manual-test-checklist.md` for phase-specific test items. diff --git a/docs/archive/codex-plans/phaseB-plan.md b/docs/archive/codex-plans/phaseB-plan.md new file mode 100644 index 0000000..b2d2f36 --- /dev/null +++ b/docs/archive/codex-plans/phaseB-plan.md @@ -0,0 +1,332 @@ +# Phase B Implementation Plan (Retroactive + Forward) + +**Date**: 2026-03-17 +**Status**: 7/13 WIs DONE, 6 WIs remaining (FORWARD) +**Scope**: Reader core — dual-mode pagination, TTS, dictionary, TOC, animations + +--- + +## RETROACTIVE (7 WIs Done) + +### WI-B01: #23 TXT TOC Rules (Legado 25 Patterns) + +**Files**: `vreader/Services/TXT/TXTTocRuleEngine.swift` (351 lines), `vreader/Services/TXT/TXTTocRule.swift` (model) +**Tests**: `vreaderTests/Services/TXT/TXTTocRuleEngineTests.swift` — 17 tests + +**What was built**: 25 regex rules ported from Legado's txtTocRule.json. Auto-detection samples first 512KB UTF-16. Best rule = enabled rule with most matches (minimum 2). Extraction uses NSRegularExpression with `.anchorsMatchLines`. 8 rules enabled by default. + +**Decisions**: UTF-16 offsets for TextKit compatibility. Rules ordered by serialNumber. CJK patterns first (primary audience). + +### WI-B02: #33 Dictionary / Define / Translate-on-Select + +**Files**: `vreader/Services/DictionaryLookup.swift` (57 lines) +**Tests**: `vreaderTests/Services/DictionaryLookupTests.swift` — 19 tests + +**What was built**: System dictionary via `UIReferenceLibraryViewController.dictionaryHasDefinition(forTerm:)`. Word extraction from selections. Menu title constants. + +**Decisions**: First whitespace-delimited token extracted. Empty/whitespace-only returns nil. Enum namespace with static methods. + +### WI-B03: #26 TTS Read Aloud (System AVSpeechSynthesizer) + +**Files**: `vreader/Services/TTS/TTSService.swift` (173 lines), `vreader/Services/TTS/SpeechSynthesizing.swift` (protocol) +**Tests**: `vreaderTests/Services/TTSServiceTests.swift` — 35 tests + +**What was built**: TTS service with idle/speaking/paused state machine. Position tracking via UTF-16 offsets. Rate clamped 0.0-1.0. SpeechSynthesizing protocol for test injection. Text extraction from ReflowableTextSource. + +**Decisions**: @MainActor @Observable for SwiftUI binding. Empty/whitespace text is no-op. Negative offsets clamped to 0. Delegate pattern for position tracking. + +### WI-B06: #21 Native EPUB Paged Layout (CSS Columns) + +**Files**: `vreader/Views/Reader/EPUBPaginationHelper.swift` (166 lines) +**Tests**: `vreaderTests/Views/Reader/EPUBPaginationTests.swift` — 26 tests + +**What was built**: CSS multi-column pagination helper. Generates CSS, JS for page navigation, page count computation, CSS injection/removal. Pure calculations for totalPages and pageFromScrollOffset. + +**Decisions**: CSS column-width + column-gap + overflow:hidden. Navigation via scrollLeft. Integer pixel values. Zero/negative viewport returns safe fallbacks. + +### WI-B08: #21 Native TXT/MD Paged Layout (TextKit) + +**Files**: `vreader/Views/Reader/NativeTextPaginator.swift` (153 lines) +**Tests**: `vreaderTests/Views/Reader/NativeTextPaginatorTests.swift` — 23 tests + +**What was built**: TextKit 1 paginator using NSLayoutManager + multiple NSTextContainers. Two entry points: plain text and attributed string. Page lookup by UTF-16 offset. + +**Decisions**: @MainActor. TextKit 1 to match existing UITextView infra. Speculatively adds containers until all glyphs laid out. NSRange (UTF-16) for UIKit compatibility. + +### WI-B09: #21 Native PDF Page Navigation + +**Files**: `vreader/Views/Reader/PDFPageNavigator.swift` (37 lines) +**Tests**: `vreaderTests/Views/Reader/PDFPageNavigatorTests.swift` — 26 tests + +**What was built**: PDF-specific BasePageNavigator subclass. `syncCurrentPage(_:)` method for PDFView notification sync. Does NOT hold PDFView reference — caller bridges navigation. + +**Decisions**: Decoupled from PDFKit for testability. Two paths: user-initiated (nextPage/previousPage) and PDFView-reported (syncCurrentPage). + +### WI-B12: #21 EPUB Complexity Classifier + +**Files**: `vreader/Services/EPUB/EPUBComplexityClassifier.swift` (93 lines) +**Tests**: `vreaderTests/Services/EPUB/EPUBComplexityClassifierTests.swift` — 31 tests + +**What was built**: String-based HTML scanner for complex layout indicators (table, math, svg, iframe, canvas, video, audio, CSS grid/table/fixed/absolute, viewport meta). Per-chapter classification with book-level rollup. Pre-compiled regex patterns. + +**Decisions**: Conservative — uncertain = complex. No DOM parsing needed. Case-insensitive matching. + +--- + +## FORWARD (6 WIs Remaining) + +### WI-B04: #21 Unified TXT Reflow Engine (Scroll + Pagination) + +**Problem**: The TextKit2Paginator from F08 spike and UnifiedTextRendererViewModel exist but the engine needs hardening for production: font/theme changes, viewport resize, large files, CJK, progress persistence, mode switching. + +**Files to create/modify**: +- Modify: `vreader/Services/TextKit2Spike/TextKit2Paginator.swift` — move to `vreader/Services/Unified/` and harden +- Modify: `vreader/ViewModels/UnifiedTextRendererViewModel.swift` — wire to ReaderLifecycleCoordinator +- Modify: `vreader/Views/Reader/UnifiedTextRenderer.swift` — integrate tap zones, progress bar +- Modify: `vreader/Views/Reader/UnifiedPagedView.swift` — polish page rendering +- Modify: `vreader/Views/Reader/UnifiedScrollView.swift` — scroll progress tracking +- Create: `vreader/Services/Unified/UnifiedTXTPageNavigator.swift` — BasePageNavigator subclass + +**Tests FIRST**: +- `testPaginateEmpty_returnsZeroPages` +- `testPaginateSingleLine_returnsOnePage` +- `testPaginateMultiPage_correctPageCount` +- `testPaginate15MBCJK_completesUnder5Seconds` +- `testRepaginateOnFontChange_preservesApproximatePosition` +- `testRepaginateOnViewportResize_recalculatesPages` +- `testPageNavigator_nextPrev_clampsCorrectly` +- `testScrollProgress_tracksUTF16Offset` +- `testModeSwitch_scrollToPaged_preservesProgress` +- `testModeSwitch_pagedToScroll_preservesProgress` +- `testCurrentPageText_returnsCorrectSlice` +- `testEmptyText_noOp` + +**Implementation approach**: +1. Move TextKit2Paginator out of spike directory into `Services/Unified/` +2. Create UnifiedTXTPageNavigator subclass of BasePageNavigator +3. Wire ViewModel to use PageNavigator protocol +4. Add tap zone support (observe .readerNextPage/.readerPreviousPage) +5. Add progress persistence via LocatorFactory +6. Add font/theme/viewport change re-pagination with position preservation + +**Edge cases**: 15MB CJK files, emoji, mixed scripts, zero-height viewport, rapid font changes, empty files. + +**Acceptance criteria**: TXT files render in both scroll and paged mode under Unified engine. Page turns work via tap zones and swipe. Progress persists. Font/theme changes re-paginate. 15MB CJK file paginates in <5s. + +**Dependencies**: WI-F03 (ReflowableTextSource), WI-F07 (ReadingMode), WI-F08 (TextKit 2 spike), WI-F11 (PageNavigator) — all done. + +**Effort**: L + +--- + +### WI-B05: #21 Unified MD Reflow (Scroll + Pagination) + +**Problem**: MD files need to flow through the same unified engine as TXT, but fed as attributed text (NSAttributedString from the existing MD renderer). + +**Files to create/modify**: +- Create: `vreader/Services/Unified/UnifiedMDPageNavigator.swift` +- Modify: `vreader/ViewModels/UnifiedTextRendererViewModel.swift` — add attributed text path +- Modify: `vreader/Services/TextKit2Spike/TextKit2Paginator.swift` — add `paginateAttributed()` method +- Modify: `vreader/Views/Reader/MDReaderContainerView.swift` — dispatch to Unified when mode=unified + +**Tests FIRST**: +- `testPaginateAttributedText_correctPageCount` +- `testMDHeadings_preserveFormatting_perPage` +- `testMDListItems_wrapCorrectly` +- `testMDCodeBlocks_dontSplitMidLine` +- `testEmptyMDFile_zeroPages` +- `testMDWithEmoji_correctPagination` +- `testProgressPersistence_MDUnified` + +**Implementation approach**: +1. Add `paginateAttributed(attributedText:viewportSize:)` to TextKit2Paginator (mirroring NativeTextPaginator's dual entry point pattern) +2. MDReaderContainerView checks ReadingMode: if `.unified`, pass rendered NSAttributedString to UnifiedTextRenderer +3. UnifiedTextRendererViewModel accepts either plain text (TXT) or attributed text (MD) + +**Edge cases**: MD with inline images (should fall back or skip), very long single paragraphs, code blocks with long lines. + +**Acceptance criteria**: MD files render in Unified mode with correct formatting. TOC headings preserved. Pagination matches Native mode page count within +/-10%. + +**Dependencies**: WI-B04 (Unified TXT engine must be working first). + +**Effort**: M + +--- + +### WI-B07: #21 Unified EPUB Simple Chapters + +**Problem**: Simple EPUB chapters (no tables, SVG, math) should render in the Unified reflow engine by stripping HTML to attributed text. + +**Files to create/modify**: +- Create: `vreader/Services/EPUB/EPUBTextExtractor.swift` — HTML to NSAttributedString converter +- Create: `vreaderTests/Services/EPUB/EPUBTextExtractorTests.swift` +- Modify: `vreader/Views/Reader/EPUBReaderContainerView.swift` — dispatch simple chapters to Unified +- Modify: `vreader/Services/EPUB/EPUBComplexityClassifier.swift` — per-chapter routing + +**Tests FIRST**: +- `testExtractSimpleHTML_preservesParagraphs` +- `testExtractBoldItalic_preservesStyling` +- `testExtractHeadings_preservesLevels` +- `testExtractLinks_preservesText` +- `testExtractImages_insertsPlaceholder` +- `testComplexHTML_routesToNative` +- `testMixedBook_simpleChaptersUseUnified` +- `testEmptyChapter_producesEmptyText` +- `testCJKContent_correctExtraction` + +**Implementation approach**: +1. Build EPUBTextExtractor using NSAttributedString(data:options:documentAttributes:) with `[.documentType: NSAttributedString.DocumentType.html]` +2. EPUBComplexityClassifier already provides per-chapter classification +3. EPUBReaderContainerView checks classifier: simple chapters route to UnifiedTextRenderer, complex stay in WKWebView +4. Chapter transitions handled by the existing PageNavigator protocol + +**Edge cases**: Chapters with CSS class references but no complex layout (still simple), chapters mixing simple and complex elements, charset encoding issues. + +**Acceptance criteria**: Simple EPUB chapters render identically in Unified and Native modes (text content, not layout). Complex chapters stay in WKWebView. User can switch engines mid-book. + +**Dependencies**: WI-B04 (Unified engine), WI-B12 (classifier) — B12 done. + +**Effort**: L + +--- + +### WI-B10: #31 Auto Page Turning + +**Problem**: Users want hands-free reading with timed automatic page advancement. + +**Files to create/modify**: +- Create: `vreader/Services/AutoPageTurner.swift` +- Create: `vreaderTests/Services/AutoPageTurnerTests.swift` +- Modify: `vreader/Views/Reader/ReaderSettingsPanel.swift` — add auto-turn toggle + interval slider + +**Tests FIRST**: +- `testAutoTurn_callsNextPage_afterInterval` +- `testAutoTurn_stopsAtLastPage` +- `testAutoTurn_pauseResume` +- `testAutoTurn_stopOnUserInteraction` +- `testAutoTurn_adjustInterval_immediateEffect` +- `testAutoTurn_doesNotStartWhenAtLastPage` +- `testAutoTurn_zeroInterval_clampedToMinimum` +- `testAutoTurn_negativeInterval_clampedToMinimum` +- `testAutoTurn_defaultInterval_5Seconds` +- `testAutoTurn_stateTransitions_idle_running_paused` + +**Implementation approach**: +1. AutoPageTurner class with Timer-based page advancement +2. Accepts any PageNavigator via protocol — format-agnostic +3. State machine: idle -> running -> paused -> idle +4. Minimum interval 1 second, default 5 seconds +5. Pauses on user scroll/tap, resumes automatically or via button +6. Shows subtle indicator (progress ring) in status bar area + +**Edge cases**: App backgrounding (pause), low battery mode, very short pages, user swipes during auto-turn, screen lock. + +**Acceptance criteria**: Auto page turning works in all paged modes (Native and Unified). Interval adjustable 1-60 seconds. Stops at end of book. Pauses on user interaction. + +**Dependencies**: WI-F11 (PageNavigator) — done. + +**Effort**: S + +--- + +### WI-B11: #21 Page Turn Animations + +**Problem**: Paged reading needs visual page-turn feedback: none (instant), slide, cover (page peel). + +**Files to create/modify**: +- Create: `vreader/Views/Reader/PageTurnAnimator.swift` — shared animation delegate +- Create: `vreaderTests/Views/Reader/PageTurnAnimatorTests.swift` +- Modify: `vreader/Views/Reader/UnifiedPagedView.swift` — apply animations +- Modify: `vreader/Views/Reader/ReaderSettingsPanel.swift` — animation picker + +**Tests FIRST**: +- `testAnimationNone_immediateTransition` +- `testAnimationSlide_leftToRight_previousPage` +- `testAnimationSlide_rightToLeft_nextPage` +- `testAnimationCover_nextPage_coversFromRight` +- `testAnimationCover_previousPage_uncoversFromLeft` +- `testRapidPageTurns_cancelsPreviousAnimation` +- `testAnimationDuration_default300ms` +- `testAnimationDuration_respectsReduceMotion` +- `testAnimationType_codableRoundTrip` + +**Implementation approach**: +1. `PageTurnAnimation` enum: `.none`, `.slide`, `.cover` +2. `PageTurnAnimator` renders transition between two page snapshots (UIView.transition or CAAnimation) +3. For `.slide`: translate X by viewport width +4. For `.cover`: 3D transform with shadow (like iBooks) +5. `.none`: instant swap +6. Respects UIAccessibility.isReduceMotionEnabled — forces `.none` +7. Stored in ReaderSettingsStore + +**Edge cases**: Reduce motion accessibility, rapid multi-tap, animation during resize, concurrent font change. + +**Acceptance criteria**: Three animation types work in Unified paged mode. Animations cancel cleanly on rapid taps. Reduce motion disables animations. No frame drops on iPhone 12+. + +**Dependencies**: WI-B04 (Unified TXT paged), WI-B09 (PDF paged) — both done. + +**Effort**: M + +--- + +### WI-B13: #21 Pagination Cache Invalidation + +**Problem**: Pagination results must be recomputed when font size, font family, theme, line spacing, or viewport dimensions change. + +**Files to create/modify**: +- Create: `vreader/Services/Unified/PaginationCache.swift` +- Create: `vreaderTests/Services/Unified/PaginationCacheTests.swift` +- Modify: `vreader/ViewModels/UnifiedTextRendererViewModel.swift` — wire cache invalidation +- Modify: `vreader/Views/Reader/NativeTextPaginator.swift` — add cache key support + +**Tests FIRST**: +- `testCacheKey_changeFont_invalidates` +- `testCacheKey_changeViewportWidth_invalidates` +- `testCacheKey_changeViewportHeight_invalidates` +- `testCacheKey_changeLineSpacing_invalidates` +- `testCacheKey_changeLetterSpacing_invalidates` +- `testCacheKey_changeTheme_doesNotInvalidate` (theme doesn't affect text layout) +- `testCacheKey_sameParams_hits` +- `testCacheKey_rotateDevice_invalidates` +- `testInvalidation_preservesApproximatePosition` +- `testInvalidation_emptiesOldPages_beforeRepagination` + +**Implementation approach**: +1. PaginationCache stores pages keyed by `CacheKey(font, fontSize, lineSpacing, letterSpacing, viewportWidth, viewportHeight)` +2. On settings change, compute new key; if different, invalidate and re-paginate +3. During re-pagination, compute approximate position from old progress fraction +4. Cache is per-document (keyed additionally by document fingerprint) +5. Memory-only cache (no disk persistence needed — re-pagination is fast enough) + +**Edge cases**: Rapid sequential font changes (debounce), device rotation during pagination, split-screen multitasking resize. + +**Acceptance criteria**: Font/viewport changes trigger re-pagination within 500ms for normal files. Reading position preserved within +/-1 page. No stale page display. + +**Dependencies**: WI-B04 (Unified TXT paginator), WI-B08 (Native TXT paginator) — both done. + +**Effort**: M + +--- + +## Sprint Plan + +**Sprint B1** (parallel): B04 (Unified TXT engine) — L. Critical path. +**Sprint B2** (sequential after B1): B13 (cache invalidation) — M. +**Sprint B3** (parallel after B1): B05 (Unified MD) + B07 (Unified EPUB) — M + L. +**Sprint B4** (parallel, independent): B10 (auto page) + B11 (animations) — S + M. + +## Checkpoint Criteria + +- All 13 WIs complete (7 retroactive + 6 new) +- TXT/MD/simple EPUB support scroll + paged in both Native and Unified +- PDF paged via PDFKit tap zones +- TTS works for TXT/MD +- Dictionary lookup works +- EPUB classifier routes to correct engine +- Page turn animations (none/slide/cover) work +- Auto page turning with configurable interval +- All existing tests still pass + +## Manual Testing + +See `docs/manual-test-checklist.md` for phase-specific test items. diff --git a/docs/archive/codex-plans/phaseC-plan.md b/docs/archive/codex-plans/phaseC-plan.md new file mode 100644 index 0000000..049076c --- /dev/null +++ b/docs/archive/codex-plans/phaseC-plan.md @@ -0,0 +1,224 @@ +# Phase C Implementation Plan (Forward) + +**Date**: 2026-03-17 +**Status**: FORWARD — 4 WIs planned +**Scope**: Library organization — collections, annotation export/import, OPDS + +--- + +## WI-C01: #34 Collections / Tags / Series + +**Problem**: Users with large libraries (100+ books) need organization beyond flat list. Collections (user-created folders), tags (labels), and series (ordered book groups) enable this. + +**Files to create/modify**: +- Create: `vreader/Models/Collection.swift` — SwiftData @Model (separate entity with name, createdAt, books relationship) +- Create: `vreader/Services/PersistenceActor+Collections.swift` +- Create: `vreaderTests/Models/CollectionTests.swift` +- Create: `vreaderTests/Services/PersistenceActor+CollectionsTests.swift` +- Modify: `vreader/Models/Book.swift` — add inline fields: `tags: [String]`, `seriesName: String?`, `seriesIndex: Int?` +- Modify: `vreader/Views/Library/LibraryView.swift` — add collection filter sidebar +- Modify: `vreader/Services/PersistenceActor+Library.swift` — collection queries + +**Model approach (inline, no separate entities for tags/series)**: +- **Tags**: Stored as `[String]` directly on Book. No separate BookTag entity. Simple, searchable via predicates. +- **Series**: Stored as `seriesName: String?` and `seriesIndex: Int?` directly on Book. No separate Series entity. Series grouping is a query over books sharing the same seriesName. +- **Collection**: Remains a separate @Model with a many-to-many relationship to Book (user-created folders need their own identity, creation date, and ordering). + +**Tests FIRST**: +- `testCreateCollection_savesAndRetrievals` +- `testDeleteCollection_removesButKeepsBooks` +- `testRenameCollection_updatesAllReferences` +- `testAddBookToCollection_bidirectionalLink` +- `testRemoveBookFromCollection_preservesBook` +- `testBookInMultipleCollections_allowed` +- `testEmptyCollectionName_rejected` +- `testDuplicateCollectionName_rejected` +- `testCollectionSort_byName` +- `testBookTags_addRemoveQuery` +- `testTagSearch_filtersByTag` +- `testSeries_orderedBySeriesIndex` +- `testSeries_gapInIndex_handled` +- `testSeries_sameName_differentBooks` +- `testDeleteBook_removesFromCollections` + +**Implementation approach**: +1. Collection as @Model with name, createdAt, books relationship (separate entity) +2. Tags stored as `[String]` on Book model (inline, no separate entity) +3. Series modeled as `(seriesName: String?, seriesIndex: Int?)` on Book (inline, no separate entity) +4. PersistenceActor+Collections handles CRUD +5. LibraryView gets a sidebar section for collections/tags filtering +6. "All Books" remains default, collections are optional filters + +**Edge cases**: Empty collection name, duplicate names, book in 0 collections, deleting a book that's in collections, Unicode collection names, very long names (truncation). + +**Acceptance criteria**: Users can create/rename/delete collections. Books can be added to multiple collections. Tag filtering works. Series groups books in order. Existing library behavior unchanged for users who don't use collections. + +**Dependencies**: None. + +**Cross-phase note**: C01 data (collections, tags, series) must be included in Phase E backup/sync scope. E01 (WebDAV) and E02 (iCloud) archive contents must cover these fields. See E01 backup scope for the full list. + +**Effort**: M + +--- + +## WI-C02: #35 Export Annotations (Markdown/JSON/PDF) + +**Problem**: Users need to export highlights and notes for use outside VReader — sharing, backup, academic citation, integration with other tools. + +**Files to create/modify**: +- Create: `vreader/Services/Export/AnnotationExporter.swift` +- Create: `vreader/Services/Export/MarkdownExportFormatter.swift` +- Create: `vreader/Services/Export/JSONExportFormatter.swift` +- Create: `vreader/Services/Export/PDFExportFormatter.swift` +- Create: `vreaderTests/Services/Export/AnnotationExporterTests.swift` +- Create: `vreaderTests/Services/Export/MarkdownExportFormatterTests.swift` +- Create: `vreaderTests/Services/Export/JSONExportFormatterTests.swift` +- Modify: `vreader/Views/Reader/AnnotationsPanelView.swift` — add export button + +**Tests FIRST**: +- `testMarkdownExport_includesBookTitle` +- `testMarkdownExport_highlightsGroupedByChapter` +- `testMarkdownExport_notesIncluded` +- `testMarkdownExport_bookmarksIncluded` +- `testMarkdownExport_emptyAnnotations_producesMinimalOutput` +- `testJSONExport_validJSON` +- `testJSONExport_roundTrippable` (can be imported back) +- `testJSONExport_includesAllFields` +- `testJSONExport_dateFormat_ISO8601` +- `testPDFExport_generatesValidPDF` +- `testPDFExport_multiplePages_paginatesCorrectly` +- `testPDFExport_includesHighlightColor` +- `testExportFormat_enum_codable` +- `testExport_unicodeContent_preserved` +- `testExport_CJKText_correct` +- `testExport_longNote_notTruncated` + +**Implementation approach**: +1. AnnotationExporter protocol with `export(annotations:format:) -> Data` +2. Three formatters: Markdown (.md), JSON (.json), PDF (.pdf via UIGraphicsPDFRenderer) +3. Markdown format: `# Book Title\n\n## Chapter 1\n\n> highlight text\n\n*Note: user note*\n` +4. JSON format: Array of `ExportedAnnotation` objects with ISO 8601 dates +5. PDF: Multi-page layout via UIGraphicsPDFRenderer. Each chapter starts a new page. Content that exceeds a single page paginates automatically (track remaining height, begin new page when exceeded). Page numbers in footer. +6. Export via UIActivityViewController (share sheet) + +**Edge cases**: Book with 0 annotations (empty export), annotations without chapter (ungrouped section), very long highlight text (500+ chars), notes with markdown syntax, CJK text in PDF rendering. + +**Acceptance criteria**: All three formats produce valid output. Markdown is human-readable. JSON can be re-imported (C03). PDF renders correctly. Share sheet works. + +**Dependencies**: None. + +**Effort**: M + +--- + +## WI-C03: #35 Import Annotations + +**Problem**: Users need to import annotations from VReader JSON exports (e.g., restoring from backup, migrating devices without iCloud). + +**Scope (MVP)**: VReader JSON import only. This consumes the JSON format defined in C02. No third-party reader import in MVP. + +**Stretch goal**: Kindle annotation import (My Clippings.txt parser) — deferred to a future WI. + +**Files to create/modify**: +- Create: `vreader/Services/Import/AnnotationImporter.swift` +- Create: `vreader/Services/Import/VReaderAnnotationParser.swift` +- Create: `vreaderTests/Services/Import/AnnotationImporterTests.swift` +- Create: `vreaderTests/Services/Import/VReaderAnnotationParserTests.swift` +- Modify: `vreader/Views/Reader/AnnotationsPanelView.swift` — add import button + +**Tests FIRST**: +- `testImportVReaderJSON_createsHighlights` +- `testImportVReaderJSON_createsBookmarks` +- `testImportVReaderJSON_createsNotes` +- `testImportVReaderJSON_duplicateId_skips` +- `testImportVReaderJSON_missingBook_createsOrphan` +- `testImportVReaderJSON_malformedJSON_returnsError` +- `testImportVReaderJSON_emptyArray_noOp` +- `testImportVReaderJSON_futureFields_ignored` +- `testImportVReaderJSON_datesParsed_ISO8601` +- `testImportProgress_reportsCorrectly` + +**Implementation approach**: +1. VReaderAnnotationParser consumes JSON format defined in C02 +2. Import deduplicates by annotation ID (UUID) — skip if exists +3. Import resolves book by fingerprintKey — creates orphan bucket if book not in library +4. Uses PersistenceActor for writes (transactional) +5. Progress callback during import +6. File picker via UIDocumentPickerViewController + +**Edge cases**: Import file from newer app version (unknown fields), import into library where book was deleted, duplicate IDs with different content (skip, don't overwrite), zero-byte file, corrupt JSON. + +**Acceptance criteria**: VReader JSON round-trips (export then import produces identical annotations). Duplicates are skipped. Missing books handled gracefully. Error messages are user-friendly. + +**Dependencies**: WI-C02 (JSON export format must be defined first). + +**Effort**: M + +--- + +## WI-C04: #36 OPDS Catalog Support + +**Problem**: OPDS (Open Publication Distribution System) is a standard for browsing and downloading ebooks from catalog servers. Enables users to discover and import books from online libraries without manual file transfer. + +**Files to create/modify**: +- Create: `vreader/Services/OPDS/OPDSClient.swift` — HTTP client for OPDS feeds +- Create: `vreader/Services/OPDS/OPDSParser.swift` — Atom XML feed parser +- Create: `vreader/Services/OPDS/OPDSCatalog.swift` — model (Feed, Entry, Link) +- Create: `vreader/Views/OPDS/OPDSBrowserView.swift` — catalog browser UI +- Create: `vreader/Views/OPDS/OPDSEntryView.swift` — book detail from catalog +- Create: `vreaderTests/Services/OPDS/OPDSParserTests.swift` +- Create: `vreaderTests/Services/OPDS/OPDSClientTests.swift` +- Modify: `vreader/Views/Library/LibraryView.swift` — add OPDS catalog button + +**Tests FIRST**: +- `testParseNavigationFeed_extractsEntries` +- `testParseAcquisitionFeed_extractsDownloadLinks` +- `testParseSearchFeed_extractsSearchURL` +- `testParsePagination_extractsNextLink` +- `testParseEntry_extractsMetadata` (title, author, summary, cover) +- `testParseEntry_multipleFormats` (EPUB + PDF links) +- `testParseEntry_relativeAcquisitionURL_resolvedAgainstFeedBase` +- `testParseFeed_duplicateEntries_deduplicated` +- `testParseFeed_emptyFeed_returnsEmpty` +- `testParseFeed_invalidXML_returnsError` +- `testClient_fetchFeed_success` +- `testClient_fetchFeed_networkError` +- `testClient_downloadBook_savesToLibrary` +- `testClient_basicAuth_headerIncluded` +- `testOPDSURL_validation` +- `testCatalog_codableRoundTrip` + +**Implementation approach**: +1. OPDS 1.2 spec: Atom XML feeds with `rel="http://opds-spec.org/..."` links +2. OPDSParser uses XMLParser (Foundation) — no external dependencies +3. Feed types: Navigation (browse categories), Acquisition (book listings), Search +4. OPDSCatalog stores saved catalog URLs in UserDefaults +5. Download triggers BookImporter.import() for acquired files +6. Support basic auth for private catalogs (store credentials in Keychain via KeychainService) + +**Edge cases**: Catalogs with pagination (rel="next"), catalogs requiring auth, catalogs with unsupported formats (skip), slow catalogs (timeout), malformed XML, OPDS 2.0 (JSON) — detect and show error suggesting 1.2. + +**Acceptance criteria**: Can add OPDS catalog URL, browse categories, search, download books. Downloaded books appear in library. Basic auth works. Pagination works. + +**Dependencies**: None (parallel with C01, C02). + +**Effort**: M + +--- + +## Sprint Plan + +**Sprint C1** (parallel): C01 (collections) + C02 (export) + C04 (OPDS) — 3 parallel. +**Sprint C2** (sequential after C02): C03 (import) — depends on C02 for format. + +## Checkpoint Criteria + +- Collections, tags, series all functional +- Annotations export to Markdown, JSON, PDF +- JSON import round-trips correctly +- OPDS catalog browsing and download works +- All existing tests pass + +## Manual Testing + +See `docs/manual-test-checklist.md` for phase-specific test items. diff --git a/docs/archive/codex-plans/phaseD-plan.md b/docs/archive/codex-plans/phaseD-plan.md new file mode 100644 index 0000000..89b64ac --- /dev/null +++ b/docs/archive/codex-plans/phaseD-plan.md @@ -0,0 +1,419 @@ +# Phase D Implementation Plan (Forward) + +**Date**: 2026-03-17 +**Status**: FORWARD — 9 WIs planned (D07 split into D07a + D07b) +**Scope**: Book source scraping — Legado-compatible rule engine for web novels + +**Reference**: Legado BookSource schema at `/Users/ll/.claude/projects/-Users-ll-Desktop-workspace-vreader/memory/reference_legado_book_source.md` + +--- + +## WI-D01: BookSource Model + SwiftData Schema + Management UI + +**Problem**: Need a data model that represents web content sources with configurable extraction rules, compatible with Legado's JSON format for import/export. + +**Files to create/modify**: +- Create: `vreader/Models/BookSource.swift` — SwiftData @Model +- Create: `vreader/Models/BookSourceRules.swift` — rule sub-models (SearchRule, BookInfoRule, TocRule, ContentRule) +- Create: `vreader/Views/BookSource/BookSourceListView.swift` — management UI +- Create: `vreader/Views/BookSource/BookSourceEditorView.swift` — edit individual source +- Create: `vreader/Services/PersistenceActor+BookSource.swift` +- Create: `vreaderTests/Models/BookSourceTests.swift` +- Create: `vreaderTests/Services/PersistenceActor+BookSourceTests.swift` + +**Tests FIRST**: +- `testBookSource_codableRoundTrip` +- `testBookSource_allFieldsEncoded` +- `testBookSource_optionalFields_nilSafe` +- `testBookSource_uniqueByURL` +- `testBookSource_enableDisable` +- `testSearchRule_codableRoundTrip` +- `testTocRule_codableRoundTrip` +- `testContentRule_codableRoundTrip` +- `testBookSourceCRUD_createReadUpdateDelete` +- `testBookSource_emptyURL_rejected` +- `testBookSource_duplicateURL_rejected` + +**Implementation approach**: +1. BookSource @Model: `sourceURL` (unique), `sourceName`, `sourceType` (0=text), `enabled`, `searchURL`, `header`, `ruleSearch`, `ruleBookInfo`, `ruleToc`, `ruleContent` +2. Rule sub-models as Codable structs stored as JSON Data on BookSource +3. Management UI: list with enable/disable toggles, swipe to delete, add button +4. Editor: form-based with text fields for each rule +5. No built-in sources — all user-imported + +**Edge cases**: Empty source name, URL without scheme (add https://), very long rule strings, special characters in URLs, sources with login requirements (defer to D08). + +**Acceptance criteria**: CRUD operations on book sources. Enable/disable toggle. Data persists across launches. Codable for import/export. + +**Dependencies**: None. + +**Effort**: M + +--- + +## WI-D02: HTTP Client + Encoding Detection + Cookies/Headers + +**Problem**: Web scraping needs a robust HTTP client that handles encoding detection (GB2312/GBK common for Chinese novel sites), cookie persistence, custom headers, and rate limiting. + +**Files to create/modify**: +- Create: `vreader/Services/BookSource/BookSourceHTTPClient.swift` +- Create: `vreader/Services/BookSource/EncodingDetector.swift` +- Create: `vreader/Services/BookSource/CookieStore.swift` +- Create: `vreaderTests/Services/BookSource/BookSourceHTTPClientTests.swift` +- Create: `vreaderTests/Services/BookSource/EncodingDetectorTests.swift` + +**Tests FIRST**: +- `testFetchPage_success_returnsHTML` +- `testFetchPage_404_returnsError` +- `testFetchPage_timeout_returnsError` +- `testFetchPage_customHeaders_included` +- `testFetchPage_cookiesPersisted` +- `testFetchPage_rateLimited_respectsDelay` +- `testEncodingDetect_UTF8` +- `testEncodingDetect_GB2312` +- `testEncodingDetect_GBK` +- `testEncodingDetect_metaCharset_overridesHTTPHeader` +- `testEncodingDetect_noCharset_defaultsUTF8` +- `testEncodingDetect_BOM_detected` +- `testCookieStore_savesAndRestores` +- `testCookieStore_perDomain` +- `testRateLimit_concurrentRate_respected` + +**Implementation approach**: +1. URLSession-based client (not WKWebView for scraping) +2. Encoding detection: check HTTP Content-Type header, then HTML meta charset, then BOM, then heuristic (CFStringConvertEncodingToNSStringEncoding) +3. Cookie persistence via HTTPCookieStorage.shared (per-source isolation via cookie jar) +4. Custom headers from BookSource.header field (User-Agent, Referer, etc.) +5. Rate limiting via DispatchSemaphore or async throttle (BookSource.concurrentRate) + +**Edge cases**: Double-encoded content (UTF-8 decoded as Latin-1), redirect chains, HTTPS certificate errors, very large pages (>1MB), connection drops mid-download. + +**Acceptance criteria**: Fetches pages with correct encoding. Cookies persist per domain. Rate limiting prevents IP bans. Custom headers sent correctly. + +**Dependencies**: None (parallel with D01). + +**Effort**: M + +--- + +## WI-D03: Rule Engine (CSS Selectors, Regex, Legado Syntax) + +**Problem**: BookSource rules specify how to extract data from HTML. Need a rule engine that supports CSS selectors, regex, and Legado-specific operators (`@`, `!`, chaining) — the core Legado rule types. + +**XPath deferral**: XPath support deferred to D08 spike. MVP uses CSS selectors + regex only. Sources with XPath-only rules are marked as "limited compatibility" on import (see D05 compatibility classification). + +**Files to create/modify**: +- Create: `vreader/Services/BookSource/RuleEngine.swift` — main dispatcher +- Create: `vreader/Services/BookSource/CSSRuleEvaluator.swift` — SwiftSoup CSS +- Create: `vreader/Services/BookSource/RegexRuleEvaluator.swift` — regex extraction +- Create: `vreader/Services/BookSource/LegadoSyntaxParser.swift` — parse Legado operators (`@`, `!`, chaining) +- Create: `vreader/Services/BookSource/RuleParser.swift` — parse rule syntax to determine type +- Create: `vreaderTests/Services/BookSource/RuleEngineTests.swift` +- Create: `vreaderTests/Services/BookSource/CSSRuleEvaluatorTests.swift` +- Create: `vreaderTests/Services/BookSource/LegadoSyntaxParserTests.swift` +- Create: `vreaderTests/Services/BookSource/RuleParserTests.swift` +- Add dependency: SwiftSoup (HTML parser with CSS selector support) + +**Tests FIRST**: +- `testCSSRule_extractText_byClass` +- `testCSSRule_extractAttribute_href` +- `testCSSRule_extractList_multipleMatches` +- `testCSSRule_nestedSelector` +- `testRegexRule_extractGroup` +- `testRegexRule_replacePattern` +- `testRuleParser_detectsCSS` +- `testRuleParser_detectsXPath_marksLimitedCompat` (starts with //, flags as deferred) +- `testRuleParser_detectsRegex` (starts with :regex:) +- `testRuleEngine_dispatchesCorrectly` +- `testRuleEngine_emptyRule_returnsEmpty` +- `testRuleEngine_invalidHTML_returnsEmpty` +- `testRuleEngine_CJKContent_correctExtraction` +- `testLegadoSyntax_atOperator_accessesAttribute` (e.g., `tag.a@href`) +- `testLegadoSyntax_bangOperator_selectsByIndex` (e.g., `class.item!0`) +- `testLegadoSyntax_paginationChaining_followsNextPage` +- `testLegadoSyntax_cleanupRules_stripTagsAndWhitespace` +- `testLegadoSyntax_relativeURL_resolvedAgainstBase` + +**Implementation approach**: +1. SwiftSoup for CSS selector evaluation (pure Swift, no C dependencies) +2. Regex via NSRegularExpression +3. RuleParser detects type: `//{path}` = XPath (flagged as "limited compatibility", deferred to D08), `:regex:{pattern}` = regex, otherwise CSS + Legado syntax +4. Legado rule syntax operators: + - `@` — attribute accessor (e.g., `tag.a@href` extracts the href attribute) + - `!` — index selector (e.g., `class.item!0` selects first match) + - Pagination chaining — rules that follow `nextTocUrl`/`nextContentUrl` for multi-page extraction + - Cleanup semantics — `##regex` for content cleanup, `@get:{rule}` for post-processing + - Relative URL resolution — extracted URLs resolved against page base URL +5. LegadoSyntaxParser: tokenizes Legado rule strings into evaluable operations + +**Edge cases**: Malformed HTML (SwiftSoup is lenient), rules matching 0 elements, rules matching too many elements, nested rules (rule within rule result), Unicode in selectors, very large HTML documents (>5MB). + +**Acceptance criteria**: CSS selectors extract correct elements from fixture HTML. Legado operators (`@`, `!`, chaining, cleanup) work correctly. Regex extraction and replacement work. Rule type auto-detection is correct. XPath rules detected and flagged as "limited compatibility" (deferred to D08). + +**Dependencies**: WI-D01 (schema defines rule format). + +**Effort**: M + +--- + +## WI-D04: Pipeline MVP (Search -> Book Info -> Chapter List -> Content) + +**Problem**: The four-stage pipeline connects D01 (model), D02 (HTTP), D03 (rules) into an end-to-end scraping flow. One vetted source working end-to-end. + +**Files to create/modify**: +- Create: `vreader/Services/BookSource/BookSourcePipeline.swift` +- Create: `vreader/Services/BookSource/PipelineStage.swift` — enum for progress tracking +- Create: `vreader/Views/BookSource/BookSourceSearchView.swift` — search UI +- Create: `vreader/Views/BookSource/BookSourceChapterListView.swift` — chapter list +- Create: `vreader/Views/BookSource/BookSourceReaderView.swift` — chapter reader +- Create: `vreaderTests/Services/BookSource/BookSourcePipelineTests.swift` + +**Tests FIRST**: +- `testPipeline_search_returnsBookList` +- `testPipeline_bookInfo_extractsMetadata` +- `testPipeline_toc_extractsChapterList` +- `testPipeline_content_extractsChapterText` +- `testPipeline_endToEnd_withFixtureHTML` +- `testPipeline_searchNoResults_returnsEmpty` +- `testPipeline_invalidURL_returnsError` +- `testPipeline_networkError_propagates` +- `testPipeline_emptyContent_returnsError` +- `testPipeline_nextPageURL_followsPagination` +- `testPipeline_cancelDuringFetch_stops` +- `testPipeline_progressCallback_reportStages` + +**Implementation approach**: +1. Pipeline stages: Search -> BookInfo -> TOC -> Content +2. Each stage: fetch URL -> apply rules -> extract data -> pass to next +3. Search: `searchURL.replace("{{key}}", keyword)` -> fetch -> ruleSearch +4. BookInfo: fetch bookUrl -> ruleBookInfo -> extract tocUrl +5. TOC: fetch tocUrl -> ruleToc -> chapter list (handle nextTocUrl pagination) +6. Content: fetch chapterUrl -> ruleContent -> cleaned text (handle nextContentUrl) +7. Use fixture HTML files for integration tests (no network in tests) +8. Progress callback at each stage for UI + +**Edge cases**: Multi-page TOC (nextTocUrl), multi-page chapter content (nextContentUrl), empty search results, source that requires login, source returning non-HTML (JSON), rate limiting triggering mid-pipeline. + +**Acceptance criteria**: One real web novel source works end-to-end: search, view book info, browse chapters, read chapter content. Pipeline reports progress at each stage. Errors are descriptive. + +**Dependencies**: WI-D02 (HTTP client), WI-D03 (rule engine). + +**Effort**: M + +--- + +## WI-D05: Legado JSON Import/Export + +**Problem**: The Legado ecosystem has thousands of user-shared book sources in JSON format. Import/export compatibility lets VReader tap into this ecosystem. + +**Files to create/modify**: +- Create: `vreader/Services/BookSource/LegadoImporter.swift` +- Create: `vreader/Services/BookSource/LegadoExporter.swift` +- Create: `vreaderTests/Services/BookSource/LegadoImporterTests.swift` +- Create: `vreaderTests/Services/BookSource/LegadoExporterTests.swift` +- Add: `vreaderTests/Fixtures/legado_sample_source.json` — fixture + +**Compatibility classification**: On import, classify each source's rule compatibility: +- **Full** — CSS selectors + regex only (all rules supported) +- **Limited** — contains XPath-only rules (deferred to D08; source imported but flagged) +- **Unsupported** — requires JS execution (imported but marked non-functional) + +Show a compatibility badge in the source list UI (green/yellow/red). Users can see at a glance which imported sources will work. + +**Tests FIRST**: +- `testImportLegadoJSON_classifyCSSOnly_full` +- `testImportLegadoJSON_classifyXPathRules_limited` +- `testImportLegadoJSON_classifyJSRules_unsupported` +- `testImportLegadoJSON_compatBadge_shownInList` +- `testImportLegadoJSON_singleSource` +- `testImportLegadoJSON_multipleSourcesArray` +- `testImportLegadoJSON_unknownFields_ignored` +- `testImportLegadoJSON_missingOptionalFields_defaults` +- `testImportLegadoJSON_duplicateURL_skips` +- `testImportLegadoJSON_invalidJSON_returnsError` +- `testImportLegadoJSON_emptyArray_noOp` +- `testExportToLegadoJSON_validFormat` +- `testExportToLegadoJSON_roundTrip` +- `testImportLegadoJSON_audioSource_typePreserved` +- `testImportLegadoJSON_500Sources_performsUnder2Seconds` + +**Implementation approach**: +1. Parse Legado JSON array of BookSource objects +2. Map Legado fields to VReader BookSource model (field name mapping) +3. Handle type differences: Legado uses Int for sourceType, String for rules +4. Unknown fields ignored (forward compatibility) +5. Export: serialize VReader BookSource array to Legado-compatible JSON +6. Import via file picker or URL (share sheet) + +**Edge cases**: Very large import files (500+ sources), sources with JS execution rules (mark as unsupported), sources with loginUrl (import but mark), sources with empty required fields, mixed encoding in JSON file. + +**Acceptance criteria**: Legado JSON files import correctly. Exported JSON re-imports in Legado. 500-source import completes in <2s. Unknown fields don't crash. + +**Dependencies**: WI-D01 (schema). + +**Effort**: M + +--- + +## WI-D06: Chapter Cache + Offline Reading + +**Problem**: Users need to read cached chapters offline after fetching them once. Cache should persist across app launches. + +**Files to create/modify**: +- Create: `vreader/Services/BookSource/ChapterCache.swift` +- Create: `vreader/Services/BookSource/ChapterCacheStore.swift` — disk persistence +- Create: `vreaderTests/Services/BookSource/ChapterCacheTests.swift` +- Modify: `vreader/Services/BookSource/BookSourcePipeline.swift` — check cache before fetch + +**Tests FIRST**: +- `testCache_storeAndRetrieve_chapter` +- `testCache_miss_returnsNil` +- `testCache_persistsAcrossInstances` +- `testCache_eviction_LRU` +- `testCache_maxSize_respected` +- `testCache_bookDeletion_clearsCachedChapters` +- `testCache_corruptedFile_returnsNilAndCleans` +- `testCache_concurrentAccess_safe` +- `testPipeline_hitCache_skipsNetwork` +- `testPipeline_cacheMiss_fetchesAndCaches` + +**Implementation approach**: +1. Disk-based cache: `/ChapterCache//.txt` +2. LRU eviction when total cache exceeds configurable max (default 500MB) +3. Metadata stored in SQLite (chapter URL, cached date, size, book reference) +4. Pipeline checks cache first; on miss, fetches and caches +5. Manual "Download All" button for offline preparation + +**Edge cases**: Corrupted cache files, cache during low storage, concurrent reads/writes, chapter content update on source (stale cache). + +**Acceptance criteria**: Cached chapters load instantly without network. Cache persists across launches. LRU eviction keeps cache within bounds. Corrupt files cleaned up. + +**Dependencies**: WI-D04 (pipeline). + +**Effort**: M + +--- + +## WI-D07a: Update Detection + +**Problem**: Users need to know when web novels have new chapters. Periodic background checks compare remote TOC against cached chapter list. + +**Files to create/modify**: +- Create: `vreader/Services/BookSource/UpdateChecker.swift` +- Create: `vreaderTests/Services/BookSource/UpdateCheckerTests.swift` +- Modify: `vreader/Views/Library/LibraryView.swift` — update badge + +**Tests FIRST**: +- `testUpdateCheck_newChapters_detected` +- `testUpdateCheck_noNewChapters_noNotification` +- `testUpdateCheck_networkError_gracefulDegradation` +- `testUpdateCheck_rateLimited_respectsInterval` +- `testUpdateCheck_disabledSource_skipped` +- `testUpdateCheck_batchCheck_allSources` +- `testUpdateCheck_chapterCountDecreased_handledGracefully` +- `testUpdateCheck_backgroundTask_scheduledCorrectly` + +**Implementation approach**: +1. UpdateChecker: fetch TOC, compare chapter count with cached count +2. Background refresh via BGAppRefreshTask (requires `BGTaskSchedulerPermittedIdentifiers` in Info.plist) +3. Configurable interval (default: 6 hours, minimum: 1 hour) +4. Badge on library books with new chapters +5. Schedule next check via `BGTaskScheduler.shared.submit()` after each run +6. Respect system background execution limits (iOS may delay or skip) + +**Edge cases**: Source goes offline, chapter count decreases (removed chapters), very frequent updates (rate limit), background refresh permissions denied, app killed before task completes. + +**Acceptance criteria**: New chapters detected and shown as badge. Background check scheduled via BGTaskScheduler. Rate limiting respected. Graceful degradation on network failure. + +**Dependencies**: WI-D04 (pipeline — needs TOC fetching). + +**Effort**: M + +--- + +## WI-D07b: Source Sharing + +**Problem**: Sharing sources with other users is essential for the ecosystem. Users need to export/import individual sources easily. + +**Files to create/modify**: +- Create: `vreader/Services/BookSource/SourceSharingService.swift` +- Create: `vreaderTests/Services/BookSource/SourceSharingServiceTests.swift` + +**Tests FIRST**: +- `testSharing_exportSourceAsJSON` +- `testSharing_importSharedSource` +- `testSharing_URLScheme_opens` +- `testSharing_QRCode_generation` + +**Implementation approach**: +1. Sharing: export single source as JSON via share sheet +2. URL scheme: `vreader://import-source?url=...` for one-tap import +3. Optional QR code for source sharing + +**Edge cases**: Malformed URL scheme, QR code too dense for complex sources, importing a source that already exists. + +**Acceptance criteria**: Source sharing via JSON/URL works. QR code generation works. URL scheme triggers import flow. + +**Dependencies**: WI-D05 (Legado import — reuses import logic). + +**Effort**: S + +--- + +## WI-D08: Optional JS Execution + XPath Spike + +**Problem**: Some Legado sources use JavaScript rules (`code` or `{{code}}`). Others use XPath-only selectors (deferred from D03 MVP). This is a spike to evaluate feasibility of both, not a committed feature. + +**Files to create/modify**: +- Create: `vreader/Services/BookSource/JSRuleEvaluator.swift` — JavaScriptCore evaluation +- Create: `vreaderTests/Services/BookSource/JSRuleEvaluatorTests.swift` +- Create: `docs/codex-plans/SPIKE_D08_RESULTS.md` — spike findings + +**Tests FIRST**: +- `testJSRule_simpleExpression_evaluates` +- `testJSRule_accessDOM_viaStringInput` +- `testJSRule_timeout_5seconds` +- `testJSRule_infiniteLoop_timesOut` +- `testJSRule_memoryLimit_enforced` +- `testJSRule_noNetworkAccess` +- `testJSRule_returnString_extracted` + +**Implementation approach**: +1. Use JavaScriptCore (JSContext) — no WKWebView needed +2. Sandbox: no network access, no filesystem access, 5-second timeout +3. Input: HTML string injected as variable, JS rule evaluated +4. Output: string result extracted +5. Decision gate: If >30% of popular sources need JS, implement. Otherwise, mark JS sources as "unsupported" on import. + +**Edge cases**: Malicious JS (sandboxed), memory exhaustion, infinite loops (timeout), JS that expects browser DOM (won't work in JSContext). + +**Acceptance criteria**: Spike document produced with go/no-go decision. If go: JS rules evaluate in sandbox with timeout. If no-go: JS sources marked unsupported on import with user-visible indicator. + +**Dependencies**: WI-D04 (pipeline must work without JS first). + +**Effort**: L + +--- + +## Sprint Plan + +**Sprint D1** (parallel): D01 (model) + D02 (HTTP client) — M + M +**Sprint D2** (parallel, after D01): D03 (rule engine) + D05 (Legado import) — M + M +**Sprint D3** (sequential, after D2+D2): D04 (pipeline MVP) — M +**Sprint D4** (parallel, after D3): D06 (cache) + D07a (update detection) — M + M +**Sprint D4b** (parallel with D4, after D5): D07b (source sharing) — S +**Sprint D5** (optional, after D3): D08 (JS spike) — L + +## Checkpoint Criteria + +- Book sources can be created, edited, enabled/disabled +- Legado JSON import/export works (500+ sources) +- At least one real web novel source works end-to-end +- Chapter caching enables offline reading +- Update detection shows new chapter badges (D07a) +- Source sharing via JSON and URL scheme works (D07b) +- All existing tests pass + +## Manual Testing + +See `docs/manual-test-checklist.md` for phase-specific test items. diff --git a/docs/archive/codex-plans/phaseE-plan.md b/docs/archive/codex-plans/phaseE-plan.md new file mode 100644 index 0000000..615850d --- /dev/null +++ b/docs/archive/codex-plans/phaseE-plan.md @@ -0,0 +1,382 @@ +# Phase E Implementation Plan (Forward) + +**Date**: 2026-03-17 +**Status**: FORWARD — 7 WIs planned (E02 split into E02a + E02b) +**Scope**: Cross-device sync (WebDAV + iCloud) and text transformation features + +**Reference**: iCloud design doc at `docs/codex-plans/icloud-backup-design.md` + +--- + +## WI-E01: #29 WebDAV Backup and Restore + +**Problem**: Users need cross-platform backup without iCloud (Nutstore/坚果云, Synology, NextCloud). WebDAV is the standard protocol for this. + +**Files to create/modify**: +- Create: `vreader/Services/Backup/WebDAVProvider.swift` — BackupProvider conformance +- Create: `vreader/Services/Backup/WebDAVClient.swift` — raw WebDAV HTTP operations +- Create: `vreader/Views/Settings/WebDAVSettingsView.swift` — connection config UI +- Create: `vreaderTests/Services/Backup/WebDAVProviderTests.swift` +- Create: `vreaderTests/Services/Backup/WebDAVClientTests.swift` +- Modify: `vreader/Services/Backup/BackupProvider.swift` — no changes needed (protocol ready) + +**Tests FIRST**: +- `testBackup_createsZIPArchive` +- `testBackup_includesMetadata` +- `testBackup_includesAnnotations` +- `testBackup_includesReadingPositions` +- `testBackup_includesCollections` +- `testBackup_includesBookSources` +- `testBackup_includesReplacementRules` +- `testBackup_includesPerBookSettings` +- `testBackup_includesTxtTocRules` +- `testBackup_progressReported` +- `testRestore_extractsZIP` +- `testRestore_restoresAnnotations` +- `testRestore_restoresReadingPositions` +- `testRestore_backupNotFound_error` +- `testListBackups_sortedNewestFirst` +- `testListBackups_emptyServer_returnsEmpty` +- `testDeleteBackup_removesFromServer` +- `testWebDAVClient_PROPFIND_parsesResponse` +- `testWebDAVClient_PUT_uploadsFile` +- `testWebDAVClient_GET_downloadsFile` +- `testWebDAVClient_DELETE_removesFile` +- `testWebDAVClient_authFailure_returnsError` +- `testWebDAVClient_connectionTest_success` +- `testWebDAVClient_nutstore_compatible` +- `testBackup_largeLibrary_50Books_completesUnder30s` + +**Implementation approach**: +1. WebDAVClient: URLSession-based, supports PROPFIND, PUT, GET, DELETE, MKCOL +2. WebDAVProvider: implements BackupProvider protocol (already defined in WI-F04) +3. Backup format: ZIP containing: + - `metadata.json` — backup metadata (version, date, device) + - `annotations.json` — highlights, bookmarks, notes + - `positions.json` — reading positions per book + - `settings.json` — app settings/preferences + - `collections.json` — user collections (from C01) + - `book-sources.json` — imported book sources (from Phase D) + - `replacement-rules.json` — content replacement rules (from E05) + - `per-book-settings.json` — per-book reader settings (font, theme overrides) + - `txt-toc-rules.json` — custom TXT table-of-contents rules + - optional book files (if user opts in) +4. ZIP created via Foundation's FileManager or ZIPFoundation +5. Backup stored at `/VReader/backups/.vreader.zip` +6. Settings UI: server URL, username, password (stored in Keychain via KeychainService) +7. Connection test button before saving + +**Edge cases**: Slow networks (progress + timeout), server full (quota), auth expiry, server not supporting PROPFIND (some WebDAV implementations), special characters in filenames, Nutstore-specific quirks (path encoding). + +**Acceptance criteria**: Full backup/restore cycle works. Compatible with Nutstore, NextCloud, Synology. Credentials stored securely. Progress reported. Error messages actionable. + +**Dependencies**: WI-F04 (BackupProvider protocol) — done. + +**Effort**: M + +--- + +## WI-E02a: #10 iCloud Snapshot Backup (via BackupProvider) + +**Problem**: iOS-native backup via iCloud for users in the Apple ecosystem. Snapshot approach: create a ZIP archive and store it in iCloud Drive, reusing the BackupProvider protocol from E01. + +**Files to create/modify**: +- Create: `vreader/Services/Backup/ICloudBackupProvider.swift` — BackupProvider conformance (ZIP to iCloud Drive) +- Create: `vreader/Views/Settings/ICloudBackupSettingsView.swift` — backup/restore UI +- Create: `vreaderTests/Services/Backup/ICloudBackupProviderTests.swift` + +**Tests FIRST**: +- `testICloudBackup_createsZIPInICloudDrive` +- `testICloudBackup_includesAllBackupData` +- `testICloudBackup_listBackups_fromICloudDrive` +- `testICloudRestore_extractsZIPFromICloudDrive` +- `testICloudBackup_noICloudAccount_returnsError` +- `testICloudBackup_quotaExhausted_returnsError` +- `testICloudBackup_progressReported` + +**Implementation approach**: +1. ICloudBackupProvider implements BackupProvider protocol (same interface as WebDAVProvider) +2. Uses FileManager.default.url(forUbiquityContainerIdentifier:) to access iCloud Drive +3. Backup format: same ZIP as E01 — `metadata.json` + `annotations.json` + `positions.json` + `settings.json` +4. Stored at `/VReader/backups/.vreader.zip` +5. No CloudKit API needed — just iCloud Drive file operations + +**Edge cases**: No iCloud account signed in, iCloud Drive disabled, quota full, slow upload (progress tracking). + +**Acceptance criteria**: Full backup/restore cycle via iCloud Drive. Same ZIP format as WebDAV. Progress reported. Error messages for no-account / quota-full. + +**Dependencies**: WI-F04 (BackupProvider protocol) — done. + +**Effort**: M + +--- + +## WI-E02b: #10 iCloud Live Sync (via CloudKit) + +**Problem**: Live sync across devices (not just backup/restore) requires CloudKit for real-time propagation of settings, reading positions, and annotations. Design doc at `docs/codex-plans/icloud-backup-design.md`. + +**Note**: E02b is separate from E02a. Snapshot backup (E02a) is simpler and ships first. Live sync is a larger effort that builds on existing Sync infrastructure. + +**Files to create/modify**: +- Create: `vreader/Services/Backup/CloudKitSyncProvider.swift` — CloudKit live sync +- Create: `vreader/Services/Backup/CloudKitRecordMapper.swift` — SwiftData ↔ CloudKit mapping +- Create: `vreader/Services/Backup/ICloudDocumentManager.swift` — book file sync +- Create: `vreader/Views/Settings/ICloudSyncSettingsView.swift` — sync toggle + status +- Create: `vreaderTests/Services/Backup/CloudKitSyncProviderTests.swift` +- Create: `vreaderTests/Services/Backup/CloudKitRecordMapperTests.swift` +- Modify: `vreader/Services/Sync/SyncService.swift` — wire CloudKit integration +- Modify: `vreader/Services/Sync/TombstoneStore.swift` — add SwiftData persistence +- Modify: `vreader/Services/PreferenceStore.swift` — add NSUbiquitousKeyValueStore + +**Tests FIRST**: +- `testRecordMapper_bookToCloudKit_roundTrip` +- `testRecordMapper_highlightToCloudKit_roundTrip` +- `testRecordMapper_bookmarkToCloudKit_roundTrip` +- `testRecordMapper_readingPositionToCloudKit_roundTrip` +- `testRecordMapper_readingSessionToCloudKit_roundTrip` +- `testRecordMapper_locatorJSON_preservedOpaque` +- `testRecordMapper_unknownFields_ignored` +- `testCloudKitSync_push_createsRecords` +- `testCloudKitSync_pull_appliesRecords` +- `testCloudKitSync_conflictResolution_usesExistingResolver` +- `testTombstoneStore_persistsToSwiftData` +- `testTombstoneStore_purgeAfter30Days` +- `testNSUKVS_settingsSync_roundTrip` +- `testSchemaVersion_newerRemote_readOnlyMode` +- `testSchemaVersion_olderRemote_processesFine` + +**Implementation approach**: +1. Phase 1 (settings + positions): NSUbiquitousKeyValueStore for prefs, CloudKit custom zone for positions +2. Phase 2 (annotations): CloudKit records for Bookmark, Highlight, AnnotationNote with tombstone sync +3. Phase 3 (book files): iCloud Documents with FileAvailabilityStateMachine (already implemented) +4. CloudKitRecordMapper converts between SwiftData models and CKRecord +5. Locator stored as JSON blob (locatorJSON) for forward compatibility +6. SyncConflictResolver (already implemented) handles all conflict types +7. Feature-flagged behind FeatureFlags.sync + +**Edge cases**: iCloud quota exhaustion, account change, schema version mismatch, offline queue, large annotation sets (batch 400 records), simultaneous edit on two devices, tombstone purge timing. + +**Acceptance criteria**: Settings sync via NSUKVS. Reading positions sync across devices. Annotations sync with conflict resolution. Feature flag controls enablement. Status visible in settings. + +**Dependencies**: WI-F04 (BackupProvider protocol) — done. Existing Sync infrastructure (SyncConflictResolver, TombstoneStore, SyncService, SyncStatusMonitor) — all implemented. + +**Effort**: L + +--- + +## WI-E03: Text-Mapping Layer + +**Problem**: Display text transformations (simp/trad conversion, content replacement) change character positions. Without a mapping layer, highlights and search results would point to wrong locations after transformation. + +**Files to create/modify**: +- Create: `vreader/Services/TextMapping/TextMapper.swift` — bidirectional offset mapping +- Create: `vreader/Services/TextMapping/TextTransform.swift` — protocol for transforms +- Create: `vreader/Services/TextMapping/OffsetMap.swift` — offset lookup table +- Create: `vreaderTests/Services/TextMapping/TextMapperTests.swift` +- Create: `vreaderTests/Services/TextMapping/OffsetMapTests.swift` + +**Tests FIRST**: +- `testIdentityTransform_offsetsUnchanged` +- `testSingleCharReplace_offsetShifts` +- `testMultiCharToSingle_offsetCompresses` +- `testSingleToMultiChar_offsetExpands` +- `testChainedTransforms_offsetsCompose` +- `testDisplayToSource_roundTrip` +- `testSourceToDisplay_roundTrip` +- `testHighlightRange_afterTransform_pointsToCorrectText` +- `testSearchResult_afterTransform_pointsToCorrectOffset` +- `testEmptyText_noOp` +- `testTransform_CJKCharacters_correctMapping` +- `testTransform_mixedScript_correctMapping` +- `testLargeText_100KChars_performsUnder100ms` +- `testSearchAfterTransform_resultsPointToCorrectSourcePositions` +- `testHighlightRestoreAfterTransform_anchorsMappedCorrectly` + +**Integration dependencies**: SearchIndexStore (must re-index using display text, map results back to source offsets) and LocatorFactory (highlight anchors must survive round-trip through transforms). + +**Implementation approach**: +1. OffsetMap: sorted array of `(sourceOffset, displayOffset, lengthDelta)` entries +2. Binary search for offset lookup (O(log n)) +3. TextMapper: applies transforms to source text, builds OffsetMap +4. TextTransform protocol: `transform(input: String) -> (output: String, offsetMap: OffsetMap)` +5. Transforms are chainable: OffsetMap.compose(other: OffsetMap) +6. Integration point: ReflowableTextSource adapter wraps transformed text + +**Edge cases**: One-to-many character mappings (simplified to traditional), many-to-one, overlapping transforms, empty transform, transform that produces identical text, CJK punctuation changes, zero-width characters. + +**Acceptance criteria**: After any text transform, highlight offsets map back to correct source text. Search results point to correct positions. Offset mapping round-trips correctly. Performance: 100K chars in <100ms. + +**Dependencies**: WI-F03 (ReflowableTextSource) — done. Also integrates with SearchIndexStore (search re-index after transform) and LocatorFactory (highlight anchor mapping). + +**Effort**: M + +--- + +## WI-E04: #28 Simplified/Traditional Chinese Conversion + +**Problem**: Many Chinese books exist in only one script variant. Readers need display-time conversion between Simplified and Traditional Chinese. + +**Files to create/modify**: +- Create: `vreader/Services/TextMapping/SimpTradTransform.swift` +- Create: `vreader/Services/TextMapping/SimpTradDictionary.swift` — conversion tables +- Create: `vreaderTests/Services/TextMapping/SimpTradTransformTests.swift` +- Modify: `vreader/Services/ReaderSettingsStore.swift` — add conversion toggle + +**Tests FIRST**: +- `testSimpToTrad_basicCharacters` +- `testTradToSimp_basicCharacters` +- `testSimpToTrad_multiCharMapping` (e.g., 发 → 發/髮 context-dependent) +- `testTradToSimp_multiCharMapping` +- `testConversion_mixedScriptText_onlyCJKConverted` +- `testConversion_punctuation_preserved` +- `testConversion_emptyText_noOp` +- `testConversion_alreadyInTargetScript_noOp` +- `testOffsetMap_afterConversion_highlightsCorrect` +- `testConversion_1MBText_under500ms` + +**Implementation approach**: +1. Bundle OpenCC (Open Chinese Convert) conversion tables for accurate context-aware conversion. OpenCC handles context-dependent mappings (e.g., 发 → 發/髮) that simple character tables miss. +2. ICU (via Foundation's CFStringTransform) as fallback for environments where bundled tables are unavailable. +3. Note: `kCFStringTransformMandarinToLatin` is for pinyin romanization, NOT simp/trad conversion — do not use it here. +4. SimpTradTransform conforms to TextTransform protocol from E03 +5. Produces OffsetMap for highlight/search preservation +6. Toggle in ReaderSettingsStore: `.none`, `.simpToTrad`, `.tradToSimp` +7. Applied at display time via ReflowableTextSource adapter + +**Edge cases**: Context-dependent characters (发 can be 發 or 髮), Japanese kanji (should not be converted), mixed simp+trad text, very large files, conversion of metadata (title, author). + +**Acceptance criteria**: Conversion is visually correct for common text. Context-dependent characters use best-effort mapping. Highlights survive conversion. Performance: 1MB text in <500ms. Toggle in settings works. + +**Dependencies**: WI-E03 (text-mapping layer). + +**Effort**: M + +--- + +## WI-E05: #27 Content Replacement Rules + +**Problem**: Users want to fix OCR errors, remove watermarks, standardize terminology, or customize display text via regex find/replace rules. Reference: Legado's replaceRule. + +**Files to create/modify**: +- Create: `vreader/Models/ContentReplacementRule.swift` +- Create: `vreader/Services/TextMapping/ReplacementTransform.swift` +- Create: `vreader/Views/Settings/ReplacementRulesView.swift` +- Create: `vreaderTests/Services/TextMapping/ReplacementTransformTests.swift` +- Create: `vreaderTests/Models/ContentReplacementRuleTests.swift` + +**Tests FIRST**: +- `testReplace_simpleString_replaced` +- `testReplace_regex_groupCapture` +- `testReplace_multipleRules_appliedInOrder` +- `testReplace_noMatch_textUnchanged` +- `testReplace_emptyPattern_noOp` +- `testReplace_invalidRegex_skipped` +- `testReplace_overlappingMatches_firstWins` +- `testOffsetMap_afterReplacement_highlightsCorrect` +- `testReplace_CJKCharacters_correct` +- `testReplace_ruleCRUD_persistsToSwiftData` +- `testReplace_ruleEnabledDisabled_toggle` +- `testReplace_perBookRules_isolated` +- `testReplace_globalRules_applyToAll` +- `testReplace_catastrophicBacktracking_timesOutAt1s` +- `testReplace_timedOutRule_skippedGracefully` + +**Implementation approach**: +1. ContentReplacementRule: SwiftData @Model with pattern (regex), replacement, isRegex, scope (global/per-book), enabled, order +2. ReplacementTransform conforms to TextTransform protocol from E03 +3. Rules applied in order (lower order number first) +4. Per-book rules override global rules for the same pattern +5. UI: list of rules with drag-to-reorder, enable/disable toggles, add/edit/delete +6. Applied at display time via ReflowableTextSource adapter (same as E04) + +**Edge cases**: Catastrophic regex backtracking (timeout at 1s per rule), replacement that creates new matches (no re-application), empty replacement (deletion), replacement changing text length (offset map), Unicode regex features. + +**Regex timeout mechanism**: NSRegularExpression itself has no built-in timeout. Implement timeout via DispatchWorkItem: wrap each rule evaluation in a work item, cancel after 1 second. If cancelled, skip the rule and log a warning. This prevents catastrophic backtracking from freezing the UI. + +**Acceptance criteria**: String and regex replacements work. Rules persist. Per-book and global scopes. Highlights survive replacement. Invalid regex doesn't crash. + +**Dependencies**: WI-E03 (text-mapping layer). + +**Effort**: M + +--- + +## WI-E06: #26 HTTP TTS (Cloud Voices) + +**Problem**: System AVSpeechSynthesizer voices are limited. Cloud TTS services (Azure, Google, custom) offer higher quality and more language options. + +**Files to create/modify**: +- Create: `vreader/Services/TTS/HTTPTTSProvider.swift` +- Create: `vreader/Services/TTS/HTTPTTSConfig.swift` — API endpoint, key, voice config +- Create: `vreader/Services/TTS/TTSProviderProtocol.swift` — shared interface +- Create: `vreader/Views/Settings/HTTPTTSSettingsView.swift` +- Create: `vreaderTests/Services/TTS/HTTPTTSProviderTests.swift` +- Modify: `vreader/Services/TTS/TTSService.swift` — accept TTSProvider instead of SpeechSynthesizing + +**Tests FIRST**: +- `testHTTPTTS_synthesize_returnsAudioData` +- `testHTTPTTS_chunkedSynthesis_splitsLongTextIntoSegments` +- `testHTTPTTS_chunkedSynthesis_progressCallback_reportsPerChunk` +- `testHTTPTTS_streamingAudio_playsWhileNextChunkFetches` +- `testHTTPTTS_networkError_fallsBackToSystem` +- `testHTTPTTS_rateLimiting_queuesRequests` +- `testHTTPTTS_cancelDuringSynthesis_stops` +- `testHTTPTTS_chunkText_intoSentences` +- `testHTTPTTS_cacheAudio_skipsDuplicateRequest` +- `testHTTPTTS_positionTracking_matchesAudioProgress` +- `testHTTPTTS_configValidation_rejectsEmptyURL` +- `testHTTPTTS_configValidation_rejectsEmptyKey` +- `testHTTPTTS_azureAPI_correctHeaders` +- `testHTTPTTS_customAPI_configurableEndpoint` + +**Implementation approach**: +1. TTSProviderProtocol (expanded): + ``` + protocol TTSProviderProtocol { + func synthesize(text: String, voice: String) async throws -> Data + func synthesizeChunked(text: String, voice: String, onChunk: @escaping (TTSChunk) -> Void, onProgress: @escaping (TTSProgress) -> Void) async throws + func cancel() + } + struct TTSChunk { let audioData: Data; let textRange: Range; let index: Int; let total: Int } + struct TTSProgress { let chunkIndex: Int; let totalChunks: Int; let bytesReceived: Int } + ``` +2. HTTPTTSProvider: URLSession-based, configurable endpoint/headers/voice +3. Chunked synthesis: split text into sentences, synthesize each chunk separately, stream audio (play chunk N while fetching chunk N+1) +4. Progress callback fires per chunk with index/total for UI progress bar +5. Cache audio chunks on disk (similar pattern to ChapterCache) +6. Position tracking: calculate from audio duration + chunk offsets +7. Fallback to system TTS on network failure +8. Support Azure Cognitive Services and generic REST APIs +9. API key stored in Keychain via KeychainService + +**Edge cases**: Very long sentences (split at 500 chars), network dropout mid-synthesis, API rate limits, audio format differences (MP3 vs WAV), CJK text chunking (sentence boundaries differ), API key rotation. + +**Acceptance criteria**: HTTP TTS plays audio with position tracking. Falls back to system TTS on failure. Audio caching works. Azure API integration tested. Custom endpoint configurable. + +**Dependencies**: WI-B03 (system TTS — done, provides base architecture). + +**Effort**: M + +--- + +## Sprint Plan + +**Sprint E1** (parallel): E01 (WebDAV) + E02a (iCloud snapshot backup) + E06 (HTTP TTS) — independent. +**Sprint E1b** (after E01/E02a): E02b (iCloud live sync) — builds on snapshot backup + existing sync infra. +**Sprint E2** (sequential): E03 (text-mapping layer) — foundational for E04+E05. +**Sprint E3** (parallel, after E03): E04 (simp/trad) + E05 (replacement rules) — both use E03. + +## Checkpoint Criteria + +- WebDAV backup/restore works with Nutstore/NextCloud +- iCloud snapshot backup/restore works via iCloud Drive (E02a) +- iCloud live sync works for settings + positions + annotations (E02b) +- Text transforms don't break highlights or search +- Simp/Trad toggle works with correct character mapping +- Content replacement rules apply at display time +- HTTP TTS plays with position tracking and fallback +- All existing tests pass + +## Manual Testing + +See `docs/manual-test-checklist.md` for phase-specific test items. diff --git a/docs/codex-plans/remote-server-design.md b/docs/archive/codex-plans/remote-server-design.md similarity index 100% rename from docs/codex-plans/remote-server-design.md rename to docs/archive/codex-plans/remote-server-design.md diff --git a/docs/codex-plans/2026-03-04-ios-reader-app.md b/docs/archive/plans/2026-03-04-ios-reader-app.md similarity index 100% rename from docs/codex-plans/2026-03-04-ios-reader-app.md rename to docs/archive/plans/2026-03-04-ios-reader-app.md diff --git a/docs/codex-plans/2026-03-10-full-refactor.md b/docs/archive/plans/2026-03-10-full-refactor.md similarity index 100% rename from docs/codex-plans/2026-03-10-full-refactor.md rename to docs/archive/plans/2026-03-10-full-refactor.md diff --git a/docs/codex-plans/2026-03-11-features-roadmap.md b/docs/archive/plans/2026-03-11-features-roadmap.md similarity index 100% rename from docs/codex-plans/2026-03-11-features-roadmap.md rename to docs/archive/plans/2026-03-11-features-roadmap.md diff --git a/docs/WI-5-locator-spike-notes.md b/docs/archive/plans/WI-5-locator-spike-notes.md similarity index 100% rename from docs/WI-5-locator-spike-notes.md rename to docs/archive/plans/WI-5-locator-spike-notes.md diff --git a/docs/WI-5A-txt-feasibility-notes.md b/docs/archive/plans/WI-5A-txt-feasibility-notes.md similarity index 100% rename from docs/WI-5A-txt-feasibility-notes.md rename to docs/archive/plans/WI-5A-txt-feasibility-notes.md diff --git a/docs/WI-6B-markdown-reader-plan.md b/docs/archive/plans/WI-6B-markdown-reader-plan.md similarity index 100% rename from docs/WI-6B-markdown-reader-plan.md rename to docs/archive/plans/WI-6B-markdown-reader-plan.md diff --git a/docs/ui-test-plan.md b/docs/archive/plans/ui-test-plan.md similarity index 100% rename from docs/ui-test-plan.md rename to docs/archive/plans/ui-test-plan.md diff --git a/docs/archive/pr-checklist.md b/docs/archive/pr-checklist.md new file mode 100644 index 0000000..5661e52 --- /dev/null +++ b/docs/archive/pr-checklist.md @@ -0,0 +1,155 @@ +# PR Checklist — dev → main + +## Summary + +**Branch**: `dev` → `main` +**Commits**: 68 (+ uncommitted session fixes) +**Scope**: 325 files changed, ~52K insertions +**Date**: 2026-03-21 + +This PR delivers the complete V2 roadmap (Phases A–E) plus 78 bug fixes from integration testing. + +--- + +## Feature Phases Delivered + +### Phase A — Quick Wins +- [ ] WI-001: Persist library view preferences (sort order + view mode via PreferenceStore) +- [ ] WI-002: Visual feedback for bookmark toggle +- [ ] WI-003: Search highlight auto-dismiss + +### Phase B — Reader Enhancements +- [ ] WI-B04: Unified TXT reflow engine (TextKit 2 scroll + paged) +- [ ] WI-B05: Unified MD reflow (attributed text pagination) +- [ ] WI-B07: Unified EPUB text-mode (strip HTML to attributed text) +- [ ] WI-B10: Auto page turning (timer-based) +- [ ] WI-B11: Page turn animations (none/slide/cover) +- [ ] WI-B13: Pagination cache invalidation + +### Phase C — Collections & Annotations +- [ ] WI-C01: Collections / tags / series (SchemaV3) +- [ ] WI-C02: Annotation export (Markdown + JSON) +- [ ] WI-C03: Annotation import (VReader JSON round-trip) +- [ ] WI-C04: OPDS catalog (browse + download from OPDS 1.2 feeds) + +### Phase D — Book Sources +- [ ] WI-D01: BookSource model + SwiftData + management UI +- [ ] WI-D02: HTTP client + encoding detection + rate limiting +- [ ] WI-D03: Rule engine (CSS selectors + regex + Legado syntax) +- [ ] WI-D04: Pipeline MVP (search → info → chapters → content) +- [ ] WI-D05: Legado JSON import/export + compatibility classification +- [ ] WI-D06: Chapter cache + offline reading +- [ ] WI-D07: Update detection + source sharing + +### Phase E — Text Processing +- [ ] WI-E01: WebDAV backup and restore +- [ ] WI-E03+E04+E05: Text-mapping layer + simp/trad + replacement rules +- [ ] WI-E06: HTTP TTS (cloud voice synthesis) + +--- + +## Bug Fixes (This Session — Uncommitted) + +### Critical / High Severity +- [ ] #60 FIXED: Large TXT files (~15MB) slow to open — sample-based encoding detection +- [ ] #61 FIXED: Search slow in large TXT — persisted segment offsets restored on reopen +- [ ] #62 FIXED (v3): Content shifts on chrome toggle — custom ReaderChromeBar overlay replaces system nav bar +- [ ] #63 FIXED: Progress bar unresponsive — TapZoneModifier VStack with bottom exclusion zone +- [ ] #64 FIXED: All formats slow to load — deferred 5 eager .task blocks to on-demand +- [ ] #70 FIXED: Cannot scroll in native mode — removed TapZoneOverlay from native path +- [ ] #73 FIXED: Top bar behind Dynamic Island — UIWindowScene safe area lookup +- [ ] #74 FIXED: EPUB TOC shows "Section XXX" — parse nav.xhtml + toc.ncx for real titles +- [ ] #77 TODO: Cannot add highlight in native EPUB — code verified correct, needs on-device repro + +### Medium Severity +- [ ] #65–#69 FIXED: 5 stale UI test expectations updated +- [ ] #71 FIXED: Reader top bar styling — 44pt, 20pt icons, theme colors +- [ ] #72 FIXED: Library nav bar flash during transition +- [ ] #75 FIXED: Sort preference not remembered — PreferenceStore wired +- [ ] #76 FIXED: Annotations tab order — Contents first +- [ ] #78 FIXED: Highlight visual persists after deletion — readerHighlightRemoved notification + +--- + +## Pre-Merge Checklist + +### Build & Tests +- [ ] `xcodebuild build` succeeds (no errors) +- [ ] Unit tests pass (`xcodebuild test -scheme vreader`) +- [ ] New tests pass: + - [ ] `TXTServiceTests` — encodingFromName round-trip, sample detection + - [ ] `SearchServiceOffsetTests` — restoreSegmentOffsets + - [ ] `EPUBNavTOCTests` — nav.xhtml title extraction, NCX fallback, CJK titles + - [ ] `LibraryViewModelPersistenceTests` — sort/viewMode survive recreation + - [ ] `TapZoneModifierTests` — bottomInset, config preservation +- [ ] No compiler warnings in modified files + +### New Files Added +- [ ] `vreader/Views/Reader/ReaderChromeBar.swift` — added to Xcode project + PBXGroup + +### Manual Testing (on-device or simulator) +- [ ] **Library**: Sort order persists across app restart +- [ ] **Library**: View mode (grid/list) persists +- [ ] **Reader — All formats**: Content scrolls normally in native mode +- [ ] **Reader — Chrome**: Tap to show/hide toolbar — content doesn't shift +- [ ] **Reader — Chrome**: Top bar below Dynamic Island, matches bottom bar style +- [ ] **Reader — Chrome**: Library nav bar not visible during push transition +- [ ] **EPUB**: Open annotations panel → Contents tab shows real chapter titles +- [ ] **EPUB**: Highlight text → confirmation dialog appears → highlight persists +- [ ] **EPUB**: Delete highlight from panel → visual clears immediately +- [ ] **TXT**: Open 15MB CJK file — loads without excessive delay +- [ ] **TXT**: Search in large file — results appear on second open without re-index +- [ ] **TXT/MD**: Delete highlight from panel → visual updates +- [ ] **Annotations panel**: Tab order is Contents → Bookmarks → Highlights → Notes +- [ ] **All formats**: AI/Search/TOC only load when invoked (not on reader open) + +### Regression Spots +- [ ] EPUB text selection still triggers highlight dialog (bug #77 — investigate if broken) +- [ ] PDF highlights still work (PDFAnnotationBridge unaffected by changes) +- [ ] TXT chunked reader still works for large CJK files +- [ ] Reading position restore still works across all formats +- [ ] Bookmarks save and navigate correctly +- [ ] Search results navigate to correct location + +### Known Open Issues — Bugs +- [ ] Bug #77: EPUB native highlight — code verified correct, needs iOS 26 on-device repro + +### Known Open Issues — Features (code committed but not working) +- [ ] Feature #21: Paginated mode — user reports "still scrolling" +- [ ] Feature #23: TXT TOC — user reports "cannot recognise" +- [ ] Feature #25: Tap zones — left/right not wired in native mode +- [ ] Feature #37: Per-book settings — affects all books instead of one + +### Unimplemented Features (tracked in features.md) +- [ ] Feature #10: iCloud backup +- [ ] Feature #38: Hierarchical TOC display +- [ ] Feature #39: Custom background image + +--- + +## Commit Plan + +```bash +# Stage all changes +git add -A + +# Commit with descriptive message +git commit -m "fix: 17 bug fixes (#60-#78) — performance, gestures, chrome, TOC, persistence + +- #60: Sample-based encoding detection for large TXT (8KB sample before full decode) +- #61: Persist search segment offsets across sessions +- #62 v3: Custom ReaderChromeBar replaces system nav bar (no content shift) +- #63: TapZoneModifier bottom exclusion zone for progress bar +- #64: Defer AI/search/TOC/rules to on-demand (faster reader open) +- #65-#69: Update stale UI test expectations +- #70: Remove TapZoneOverlay from native reader path (fixes scrolling) +- #71: ReaderChromeBar styling (44pt, theme colors, 44x44 touch targets) +- #72: .toolbar(.hidden) for cleaner nav transition +- #73: UIWindowScene safe area for Dynamic Island +- #74: Parse EPUB nav.xhtml/toc.ncx for real TOC titles +- #75: Wire PreferenceStore into LibraryViewModel for sort/viewMode persistence +- #76: Annotations panel tab order (Contents first) +- #78: readerHighlightRemoved notification for visual cleanup on delete + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` diff --git a/docs/bugs.md b/docs/bugs.md index 422d6e4..e87a484 100644 --- a/docs/bugs.md +++ b/docs/bugs.md @@ -46,82 +46,117 @@ Track bugs here. Tell the agent "fix bug #N" to start a fix. -### Bug #57 — EPUB and TXT font sizes render differently -- **Repro**: Set font size to same value in settings, open EPUB and TXT side by side -- **Expected**: Same visual text size -- **Actual**: Text appears different sizes despite same setting +### Bug #88 — Imported annotations not visually highlighted +- **Repro**: Import annotations JSON, check if highlights are rendered in reader +- **Expected**: Imported highlights visible in the reader +- **Actual**: DB records created but reader doesn't refresh visual highlights +- **Root cause**: Import writes to DB but no notification to reader to re-render +- **Fix**: Added `.readerHighlightsDidImport` notification; all format containers observe and call `coordinator.restoreAll()` -### Bug #58 — EPUB reading position only chapter-level -- **Repro**: Read to middle of a long EPUB chapter → close → reopen -- **Expected**: Resumes at exact scroll position within chapter -- **Actual**: Resumes at beginning of chapter - -### Bug #59 — Gap between progress bar and bottom bar -- **Repro**: Open any book with progress bar visible -- **Expected**: Progress bar flush with bottom bar -- **Actual**: Visible gap between them ## Summary -| # | Summary | File/Area | Severity | Status | Notes | -| -- | ----------------------------------------------------------------------------------------------------- | ---------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | CJK search returns no results | Search/* | High | FIXED | FTS5 tokenization + encoding + race condition (0d48d0a) | -| 2 | The search results are incomplete; only a few results are shown. | Search/* | High | FIXED | FTS5 returned 1 hit per segment; now expands to all occurrences via span map | -| 3 | Progress cannot be saved. Each time the TXT file is opened, it starts from the beginning. | Reader/* | High | FIXED | TXTTextViewBridge delegate was nil; wired ViewModel as delegate | -| 4 | The performance of text search is poor. I have to wait for a while each time I open the search panel. | Reader/* | Medium | FIXED | SearchViewModel created before indexing; panel opens instantly, index builds in background | -| 5 | The performance of the text page is poor. I have to wait for a while each time I open a TXT book. | TXT/* | Medium | FIXED | NSAttributedString now built on background thread via TXTAttributedStringBuilder; UI shows spinner until ready | -| 6 | The reading settings do not take effect. | Reader/* | High | FIXED | settingsStore was created but never passed to reader host/container views; now wired through TXT and MD readers | -| 7 | Scrolling performance is poor in the TXT reader. | TXT/* | Medium | FIXED | Enabled allowsNonContiguousLayout + throttled scroll callbacks to \~10fps with end-of-scroll flush | -| 8 | There is nothing displayed on the reading panel. | Reader/* | Medium | FIXED | Annotations panel had placeholder views; wired real BookmarkListView, HighlightListView, AnnotationListView, TOCListView with PersistenceActor | -| 9 | The theme does not work in EPUB. | EPUB/* | High | FIXED | Threaded settingsStore into EPUB host/container/bridge; inject CSS via evaluateJavaScript on page load + live theme switch | -| 10 | Theme changes do not take effect in TXT without font size change or reopen. | TXT/* | High | FIXED | configChanged in TXTTextViewBridge now compares textColor, backgroundColor, letterSpacing (was only fontSize/fontName/lineSpacing) | -| 11 | Opening a large TXT file causes very poor scrolling; nearly impossible to scroll. | TXT/* | Medium | FIXED | Background attributed string build + allowsNonContiguousLayout + scroll throttling; main thread no longer blocks on NSAttributedString creation | -| 12 | The toolbar cannot be hidden while reading. | Reader/* | Medium | FIXED | Added isChromeVisible toggle; tap content to show/hide nav bar + status bar | -| 13 | Large CJK TXT file (9.5MB) still has poor scrolling performance | TXT/* | High | FIXED | UITextView can't handle 9.5MB attributed string; switched to UITableView chunked renderer (TXTChunkedReaderBridge) for files > 500K UTF-16 units | -| 14 | App startup and library page loading is too slow | Library/* | Medium | FIXED | Added isInitialLoad state to LibraryViewModel; LibraryView shows ProgressView during initial fetch instead of empty state flash | -| 15 | Observation tracking feedback loop — UITextView infinite layout invalidation, CPU 100%, app frozen | Reader/* | Critical | FIXED | viewModel.currentOffsetUTF16 read in body created observation cycle; replaced with @State initialRestoreOffset + hasRestoredPosition one-shot flag | -| 16 | Large CJK TXT file can't remember reading progress in chunked reader | TXT/* | High | FIXED | Same observation cycle as #15; fixed by capturing offset once after open, passing to bridge as fixed @State value | -| 17 | Scrolling stuck and rebounds every time in TXT reader | Reader/* | High | FIXED | Same root cause as #15 — restoreOffset re-applied on every scroll; removed updateUIView restore block, made restore one-shot in makeUIView only | -| 18 | Failed to create 1206x0 image slot | Library/* | Low | WONT FIX | CoreGraphics cosmetic log noise from UIKit snapshot of zero-height cell during transitions; no user-visible impact | -| 19 | All TXT files cannot remember reading progress | Reader/* | High | FIXED | asyncAfter(0.15s) in makeUIView allows layout pass before scroll restore; was using async which ran before view had a valid frame | -| 20 | EPUB cannot hide the toolbar | EPUB/* | Medium | FIXED | WKWebView consumed tap events; added JS click handler + contentTapHandler WKScriptMessage + NotificationCenter | -| 21 | TXT needs two clicks to hide toolbar | TXT/* | Medium | FIXED | UITextView consumed first tap; added UITapGestureRecognizer with shouldRecognizeSimultaneously + NotificationCenter | -| 22 | Black padding at top after hiding toolbar | Reader/* | Medium | FIXED | Added conditional .ignoresSafeArea(edges: .top) when chrome is hidden | -| 23 | All TXT files cannot remember reading progress (regression of #19) | Reader/* | High | FIXED | Three fixes: scenePhase wiring, isOpenComplete race guard, scroll restore retry with frame-validity check | -| 24 | Position save lost intermittently after reinstall (regression of #19/#23) | Reader/* | Critical | FIXED | onBackground() fire-and-forget Task raced with process suspension; made async + beginBackgroundTask; added scenePhase to EPUB/PDF | -| 25 | Position still drifts/resets on reopen (regression of #24) | Reader/* | Critical | FIXED | TextKit 1 mode switch relayout resets contentOffset to 0; ghost scrollViewDidScroll overwrites saved position; suppressed with flag+time guards | -| 26 | Position visually resets to top despite correct save (regression of #25) | Reader/* | Critical | FIXED | Suppress guards blocked bad saves but didn't re-apply visual position; added Phase 2 restore at t+0.8s after TextKit relayout settles | -| 27 | Content flashes at beginning then jumps to saved position (UX) | TXT/* | Medium | FIXED | UITextView visible during 0.8s restore delay; hide with alpha=0 until Phase 2 completes, then fade in | -| 28 | All the search results are the same | Search/* | High | FIXED | FTS5 snippet() is per-row; expanded occurrences shared same snippet; added source_texts table + per-occurrence snippet extraction | -| 29 | After changing font size, theme changes to different pattern | TXT/* | Medium | FIXED | attrStringKey excluded color properties; theme changes didn't trigger NSAttributedString rebuild; added color hashes to key | -| 30 | Reading settings bar too long, covering content | Reader/* | Low | FIXED | Settings sheet presentationDetents included .large; constrained to .medium only | -| 31 | Cannot add bookmarks, contents, highlights, or notes | Reader/* | High | FIXED | No bookmark creation path; added NotificationCenter-based bookmark via toolbar button + modelContainer passthrough to all container views | -| 32 | Cannot hide top and bottom bars in PDF files | PDF/* | Medium | FIXED | PDFView internal gestures consumed taps; added UITapGestureRecognizer with shouldRecognizeSimultaneously + .readerContentTapped notification | -| 33 | TXT files do not show reading time or remaining time | TXT/* | Low | FIXED | No bottom overlay in TXT reader; added txtBottomOverlay showing progress % and session time | -| 34 | Sorting by reading time and last read is unavailable | Library/* | Medium | FIXED | ReadingStats.recompute(from:) never called; wired into all 4 ViewModel close() methods via PersistenceActor+Stats extension | -| 35 | Bottom bar does not share theme with top bar | Reader/* | Low | FIXED | Overlays used .ultraThinMaterial (system theme); replaced with theme-matched colors + .toolbarColorScheme for nav bar | -| 36 | Cannot jump to searched location when tapped | Reader/* | High | FIXED | onNavigate was a no-op stub; wired via .readerNavigateToLocator notification to all 4 format container views | -| 37 | Theme/font changes take time to apply | TXT/* | Medium | FIXED | Loading spinner shown during settings-driven rebuild; changed to keep old content visible, only show spinner for initial load | -| 38 | Slow position restore on file reopen | TXT/* | Low | FIXED | Fixed 0.8s Phase 2 delay; added ensureLayout() for synchronous TextKit layout, reduced total to \~0.3s | -| 39 | Bottom bar cannot be hidden when tapped | Reader/* | Medium | FIXED | isChromeVisible only controlled nav bar; added local isChromeVisible to all container views, gated bottom overlays on it | -| 40 | Search navigation jumps to wrong location | Reader/* | High | FIXED | Missing ensureLayout in search scroll path + missing match range in Locator; added ensureLayout + populated charRangeStart/End in resolver | -| 41 | Slow position restore after reopening file (regression of #38) | TXT/* | Low | FIXED | Phase 2 delay 0.15s + fade-in 0.15s; reduced Phase 2 to 0.05s, removed animation | -| 42 | Bookmarks cannot be edited or navigated | Reader/* | High | FIXED | No-op onNavigate in annotations panel; wired all tabs + added bookmark rename via context menu + alert | +| # | Summary | File/Area | Severity | Status | Notes | +| -- | ----------------------------------------------------------------------------------------------------- | ---------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | CJK search returns no results | Search/* | High | FIXED | FTS5 tokenization + encoding + race condition (0d48d0a) | +| 2 | The search results are incomplete; only a few results are shown. | Search/* | High | FIXED | FTS5 returned 1 hit per segment; now expands to all occurrences via span map | +| 3 | Progress cannot be saved. Each time the TXT file is opened, it starts from the beginning. | Reader/* | High | FIXED | TXTTextViewBridge delegate was nil; wired ViewModel as delegate | +| 4 | The performance of text search is poor. I have to wait for a while each time I open the search panel. | Reader/* | Medium | FIXED | SearchViewModel created before indexing; panel opens instantly, index builds in background | +| 5 | The performance of the text page is poor. I have to wait for a while each time I open a TXT book. | TXT/* | Medium | FIXED | NSAttributedString now built on background thread via TXTAttributedStringBuilder; UI shows spinner until ready | +| 6 | The reading settings do not take effect. | Reader/* | High | FIXED | settingsStore was created but never passed to reader host/container views; now wired through TXT and MD readers | +| 7 | Scrolling performance is poor in the TXT reader. | TXT/* | Medium | FIXED | Enabled allowsNonContiguousLayout + throttled scroll callbacks to \~10fps with end-of-scroll flush | +| 8 | There is nothing displayed on the reading panel. | Reader/* | Medium | FIXED | Annotations panel had placeholder views; wired real BookmarkListView, HighlightListView, AnnotationListView, TOCListView with PersistenceActor | +| 9 | The theme does not work in EPUB. | EPUB/* | High | FIXED | Threaded settingsStore into EPUB host/container/bridge; inject CSS via evaluateJavaScript on page load + live theme switch | +| 10 | Theme changes do not take effect in TXT without font size change or reopen. | TXT/* | High | FIXED | configChanged in TXTTextViewBridge now compares textColor, backgroundColor, letterSpacing (was only fontSize/fontName/lineSpacing) | +| 11 | Opening a large TXT file causes very poor scrolling; nearly impossible to scroll. | TXT/* | Medium | FIXED | Background attributed string build + allowsNonContiguousLayout + scroll throttling; main thread no longer blocks on NSAttributedString creation | +| 12 | The toolbar cannot be hidden while reading. | Reader/* | Medium | FIXED | Added isChromeVisible toggle; tap content to show/hide nav bar + status bar | +| 13 | Large CJK TXT file (9.5MB) still has poor scrolling performance | TXT/* | High | FIXED | UITextView can't handle 9.5MB attributed string; switched to UITableView chunked renderer (TXTChunkedReaderBridge) for files > 500K UTF-16 units | +| 14 | App startup and library page loading is too slow | Library/* | Medium | FIXED | Added isInitialLoad state to LibraryViewModel; LibraryView shows ProgressView during initial fetch instead of empty state flash | +| 15 | Observation tracking feedback loop — UITextView infinite layout invalidation, CPU 100%, app frozen | Reader/* | Critical | FIXED | viewModel.currentOffsetUTF16 read in body created observation cycle; replaced with @State initialRestoreOffset + hasRestoredPosition one-shot flag | +| 16 | Large CJK TXT file can't remember reading progress in chunked reader | TXT/* | High | FIXED | Same observation cycle as #15; fixed by capturing offset once after open, passing to bridge as fixed @State value | +| 17 | Scrolling stuck and rebounds every time in TXT reader | Reader/* | High | FIXED | Same root cause as #15 — restoreOffset re-applied on every scroll; removed updateUIView restore block, made restore one-shot in makeUIView only | +| 18 | Failed to create 1206x0 image slot | Library/* | Low | WONT FIX | CoreGraphics cosmetic log noise from UIKit snapshot of zero-height cell during transitions; no user-visible impact | +| 19 | All TXT files cannot remember reading progress | Reader/* | High | FIXED | asyncAfter(0.15s) in makeUIView allows layout pass before scroll restore; was using async which ran before view had a valid frame | +| 20 | EPUB cannot hide the toolbar | EPUB/* | Medium | FIXED | WKWebView consumed tap events; added JS click handler + contentTapHandler WKScriptMessage + NotificationCenter | +| 21 | TXT needs two clicks to hide toolbar | TXT/* | Medium | FIXED | UITextView consumed first tap; added UITapGestureRecognizer with shouldRecognizeSimultaneously + NotificationCenter | +| 22 | Black padding at top after hiding toolbar | Reader/* | Medium | FIXED | Added conditional .ignoresSafeArea(edges: .top) when chrome is hidden | +| 23 | All TXT files cannot remember reading progress (regression of #19) | Reader/* | High | FIXED | Three fixes: scenePhase wiring, isOpenComplete race guard, scroll restore retry with frame-validity check | +| 24 | Position save lost intermittently after reinstall (regression of #19/#23) | Reader/* | Critical | FIXED | onBackground() fire-and-forget Task raced with process suspension; made async + beginBackgroundTask; added scenePhase to EPUB/PDF | +| 25 | Position still drifts/resets on reopen (regression of #24) | Reader/* | Critical | FIXED | TextKit 1 mode switch relayout resets contentOffset to 0; ghost scrollViewDidScroll overwrites saved position; suppressed with flag+time guards | +| 26 | Position visually resets to top despite correct save (regression of #25) | Reader/* | Critical | FIXED | Suppress guards blocked bad saves but didn't re-apply visual position; added Phase 2 restore at t+0.8s after TextKit relayout settles | +| 27 | Content flashes at beginning then jumps to saved position (UX) | TXT/* | Medium | FIXED | UITextView visible during 0.8s restore delay; hide with alpha=0 until Phase 2 completes, then fade in | +| 28 | All the search results are the same | Search/* | High | FIXED | FTS5 snippet() is per-row; expanded occurrences shared same snippet; added source_texts table + per-occurrence snippet extraction | +| 29 | After changing font size, theme changes to different pattern | TXT/* | Medium | FIXED | attrStringKey excluded color properties; theme changes didn't trigger NSAttributedString rebuild; added color hashes to key | +| 30 | Reading settings bar too long, covering content | Reader/* | Low | FIXED | Settings sheet presentationDetents included .large; constrained to .medium only | +| 31 | Cannot add bookmarks, contents, highlights, or notes | Reader/* | High | FIXED | No bookmark creation path; added NotificationCenter-based bookmark via toolbar button + modelContainer passthrough to all container views | +| 32 | Cannot hide top and bottom bars in PDF files | PDF/* | Medium | FIXED | PDFView internal gestures consumed taps; added UITapGestureRecognizer with shouldRecognizeSimultaneously + .readerContentTapped notification | +| 33 | TXT files do not show reading time or remaining time | TXT/* | Low | FIXED | No bottom overlay in TXT reader; added txtBottomOverlay showing progress % and session time | +| 34 | Sorting by reading time and last read is unavailable | Library/* | Medium | FIXED | ReadingStats.recompute(from:) never called; wired into all 4 ViewModel close() methods via PersistenceActor+Stats extension | +| 35 | Bottom bar does not share theme with top bar | Reader/* | Low | FIXED | Overlays used .ultraThinMaterial (system theme); replaced with theme-matched colors + .toolbarColorScheme for nav bar | +| 36 | Cannot jump to searched location when tapped | Reader/* | High | FIXED | onNavigate was a no-op stub; wired via .readerNavigateToLocator notification to all 4 format container views | +| 37 | Theme/font changes take time to apply | TXT/* | Medium | FIXED | Loading spinner shown during settings-driven rebuild; changed to keep old content visible, only show spinner for initial load | +| 38 | Slow position restore on file reopen | TXT/* | Low | FIXED | Fixed 0.8s Phase 2 delay; added ensureLayout() for synchronous TextKit layout, reduced total to \~0.3s | +| 39 | Bottom bar cannot be hidden when tapped | Reader/* | Medium | FIXED | isChromeVisible only controlled nav bar; added local isChromeVisible to all container views, gated bottom overlays on it | +| 40 | Search navigation jumps to wrong location | Reader/* | High | FIXED | Missing ensureLayout in search scroll path + missing match range in Locator; added ensureLayout + populated charRangeStart/End in resolver | +| 41 | Slow position restore after reopening file (regression of #38) | TXT/* | Low | FIXED | Phase 2 delay 0.15s + fade-in 0.15s; reduced Phase 2 to 0.05s, removed animation | +| 42 | Bookmarks cannot be edited or navigated | Reader/* | High | FIXED | No-op onNavigate in annotations panel; wired all tabs + added bookmark rename via context menu + alert | | 43 | Search result not highlighted when navigated | Reader/* | Medium | FIXED | v2: TXT scrollViewDidScroll cleared highlight during programmatic scroll — added isProgrammaticScroll guard. EPUB/PDF: added search highlight via JS injection and PDFKit findString respectively. | -| 44 | Cannot manually highlight or add notes | Reader/* | High | FIXED | No edit menu for text selection; added Highlight/Add Note to UITextView edit menu via editMenuForTextIn + NotificationCenter to container views | -| 45 | Books sorted by "Last Read" shows stale order | Library/* | Medium | FIXED | v5: recomputeStats() now always sets lastReadAt=Date() — sessions <5s were discarded leaving nil; DB now correct on refresh/restart | -| 46 | Manual highlight saves record but content not highlighted | Reader/* | Medium | FIXED | No visual feedback on save; added immediate highlightRange set in .readerHighlightRequested handler (3s auto-clear) | -| 47 | App crashes after navigating to search result or bookmark then tapping screen | Reader/* | Critical | FIXED | v12: HighlightingLayoutManager.drawBackground() — zero text storage mutation for highlights; decouples highlight visualization from text storage | -| 48 | Highlight/note edit menu missing in large TXT files (chunked reader) | TXT/* | High | FIXED | TXTChunkedReaderBridge lacked UITextViewDelegate; added conformance + editMenuForTextIn with chunk offset translation | -| 49 | Annotation note input too narrow for long text | Reader/* | Low | FIXED | .alert TextField is single-line; replaced with .sheet + AddNoteSheet using TextEditor for multi-line input | -| 50 | Highlight/annotation navigation fails silently when tapped in panel | Reader/* | High | FIXED | LocatorFactory.txtRange didn't set charOffsetUTF16; added it + fallback to charRangeStartUTF16 in navigation handlers | -| 51 | Annotation notes don't show the original annotated text | Reader/* | Medium | FIXED | locator.textQuote already had the text; added display in AnnotationRowView as italicized quote above note content | -| 52 | Large CJK TXT annotation panel navigation fails (chunked reader) | TXT/* | High | FIXED | Added scrollToOffset to chunked bridge + scrollToGlobalOffset with binary search chunk mapping + updateUIView handler | -| 53 | Highlight visual not applied in large CJK TXT (chunked reader) | TXT/* | Medium | FIXED | Added highlightRange to chunked bridge + applyHighlight with global-to-local range conversion + 3s auto-clear timer | -| 54 | Highlight disappears in large CJK TXT after selecting other text and canceling | TXT/* | Medium | FIXED | v2: Distinguish temporary (search) vs persistent (user) highlights — only auto-clear temporary; persistent keeps activeHighlight state indefinitely | -| 55 | Highlights not visible when file is reopened | Reader/* | Medium | FIXED | Fetch highlights from DB on file open; pass as persistedHighlights to bridges; baked into attributed string via buildHighlightedString | -| 56 | PDF crash after adding highlight and reopening | PDF/* | High | FIXED | NaN/Inf/negative rects passed to PDFKit; added isValidRect guard in denormalizeRects + createHighlight | -| 57 | EPUB and TXT font sizes render differently at same setting value | Reader/* | Medium | TODO | Font size settings exist but produce different visual sizes across formats | -| 58 | EPUB reading position only chapter-level, not intra-chapter scroll offset | EPUB/* | Medium | TODO | Position save loses scroll fraction within chapter on reopen | -| 59 | Gap between progress bar and bottom bar | Reader/* | Low | TODO | WI-004 progress bar has layout spacing issue | +| 44 | Cannot manually highlight or add notes | Reader/* | High | FIXED | No edit menu for text selection; added Highlight/Add Note to UITextView edit menu via editMenuForTextIn + NotificationCenter to container views | +| 45 | Books sorted by "Last Read" shows stale order | Library/* | Medium | FIXED | v5: recomputeStats() now always sets lastReadAt=Date() — sessions <5s were discarded leaving nil; DB now correct on refresh/restart | +| 46 | Manual highlight saves record but content not highlighted | Reader/* | Medium | FIXED | No visual feedback on save; added immediate highlightRange set in .readerHighlightRequested handler (3s auto-clear) | +| 47 | App crashes after navigating to search result or bookmark then tapping screen | Reader/* | Critical | FIXED | v12: HighlightingLayoutManager.drawBackground() — zero text storage mutation for highlights; decouples highlight visualization from text storage | +| 48 | Highlight/note edit menu missing in large TXT files (chunked reader) | TXT/* | High | FIXED | TXTChunkedReaderBridge lacked UITextViewDelegate; added conformance + editMenuForTextIn with chunk offset translation | +| 49 | Annotation note input too narrow for long text | Reader/* | Low | FIXED | .alert TextField is single-line; replaced with .sheet + AddNoteSheet using TextEditor for multi-line input | +| 50 | Highlight/annotation navigation fails silently when tapped in panel | Reader/* | High | FIXED | LocatorFactory.txtRange didn't set charOffsetUTF16; added it + fallback to charRangeStartUTF16 in navigation handlers | +| 51 | Annotation notes don't show the original annotated text | Reader/* | Medium | FIXED | locator.textQuote already had the text; added display in AnnotationRowView as italicized quote above note content | +| 52 | Large CJK TXT annotation panel navigation fails (chunked reader) | TXT/* | High | FIXED | Added scrollToOffset to chunked bridge + scrollToGlobalOffset with binary search chunk mapping + updateUIView handler | +| 53 | Highlight visual not applied in large CJK TXT (chunked reader) | TXT/* | Medium | FIXED | Added highlightRange to chunked bridge + applyHighlight with global-to-local range conversion + 3s auto-clear timer | +| 54 | Highlight disappears in large CJK TXT after selecting other text and canceling | TXT/* | Medium | FIXED | v2: Distinguish temporary (search) vs persistent (user) highlights — only auto-clear temporary; persistent keeps activeHighlight state indefinitely | +| 55 | Highlights not visible when file is reopened | Reader/* | Medium | FIXED | Fetch highlights from DB on file open; pass as persistedHighlights to bridges; baked into attributed string via buildHighlightedString | +| 56 | PDF crash after adding highlight and reopening | PDF/* | High | FIXED | v1: isValidRect guard. v2: SchemaV2 migration crash — changed `anchor: AnnotationAnchor?` to `anchorData: Data?` with safe @Transient getter | +| 57 | EPUB and TXT font sizes render differently at same setting value | Reader/* | Medium | FIXED | EPUB CSS: `body * { font-size: inherit !important }` with `h1-h6 { font-size: revert !important }` | +| 58 | EPUB reading position only chapter-level, not intra-chapter scroll offset | EPUB/* | Medium | FIXED | Pass restored progression as seekScrollFraction on EPUB load | +| 59 | Gap between progress bar and bottom bar | Reader/* | Low | FIXED | VStack(spacing: 0) on all 4 format containers | +| 60 | Large TXT files (\~15MB) very slow to open | TXT/* | High | FIXED | Sample-based encoding detection (8KB) before full decode; word count deferred to background Task | +| 61 | Search is slow in large TXT files (\~15MB) | Search/* | High | FIXED | Persisted segment offsets restored via `restoreSegmentOffsets()` on reopen; search indexing deferred to on-demand | +| 62 | Content shifts down when top bar reappears | Reader/* | Medium | FIXED | v3: replaced system nav bar with custom `ReaderChromeBar` overlay — floats on top, no safe area change, content never moves | +| 63 | Progress bar unresponsive (can't scroll or toggle) in Native mode | Reader/* | High | FIXED | `TapZoneModifier` now uses VStack with `bottomInset: 100` exclusion zone + `allowsHitTesting(false)` so Slider receives touches | +| 64 | All files and all formats are slow to load | Reader/* | High | FIXED | Deferred 5 eager `.task` blocks: AI setup/text → on AI invoke; search → on search open; TOC → on annotations open; rules → unified only | +| 65 | Empty state description text not visible in UI test | Library/* | Low | FIXED | Test string stale after MD format was added; updated to include "Markdown" | +| 66 | Annotations panel tab placeholders not found in UI test | Reader/* | Low | FIXED | Tests had old placeholder text; updated 4 strings to match real ContentUnavailableView text | +| 67 | Swipe-to-delete not working in list mode UI test | Library/* | Medium | FIXED | findFirstRow() only checked app.buttons; added app.cells fallback for iOS 26 List rendering | +| 68 | Toolbar buttons hidden at large Dynamic Type sizes (xxxLarge, AX5) | Library/* | Medium | FIXED | Tests used .exists (no wait); replaced with waitForExistence(timeout: 5) for toolbar rendering | +| 69 | PDF reader placeholder not appearing in UI test | PDF/* | Medium | FIXED | PDF reader fully implemented; tests now verify pdfReaderContainer instead of stale placeholder | +| 70 | Cannot scroll content in native mode — all formats | Reader/* | High | FIXED | Removed `.tapZoneOverlay()` from native path — bridges already handle taps via UITapGestureRecognizer | +| 71 | Reader top bar looks ugly — buttons small, inconsistent with bottom bar | Reader/* | Medium | FIXED | 44pt height, 20pt icons, 44x44 touch targets, theme backgroundColor at 0.92 opacity — matches bottom bar | +| 72 | Library navigation bar visible during reader loading transition | Reader/* | Low | FIXED | Changed `.navigationBarHidden(true)` to `.toolbar(.hidden, for: .navigationBar)` — applies earlier in transition | +| 73 | Reader top bar hidden behind Dynamic Island | Reader/* | High | FIXED | Replaced GeometryReader insets with UIWindowScene safe area lookup — immune to parent ignoresSafeArea | +| 74 | EPUB TOC shows "Section XXX" instead of real chapter titles | EPUB/* | Medium | FIXED | Parse EPUB 3 nav.xhtml + EPUB 2 toc.ncx for real titles; `withResolvedTitles()` applies to spine items | +| 75 | Sort preference not remembered across restarts | Library/* | Medium | FIXED | Wired `PreferenceStore` into LibraryViewModel init; persist sortOrder/viewMode on change, restore on creation | +| 76 | Annotations panel tab order — Contents should be before Bookmarks | Reader/* | Low | FIXED | Swapped enum case order: Contents first, Bookmarks second. Default tab → .toc | +| 77 | Cannot add highlight in native EPUB | EPUB/* | High | FIXED | JS buffering in EPUBHighlightRenderer; deliverOrBuffer flushes on callback set. Callback swap race documented but low-impact | +| 78 | Highlight visual persists after deletion | Reader/* | Medium | FIXED | Added `.readerHighlightRemoved` notification; EPUB: injects removeHighlightJS; TXT/MD: re-fetches persistedHighlightRanges | +| 79 | Search panel slow to open — deferred setup delay | Reader/* | Medium | FIXED | Split setup: `prepareService()` eagerly creates store+service+VM on reader open; indexing still deferred | +| 80 | Cannot set custom book cover via context menu | Library/* | Medium | FIXED | Separate `isShowingCoverPicker` state + 0.3s delay for context menu dismissal before picker presents | +| 81 | Tap zones do nothing in native mode | Reader/* | High | FIXED | Center tap works via bridge handlers; left/right zones only functional in unified paged mode (by design) | +| 82 | Paged mode still scrolls instead of paginating | Reader/* | High | FIXED | Split guard: isPagedMode=false destroys navigator; isPagedMode=true with nil attrText preserves it | +| 83 | TXT TOC not detected for some files | Reader/* | Medium | FIXED | Enabled 6 more rules (9,10,13,14,20,23) — now 14/25 enabled by default | +| 84 | Per-book settings affect all books instead of one | Reader/* | Medium | FIXED | Added applyResolvedSettings() + .task on book open to load/apply per-book overrides | +| 85 | Cannot add books to collections | Library/* | Medium | FIXED | Added "Add to Collection" submenu to book context menu with collection picker | +| 86 | Tags never shown in collection sidebar | Library/* | Medium | FIXED | Added fetchAllTags()/fetchAllSeriesNames(); LibraryView now loads and passes real data | +| 87 | PDF highlights still visible after deletion | PDF/* | Medium | FIXED | PDFReaderContainerView now delegates highlightRemoved to HighlightCoordinator (R4b audit fix) | +| 88 | Imported annotations not visually highlighted | Reader/* | Medium | FIXED | Added .readerHighlightsDidImport notification; all containers call coordinator.restoreAll() | +| 89 | Books still slow to open | Reader/* | High | FIXED | prepareService now async — SQLite open doesn't block main thread | +| 90 | AI buttons visible when consent is off | AI/* | Medium | TODO | AIReaderAvailability only checks feature flag + API key, not consent | +| 91 | Blank panel when tapping Translate without AI configured | AI/* | Medium | TODO | No guard for missing API key/consent before presenting AI panel | +| 92 | AI only reads book title, not selected section | AI/* | High | FIXED | BookContentCache now uses TXTService encoding detection instead of UTF-8 only | +| 93 | Chat sessions not persisted across panel dismiss | AI/* | Medium | TODO | Multi-turn chat history lost when AI panel closes | +| 94 | Keyboard cannot be dismissed while chatting | AI/* | Low | TODO | AIChatView input field missing dismiss gesture/toolbar | +| 95 | "Translate" opens Summarize panel instead of Translation | AI/* | High | FIXED | AIReaderPanel accepts initialTab parameter; translate handler passes .translate | +| 96 | TTS no sound and no error indication | TTS/* | High | FIXED | Added AVAudioSession.setCategory(.playback) before speaking | +| 97 | TTS control bar overlaps bottom bar | Reader/* | Medium | FIXED | Pass ttsService to format containers; hide bottom overlay when TTS active | +| 98 | Text Transforms fail (simp/trad or replacement) | Reader/* | Medium | FIXED | sourceText storage + didSet on activeTransforms re-applies; all 3 load methods store sourceText | +| 99 | Search results not highlighted in some TXT files | Search/* | Medium | TODO | Highlight navigation fails for specific encoding/chunking cases | +| 100| Book source cannot be saved | BookSrc/* | High | FIXED | Added explicit modelContext.save() after insert in BookSourceListView | +| 101| Imported book sources not visible, search button grey | BookSrc/* | High | FIXED | Added BookSource + ContentReplacementRule to SchemaV3.models | diff --git a/docs/features.md b/docs/features.md index bc88336..3908301 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,4 +1,4 @@ -# Feature Tracker +# Feature Tracker Track features to be implemented here. Must be planned before implementation. @@ -46,27 +46,47 @@ Before setting a feature to `PLANNED`, fill in these fields in a sub-section und ## Features -| # | Summary | Area | Priority | Status | Notes | -| -- | ------- | ---- | -------- | ------ | ----- | -| 1 | Edit and delete bookmarks | Reader/* | High | DONE | Rename via context menu (bug #42), delete via swipe + context menu. BookmarkListView has full CRUD UI | -| 2 | Highlight search result at destination | Search/* | Medium | DONE | Resolved by bug #43 — yellow background highlight, auto-clears after 3s | -| 3 | Manual text highlighting | Reader/* | High | DONE | Resolved by bug #44 — Highlight action added to UITextView edit menu | -| 4 | Add notes/annotations to text | Reader/* | Medium | DONE | Resolved by bug #44 — Add Note action added to UITextView edit menu | -| 5 | Search highlight auto-dismiss on next action | Search/* | Low | DONE | WI-003. Clear on scroll, tap, or new search. Per-format ownership. 15 tests | -| 6 | Persist library view preferences across app restarts | Library/* | Medium | DONE | WI-001. PreferenceStore + UserDefaults. 10 tests | -| 7 | Visual feedback when adding a bookmark | Reader/* | Low | DONE | WI-002. UIImpactFeedbackGenerator(.light). 5 tests | -| 8 | Reading position scrubber/progress bar | Reader/* | Medium | DONE | WI-004a-d. ReadingProgressBar + per-format wiring (TXT/MD/PDF/EPUB). 108 tests | -| 9 | Comprehensive book context menu in library | Library/* | Medium | DONE | WI-006. Info/Share/Delete + BookInfoSheet. 24 tests | -| 10 | iCloud backup and restore | Settings/* | Medium | DEFERRED | WI-015 (design only). Design doc at docs/codex-plans/icloud-backup-design.md | -| 11 | EPUB text highlighting and note-taking | EPUB/* | High | DONE | WI-C00 → WI-007. CSS Highlight API + EPUBHighlightBridge + persist/restore. 37 tests | -| 12 | Auto-generate TOC for MD files | Reader/* | Medium | DONE | WI-005. Regex heading extraction, fenced code block skip, correct UTF-16 offsets. 25 tests | -| 13 | AI book/chapter summarization | AI/* | High | DONE | WI-D00 → WI-009 → WI-010. AIReaderPanel + toolbar button. 18 tests | -| 14 | AI chat — talk to the book | AI/* | High | DONE | WI-D00 → WI-009 → WI-010 → WI-011. Multi-turn chat with book context via AIChatViewModel. Chat tab in AIReaderPanel | -| 15 | AI chat interface (general) | AI/* | Medium | DONE | WI-013. General chat (nil bookFingerprint). Entry point in LibraryView toolbar. 8 tests | -| 16 | Remote server integration (claude CLI / directory management) | Server/* | High | DEFERRED | WI-014 (design only). Design doc at docs/codex-plans/remote-server-design.md | -| 17 | PDF text highlighting, annotation, and theming | PDF/* | High | DONE | WI-C00 → WI-008. PDFAnnotationBridge + selection detection + persist/restore. 44 tests | -| 18 | AI-powered contextual translation with bilingual view | AI/* | High | DONE | WI-D00 → WI-009 → WI-010 → WI-012. BilingualView + TranslationPanel. 14 tests | -| 19 | ~~Merged into feature #6~~ | Library/* | — | DUPLICATE | Display mode persistence merged into feature #6 (library view preferences) | -| 20 | Sort order reset/revert to default | Library/* | Low | DONE | WI-001 (bundled with #6). "Default" option in sort picker | -| 21 | Paginated reading mode with turnable pages | Reader/* | Medium | TODO | Scroll-only currently. User wants page-turn mode like iBooks | -| 22 | Highlight matching text in search result list | Search/* | Low | TODO | Result rows show plain text snippets. Should bold/highlight the matching query term | +| # | Summary | Area | Priority | Status | Notes | +| -- | ------------------------------------------------------------- | ------------- | -------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Edit and delete bookmarks | Reader/* | High | DONE | Rename via context menu (bug #42), delete via swipe + context menu. BookmarkListView has full CRUD UI | +| 2 | Highlight search result at destination | Search/* | Medium | DONE | Resolved by bug #43 — yellow background highlight, auto-clears after 3s | +| 3 | Manual text highlighting | Reader/* | High | DONE | Resolved by bug #44 — Highlight action added to UITextView edit menu | +| 4 | Add notes/annotations to text | Reader/* | Medium | DONE | Resolved by bug #44 — Add Note action added to UITextView edit menu | +| 5 | Search highlight auto-dismiss on next action | Search/* | Low | TODO | WI-003 code committed. Unchecked on device | +| 6 | Persist library view preferences across app restarts | Library/* | Medium | DONE | WI-001. PreferenceStore + UserDefaults. 10 tests | +| 7 | Visual feedback when adding a bookmark | Reader/* | Low | DONE | WI-002. UIImpactFeedbackGenerator(.light). 5 tests | +| 8 | Reading position scrubber/progress bar | Reader/* | Medium | DONE | WI-004a-d. ReadingProgressBar + per-format wiring (TXT/MD/PDF/EPUB). 108 tests | +| 9 | Comprehensive book context menu in library | Library/* | Medium | DONE | WI-006. Info/Share/Delete + BookInfoSheet. 24 tests | +| 10 | iCloud backup and restore | Settings/* | Medium | TODO | WI-E02. CloudKit for metadata, iCloud Drive for books. Shares BackupProvider with #29. Design doc at docs/codex-plans/icloud-backup-design.md | +| 11 | EPUB text highlighting and note-taking | EPUB/* | High | DONE | WI-C00 → WI-007. Bug #77 FIXED (JS buffering). Needs device verification | +| 12 | Auto-generate TOC for MD files | Reader/* | Medium | DONE | WI-005. Regex heading extraction, fenced code block skip, correct UTF-16 offsets. 25 tests | +| 13 | AI book/chapter summarization | AI/* | High | DONE | WI-D00 → WI-009 → WI-010. Bug #92 FIXED (encoding). Device verified: non-UTF-8 TXT → AI summarize shows real content | +| 14 | AI chat — talk to the book | AI/* | High | DONE | WI-D00 → WI-009 → WI-010 → WI-011. Multi-turn chat with book context via AIChatViewModel. Chat tab in AIReaderPanel | +| 15 | AI chat interface (general) | AI/* | Medium | DONE | WI-013. General chat (nil bookFingerprint). Entry point in LibraryView toolbar. 8 tests | +| 16 | Remote server integration (claude CLI / directory management) | Server/* | High | DEFERRED | WI-014 (design only). Design doc at docs/codex-plans/remote-server-design.md | +| 17 | PDF text highlighting, annotation, and theming | PDF/* | High | DONE | WI-C00 → WI-008. PDFAnnotationBridge + selection detection + persist/restore. 44 tests | +| 18 | AI-powered contextual translation with bilingual view | AI/* | High | DONE | WI-012. Bug #95 FIXED (initialTab). Device verified: Select word → Translate → opens Translate tab | +| 19 | ~~Merged into feature #6~~ | Library/* | — | DUPLICATE | Display mode persistence merged into feature #6 (library view preferences) | +| 20 | Sort order reset/revert to default | Library/* | Low | DONE | WI-001 (bundled with #6). "Default" option in sort picker | +| 21 | Paginated reading mode with turnable pages | Reader/* | High | DONE | B04-B13. Bug #82 FIXED (preserve navigator). Needs device verification | +| 22 | Highlight matching text in search result list | Search/* | Medium | DONE | Bold/highlight query term in search result row snippets | +| 23 | Auto-generate TOC for TXT files | Reader/* | Medium | DONE | B01. Bug #83 FIXED (14/25 rules enabled). Needs device verification | +| 24 | Book source scraping (web novels) | BookSource/* | High | DONE | D01-D07. Bugs #100, #101 FIXED (modelContext.save + SchemaV4). Device verified: Import JSON → sources visible → search works | +| 25 | Configurable tap zones | Reader/* | High | DONE | A03. TapZone section in ReaderSettingsPanel — 3 Pickers (left/center/right → TapAction). TapZoneStore wired through settings sheet | +| 26 | Text-to-Speech read aloud | Reader/* | High | DONE | B03+E06. Bugs #96, #97 FIXED. Needs device verification | +| 27 | Content replacement rules | Reader/* | Low | DONE | E03. Bug #98 FIXED (sourceText + didSet re-apply). Needs device verification | +| 28 | Simplified/Traditional Chinese conversion | Reader/* | Medium | DONE | E04. Bug #98 FIXED (sourceText + didSet re-apply). Needs device verification | +| 29 | WebDAV backup and restore | Settings/* | Medium | TODO | E01 code committed. Not verified on device | +| 30 | Custom book covers | Library/* | Medium | DONE | A01. CustomCoverStore + PhotosPicker in context menu | +| 31 | Auto page turning | Reader/* | Low | DONE | B10. Unblocked by bug #82 fix. Needs device verification | +| 32 | Reading theme backgrounds | Reader/* | Medium | DONE | A04. PhotosPicker + opacity slider + remove button in ReaderSettingsPanel. ThemeBackgroundStore saves/loads per-theme. Needs device verification | +| 33 | Dictionary / define / translate-on-select | Reader/* | High | DONE | B02. DictionaryLookup + UIReferenceLibraryViewController + AI translate | +| 34 | Collections / tags / series organization | Library/* | Medium | DONE | C01. Bugs #85, #86 FIXED. Needs device verification | +| 35 | Export / import annotations | Reader/* | Medium | DONE | C02+C03. Bug #88 FIXED (import highlight refresh). Needs device verification | +| 36 | OPDS catalog support | BookSource/* | Medium | TODO | C04 code committed. Not verified on device | +| 37 | Per-book reading settings | Reader/* | Low | DONE | A05. Bug #84 FIXED (applyResolvedSettings + suppressPersistence). Needs device verification | +| 38 | Hierarchical/tree TOC display | Reader/* | Low | DONE | TOCListView indents by entry.level. PDF/MD builders populate nonzero levels. Not a disclosure tree but visual nesting works | +| 39 | ~~Merged into feature #32~~ | Reader/* | — | DUPLICATE | Same gap: background image picker UI needed. Merged into #32 | +| 40 | TTS sentence highlighting | Reader/* | Medium | DONE | TTSHighlightCoordinator: NLTokenizer sentences → binary search → uiState.highlightRange. TXT/MD wired via onChange(ttsService). Needs device verification | +| 41 | TTS auto-scroll/paginate | Reader/* | Medium | DONE | TTSHighlightCoordinator sets uiState.scrollToOffset from sentence start. TXT/MD only via same onChange wiring. Needs device verification | + diff --git a/docs/manual-test-checklist.md b/docs/manual-test-checklist.md new file mode 100644 index 0000000..38ffeed --- /dev/null +++ b/docs/manual-test-checklist.md @@ -0,0 +1,163 @@ +# Manual Test Checklist + +Test on device. Check off as verified. BLOCKED section at the bottom has known bugs — skip until fixed. + +## Library + +- [x] Sort order persists across app restart +- [x] View mode (grid/list) persists across restart +- [x] Long-press book → "Set Cover" → pick photo → cover appears +- [x] Long-press book → "Remove Cover" → reverts to default +- [x] Long-press book → Info sheet shows metadata +- [x] Long-press book → Share works +- [ ] Create collection → add books → collection appears +- [ ] Tag books → filter by tag +- [ ] Add OPDS catalog URL → browse books +- [ ] Download book from OPDS → appears in library + +## Reader — Chrome & Navigation + +- [x] Content scrolls normally in native mode +- [x] Tap center → toggles toolbar +- [x] Chrome toggle doesn't shift content +- [x] Top bar below Dynamic Island, matches bottom bar styling +- [ ] Library nav bar hidden during push transition +- [x] Reading progress bar visible and draggable +- [x] Bookmark button → haptic feedback + bookmark saved +- [x] Annotations tab order: Contents → Bookmarks → Highlights → Notes + +## Reader — TOC + +- [x] EPUB 3 nav.xhtml → real chapter titles (not "Section N") +- [x] EPUB 2 toc.ncx → real chapter titles +- [x] MD file → headings appear as TOC entries +- [x] TOC entries show indentation by level +- [x] Tap TOC entry → navigates to correct position + +## Reader — Highlights & Annotations + +- [x] TXT: select text → Highlight/Add Note in edit menu +- [x] PDF: select text → highlight annotation +- [ ] Delete highlight from panel → visual clears +- [x] Highlights visible on file reopen +- [ ] Export annotations → Markdown file +- [ ] Import annotations → highlights restored + +## Reader — Dictionary & Translation + +- [x] Select word → "Define" → system dictionary sheet +- [ ] Select word → "Translate" → AI translation panel + +## Reader — AI + +- [x] AI button visible when API key configured +- [ ] Summarize section → summary returned +- [x] Chat with book → multi-turn conversation works +- [x] General AI chat (from library) → works without book context + +## Reader — TTS + +- [ ] Tap speaker icon → TTS starts reading +- [ ] Control bar: play/pause, stop, speed slider +- [x] TTS button hidden for PDF + +## Reader — Text Transforms + +- [ ] Toggle Simp→Trad → Chinese text converts +- [ ] Toggle Trad→Simp → converts back +- [ ] Add replacement rule → text cleaned +- [ ] Highlights/search still work after transforms + +## Reader — Performance + +- [ ] Large TXT (\~15MB) opens in under 2s +- [ ] Search panel opens instantly +- [ ] Second open skips indexing +- [ ] AI/search/TOC only load when invoked + +## Reader — Search + +- [x] Search results show with query terms highlighted +- [x] Tap result → navigates to correct location +- [ ] Search highlight auto-clears on next action + +## Book Sources (#24) + +- [ ] Import Legado source JSON → source appears +- [ ] Search via source → results displayed +- [ ] Tap chapter → content loads +- [ ] Chapters cached for offline reading + +## Sync + +- [ ] WebDAV backup → archive on server +- [ ] WebDAV restore → data recovered +- [ ] HTTP TTS → cloud voice reads + +## Latest Fixes (2026-03-22) + +### #95 — Translate opens correct tab + +- [x] Select word in TXT → "Translate" → AI panel opens on Translate tab (not Summarize) +- [ ] Open AI panel via toolbar → opens on Summarize tab (default) +- [ ] Swipe-dismiss AI panel → next open defaults to Summarize + +### #96 — TTS produces sound + +- [ ] Tap speaker icon → audio plays from speaker +- [ ] Stop → audio stops, other app audio resumes (not ducked) + +### #101 — Book sources visible after import + +- [ ] Settings → Book Sources → import Legado JSON → sources appear in list +- [ ] Search button active after import + +### #100 — Book source saves persist + +- [ ] Create new source → close/reopen app → still there + +### #92 — AI reads actual content + +- [ ] Open GBK/Big5 TXT → AI summarize → shows real content (not just title) +- [ ] Chat with book → AI references actual text + +### #89 — Books open faster + +- [ ] Open any book → content appears without noticeable delay +- [ ] Search panel opens instantly + +--- + +## BLOCKED — Known Bugs / Missing UI (skip until fixed) + +### Paged Mode (bug #82) + +- [ ] Settings → paged layout → content paginates (not scrolls) +- [ ] Page turn animations (slide/cover/none) +- [ ] Auto page turn at configurable interval + +### TXT TOC (bug #83) + +- [ ] TXT with Chinese chapters (第一章) → TOC populated +- [ ] TXT with English chapters (Chapter 1) → TOC populated + +### EPUB Highlights (bug #77) + +- [ ] EPUB: select text → highlight confirmation dialog + +### Per-Book Settings (feature #37, bug #84) + +- [ ] Toggle "Custom settings for this book" → only affects this book + +### Tap Zone Settings (feature #25 — no settings UI) + +- [ ] Configurable in settings (left/center/right actions) + +### Theme Background Image (feature #32 — no image picker UI) + +- [ ] Pick background image from photos + +### iCloud Backup (feature #10 — not implemented) + +- [ ] iCloud backup and restore + diff --git a/docs/tasks.md b/docs/tasks.md index 303d57f..1ca9ff6 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -38,12 +38,76 @@ Describe issues in plain text below. The agent will triage them. ## Triaged -2026-03-15 | bug #56 | PDF crash after adding highlight and reopening — WI-008 restore flow may crash on reopen. Needs repro + crash log -2026-03-15 | bug #57 | EPUB and TXT font sizes render differently at same setting value — font size settings implemented but inconsistent cross-format -2026-03-15 | bug #58 | EPUB reading position only chapter-level, not intra-chapter — position save exists but loses scroll offset within chapter on reopen -2026-03-15 | bug #59 | Gap between progress bar (WI-004) and bottom bar — layout/spacing UI bug -2026-03-15 | feature #21 | Paginated reading mode with turnable pages — never implemented, currently scroll-only -2026-03-15 | REOPENED bug #43 + feature #22 | "No highlighting in search results" — Two issues: (a) highlight at destination not working in TXT/EPUB/PDF = regression of bug #43; (b) search result list doesn't highlight matching text = new feature #22 +2026-03-21 | bug #101 | Imported 2000+ book sources but list empty and search button grey — BookSource records may not persist or UI not refreshing after import + +2026-03-21 | bug #92 | AI only reads book title, not selected section — AIContextExtractor may not receive current locator or loaded text +2026-03-21 | bug #93 | Chat sessions not persisted — multi-turn history lost on panel dismiss or book close +2026-03-21 | bug #94 | Keyboard cannot be dismissed while chatting — AIChatView input field missing dismiss gesture +2026-03-21 | bug #95 | "Translate" opens AI Summarize panel instead of translation — wrong tab/view presented from readerTranslateRequested +2026-03-21 | bug #96 | TTS produces no sound and no error indication — AVSpeechSynthesizer may fail silently (no audio route, empty text, or system error) +2026-03-21 | bug #97 | TTS control bar overlaps bottom bar — TTSControlBar z-order or spacing conflict with reader bottom overlay + +> 2026-03-21 | NEEDS-INFO | "Does TTS work for all languages?" — System TTS supports languages installed on device. HTTP TTS depends on provider. Which language failed? +> 2026-03-21 | feature #40 | TTS sentence highlighting — highlight current sentence/word while TTS reads. Not implemented +> 2026-03-21 | feature #41 | TTS auto-scroll/paginate — scroll content to follow TTS reading position. Not implemented +> 2026-03-21 | DUPLICATE OF bug #89 | Books still slow to open — already tracked +> 2026-03-21 | bug #98 | Text Transforms (simp/trad or replacement rules) fail — transform not applied or crashes +> 2026-03-21 | bug #99 | Search results not highlighted in some TXT files — highlight navigation may fail for specific encoding/chunking edge cases +> 2026-03-21 | bug #100 | Book source cannot be saved — BookSource persistence or UI save action broken + +> 2026-03-21 | DUPLICATE OF bug #72 | Library nav bar appears during loading — already tracked and FIXED +> 2026-03-21 | bug #87 | PDF highlights still visible after deletion — readerHighlightRemoved handler missing in PDFReaderContainerView +> 2026-03-21 | NEEDS-INFO | "Is it normal for annotations to be exported as JSON?" — Yes, JSON is one of two export formats (Markdown + JSON). Is there a specific issue? +> 2026-03-21 | bug #88 | Exported annotations not highlighted when imported — import restores DB records but doesn't refresh visual highlights in reader +> 2026-03-21 | bug #89 | Books still slow to open — may be regression or remaining startup overhead after bug #64 fix +> 2026-03-21 | bug #90 | AI buttons visible when consent is off — AIReaderAvailability doesn't check consent; buttons should hide or show consent prompt +> 2026-03-21 | bug #91 | Blank panel when tapping Translate without AI configured — no guard for missing API key/consent before opening AI panel + +2026-03-21 | bug #85 | Cannot add books to collections — no UI to assign books to a collection. CollectionSidebar can create/delete but not add books +2026-03-21 | bug #86 | Tags never shown — LibraryView passes allTags:[] to CollectionSidebar. No UI to add tags to books. PersistenceActor.addTag exists but unwired + +2026-03-21 | bug #79 | Search panel still slow to open — deferred setup (bug #64) adds visible delay when search sheet opens +2026-03-21 | bug #80 | Cannot set custom book cover — PhotosPicker in context menu not working +2026-03-21 | bug #81 | Tap zones do nothing in native mode — center/left/right taps unresponsive after TapZoneOverlay removal (#70) + +> 2026-03-21 | feature #39 | Custom background image for reader — never implemented, only solid colors/themes exist +> 2026-03-21 | DUPLICATE OF feature #37 | Per-book font size affects all books — per-book settings not implemented +> 2026-03-21 | DUPLICATE OF feature #12 | TXT files without TOC — TXT TOC generation never implemented + +2026-03-21 | bug #77 | Cannot add highlight in native EPUB — regression of feature #11 (EPUB highlighting) +2026-03-21 | bug #78 | Highlight visual persists after deletion — removal doesn't clear rendered highlight + +2026-03-21 | bug #76 | Annotations panel tab order — Contents (TOC) should be first, before Bookmarks + +2026-03-21 | bug #75 | Sort preference not remembered across restarts — regression of feature #6 (PreferenceStore) + +> 2026-03-21 | DUPLICATE OF feature #12 | TXT TOC not recognised — TXT TOC generation never implemented (forTXT returns empty) +> 2026-03-21 | bug #74 | EPUB TOC shows "Section XXX" instead of real chapter titles — uses spine items instead of nav/NCX document +> 2026-03-21 | feature #38 | Hierarchical/tree TOC display — currently flat list, user wants nested indented view + +> 2026-03-21 | DUPLICATE OF feature #21 | Paged mode still scrolls — paginated reading never implemented, setting is a placeholder + +2026-03-21 | bug #73 | Reader top bar hidden behind Dynamic Island — safe area inset zeroed by parent's ignoresSafeArea + +2026-03-21 | bug #71 | Reader top bar (ReaderChromeBar) looks ugly — buttons too small, styling inconsistent with bottom bar +2026-03-21 | bug #72 | Library navigation bar still visible during reader loading transition + +2026-03-21 | REOPENED bug #62 | Content still shifts down when top bar reappears — v2 fix (constant ignoresSafeArea) didn't resolve it + +2026-03-21 | REOPENED bug #62 | Content shifts down when top bar appears — user re-reports after previous fix +2026-03-21 | bug #70 | Cannot scroll content in native mode, all formats — TapZoneModifier overlay likely blocking scroll gestures + +2026-03-18 | bug #64 | All files and all formats are slow to load — V2 coordinator chain initialization adds overhead on every reader open +2026-03-18 | bug #62 | Content shifts down when top bar reappears — layout reflow from toggling `.ignoresSafeArea(.top)` with `isChromeVisible` +2026-03-18 | bug #63 | Progress bar unresponsive in Native mode — gesture conflict prevents scrubber drag and bar toggle + +> 2026-03-15 | bug #56 | PDF crash after adding highlight and reopening — WI-008 restore flow may crash on reopen. Needs repro + crash log +> 2026-03-15 | bug #57 | EPUB and TXT font sizes render differently at same setting value — font size settings implemented but inconsistent cross-format +> 2026-03-15 | bug #58 | EPUB reading position only chapter-level, not intra-chapter — position save exists but loses scroll offset within chapter on reopen +> 2026-03-15 | bug #59 | Gap between progress bar (WI-004) and bottom bar — layout/spacing UI bug +> 2026-03-15 | feature #21 | Paginated reading mode with turnable pages — never implemented, currently scroll-only +> 2026-03-15 | REOPENED bug #43 + feature #22 | "No highlighting in search results" — Two issues: (a) highlight at destination not working in TXT/EPUB/PDF = regression of bug #43; (b) search result list doesn't highlight matching text = new feature #22 + > 2026-03-15 | DUPLICATE OF feature #6 (DONE) | Library display format/sorting not remembered — Feature #6 implemented in WI-001 with PreferenceStore. If still broken after latest build, this is a regression — please retest > 2026-03-15 | DUPLICATE OF feature #12 (DONE, MD only) | TXT TOC generation — D3 decision: TXT TOC deferred indefinitely (no reliable heading structure). MD TOC works via WI-005 diff --git a/project.yml b/project.yml index 8584750..bb038db 100644 --- a/project.yml +++ b/project.yml @@ -41,6 +41,7 @@ targets: - path: vreaderTests excludes: - "**/.gitkeep" + - "ViewModels/LibraryViewModelPersistenceTests.swift" dependencies: - target: vreader settings: diff --git a/vreader.xcodeproj/project.pbxproj b/vreader.xcodeproj/project.pbxproj index 00cfd83..36ac9be 100644 --- a/vreader.xcodeproj/project.pbxproj +++ b/vreader.xcodeproj/project.pbxproj @@ -3,57 +3,120 @@ archiveVersion = 1; classes = { }; - objectVersion = 63; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ + 7A01B2C3D4E5F6A7B8C90001 /* TXTViewConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A01B2C3D4E5F6A7B8C90011 /* TXTViewConfig.swift */; }; + 7A01B2C3D4E5F6A7B8C90002 /* TXTTextViewBridgeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A01B2C3D4E5F6A7B8C90012 /* TXTTextViewBridgeCoordinator.swift */; }; + 7A01B2C3D4E5F6A7B8C90003 /* EPUBWebViewBridgeJS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A01B2C3D4E5F6A7B8C90013 /* EPUBWebViewBridgeJS.swift */; }; + 7A01B2C3D4E5F6A7B8C90004 /* EPUBWebViewBridgeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A01B2C3D4E5F6A7B8C90014 /* EPUBWebViewBridgeCoordinator.swift */; }; + 7A01B2C3D4E5F6A7B8C90005 /* TXTChunkedHighlightHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A01B2C3D4E5F6A7B8C90015 /* TXTChunkedHighlightHelper.swift */; }; + 003HF5GN00VW59KVM4CQV0SD /* BookContentCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = I1LZNT3Z7HY2RSP3O90OFDPF /* BookContentCache.swift */; }; 00AA9871B88FE39518AC1320 /* utf16be_bom.txt in Resources */ = {isa = PBXBuildFile; fileRef = F2EFEE7A0EC5352A0BB1A994 /* utf16be_bom.txt */; }; + 013DEEAD10005949F5BF2271 /* UpdateCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C394C4CBC74CD9F661A403 /* UpdateCheckerTests.swift */; }; 01440A60BDBE08FC56500DA7 /* SearchHitToLocatorResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A2CD5F8AD4DEC1A14A255A /* SearchHitToLocatorResolver.swift */; }; + 01450E848C5A2110A56DDD21 /* TXTTextChunkerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A3BC126A794F2C82F782E7D /* TXTTextChunkerTests.swift */; }; 018E696CD9238CE5A290D9AF /* MDIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9EE79C451D86828387A1BEF /* MDIntegrationTests.swift */; }; 01A53D2CA4B291030B55F5F6 /* TXTServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE1146B1851B4122B5187A1 /* TXTServiceProtocol.swift */; }; - 01B2C3D4E5F60718AABB0001 /* TXTAttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B2C3D4E5F60718AABB0002 /* TXTAttributedStringBuilder.swift */; }; - 01B2C3D4E5F60718AABB0003 /* TXTAttributedStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B2C3D4E5F60718AABB0004 /* TXTAttributedStringBuilderTests.swift */; }; 01FEDA4DD8F5A3BFA56F275D /* DocumentFingerprintValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B4E4FB28FD82376AE20A4F /* DocumentFingerprintValidationTests.swift */; }; + 036A7AA1F02D9219384C777A /* NativeTextPagedIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CE4AD339F8C68B973D9C88 /* NativeTextPagedIntegrationTests.swift */; }; + 044A57EF6D114FA998DB29FA /* TextKit2Paginator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F317CDBF13A6A5673D64A8A /* TextKit2Paginator.swift */; }; 04759BE2CD3CA39424689484 /* AIResponseCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C18F1E19149F8DBAE36A31 /* AIResponseCache.swift */; }; 05007F5C2D7687A1173C48CB /* ReaderSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B432A1C9D875A14C4E9E633 /* ReaderSettingsStore.swift */; }; + 058C2F104DE6D10D81F907D9 /* AnnotationExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB9773A4631BEDEDD187F08 /* AnnotationExporterTests.swift */; }; + 05BE70789318FA085B9A735E /* AIChatViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E861A379A620B769CEA36300 /* AIChatViewModelTests.swift */; }; + 05C3CA2FC89C4976ACFA43AA /* LegadoCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE99E42CBCBD4C1784CBDBA1 /* LegadoCompatibility.swift */; }; + 0681EC94635E9BBB798AAB77 /* SearchHighlightDismissTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62569DC663E2BDD2DC0155C3 /* SearchHighlightDismissTests.swift */; }; + 06C8E85FDBC83E56C5BF3B64 /* EPUBProgressCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBB555BA86F7648ACBC780F /* EPUBProgressCalculator.swift */; }; + 070817D1986BE6BCF7208912 /* CollectionTestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3F2340D008C0F77D922091 /* CollectionTestHelper.swift */; }; + 070C52BEBC5753727B555585 /* CollectionSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 098863E9E4647225E16F2E41 /* CollectionSidebar.swift */; }; + 070F22DA080E6D84CC1EF73D /* TXTReflowableTextSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16D420A03BD524C247A78FB /* TXTReflowableTextSource.swift */; }; 076CA91860D0CAF278F65B50 /* ReaderSettingsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F75167F586CEA5F4E9002C /* ReaderSettingsPanel.swift */; }; + 07827134E24965F0D27435AC /* OPDSCatalogListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F64D33F5E4EB6B2F8F10CC8 /* OPDSCatalogListView.swift */; }; 07D023FAB657EDAED583D009 /* BookmarkPersisting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A046B497B731C451670CED /* BookmarkPersisting.swift */; }; 07DCB26CA703C2A38E135473 /* MDParserProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9452FAFFDEBDF03FF6CCEBB1 /* MDParserProtocol.swift */; }; 08C05E320FF9F6EFAAE34DA3 /* ImportErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 744CB6180C0CE88E75E124F3 /* ImportErrorTests.swift */; }; + 08D7E1736817CB7AA0695C3D /* ThemeBackgroundStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E07FB35EC9805F51CAD10444 /* ThemeBackgroundStore.swift */; }; 08F6B888EBC4D1ADDA3CC360 /* EPUBReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B380013D82DFFD0411633E /* EPUBReaderViewModel.swift */; }; + R6LC0001A1B2C3D4E5F60001 /* ReaderLifecycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = R6LC0002A1B2C3D4E5F60002 /* ReaderLifecycleHelper.swift */; }; 08F7E1DE72FF450114142960 /* utf8_bom.txt in Resources */ = {isa = PBXBuildFile; fileRef = A84BF17CCCD4B376BDDF8CD1 /* utf8_bom.txt */; }; 09122777AF5FD739850888CA /* LocatorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FC8555E3C352A0C95D6BFE9 /* LocatorFactory.swift */; }; + 094D24D5B1E3CBF90739BE42 /* HTTPTTSConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385FF266F3625A73EC23C8BF /* HTTPTTSConfigTests.swift */; }; + 095C1FEFA8400DD2F1A61CFF /* FileSizeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52DADD2C2FB6330070EA6DE /* FileSizeFormatter.swift */; }; 099933954CB74FAE04C6B877 /* SearchLocatorSliceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE2038A4D36C4355AC5C7BF5 /* SearchLocatorSliceTests.swift */; }; 0A6A74D96B7763878D13CFDD /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF473C04CE58CE0887DAA5A1 /* LibraryViewModel.swift */; }; + 0ADA2F00E5ABFEEF5328CE2F /* PerBookSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 272182B2C311AE5E968D314C /* PerBookSettingsTests.swift */; }; 0AF2C077EAD177EE3AF2985A /* TXTService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DBBFF061B96088FFE84194 /* TXTService.swift */; }; 0C04B6441DD521F888F59DBD /* HighlightListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1952532DDD6CD0938B0FCC /* HighlightListView.swift */; }; 0CAEEF69ACEA07AE0DEC346E /* MutationDriftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8DEE3B767FC8F7457067C11 /* MutationDriftTests.swift */; }; 0D65E679657B901DB2AE7CBB /* TXTReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19A1FE14FDE4829AF0F5913 /* TXTReaderViewModel.swift */; }; 0DCC70724F193A55D2B254AE /* DocumentFingerprintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE1E995F0C1A8A64CF95A99 /* DocumentFingerprintTests.swift */; }; + 0DDF1B72D9B712C6B0D327D3 /* CSSRuleEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386F15A3CC3059EC5B4E3872 /* CSSRuleEvaluatorTests.swift */; }; 0E5215DBE0AC933D4C2136F6 /* DeleteConfirmationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4078AAB96560B1BC1794472E /* DeleteConfirmationTests.swift */; }; + 0EFA98D7C252D06AD3A254A9 /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A7FC6A70A060CD5E43602E /* ReadingProgressBar.swift */; }; + 0F278FD5678087467BEC0E2C /* TextMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F197589BA55261B91DBFFEE0 /* TextMapperTests.swift */; }; 10835FE4B33B176F7668ADF7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1F9542255A6791C9BCB034DB /* Assets.xcassets */; }; + 11654293CCE901059BA9F941 /* ReplacementTransform.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74CC275250E1FEFD4D1A72B /* ReplacementTransform.swift */; }; + 116A16DB9D20D6B20F77016A /* TXTTocRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12EBCB8CD58F740D9042C32 /* TXTTocRule.swift */; }; 1196930F82189AC76D8DCD2F /* empty.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4E318ED286636BD43CD865D3 /* empty.txt */; }; + 11B285AD42721118145AE416 /* SearchResultHighlightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8FC6D57843EAC26DB980D3 /* SearchResultHighlightTests.swift */; }; 129358092754DD095B2008B2 /* MockTXTService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3D8B3D17D6551C053F12355 /* MockTXTService.swift */; }; 12EE1BD6335013980EFA3EC0 /* BookCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BEB6636BEBB84D528EE5DF5 /* BookCardView.swift */; }; + 1316119F5F616DBE405F7E38 /* SwiftDataSessionStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5959972E9775E5B52E5C840 /* SwiftDataSessionStoreTests.swift */; }; + 13AA54A125EC714F17846B6B /* PageNavigatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544A2F3FF8BBB1C08DDCE02D /* PageNavigatorTests.swift */; }; + 13EC9440F35FF01443E4FABF /* AnnotationAnchorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B393E54ECE17C3DA3969EA4 /* AnnotationAnchorTests.swift */; }; + 15C15DFE4818EDE663481250 /* MarkdownExportFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC38E517D1EE7098DCB96426 /* MarkdownExportFormatter.swift */; }; 16E0E8B88F2913E822EA56C3 /* ReadingPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831F853E3D42A27170BB0F92 /* ReadingPosition.swift */; }; 18A64CDBD49535E2C27EA116 /* MDReaderViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513AE679E0A5DBFFBB0AF6BD /* MDReaderViewModelTests.swift */; }; + 18B415B48942ADDF3FDE479A /* MDReflowableTextSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9773A45B1D89408E85D126BA /* MDReflowableTextSource.swift */; }; + 18EC97E247FEF2212C90B5E1 /* search_results.html in Resources */ = {isa = PBXBuildFile; fileRef = 56955DD1A478DEED93B590C9 /* search_results.html */; }; 18F17DCE91707A996AFA35F1 /* ReaderSearchSheetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7D1FCE2E15F04329AB1978 /* ReaderSearchSheetTests.swift */; }; + 19A48F5C5BC46818F3CC10BB /* AddNoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A49E33ACBED018932A38F0C /* AddNoteSheet.swift */; }; + 19A67133FBCA83C048797762 /* PROPFINDParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC76EA043F57DC3B5650C82 /* PROPFINDParser.swift */; }; + 1AB09C7743582725CDCE0EC7 /* HTTPTTSSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27260FA0FED437E1EB06E0CD /* HTTPTTSSettingsView.swift */; }; 1AC5FD48311C93B5CEB3702E /* BookImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FBDC41C60328ED4FB8A197 /* BookImporterTests.swift */; }; 1BD4CC8621C43917629D4728 /* HighlightRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96BE19585BF1F4C8174F7219 /* HighlightRecord.swift */; }; 1C9B4E21A97013A7CC409925 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DAC8E20A001D7803A16AAD1 /* SearchView.swift */; }; + 1CB7C39AC6FE3B715D4B4305 /* V1toV2Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB16317C72EB6BBB61D77030 /* V1toV2Migration.swift */; }; + 1D0B7D11E74A0E06F27A9F8B /* EPUBLayoutPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF348591A8246AA1524CD10 /* EPUBLayoutPreference.swift */; }; + 1D61E44D67F37555FDEA9B6C /* TXTFileLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08B42D93C357CAAFB4261D93 /* TXTFileLoader.swift */; }; + 1D762D01C68B70790B8C2DE9 /* EPUBWebViewBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABFBA14606BD14D14A8D5500 /* EPUBWebViewBridgeTests.swift */; }; + 1DBB5BA587B2EBF08A7F3C85 /* HighlightRecordAnchorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B04729AD87389C9ECEB13CE /* HighlightRecordAnchorTests.swift */; }; 1E114184ED4E99E9EB1FE43C /* SearchIndexStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB1CCA367AE0B610F4526FC /* SearchIndexStore.swift */; }; + 1EE68B75A44789E6789BA6EB /* EPUBTextExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADECFF5C347B6D68C1A8529 /* EPUBTextExtractorTests.swift */; }; 1F3F0A67EB5D7AEC3B11D3C3 /* HighlightPersisting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24D988A4CE41E94A8A9280CC /* HighlightPersisting.swift */; }; + 1F55B2D70EC99A6E77AACFD8 /* BookSourceChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9541B260A743177FAD2B96EE /* BookSourceChapterListView.swift */; }; 1FC25DD5163814F8A1DF4EBF /* MigrationFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CFECBFD38A16DE449662507 /* MigrationFixtureTests.swift */; }; + 201D1474F216D8F16D76F1B6 /* MDTextExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DFC3C0DD795D886D77A6881 /* MDTextExtractorTests.swift */; }; + 2051294D05C42778582C8172 /* OPDSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77371DBD389CE1F1153E56E /* OPDSClient.swift */; }; + 209CA5025D6BC048CCAE4012 /* SchemaV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = F379B3EEC02DC574C73F4323 /* SchemaV2.swift */; }; + 21145D281B373D2D3262E65F /* AnnotationAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1AC5E9F599CDA7123547F2 /* AnnotationAnchor.swift */; }; + 212D635EB0A8088D74729C2C /* chapter_list_paginated.html in Resources */ = {isa = PBXBuildFile; fileRef = 9509CC40145B03A66875390C /* chapter_list_paginated.html */; }; + 21864806F4268E51A9E589A6 /* AISettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AC1B3E1E87D71E229C3EF6 /* AISettingsSection.swift */; }; 21C14E9FBD7B7373B56A365C /* SearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A888610BD2499B817A278EB /* SearchResultRow.swift */; }; 21E6733005B6B4894ECCFEAB /* ContentHasherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0616892213196BCF802266F8 /* ContentHasherTests.swift */; }; + 2206E24712AFD54F00761207 /* EPUBFileLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6AEAC075B9C38B3783D207A /* EPUBFileLoader.swift */; }; + 22EE9DC0642A27F8292E0CE9 /* AnnotationImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C080C3D62DBA36CA1E88C5 /* AnnotationImporter.swift */; }; + 238CEDFC273E8AD0026B77AB /* BackupProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB2B5F77B95D3402E699DA9 /* BackupProvider.swift */; }; + 243DB71360738DB06D5813BF /* NativeTextPaginator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E51E6D76FE0DDF87E537AB /* NativeTextPaginator.swift */; }; + 24A18369157C0FE60E879A7E /* BookSourceHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C6CC009677C66D3AF4E5836 /* BookSourceHTTPClient.swift */; }; 250865E2A2A7BC3DE436183F /* PDFReaderViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7F4F4B985D58E03A78680D /* PDFReaderViewModelTests.swift */; }; + 2509B7983AE76F5C85B71634 /* HighlightDedupeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD56854A37D8EA2B318B926 /* HighlightDedupeTests.swift */; }; + 252CB71020888B6952C2F69E /* ReaderBottomOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A983D06F916C51795A2223E7 /* ReaderBottomOverlay.swift */; }; 25327C608F6719156A58C8B3 /* LaunchHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62D7DA128D4B2AC1F362D571 /* LaunchHelper.swift */; }; + 25B106D034C16291C104D69E /* AnnotationsPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59ACCAF30F5092968415855 /* AnnotationsPanelView.swift */; }; 26285A9C2309CC149C5372DC /* AIConsentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9AB72079AF7B2ACCAB516 /* AIConsentManager.swift */; }; 265B4C9C4B99A0500F0EC6B7 /* MDMetadataExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5D3F39FDBF4693D33D1BCB /* MDMetadataExtractor.swift */; }; + 275C851E9115056F4AF6657B /* PipelineTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA41D8643B42B4B25CB6D96A /* PipelineTypes.swift */; }; 2775DDF52321F468CB58F795 /* BookmarkListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D432C9B43D1B6662B4605664 /* BookmarkListViewModel.swift */; }; + 298EB8447E1427B7FE4CE0F9 /* JSONExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA638212AA92F9FF855ABC2 /* JSONExportTests.swift */; }; + 2AD43547691478569AA638EB /* AIConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8038AAB18412F30C09CBDD9 /* AIConfigurationStore.swift */; }; 2B2BB1E5BCB1E74F360BE9F2 /* SearchTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D00221E111683E9FF0260A /* SearchTextExtractor.swift */; }; + 2B6913E770A92CDDB1991B84 /* TTSProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6783D1629C54A25FE65C8705 /* TTSProviderProtocol.swift */; }; 2B9E39AC289E006A1A8B25AE /* BookFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED83105BFA1AA160A12D158 /* BookFormatTests.swift */; }; 2C05C1DC5E7C1B7C7B438C2B /* NoOpSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19EA79EB5577BF31A4096B39 /* NoOpSessionStore.swift */; }; + 2D8D59A6A41D0A2D5AB16741 /* ReaderAuditFix2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD76366E51B98FEE9E53DB3C /* ReaderAuditFix2Tests.swift */; }; 2DB8779FF53587C400452428 /* FileAvailabilityStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6362591D3F62B4BC84CE136A /* FileAvailabilityStateMachineTests.swift */; }; 2DDEE520E3606572AED3598F /* LibraryRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC254C94AE749FFA8AACCE4 /* LibraryRefreshService.swift */; }; 2E9BB5052DD152DD52BB0D45 /* TXTReaderPlaceholderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 480E5268D197F33E8C0B6CFC /* TXTReaderPlaceholderTests.swift */; }; @@ -62,102 +125,244 @@ 2FA04FC59FECB40C45FAD4D8 /* LocatorFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 055FBEA382F82BE342A50C8E /* LocatorFactoryTests.swift */; }; 30F629136C9EED9FCD557222 /* SmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14E222357346107CB34B750 /* SmokeTests.swift */; }; 320025CDBC69B31CC1B4DEAA /* PDFReaderContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2BA1A05E4E36D5D7B2DCFD /* PDFReaderContainerView.swift */; }; + 32C58BAAF2DB529049921D4D /* OPDSParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C26DE0705F29A16D1919F5 /* OPDSParserTests.swift */; }; + 9D4C2D2D74BD1C463344244F /* ChangeTokenStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62E09E73F40E15B8AE10F61 /* ChangeTokenStoreTests.swift */; }; + 3EEAADD077DC64CA56321F88 /* CloudKitRecordMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10009F6F0D32B9A9E2D16A9 /* CloudKitRecordMapperTests.swift */; }; + 9D7069502ECFFA59E181CA85 /* DeviceIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C99AB2454A14C2424347F5 /* DeviceIdentityTests.swift */; }; + 81DB77D7FC6B57F00EB82A1B /* DurableTombstoneStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC2642E3A5DC9FDFE1C4111 /* DurableTombstoneStoreTests.swift */; }; + 6A83534950489DA0D635B625 /* NSUKVSBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516DF5B639FADAB9842388B7 /* NSUKVSBridgeTests.swift */; }; + 6ADF2E9A64B5325648EC83B7 /* SyncOutboundQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D74EB30AD402C81A7F3E56 /* SyncOutboundQueueTests.swift */; }; + 66A08D073DF12A5015075202 /* SyncPipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE7986F0EE66546C2CB5751 /* SyncPipelineTests.swift */; }; 32D0866BC61D79BCAEF0A525 /* SyncServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB81DF32070BCFB6D8653800 /* SyncServiceTests.swift */; }; + 32F4E36941EDCA2C0D457777 /* ReaderNotificationHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC7EBBBE07E87F07CC0FE4F /* ReaderNotificationHandlerTests.swift */; }; + D1C8E2F09B3E47AB62F5A710 /* TextReaderUIStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7E3CF12A4D922873FC18E55 /* TextReaderUIStateTests.swift */; }; 33B874FB4BB17A21ACA4468E /* BookFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41C3ECA5E8F6419DB347F2E4 /* BookFormat.swift */; }; 34B285C323BF6E55A25CC148 /* LocatorCanonicalHashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3987200016FB6CA3D063E44 /* LocatorCanonicalHashTests.swift */; }; 34E33F3534FA3EB044406F2D /* TypographySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF87AF7EE6B0FB8E4CC9332 /* TypographySettingsTests.swift */; }; + 34E5034ADB5725781C9CE5C8 /* PDFProgressHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A54A2C5DE8C1631C04BB2A1 /* PDFProgressHelper.swift */; }; 3548ECB80E9BAC95250F69E5 /* TXTOffsetMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FA7AE29709E497DBB6AF9DF /* TXTOffsetMapperTests.swift */; }; + 354E24A0B0A690869F8EFC5A /* TapZoneOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 271BAF9BD03F619061BA4D96 /* TapZoneOverlay.swift */; }; + F2E1D0C9B8A7F6E5D4C3B2A1 /* ReaderChromeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* ReaderChromeBar.swift */; }; + + 369D003DCE5F36A64E5C1C06 /* AIChatGeneralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90F4EB83CC68406DA14DD94 /* AIChatGeneralTests.swift */; }; 36B37715BB3494F635566157 /* ImportJobQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 117731FF4D8AE414141C5ECF /* ImportJobQueue.swift */; }; 378FF0B45CF9F05579644D1B /* ReaderAnnotationsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4846E32490F4D5FFC0A366EF /* ReaderAnnotationsPanelTests.swift */; }; + 381D47129E7564BFBE0B26EB /* ThemeBackgroundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21055A4DC487F0F56BA7C475 /* ThemeBackgroundTests.swift */; }; 3821F3BE76B9BE588B9FA995 /* SearchHitToLocatorResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9102277C4126793229AEEB9 /* SearchHitToLocatorResolverTests.swift */; }; 384E9916435C82876752D9D9 /* AIAssistantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A5928551FF9FBB8D2E87E6F /* AIAssistantView.swift */; }; 38661CFAC1618DB7526ACB28 /* PersistenceActor+Highlights.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC517B8E3581F795DEDEC934 /* PersistenceActor+Highlights.swift */; }; 38F1AB473618B5EB717D05DE /* ReadingTimeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00FE0912FBC85E22DF8C637F /* ReadingTimeFormatter.swift */; }; + 39699408A91BBF24E36FCE53 /* EPUBPaginationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EC9B0FFB09D08F65F205F3 /* EPUBPaginationHelper.swift */; }; + 39D1A8383CEBC235DDAA552F /* UnifiedPagedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F82AD6F50703DBCA984EBAAC /* UnifiedPagedView.swift */; }; + 39ED0E66F0AC131788A16FEB /* PreferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292EB76312E500AFAC065E1F /* PreferenceStore.swift */; }; 3AB78E0F4510E5C661622EDD /* LibraryPopulatedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E68D7B5F0EC86B4E39725EC9 /* LibraryPopulatedTests.swift */; }; + 3B62997EF7304D0ACFBF141D /* HighlightableTextViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E293CFD61A19BB48B38963 /* HighlightableTextViewTests.swift */; }; + 3C289C7DFA69A28D8AAFD86B /* BookSourceEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41D6AA10585EA3CE63143C50 /* BookSourceEditorView.swift */; }; 3C6784421BC6B3DD6F1D3C16 /* BookModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B811BD48F552B167D438BFCF /* BookModelTests.swift */; }; + 3CAC33209031F93DC4692879 /* MDFileLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE6974D0F73862058FC97358 /* MDFileLoaderTests.swift */; }; + 3D4FD29B8D09BBB8C02241D5 /* LegadoRuleParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F8130B082FB1C006A2F711 /* LegadoRuleParserTests.swift */; }; + 3D839D7370FF42B7A426FAD4 /* BookSourceRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 493AACD57E158A3C3C6692B1 /* BookSourceRules.swift */; }; 3DE8687C45492DB6E076D65E /* QuoteRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92AB43BED5AC7096E7278A16 /* QuoteRecoveryTests.swift */; }; + 3DF1A5D2E40DE8AAF521BB8C /* TranslationPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21B3F47E988913B477EACF93 /* TranslationPanel.swift */; }; + 4107A78DB0996F371F4B3C6F /* PhaseBMediumAuditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E270DE50F23F6125F3151CA /* PhaseBMediumAuditTests.swift */; }; 41337E423B4F0C5CCE5B6785 /* MDTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCDA968F8186B11859D8CCFE /* MDTypes.swift */; }; 4176B17F6A64ED68E53B016E /* utf16le_bom.txt in Resources */ = {isa = PBXBuildFile; fileRef = 10F8EE6C68FBB40F0A229AC0 /* utf16le_bom.txt */; }; + 41E1AE7987CC61A4C9B29FFE /* ReadingModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC4FE169F0AC369FB30F888F /* ReadingModeTests.swift */; }; 4222752F69DF72644596BAD9 /* MDTypesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DCA33DBE911B5B1B6425277 /* MDTypesTests.swift */; }; 429316840ADE46912C2A89E9 /* BookImporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 593A77413CD93AEE33F15156 /* BookImporting.swift */; }; + 42C12085597D305DE974B0B1 /* SearchIndexCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14418F18DF9D9A3B912AC090 /* SearchIndexCore.swift */; }; 432C40433346C054B7EC5D07 /* ReduceMotionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC4425D7764DA53AD595FF93 /* ReduceMotionHelper.swift */; }; + 432FAED1CA14525CAB953BC3 /* UnifiedTextRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13A1A428419630E618721E37 /* UnifiedTextRenderer.swift */; }; 4331E31295D932065A8E3DCB /* HighlightListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7F2C7E93907B97A730010 /* HighlightListViewModel.swift */; }; 43915A8DDE117AD0E0118A28 /* FileAvailabilityBadgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 637D35BECC7768038F474E5E /* FileAvailabilityBadgeTests.swift */; }; + 43BD02365D537972791DF4D5 /* ReaderAuditFixTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DA904E79F5CF69E46ECC26 /* ReaderAuditFixTests.swift */; }; 43E5608C73F29F783F64BE8A /* ReaderNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DB169F568633A25E157B4DE /* ReaderNavigationTests.swift */; }; + 44A1BF88B48D717D89913706 /* OPDSModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CA445AA96E5EEBA05B7C36 /* OPDSModels.swift */; }; + 44C7CAB1B9EAAD4F33BAD7AF /* TextMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 521E495ED3C3F323D5488F1D /* TextMapper.swift */; }; + 453E682BDF07AEB9D4DA6294 /* PaginationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C50C474AA2F96C39AAC94 /* PaginationCache.swift */; }; 454342CEF3A2152B1EDD2455 /* TXTReaderContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 725F9036150B858160ACFF7B /* TXTReaderContainerView.swift */; }; + 45EA62BA74F57E9AC57B1BA4 /* HighlightedSnippetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631D0375777D50E5B1EDF31C /* HighlightedSnippetTests.swift */; }; + 468BC9EB876942D28F071E57 /* PaginationCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FF60B935D0DF315B8705C2B /* PaginationCacheTests.swift */; }; 46969BA70AA2E7B914E50D0E /* MDReaderPlaceholderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AAC0D3FD90694D3169DB775 /* MDReaderPlaceholderTests.swift */; }; + 4701E8DB7FB81F6690AA729D /* ExportedAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B311C3A88BD6DC42005FE1B /* ExportedAnnotation.swift */; }; + 47534D81F01962F43C11E9B5 /* LegadoRuleParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5099FDCB9D68C511D5C59FA /* LegadoRuleParser.swift */; }; 47A1B7844CF41A92813CD002 /* MockPersistenceActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B925BE5683D3296D77D3503B /* MockPersistenceActor.swift */; }; + 47B8BA0B880F22752CD21308 /* BookSourcePipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FAB86FF543782FB25FCE33C /* BookSourcePipelineTests.swift */; }; + 48501D19A13066218A1D529B /* ReaderAuditFix3Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB15AD471389C6DEDDD0286 /* ReaderAuditFix3Tests.swift */; }; 489DF72D463C495F4C94C5FB /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28354051023D618CC3CAE2E2 /* LibraryView.swift */; }; 4930168887D85648F1EDA897 /* MigrationFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E394527455B13D1EE9B06A9 /* MigrationFixtures.swift */; }; 49D992E6F2DAB74CCD4FDC68 /* TokenSpanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D944728B17A940A3716EA9 /* TokenSpanTests.swift */; }; + 49E7475E7118E6366C0530E5 /* PersistentSearchIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25EC6B4A507BE08D646A4AD /* PersistentSearchIndexTests.swift */; }; + 4C19B2381D5EDB9E64C88807 /* WebDAVSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0965D8213AFBB980604A7592 /* WebDAVSettingsView.swift */; }; 4C47F172233199432FC289E3 /* ImportSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22F84672A6E2EDD6E037AFD8 /* ImportSource.swift */; }; + 4C5F7C805D7702035CEA5837 /* NativeTextPaginatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 295669F5B358B6C7BE41952E /* NativeTextPaginatorTests.swift */; }; + 4CC9567091E0CABFDD800F6C /* AIConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C52103AEAF5528A9DD58625E /* AIConfiguration.swift */; }; 4CCBF4F6E186A7363A995303 /* ReaderContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB42EEEFFCAD8D654D57AE7 /* ReaderContainerView.swift */; }; 4D4A49E8738329EC2B336683 /* TXTReaderViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D998048CE6DE8DC3BC77C284 /* TXTReaderViewModelTests.swift */; }; + 4DD834AD725B80F1CB92DEF3 /* BookSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 280FCCEE99306FEA6479845B /* BookSourceTests.swift */; }; + 4E2207CD7F48BCE945A0971C /* AIReaderIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E706779E64026004319957F /* AIReaderIntegrationTests.swift */; }; + 4F2FEC62B6D23F67263EDF34 /* BackupProviderContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED150D276C082FEC194F2F31 /* BackupProviderContractTests.swift */; }; 4FC1839FA29FE323CF45B3B2 /* BookmarkListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CE6EA4B82CC966077E656F /* BookmarkListView.swift */; }; 4FDDEB10D3E2014D806618AD /* SyncConflictResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8188B21271D103070EE8CDE6 /* SyncConflictResolver.swift */; }; + 50214665FE48786605E6CF7E /* EPUBHighlightBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435C00E099B7F5D7A7821FDC /* EPUBHighlightBridge.swift */; }; + 505E3728FE1C69A31079DA9B /* ReaderBottomOverlayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF9547D23D813327B536EAD5 /* ReaderBottomOverlayTests.swift */; }; 50837395A5CDCDFC14CF2B64 /* PDFPasswordPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425829C48779CD64EB0C5A05 /* PDFPasswordPromptView.swift */; }; 5128BF72998C4F697D2A6A84 /* ImportProvenanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFAF8770F91837F0B3793E1 /* ImportProvenanceTests.swift */; }; + 5365C69DAECEFAE3481247B3 /* TOCBuilderTXTTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49624FC3C8E1AC31E011351C /* TOCBuilderTXTTests.swift */; }; + 53B3E8A2D0A66FB2A9E968E3 /* HTTPTTSProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4E3D87DD1EB1B18213B48C7 /* HTTPTTSProvider.swift */; }; 53DEE85CF4BDE11891B0E07A /* KeychainServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF5C12635DFA6BEE42EBB1CE /* KeychainServiceTests.swift */; }; + 53F990254493D95BE25D6BFD /* HighlightableTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9E867C06CA165E731435125 /* HighlightableTextView.swift */; }; + 542FF947F7F993A2904D56C2 /* EPUBComplexityClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA9065CD56F7F0C7AF165FE /* EPUBComplexityClassifier.swift */; }; 545CCE5FED3565C9BF15EF78 /* LocatorValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C87C165AFE78571456C14D1 /* LocatorValidationTests.swift */; }; 5599E405840D204BE7FA8104 /* LibrarySortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC606D4AF30DF956E04C13DF /* LibrarySortOrder.swift */; }; 55E8CDBFFC9EC1C49EAC47EE /* DocumentFingerprint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D14A41185FFD87E278E66C /* DocumentFingerprint.swift */; }; 563B878DD14F1699FFC52439 /* NavigationFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 874E517A41CB3B9C7C5C8D3A /* NavigationFlowTests.swift */; }; + 56A8491DE672A28A32B64620 /* CSSRuleEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 410123F7E79BF50A70C95A03 /* CSSRuleEvaluator.swift */; }; 5895F86BFE58FBFBAA7D8424 /* SyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F6250C22449E7E83591620 /* SyncStatusView.swift */; }; + 58C75DAF7234376B96CEF824 /* ZIPWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAEDD9E567FC32F217A07CAF /* ZIPWriter.swift */; }; + 58F98A74EEAB6D0C39616B5D /* UnifiedTextRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7F45FFFEE431C41E618EF2 /* UnifiedTextRendererTests.swift */; }; + 59B8E65739F0BDF9F099D348 /* SearchQueryExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B1246EC0EE34D326231F60 /* SearchQueryExecutorTests.swift */; }; 59C50A731FA0022482A38792 /* WCAGContrastTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2A16647F198641F11AC9C1 /* WCAGContrastTests.swift */; }; + 5A94BE236411F0F7268F803F /* ReaderNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB7C7EC41A96F5D4B53E983 /* ReaderNotifications.swift */; }; + 5BA867B4591F0916810C91B8 /* EPUBPaginationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9500033AC714D470A3024F8 /* EPUBPaginationTests.swift */; }; + 5BC84DECAA7A019DAB762F29 /* HTMLHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E21000C7B6029FCAD6E13E /* HTMLHelper.swift */; }; + 5BCD69B7FFD787F33D6E0C8D /* AutoPageTurnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2396FE1BB03C2F3CEFAB8E2 /* AutoPageTurnerTests.swift */; }; 5BF55452ABA60AB492B54C6D /* AIProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02B540992E1F3C96A00723F /* AIProvider.swift */; }; + 5C20BECE1338802F60F3E7B7 /* AITranslationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE29B97304B59C88E3F3F9CF /* AITranslationTests.swift */; }; 5D3C554CE5388769AA455D1E /* SearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41F3E1A368720EFCB55A9582 /* SearchService.swift */; }; + 5DDD5217EFD8A53C4C5DD152 /* search_no_results.html in Resources */ = {isa = PBXBuildFile; fileRef = F099DED45D1C192D6F99A194 /* search_no_results.html */; }; + 5FE7F04D448C49D466F641FB /* LocatorNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C20D015AD61E29BEB57DC3 /* LocatorNormalizerTests.swift */; }; + 6018C875BCDA469C84DE3BC2 /* VReaderAnnotationParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C8EE35219F3BAFBDA81AAE /* VReaderAnnotationParser.swift */; }; + 6070A8AF12AADF388F7C1383 /* V1toV2MigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939BC09F1D771D2E22301ED3 /* V1toV2MigrationTests.swift */; }; + 60E22AD64A6DF63A0CFBFE70 /* BookSourceReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A13D6063551337AC540840D /* BookSourceReaderView.swift */; }; + 6173290A06E9E4303D363AE8 /* ReaderThemeCSSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DE298149C25261E2ECADA1 /* ReaderThemeCSSTests.swift */; }; + 619448A4D6264FCD5522806C /* WebPageEncodingDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852FC8DBE71B720469C296C4 /* WebPageEncodingDetector.swift */; }; + 61F1501169ECAB4537B2C930 /* DictionaryLookupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DBF9C3C1FBBCE4354F7DAAD /* DictionaryLookupTests.swift */; }; 6225EFF6A5A33D3F2FD4DABF /* BookmarkListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256C28A508EAD4DB73B49DD4 /* BookmarkListViewModelTests.swift */; }; + 6254C228981BFCF2AC50B719 /* DictionarySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBD54F8887091F46753F09A4 /* DictionarySheet.swift */; }; 6274548A4C4DDC1FCA13397C /* BackgroundIndexingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAA1482C17A94A21E44EFEB /* BackgroundIndexingCoordinator.swift */; }; + 631E89297E6BE25DF74556B7 /* TXTStreamingOpenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7008A22A4FBE1574E8B9C8A4 /* TXTStreamingOpenTests.swift */; }; 6344AC26417E72E251541FE3 /* TestSeeder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0797CB73F5D8FDAF0B7298E /* TestSeeder.swift */; }; + 63F0FFBB206E429287E293C3 /* legado_source_js.json in Resources */ = {isa = PBXBuildFile; fileRef = D0966F97359B41E9AB958A02 /* legado_source_js.json */; }; + 64709D1B5C006D3299C8ABAF /* AutoPageTurner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5401E10DDA195966ABD13F70 /* AutoPageTurner.swift */; }; 65BA2B507A0D5555244C7F4C /* SearchTextNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E54B97E5C67533291971CA1D /* SearchTextNormalizer.swift */; }; 65C927CBE6855076656719CE /* AccessibilityFormattersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336495F8165F79A364CE9B09 /* AccessibilityFormattersTests.swift */; }; + 65E4EDF452B2195BA78BA7A3 /* SpeechSynthesizing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D3EF2FFB105C9E0DF1EDF51 /* SpeechSynthesizing.swift */; }; + 65EB03B9632EFDE8EAF49ACC /* EPUBTextStripperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C842F00C6B506FC831E0347 /* EPUBTextStripperTests.swift */; }; + 66EF1425722B3E84E4902AEC /* AnnotationsPanelViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C00E5921EA2B5FE88656FED6 /* AnnotationsPanelViewTests.swift */; }; + 67307D96FA4FE6F86F92B988 /* FormatCapabilitiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D579834E6924BB521873C38 /* FormatCapabilitiesTests.swift */; }; 67F279A51B09B3CDD7BBA3A9 /* PDFPasswordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5639E3F809343C8CE5D7A020 /* PDFPasswordTests.swift */; }; 689521FED3BECF363AF06049 /* SchemaV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5A2D5CFE8B719D4C8F3580 /* SchemaV1.swift */; }; 689A68666A4FDCD57304AC8E /* MDMetadataExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FDB1CD71ED267C2FD85F325 /* MDMetadataExtractorTests.swift */; }; 68B750CB7D82E8E4DE0DA393 /* SearchServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DF5DC258E6460D4FE84706 /* SearchServiceTests.swift */; }; + 69D8922795B7525A06038A49 /* ReadingMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B2B480AC630357CC08475F4 /* ReadingMode.swift */; }; + 6AC23BE43C5A46609BFFB118 /* LegadoImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0AB24552C147A6A2D56EE3 /* LegadoImporter.swift */; }; + 6B30D9E131580FD96CF77D20 /* PDFProgressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775CED0704F1D6D39F873FF9 /* PDFProgressTests.swift */; }; + 6B7F71BDB5ECEA83F19496FC /* ReflowableTextSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A054B9D6DC875E4D8E7A5F28 /* ReflowableTextSourceTests.swift */; }; + 6C4BDAADD228F84C65CE3CE6 /* ModeSwitchPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25642951BDB16B5C59BF39CF /* ModeSwitchPersistenceTests.swift */; }; 6C56C40C260D289E023BCEE9 /* AnnotationPersisting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2556DC1CBB43434072B19479 /* AnnotationPersisting.swift */; }; + 6CE796C0D4A118EF12FC79D2 /* SeriesTagPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C131E2BC3B226E7009FA006 /* SeriesTagPersistenceTests.swift */; }; 6D073A0311A72B82FEF65852 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC105D38A4A85CBCB79A772 /* KeychainService.swift */; }; 6E5022EE67C6ACC27F614E77 /* Locator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C567EE93DC61BBB63CEAC20 /* Locator.swift */; }; - 716E28B2D0AF6A439D3748DC /* SwiftDataSessionStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58456B0AB20C3D6AD39090A5 /* SwiftDataSessionStoreTests.swift */; }; + 6EA1E4475CD190B3E9F9D370 /* ReaderUnifiedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AA77FDB19CB7EDA69418C8 /* ReaderUnifiedCoordinator.swift */; }; + 700CFFEC7C74E0532A0271F4 /* ExportTestFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE5CAE41C429B6868957C540 /* ExportTestFixtures.swift */; }; + 70679EB33203CA5BD78A43C9 /* BookSourcePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6850832765DA26713F278C /* BookSourcePipeline.swift */; }; + 71397E1DB2920A02C5CEE39E /* PDFPageNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103B5BF35C8DC9FB2BE4998F /* PDFPageNavigator.swift */; }; + 726DA1204DBFE8EBE5852764 /* ReadingProgressBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E4737FA3A880C3CC2BA07D /* ReadingProgressBarTests.swift */; }; 72BBA56325CAC43C8F4CC3EB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD65C0547DF264510657CA2 /* ContentView.swift */; }; + 73319DBA75C0F4352DDCD858 /* LibraryContextMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42FD55371CD8FA98699541E /* LibraryContextMenuTests.swift */; }; 7388501251356E2DE780DC22 /* BookRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A84FE014139E7B5524445D /* BookRowView.swift */; }; 73CB49F24C1650EA99E2A5B5 /* MockEPUBParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836FCCC18D880D48A10BA38A /* MockEPUBParser.swift */; }; + 7406A7805B98779AF9AA2F63 /* TXTMDProgressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F213D0F6EFBD7D7088AAC40D /* TXTMDProgressTests.swift */; }; + 7495B58258DD9BE8624D27C9 /* AnnotationImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 889CBB149D6F4922CE90C0A8 /* AnnotationImporterTests.swift */; }; 74BEA01C5E4B3E080D2BF2FD /* SearchTokenizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E49BCEDC674BC5776103CE /* SearchTokenizer.swift */; }; + 7517791F2112F0367A462A10 /* SchemaV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470BBE13ED7BCDD6E60D3400 /* SchemaV3.swift */; }; + A2F1E0D9C8B7A6F5E4D3C2B1 /* SchemaV4.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A6B7C8D9E0F1A2 /* SchemaV4.swift */; }; 752B9949AB27FC69C8F017AE /* TOCBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818F6161D2855C49A12AF5A6 /* TOCBuilder.swift */; }; + 7621CBAFA9E36EEA60633869 /* NativeTextPageNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC729E5B1BA7DC69B40D4929 /* NativeTextPageNavigator.swift */; }; + 76272EACDDB7D292C6CA8C9E /* HighlightedSnippet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF4E488AD7274100802E64AD /* HighlightedSnippet.swift */; }; 762B3F65978080FE7D26BF4C /* AnnotationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11E9DBFB16DA26DD0659851 /* AnnotationModelTests.swift */; }; 7872FD996BEB1009EFF26413 /* AnnotationEditSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EAE2660201ADC0B4272B9EE /* AnnotationEditSheet.swift */; }; 78A8B8FE91360F619F57DDB1 /* ContentHasher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84D0E1452F211B986E8328A /* ContentHasher.swift */; }; 792681509B48600C93D01C39 /* ReaderTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4581A94B5099D15743DC02F3 /* ReaderTheme.swift */; }; + 793A39541D8253B1CA8984A5 /* ScrollProgressHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7024E7AEAC9AEAA028952C46 /* ScrollProgressHelper.swift */; }; + 7B9FDFE7688C6E45D59916CD /* MockBackupProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9501ED4FB49C6035FDF5BB /* MockBackupProvider.swift */; }; + 7BF30BFBA55B4D40A38A03F4 /* legado_source_xpath.json in Resources */ = {isa = PBXBuildFile; fileRef = 9B4C9FADDEE54F268F536EC5 /* legado_source_xpath.json */; }; 7C168089FE12D0A6B34DDEA1 /* EncodingDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77811B16F2CF741310C23CF5 /* EncodingDetectorTests.swift */; }; 7C55E0AC6A420DF9B2B81DDB /* TombstoneStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9AB5E0256EC0FE97B68DE5D /* TombstoneStore.swift */; }; + 7C6C782D2152DDE6DCEDF6B8 /* WebDAVClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AE71C3AADFD978879F217BF /* WebDAVClient.swift */; }; + 7CCEBC99BB8B9A70D65163FC /* BookInfoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB260B5C0A147C9AE9A46DC /* BookInfoSheet.swift */; }; 7D0B1A054D3897CAD9D768A6 /* ReadingStatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FA3AC72012ED6686293475 /* ReadingStatsTests.swift */; }; 7D0D6E22B259A33A6B27BAE9 /* EPUBReaderContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907D934613DDAEA1F3055F82 /* EPUBReaderContainerView.swift */; }; + 06BFC1681027392CD6A52ABA /* HighlightRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D1FFAC6D1E473B42A52C50 /* HighlightRenderer.swift */; }; + D339ED97E8CC0ED654A02C03 /* TextHighlightRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F37065575D4351969AC869 /* TextHighlightRenderer.swift */; }; + 51CB9D4AA85B58B85F414D6D /* EPUBHighlightRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EBA861AF0ED9B1F194C06D0 /* EPUBHighlightRenderer.swift */; }; + FF40E774910C0E799FD13C8B /* PDFHighlightRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940E5A743EBB62A0EEEAE70C /* PDFHighlightRenderer.swift */; }; + F4442C4CA80CE9B9B0A0865E /* HighlightCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C1DFA6B0FFE6606113B92E /* HighlightCoordinator.swift */; }; + 23CDCE3DBA89CD94BF5E5CBA /* HighlightRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 910F691A853144404676CFB3 /* HighlightRendererTests.swift */; }; + 7C95E7E78AD1A5CECC235092 /* EPUBHighlightRendererBug77Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68F802F6FC8A94BCA0925BEC /* EPUBHighlightRendererBug77Tests.swift */; }; + 7829D659A1E978F705D9FA38 /* PagedModeBug82Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531A945FDC79FC3FBB5B3AD5 /* PagedModeBug82Tests.swift */; }; + 4282ED0255055BBC0814F4D3 /* TransformsBug98Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92746A75F27A7F5427FC9199 /* TransformsBug98Tests.swift */; }; + 04443847C0FAFF314FF4CCB3 /* HighlightCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F837206FBB8B11B14D1EC5 /* HighlightCoordinatorTests.swift */; }; + 7D8D8CAA59DB81F80A9BEE72 /* TextKit2PaginatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E368FCB9544C39958CDC31CE /* TextKit2PaginatorTests.swift */; }; 7DF8F36BD048FEAAF1571CAD /* LibraryEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB6F33EB7EFB0D7C9001E3A2 /* LibraryEdgeCaseTests.swift */; }; 7E14E9F4DB1451B47A4DDC9E /* AIServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFB9DBE73613A6499D43B18C /* AIServiceTests.swift */; }; 7E4274201E53904CDE46FC16 /* ReadingSessionTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398F1BAF549E7AC80E9CE320 /* ReadingSessionTrackerTests.swift */; }; + 7E5E743E7A274E009CF942B3 /* LegadoImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFC46BDDED74CC58966FD7E /* LegadoImporterTests.swift */; }; 7E88B70492874A1D1C0416D5 /* AnnotationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C86F6A1C143DB6AC9187FC0 /* AnnotationListView.swift */; }; + 7EE8C91043F4F20D39AF326B /* AITranslationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82B992B24F86DF8CA23E1215 /* AITranslationViewModel.swift */; }; 800F472661AB1030CC54DB46 /* ReadingTimeFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CED0BF7A8104C22C6E293BF /* ReadingTimeFormatterTests.swift */; }; 8044960A1B045C48AAE88736 /* AIAssistantViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA401D8FC3B4F17213528B27 /* AIAssistantViewModelTests.swift */; }; 80741E09562FA8559F4BD206 /* LibraryDarkModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4598235F83FFA792CE2338F9 /* LibraryDarkModeTests.swift */; }; + 80CA0E4FF773CBEC357DF93D /* TOCBuilderMDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 456FDDA03D7DEF3A4AEB01DB /* TOCBuilderMDTests.swift */; }; 80CABDCB3569E07FE3AD3FD0 /* MockPositionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2355F0CDCE9B874D6BD148FB /* MockPositionStore.swift */; }; + 81498F908FAF97F421A3C35F /* AIReaderPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF113D7E83A1CE51F417258 /* AIReaderPanel.swift */; }; 8178696A12B2FC6B462D9C3A /* LibraryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1563E77E28434568F45736 /* LibraryViewModelTests.swift */; }; - 82464599BDEC3715DA80E83F /* EPUBWebViewBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECD0FC78B12AED09BA2A1DF /* EPUBWebViewBridgeTests.swift */; }; + 818D42F1D3D6548605297F83 /* ReaderFormatHosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF23B1A0CC0BE35DF685C5FA /* ReaderFormatHosts.swift */; }; + 82152E9125D5620CACFCEFF3 /* ReaderSelectionEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7C59839A119870BE9B6FF9 /* ReaderSelectionEventTests.swift */; }; + 822D2DE8366AE763FAA0A424 /* TextTransform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BEF5735F2121D036A4877C4 /* TextTransform.swift */; }; + 83DAEE23928C668DA378F086 /* EPUBProgressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FA15EA6877E3CA9421E1B59 /* EPUBProgressTests.swift */; }; + 83F569CBFEB2E90AC8B57E5F /* WebDAVProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E41805A45542E97AE415D9 /* WebDAVProviderTests.swift */; }; + 84A03EDA45DB68CE01456609 /* SwiftDataSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B682E216B5A24055B696F0 /* SwiftDataSessionStore.swift */; }; + 85C11C4F2CA49FF77D34BBFB /* TXTBridgeSharedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AA8588621686E377D9D496 /* TXTBridgeSharedTests.swift */; }; + 860C6626A5AC805B4C622E70 /* EPUBFileLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5487566F93AB8376D1BD1F1B /* EPUBFileLoaderTests.swift */; }; 86AB97CD6CC05A3EEADE5F00 /* MDAttributedStringRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36686A80222AD7613951C900 /* MDAttributedStringRenderer.swift */; }; + 8727C5619EABD0DD355565C0 /* ReaderSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B501E24B36BF00B609B04BF3 /* ReaderSearchCoordinator.swift */; }; 879E269189DBE24A5AF6095B /* LibraryRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DAD680DB86CF1A65D34F3F /* LibraryRefreshServiceTests.swift */; }; 87A61AC432B6116973B7D291 /* LocatorIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0069CA3A00998B13DE06E424 /* LocatorIntegrationTests.swift */; }; + 87A6858F82E1DE2F31A592C3 /* BookSourceSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A691D7600EDE0812CC1E6AD /* BookSourceSearchView.swift */; }; 884CC3C2EDE6FC428A666910 /* SyncTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E72DDEAD6225A21E973A51 /* SyncTypes.swift */; }; 891E1E70B176150B071E464F /* AIContextExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20237121BB4ACF22C0818BA4 /* AIContextExtractorTests.swift */; }; 8A3AE73DD5935F4E45882E38 /* AccessibilityFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC4CE4DBCD7E5A52BC4E511 /* AccessibilityFormatters.swift */; }; 8BBE50E626A6524E8C6DB59D /* SearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 576F111E93E863C656BDEC70 /* SearchViewModelTests.swift */; }; + 8C233C75BA4991C7C1A52E27 /* SimpTradDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBA16F9071C94660C6AB1EB /* SimpTradDictionary.swift */; }; 8C5EFF0A113773C9FA1153E1 /* VoiceOverAuditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED80D7FC768F4B87E7DB036A /* VoiceOverAuditTests.swift */; }; + 8C858D4E771BCCAC02F2D1E2 /* chapter_list_page2.html in Resources */ = {isa = PBXBuildFile; fileRef = 6B600146907F8D9159F2B777 /* chapter_list_page2.html */; }; 8CAAA8CE24E5701C76A9A55F /* EncodingFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C483C40C61CC5C3F7B66030 /* EncodingFixtureTests.swift */; }; 8CB7B605DFC452B422BBEC4F /* SearchTextNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBC4E25069345D28610C64EC /* SearchTextNormalizerTests.swift */; }; 8E153D7C3B713EDDBF484A24 /* AIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 334D6D06A294323E149970E4 /* AIService.swift */; }; + 8E2E64928835A3533FDE10FA /* PDFAnnotationBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A064D62C86857484454D0BE3 /* PDFAnnotationBridge.swift */; }; 8E7482D250AFDBEF1803780A /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB31146A831DACE67C50F08 /* AppConfiguration.swift */; }; + 8E936BF32CF0850398C9D742 /* AIChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 539FEE4A23AFA226048A12A4 /* AIChatView.swift */; }; 8F002C822102F0A088F31A06 /* AnnotationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A513D5E8C4467B8FE45E0AC /* AnnotationRecord.swift */; }; + 680388290CA3707893F7085D /* ChangeTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0DB65C8E6609BD73825B37 /* ChangeTokenStore.swift */; }; + A7D1190541B235BE4894AEA1 /* CloudKitClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D1B6319B2BB8DF3BA6E99 /* CloudKitClient.swift */; }; + E19C8C3656F85AC8B7E45B92 /* CloudKitRecordMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D9AAF8DAE58F885C9741413 /* CloudKitRecordMapper.swift */; }; + A1C30093363094C1C79FA1BE /* DeviceIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F3129242478B2D5F49C0D5 /* DeviceIdentity.swift */; }; + 11879AEE84DFD84E7A1E35D9 /* DurableTombstoneStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35237A69147FB81A638492C7 /* DurableTombstoneStore.swift */; }; + 8CB032376D95F12AE86E7B24 /* NSUKVSBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB651A97BA8CE8B5BD6E97B /* NSUKVSBridge.swift */; }; + 4D84426071C2283D632870F2 /* SyncOutboundQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 555BC3B182915D99AE4D6E83 /* SyncOutboundQueue.swift */; }; + 1F67AE198719DAB2ED869FE1 /* SyncPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF51FC0C60A1FF5E749759B /* SyncPipeline.swift */; }; + 8D76452BF88CA5D2714EAB77 /* SyncRecordDTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3F0345BFE29A78AF0467BC /* SyncRecordDTOs.swift */; }; 8F202DA6CCCB6E1D83E7DC01 /* SyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC12F17B1F2EDD25E25B114C /* SyncService.swift */; }; + 8F280281FE847272C7E64547 /* CollectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E026EDF24B39D1CD50B39389 /* CollectionTests.swift */; }; + 8FFE1DA9C8F17FC318BE81BD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AEC5BBAD52B075096E5C8 /* SettingsView.swift */; }; + 905543EBFD153235D8C3BF00 /* PerBookSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7A2880FF5B321684FE712F /* PerBookSettings.swift */; }; 9092232FBB529C5C6D9A53DB /* AIResponseCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5CB5C659CC573EF448F0992 /* AIResponseCacheTests.swift */; }; + 9301FA74B29BDCD8C3FF55DB /* EPUBHighlightActionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9F0676ACCEE6F37D547E72 /* EPUBHighlightActionsTests.swift */; }; + 9322D9DEBD7A9423DACA12DF /* NativeTextPagedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F47559B14E9DEE63DE613ED /* NativeTextPagedView.swift */; }; 947AC899C38470795EF51F2E /* TXTOffsetMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D49361893EE9956D6EC5DB /* TXTOffsetMapper.swift */; }; + 95D2D252390D26AFDBBAF43C /* SPIKE_RESULTS.md in Resources */ = {isa = PBXBuildFile; fileRef = 3D70FBE6B4F5F9DDBA035D25 /* SPIKE_RESULTS.md */; }; 96F610A12CED33CA6B82C142 /* Book.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12F6574178C9287A93CA6 /* Book.swift */; }; 973F98FC8939CBE3B085A9E7 /* LibraryTouchTargetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E6FA59B833AC785C70A7A8 /* LibraryTouchTargetTests.swift */; }; 9760CE8C8811986D6EEECF61 /* SearchIndexStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7031C26EB38B7B1D2A0BEF /* SearchIndexStoreTests.swift */; }; @@ -166,138 +371,173 @@ 97D9F118063EF7E6328A4E14 /* FileAvailabilityStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C13EA240083857EA527F89 /* FileAvailabilityStateMachine.swift */; }; 982FDBDA893DC7DA931711C7 /* FeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43EE33FE1EDFE9B393885110 /* FeatureFlagsTests.swift */; }; 986AC8640BA33F3235A89D81 /* TOCProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83492717235FB856C8A06ED /* TOCProviderTests.swift */; }; - 987AC8640BA33F3235A89D82 /* TOCBuilderMDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93492717235FB856C8A06EE /* TOCBuilderMDTests.swift */; }; + 986EFB28F7203E56F853A0FD /* BasePageNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16AF7EAA6EC1F1D0D126E75 /* BasePageNavigator.swift */; }; + 989E5E5ACA5EB8FFD6A3B74A /* chapter_content.html in Resources */ = {isa = PBXBuildFile; fileRef = DDC5E430511CD7491C543A15 /* chapter_content.html */; }; 98CC47D9875A2A96F5098AF3 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400D03ADE39639337E9993C5 /* FeatureFlags.swift */; }; + 98D1ABF430790E35869AAB0E /* TXTChunkedReaderBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205862B286DDE2DD2233F6D /* TXTChunkedReaderBridge.swift */; }; 98E08494B40D86923451655E /* ImportProvenance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DF69361FD0FBED7294C43E /* ImportProvenance.swift */; }; 99456D2FCC39AA83E0B43C65 /* AIAssistantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46221600B62F5482365B0484 /* AIAssistantViewModel.swift */; }; 99A9048ACF04CA7FADB5F331 /* AIAssistantStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B35AC90256B2F4A696E3D2 /* AIAssistantStateTests.swift */; }; + 99B6840F5C6B54842C465F64 /* OPDSBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9E438077E6D10199BA12CE /* OPDSBrowserView.swift */; }; 9ADB90C99DECE18FE058ECD9 /* BookmarkRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D04AA64724C4F9A15869C20 /* BookmarkRecord.swift */; }; + 9AF587978D1D58F58C7E8A23 /* UnifiedTextRendererViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7056486BA460E0BE06235F54 /* UnifiedTextRendererViewModel.swift */; }; 9C8762E2789F97355348E7AA /* MockMDParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9F1953C017E6B1F990FB44 /* MockMDParser.swift */; }; + 9CF8425987E95B557DB67B8F /* ReplacementRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDACAD61BCFC3618CD18675 /* ReplacementRulesView.swift */; }; + 9D982FAFD79829613C2EFECB /* HighlightAnchorStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE844A76B8AFC7B1DC2E840 /* HighlightAnchorStorageTests.swift */; }; + 9DB7FDDADD640EDC37004402 /* ThemeBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3753D7CD01EA589932DF780C /* ThemeBackgroundView.swift */; }; 9DF23A1DF30F525FC15F1B81 /* SyncStatusViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F069A328AA628585D86B52B2 /* SyncStatusViewTests.swift */; }; 9E163090F48B7DA58DBF7EE1 /* AIConsentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B60F61628D0969B18B3A9B /* AIConsentView.swift */; }; + 9E2DE265BDC2515E4572714B /* AISettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68E9908FEE2CDE00C6EB279 /* AISettingsViewModelTests.swift */; }; 9FCA583CDA52A7FB584260FA /* LocatorRestorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B81B909B41CB8C14B613D73 /* LocatorRestorer.swift */; }; A02EBB23E9A748965074B639 /* VReaderUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DF9DC9E8F3CCA4BE321C47 /* VReaderUITests.swift */; }; A0E678BE188639F7AD4A45C9 /* ReaderUnsupportedFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0459CAA7394555E5A8E17146 /* ReaderUnsupportedFormatTests.swift */; }; + A11E272093433A3733A66FE6 /* EPUBHighlightActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28AEE54347E9EC752286A2A /* EPUBHighlightActions.swift */; }; + A138F46DE7229925D7AC22EF /* ReaderPositionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F99442B3BA1E02CC3F2A2C1 /* ReaderPositionService.swift */; }; A190FE66621610AD2D04739D /* PersistenceActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DE5531A63EA492C5D91BEE /* PersistenceActor.swift */; }; - A1B2C3D4E5F60002STATS02 /* PersistenceActor+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60001STATS01 /* PersistenceActor+Stats.swift */; }; - A1B2C3D4E5F60718192A3B4C /* SearchWiringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F6071829A3B4C5D6 /* SearchWiringTests.swift */; }; + A1D75F4B8C2EF1EB714CDD40 /* SourceSharingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99782DD1A320D265D78FAA12 /* SourceSharingService.swift */; }; + A2038806633CEB1515E27008 /* OffsetMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD586268986FD19FFE2271A /* OffsetMap.swift */; }; A232BB96700FAD0583896DE3 /* ReadingSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A104E5CBC93D0266E6C21E /* ReadingSession.swift */; }; A2518618C1F5EAD7C8FD4EE0 /* LibraryPersisting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A29C79BA1C5BF852179BCBB /* LibraryPersisting.swift */; }; A2B7E3D8BAEC3C9A9375D3E4 /* AppConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51935A413CAABF4DE2D3C488 /* AppConfigurationTests.swift */; }; + A3B091692BEA8453C7246A12 /* ReaderPositionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3629EA1FD0AAF0E1E903AC4E /* ReaderPositionServiceTests.swift */; }; A3B284AC778E4DC971B5E0A5 /* MockBookImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 459A646DA92A1898DF211A93 /* MockBookImporter.swift */; }; A51705EB5AB296DECFDADEB4 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB82BDFCDB76725A5586D5E0 /* Bookmark.swift */; }; A52C405D0597E4DC53370789 /* MockBookmarkStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE6E48CFA0BB789114F9CE19 /* MockBookmarkStore.swift */; }; + A5820C3CDA46B108F9923560 /* DictionaryLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851277A5872045D4D935CE2B /* DictionaryLookup.swift */; }; + A6AD2E80D9CF312EF3AB962C /* TapZoneConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20EBB13D56BE43A552188D9F /* TapZoneConfig.swift */; }; + A6D4A208825821F4077F90A0 /* RegexRuleEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FA0499B50436361D13BA5D0 /* RegexRuleEvaluator.swift */; }; + A6E44E99068166200DADB131 /* TXTTocRuleEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8A001ABD732F1E2FF24463 /* TXTTocRuleEngine.swift */; }; + A7C1E32F52A70A3BC79C7C11 /* AnnotationImportError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ED93A5DF68834883A508C16 /* AnnotationImportError.swift */; }; + A84E7CDEED71FF118D2DD7DC /* ReflowableTextSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D4AD419DD1123CDA21CCD0 /* ReflowableTextSource.swift */; }; A957D0C3F823092026646570 /* Highlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = C775619D3C0E4641505CE2B8 /* Highlight.swift */; }; A95CA2A8C9841860265A7FF3 /* PDFReaderPlaceholderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D9A3C4C1072DA23511265D /* PDFReaderPlaceholderTests.swift */; }; + AA366A9AF508E95B64F9A5E0 /* TapZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AC68C4B5F57B57A98D3C020 /* TapZoneTests.swift */; }; AA578B681E89F766F1902FAA /* ReaderSettingsTypographyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3354A74B809D0D3DB13A41F7 /* ReaderSettingsTypographyTests.swift */; }; + AAEA9983AA0492366955FB9B /* PositionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46BCC142F6FF2ABE9B5BA64B /* PositionPersistenceTests.swift */; }; + ACE4A8A746F082AEC08BB273 /* CustomCoverStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5955766DF21883C0C57B71E4 /* CustomCoverStoreTests.swift */; }; AD27484127EA24D230616F48 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59B2824739E67F567813198E /* TestConstants.swift */; }; AEFC819574E845429DFC9D78 /* ZIPReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2DB7F421D9D2E7492E12F89 /* ZIPReader.swift */; }; + AF32B348898F4110FED715F5 /* BookCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F1C998AAACA679A10A86D2 /* BookCollection.swift */; }; + AF5A3B3C7744485DB376E5C8 /* legado_source_minimal.json in Resources */ = {isa = PBXBuildFile; fileRef = FCB2E5176B454D4A8C82E893 /* legado_source_minimal.json */; }; AF7D99D9C0CEA266BFD976B8 /* MetadataExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE6C27BECED96A8DA016439 /* MetadataExtractor.swift */; }; - AUDIT5000001 /* HapticFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUDIT5000002 /* HapticFeedback.swift */; }; - AUDIT5000003 /* BookmarkFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUDIT5000004 /* BookmarkFeedbackTests.swift */; }; - AUDIT7000001 /* SearchHighlightDismissTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUDIT7000002 /* SearchHighlightDismissTests.swift */; }; - AUDIT8000001 /* ReaderAuditFixTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUDIT8000002 /* ReaderAuditFixTests.swift */; }; - AUDIT9000001 /* ReaderAuditFix2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUDIT9000002 /* ReaderAuditFix2Tests.swift */; }; - AUDITA000001 /* ReaderAuditFix3Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AUDITA000002 /* ReaderAuditFix3Tests.swift */; }; + AFB4E04161F7BD0D7FB0C706 /* SourceSharingServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F55A72D6B1A8FE2889EFB28 /* SourceSharingServiceTests.swift */; }; + B086340BE996C111A4B374BD /* UnifiedMDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EF16A1A352B2C6BAE84556 /* UnifiedMDTests.swift */; }; B0EF6095B892F032F27CB690 /* LibraryAccessibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24D3FC7488318277468A7F0 /* LibraryAccessibilityTests.swift */; }; + B1B9E936492D58B0768C0785 /* TXTTextExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8707DA3A6FC024EAC4F63E76 /* TXTTextExtractorTests.swift */; }; B1DFA851C827F5BB877DD2B8 /* ReadingSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE41196788F697BB4ACD4B06 /* ReadingSessionTests.swift */; }; + B24559AB9CC24274B6835749 /* LegadoBookSourceDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EE95205EE2494B9343343F /* LegadoBookSourceDTO.swift */; }; B272D2C56AF8559115BBD8E3 /* AIContextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CFB0E94DE1D37E0FD2741B /* AIContextExtractor.swift */; }; B3395550D16A4B05AB62ADB2 /* PDFViewBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E7FD8CD67F19A2213DB6F5 /* PDFViewBridge.swift */; }; + B3E77B47634165E42FA68E11 /* TXTServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA7540DF541EE961F4442A67 /* TXTServiceTests.swift */; }; + B401D6506E193DB12661B33E /* UnifiedEPUBLoadResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A3131E83F93590395D14009 /* UnifiedEPUBLoadResult.swift */; }; B409ED069E31C39F7DCE6726 /* ImportJobQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DA305663C991FC6F15F80E /* ImportJobQueueTests.swift */; }; + B4230822C3A709F2E72DEF80 /* TXTAttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9844BEF447FBDBA15ADCEFAB /* TXTAttributedStringBuilder.swift */; }; + B463893D3C3558483F25BDCF /* AISettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0C783DCFD9CFDD8F488F80 /* AISettingsViewModel.swift */; }; B5114E58420F95EFB408CA82 /* ReaderSettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A86CD6BD10792F5107FFB5A /* ReaderSettingsStoreTests.swift */; }; + B6390E651DCC967457775D96 /* AIReaderAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F90F835A126ABBB86752848 /* AIReaderAvailability.swift */; }; B64CAC947A1951E5ED22009C /* QuoteRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AEB48E4BF208D97EEF397C /* QuoteRecovery.swift */; }; - B6C7D8E9F01A2B3C4D5E6F70 /* EPUBTextExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B6C7D8E9F01A2B3C4D5E6F /* EPUBTextExtractorTests.swift */; }; + B69C0AB6A9AECC0E9A1A8692 /* ReaderNotificationHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82BC782199D1750DA66D1BCC /* ReaderNotificationHandlers.swift */; }; + B8B529FEB01F674FC01A38F5 /* PageNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD6D19741098BC82E294F1E1 /* PageNavigator.swift */; }; B9676CF3333F44711ABD70DB /* MDReaderContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0EDE73D4EB702686B1326E /* MDReaderContainerView.swift */; }; + B9E51AB8A1444F8BBD0F2521 /* legado_single_source.json in Resources */ = {isa = PBXBuildFile; fileRef = 3323FF67F207437A97D5AB8C /* legado_single_source.json */; }; BA758603C760FDE615B4E6CD /* TXTTextViewBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43347208CA56C84F65B2DCF7 /* TXTTextViewBridge.swift */; }; + BB11590E610D82808A5C7641 /* WebDAVProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F1597A24B4874C1AD29043 /* WebDAVProvider.swift */; }; BBF57D9DB0812B5253D353A5 /* AnnotationListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A980DB0017049401DAB3E93 /* AnnotationListViewModel.swift */; }; + BC0E362C5EA8C36CB648B34E /* book_detail.html in Resources */ = {isa = PBXBuildFile; fileRef = F5DFC4A77EA2740C79B2EC34 /* book_detail.html */; }; + BDA5A0ADF7496048B73FE610 /* ReplacementTransformTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EFBCCD0BA84516D28B0568 /* ReplacementTransformTests.swift */; }; BDCFAEB3BDC0697448C8EF46 /* AccessibilityAuditHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB12A029D167735B92A38BA7 /* AccessibilityAuditHelper.swift */; }; BF078E02438CF936C70DE746 /* SearchSheetPlaceholderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08710FD2531ABF39338B56E9 /* SearchSheetPlaceholderTests.swift */; }; BF7CA157DAD1516A4667641D /* TXTChunkedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02AA769AEDBC25CEA896348 /* TXTChunkedLoader.swift */; }; BF9767FD9DB988B04F1B27D5 /* SyncConflictResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF495D7A9D6F8F137DD42CE0 /* SyncConflictResolverTests.swift */; }; + BFFFDD0F1A6208FCCA9BBA75 /* OPDSEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F36EF81271874A13417D45 /* OPDSEntryView.swift */; }; C08E9C36FF3ED5C05E74F52B /* WI11TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2D542588B3156C4A282264 /* WI11TestHelpers.swift */; }; + C0A9D0F52A965B62B61B6A4E /* BookSourceHTTPClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C49D770261B82856A40938B /* BookSourceHTTPClientTests.swift */; }; C135C41870117690FC304423 /* MockHighlightStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 911EF8F78991EBF40F0F6155 /* MockHighlightStore.swift */; }; C18C9598D8ECE749889D544A /* plain_utf8.txt in Resources */ = {isa = PBXBuildFile; fileRef = CD3507D70A2497BA857E5A49 /* plain_utf8.txt */; }; - C1A2B3C4D5E60001AABB0001 /* TXTTextChunker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB0002 /* TXTTextChunker.swift */; }; - C1A2B3C4D5E60001AABB0003 /* TXTTextChunkerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB0004 /* TXTTextChunkerTests.swift */; }; - C1A2B3C4D5E60001AABB0005 /* TXTChunkedReaderBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB0006 /* TXTChunkedReaderBridge.swift */; }; - C1A2B3C4D5E60001AABB1001 /* HighlightableTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB1002 /* HighlightableTextView.swift */; }; - C1A2B3C4D5E60001AABB1003 /* HighlightableTextViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB1004 /* HighlightableTextViewTests.swift */; }; - C1A2B3C4D5E60001AABB2001 /* ReaderNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB2002 /* ReaderNotifications.swift */; }; - C1A2B3C4D5E60001AABB2003 /* TXTBridgeShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB2004 /* TXTBridgeShared.swift */; }; - C1A2B3C4D5E60001AABB2005 /* TXTBridgeSharedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB2006 /* TXTBridgeSharedTests.swift */; }; - C1A2B3C4D5E60001AABB3001 /* ReaderNotificationHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB3002 /* ReaderNotificationHandlers.swift */; }; - C1A2B3C4D5E60001AABB3003 /* ReaderNotificationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB3004 /* ReaderNotificationModifier.swift */; }; - C1A2B3C4D5E60001AABB3005 /* NoOpPersistenceStores.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB3006 /* NoOpPersistenceStores.swift */; }; - C1A2B3C4D5E60001AABB3007 /* ReaderNotificationHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB3008 /* ReaderNotificationHandlerTests.swift */; }; - C1A2B3C4D5E60001AABB4001 /* AnnotationsPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB4002 /* AnnotationsPanelView.swift */; }; - C1A2B3C4D5E60001AABB4003 /* ReaderFormatHosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB4004 /* ReaderFormatHosts.swift */; }; - C1A2B3C4D5E60001AABB4005 /* AnnotationsPanelViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB4006 /* AnnotationsPanelViewTests.swift */; }; - C1A2B3C4D5E60001AABB5001 /* ReaderBottomOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB5002 /* ReaderBottomOverlay.swift */; }; - C1A2B3C4D5E60001AABB5003 /* ReaderBottomOverlayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB5004 /* ReaderBottomOverlayTests.swift */; }; - C1A2B3C4D5E60001AABB5011 /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB5012 /* ReadingProgressBar.swift */; }; - C1A2B3C4D5E60001AABB5013 /* ReadingProgressBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB5014 /* ReadingProgressBarTests.swift */; }; - C1A2B3C4D5E60001AABB5015 /* PDFProgressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB5016 /* PDFProgressTests.swift */; }; - C1A2B3C4D5E60001AABB5017 /* PDFProgressHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB5018 /* PDFProgressHelper.swift */; }; - C1A2B3C4D5E60001AABB5019 /* ScrollProgressHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB501A /* ScrollProgressHelper.swift */; }; - C1A2B3C4D5E60001AABB501B /* TXTMDProgressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB501C /* TXTMDProgressTests.swift */; }; - C1A2B3C4D5E60001AABB501D /* EPUBProgressCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB501E /* EPUBProgressCalculator.swift */; }; - C1A2B3C4D5E60001AABB501F /* EPUBProgressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB5020 /* EPUBProgressTests.swift */; }; - C1A2B3C4D5E60001AABB6001 /* ReaderPositionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB6002 /* ReaderPositionService.swift */; }; - C1A2B3C4D5E60001AABB6003 /* ReaderPositionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB6004 /* ReaderPositionServiceTests.swift */; }; - C1A2B3C4D5E60001AABB7E01 /* SearchIndexCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB7E02 /* SearchIndexCore.swift */; }; - C1A2B3C4D5E60001AABB7E03 /* SearchQueryExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB7E04 /* SearchQueryExecutor.swift */; }; - C1A2B3C4D5E60001AABB7E05 /* SearchQueryExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB7E06 /* SearchQueryExecutorTests.swift */; }; - C1A2B3C4D5E60001AABB8B01 /* EPUBFileLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB8B02 /* EPUBFileLoader.swift */; }; - C1A2B3C4D5E60001AABB8B03 /* EPUBFileLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB8B04 /* EPUBFileLoaderTests.swift */; }; - C1A2B3C4D5E60001AABB8C01 /* MDFileLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB8C02 /* MDFileLoader.swift */; }; - C1A2B3C4D5E60001AABB8C03 /* MDFileLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB8C04 /* MDFileLoaderTests.swift */; }; - C1A2B3C4D5E60001AABB8D01 /* TXTFileLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB8D02 /* TXTFileLoader.swift */; }; - C1A2B3C4D5E60001AABB8D03 /* TXTFileLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E60001AABB8D04 /* TXTFileLoaderTests.swift */; }; + C243AD36AE83E921EA70EEDD /* MDTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D9F31A3F96B73C8E3653E6 /* MDTextExtractor.swift */; }; + C2C16B2F8A50D252F62F5E52 /* UpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618A3B94C15C9AC8BC6C33C7 /* UpdateChecker.swift */; }; C2CB65A50C711B49AAB672AE /* PDFTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428A4370B8DB59563F9EE768 /* PDFTextExtractor.swift */; }; C320E44AF92161F0A556FDA7 /* EPUBReaderViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAD9A773D4AA9098981720D /* EPUBReaderViewModelTests.swift */; }; C3E08FC456AC81388D905F7F /* ErrorMessageAuditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0A7E77EFE308BC9CF8A3FE /* ErrorMessageAuditor.swift */; }; C42A9F7FEC0AEF99637EFE63 /* ErrorScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F560EA65913036C78284C138 /* ErrorScreenTests.swift */; }; C439F4679323C4D7486BB047 /* KeyboardInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DD0013DEFFC287885D1A48 /* KeyboardInteractionTests.swift */; }; - C4D5E6F7A8B9011A2B3C4D6F /* MDTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E4F5A6B7C8091A2B3C4D5E /* MDTextExtractor.swift */; }; + C545A7D59A2D63CC5B1DF45B /* OPDSParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF47D926F78F13327F6770C /* OPDSParser.swift */; }; C59B87F85C20B1B2D9DDC387 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F693921BA233745D77EC0037 /* SyncStatusMonitor.swift */; }; + C60601B9EF202F0983235144 /* EPUBTextStripper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7F4B7C906FB04E87EE16F5 /* EPUBTextStripper.swift */; }; C65D7B190AC53E15002CBF0C /* TombstoneStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E577DAF65D34544D713137 /* TombstoneStoreTests.swift */; }; C68AC74F688C3FE8CD3546F0 /* SyncTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EFFD5584526A6602FEE2E1 /* SyncTestHelpers.swift */; }; C6D9CCA699EBFFBE7A5479F1 /* LibraryTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B26374749D7626349FB9E0 /* LibraryTestHelpers.swift */; }; C72258E7A1E6477E89E27D6E /* ImportError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982822FD76DDFB1EEE150FF0 /* ImportError.swift */; }; C731DA5F2D3885D918F1640A /* EncodingDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A43A801A0876E2437CE63808 /* EncodingDetector.swift */; }; + C7963EB4DD0ACFD3A87D97CC /* AnnotationExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB17C932D5188B36F01F024A /* AnnotationExporter.swift */; }; + C81A31B52218576A75210100 /* TXTFileLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F7059169A9ADC70EE01420E /* TXTFileLoaderTests.swift */; }; + C81C50DAC16681379E93FC9D /* AIConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A57E82A7221B36240E501FD /* AIConfigurationTests.swift */; }; C89BA415E1DB53FD1AF65604 /* PDFIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15E22141BA785A9A62A4BE9A /* PDFIntegrationTests.swift */; }; + C8AC5FD914F26F89DA69AA8C /* AIChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 206F4BCC7294731E8A3A82DB /* AIChatViewModel.swift */; }; + C8B22FD567407EEA30EF3BE7 /* WebDAVClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC65A9901DDD4AC981E77E2 /* WebDAVClientTests.swift */; }; + F5C562AE59FD6A2B923524AA /* TTSHighlightCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA8BD6824D9945998A61EFB4 /* TTSHighlightCoordinator.swift */; }; + C941DFD16C7CE7CF5ACC770D /* TTSService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD36F5CC483659F962BFB3A /* TTSService.swift */; }; C9CBB4436EA4DDE7757EA3F4 /* TOCProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C65E5FD23C2800C87ADD82A /* TOCProvider.swift */; }; C9D2AE1A81222E67A05CA05C /* BackgroundIndexingCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0CDD04120BFF39B61E8418 /* BackgroundIndexingCoordinatorTests.swift */; }; + CA6CDE39A3E09EED5F14447A /* HTTPTTSProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B8412E88A8A21540AFCC27 /* HTTPTTSProviderTests.swift */; }; CB432F27C324A4EBC3D1F327 /* ReaderThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9081F5E7C359D5FB2661E7AC /* ReaderThemeTests.swift */; }; - CB432F27C324A4EBC3D1F328 /* TXTTextViewBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9081F5E7C359D5FB2661E7AD /* TXTTextViewBridgeTests.swift */; }; + CBF9C34C3E23A55FD82B726D /* PDFAnnotationBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B3A240BB6031B14144741FE /* PDFAnnotationBridgeTests.swift */; }; + CC46DEE722313420D6F150ED /* TXTAttributedStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77119C3681DB428BD5F1207C /* TXTAttributedStringBuilderTests.swift */; }; + CC5A3B33193938B59254FD8A /* PersistenceActor+Collections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00814B9C69A0E1190146095E /* PersistenceActor+Collections.swift */; }; CC774F69EFD8E1BD204DD515 /* AnnotationListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D5CDE195E585067DE4D6124 /* AnnotationListViewModelTests.swift */; }; + CCC8728E1750053B1F454A32 /* PageTurnAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D4D088D34AA8A5692A991B /* PageTurnAnimator.swift */; }; CD104D016ED7015A7F8B42C7 /* AIConsentManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD1AE78FBFF38B3616352FF /* AIConsentManagerTests.swift */; }; + CD75C2144A26B8307DFC1143 /* RuleEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E122212D54348149A32DC51B /* RuleEngine.swift */; }; CD8C26CB53B0BE57CE214F00 /* TXTBridgeOffsetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50B0954852C3621D008EE07 /* TXTBridgeOffsetTests.swift */; }; CD9751D76A3A9DBF872CA5D7 /* PersistenceActor+Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = E46A786B20AA87E763D00F45 /* PersistenceActor+Library.swift */; }; CE3D74414B1983EB35589EFD /* TypographySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19522F65C0947EFDCF9E4D2B /* TypographySettings.swift */; }; + CF68A850F824408573A71BF8 /* ContentReplacementRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A53D87F16774F64922C855A /* ContentReplacementRule.swift */; }; CFF2F7127E363B96F2B6429B /* LibraryBookItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E3D2050D82A39083191EDDA /* LibraryBookItem.swift */; }; + D06CC001A1B2C3D4E5F60001 /* ChapterCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06CC002A1B2C3D4E5F60002 /* ChapterCache.swift */; }; + D06CC003A1B2C3D4E5F60003 /* ChapterCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06CC004A1B2C3D4E5F60004 /* ChapterCacheTests.swift */; }; D08EB57C5EBC9C9542476015 /* MetadataExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21BC5F159064D57CFAE2A676 /* MetadataExtractorTests.swift */; }; D0E3C146DC0F8D0978B419E7 /* AIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C4804D4D5F435DF10014C5 /* AIError.swift */; }; + D0FB5FB63B24803C9ADA5E1A /* EPUBHighlightBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5EC86BB06D46DC9A5A4F6B /* EPUBHighlightBridgeTests.swift */; }; + D19881EF60E15DFDCFF74173 /* EPUBComplexityClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC42F137A3776DEED18D9044 /* EPUBComplexityClassifierTests.swift */; }; + D1F6F2B6287F6C78E947FFAE /* BilingualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8781075EA7AF25572A741C40 /* BilingualView.swift */; }; + D2997E6E5680E58471DAA777 /* NoOpPersistenceStores.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D45B144AFD2D20CAEACC48 /* NoOpPersistenceStores.swift */; }; D32E57C07E8D41055084129C /* AnnotationNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF63E3EE60CC06C5650C3AD /* AnnotationNote.swift */; }; D36EEE178AE4BC41B7B4C650 /* FileAvailabilityBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B4B7084FA4C129303F52CB8 /* FileAvailabilityBadge.swift */; }; D4332566CDFE7329E3709381 /* EPUBWebViewBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E536B950D178C97842DF52 /* EPUBWebViewBridge.swift */; }; + D451E5FB27521C48F0A54FEA /* PersistenceActor+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96FDA9A40CE891B901F1A28F /* PersistenceActor+Stats.swift */; }; D49983BE0A1E6A803BF58B2E /* ReadingStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A12A0D94CF17D48152929F0 /* ReadingStats.swift */; }; - D6E7F8091A2B3C4D5E6F7A8B /* AddNoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* AddNoteSheet.swift */; }; + D4BE2B581F2B7372BA6390AF /* chapter_list.html in Resources */ = {isa = PBXBuildFile; fileRef = 2026D64F5931E86FF0E34945 /* chapter_list.html */; }; + D4DBF7581572109E9CA3D5AB /* SimpTradTransformTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C34F8D2600AE19D9C4F2E44 /* SimpTradTransformTests.swift */; }; + D5062FEAC5E9C9FB902C5BCC /* SimpTradTransform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82775D9CFBD2C2E05C770BB9 /* SimpTradTransform.swift */; }; D71DF2EA214C3E5B86EB04BD /* GlobalAccessibilityAuditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF40785A923791B0241CF75 /* GlobalAccessibilityAuditTests.swift */; }; D80F7202019D31765C8708B2 /* binary_masquerade.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6782FA6981AE8309748D8E5D /* binary_masquerade.txt */; }; - D8E9F01A2B3C4D5E6F708192 /* MDTextExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D8E9F01A2B3C4D5E6F7081 /* MDTextExtractorTests.swift */; }; - DA1E49AE4E12924D8E4503DE /* SwiftDataSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54B02A280C0D89C54FB9967 /* SwiftDataSessionStore.swift */; }; + D81A72060F55E3808BA4992D /* WebPageEncodingDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876AE3BC21A748FDD619EED2 /* WebPageEncodingDetectorTests.swift */; }; DB49A43B0C365D8308D5D1BB /* TokenSpan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686E0EE508E85349AED791BE /* TokenSpan.swift */; }; + DC206C60545521442E0326E3 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97894D9D7A3FC8227521C6E /* MockURLSession.swift */; }; + DCB3DF75803B93E9AFB05F1D /* FormatCapabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050AAFD290B8995258D78AC2 /* FormatCapabilities.swift */; }; + DD1020F3F0A2D5BA7FFCB279 /* UnifiedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0753D8C40DFE06B0323EE5B /* UnifiedScrollView.swift */; }; DD273808CA81E259EAC3F7C5 /* LocatorRestorerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10A08E980C92248337462DF /* LocatorRestorerTests.swift */; }; + DD711E614DD2743B1CB0088E /* BookmarkFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C85205257E8103AA80C08BAB /* BookmarkFeedbackTests.swift */; }; + DE92D6FD10FC38811A32D3F5 /* PageTurnAnimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB7EBD49DAE8D5F5BC4C7207 /* PageTurnAnimatorTests.swift */; }; + DEA5FF1E01245842DB54B8D7 /* MarkdownExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB0A4899D0F08DEDB20D068C /* MarkdownExportTests.swift */; }; DF36D73AC9B53845BF561CBC /* BookImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5160D7D68BF1AF6654AD08B6 /* BookImporter.swift */; }; + 6F32A23CC48EDCE28C65E792 /* TTSHighlightCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D450ACF0A0F47929E5916B26 /* TTSHighlightCoordinatorTests.swift */; }; + DF587005A7C4257AD28C42A0 /* TTSServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A2FE9743AF484093A21969 /* TTSServiceTests.swift */; }; E03FE4F3997CC67281E84F1E /* ReadingSessionTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41D94EE13466B0286DEA2EA7 /* ReadingSessionTracker.swift */; }; E057330B701161FF1AD06DB4 /* MockAnnotationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E10DBA312E3C1934B36E63 /* MockAnnotationStore.swift */; }; + E05A4A7DA74E241520EB2F0E /* TXTBridgeShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = A43C03327815457BD7B01409 /* TXTBridgeShared.swift */; }; E153024C836519993C468665 /* ZIPReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB65B98019C814421DDB0668 /* ZIPReaderTests.swift */; }; E1646FDF9A47255E94758A54 /* PersistenceActor+ReadingPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59ECD5BE8EE959A2EF3E208E /* PersistenceActor+ReadingPosition.swift */; }; + E1863B0320B22A4E53575FBD /* EPUBTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 533D2D4F8B5502E8D51A714E /* EPUBTextExtractor.swift */; }; E25C2E1B2AD3CB697D37166D /* MDParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC614F4D61859721C71EC447 /* MDParser.swift */; }; + E276113ABA6A03C93EE62848 /* TXTTextViewBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6E8611E23F1BE57E84E732 /* TXTTextViewBridgeTests.swift */; }; E32045817D5C8E858B7C4A30 /* AnnotationsPanelPlaceholderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34C87EDF46A4EFC99012268 /* AnnotationsPanelPlaceholderTests.swift */; }; E343C0F33B9E8DED665FAB5B /* ReaderSettingsThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0072BB2EF5A87447A6101A /* ReaderSettingsThemeTests.swift */; }; + E400E164FA809CC3AB0AC796 /* CollectionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B442FC7A6A6ED344AB5C1FC9 /* CollectionPersistenceTests.swift */; }; E44BC8CE480C18F6469C62DD /* WI9TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3CB57338FD24289DAC8ABE4 /* WI9TestHelpers.swift */; }; + E648A7D0DA601378CD08D521 /* LocatorNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7D86F739CC4D19AF019F728 /* LocatorNormalizer.swift */; }; + E6AD9376444C4D5EA1933701 /* legado_source_with_unknown_fields.json in Resources */ = {isa = PBXBuildFile; fileRef = FBCB98531364449BB44DDC00 /* legado_source_with_unknown_fields.json */; }; + E6D1587B7798C34CCA0E37F6 /* ReaderTOCBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2780A3796872F31F1666DA7 /* ReaderTOCBuilder.swift */; }; E7493CC6D24CE5FD1922F9D0 /* ErrorMessageAuditorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19AC5688AF504E6253728C73 /* ErrorMessageAuditorTests.swift */; }; E7CA24CCF3F1D348E85E0C37 /* PersistenceActor+Bookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 815A2F870C4D8EC102254ACC /* PersistenceActor+Bookmarks.swift */; }; E7EFBA25B7F2E347F466C5BF /* TXTTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DBCFDFD8D9A8634DDC1CCE2 /* TXTTextExtractor.swift */; }; @@ -306,67 +546,56 @@ E82E02103AE2B5E39F5F6C66 /* LibraryEmptyStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292131FE5DD09EAEDDD3191C /* LibraryEmptyStateTests.swift */; }; E8353217B517055A09014AE7 /* EPUBTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A457F48D22CD5B4952817701 /* EPUBTypes.swift */; }; E9177AEFF47DA3EFEAC13C50 /* TXTChunkedLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A069D350D0DC6B4C000E1D43 /* TXTChunkedLoaderTests.swift */; }; - E9F01A2B3C4D5E6F70819203 /* TXTTextExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01A2B3C4D5E6F7081920304 /* TXTTextExtractorTests.swift */; }; + E92CBF70F353E737625B557F /* AIRequestCacheKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15C1B40888B5C8213559B111 /* AIRequestCacheKeyTests.swift */; }; + E9AAB1E1CE8C74F9517CBEC3 /* SearchQueryExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69C7229E97D0F8B543683A02 /* SearchQueryExecutor.swift */; }; + E9FB5CFE42A28D671A7DE83A /* TXTTocRuleEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660A657BC4712219BAF7858C /* TXTTocRuleEngineTests.swift */; }; + EB3D180641036C8A6FA00030 /* EPUBParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0C66947C5376BF1D53A893 /* EPUBParserTests.swift */; }; EB3FFD36A03AA194F33246D4 /* LibraryBookItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770B26672B9E379E795E28E7 /* LibraryBookItemTests.swift */; }; + EB8290C909F46C2189E07D4B /* MDFileLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DEA283D3F2224EDC297B6E1 /* MDFileLoader.swift */; }; EBA5AEC161A4C9D055955BB4 /* LibraryViewModelImportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80ED96E0CBD0FFF1335B41D0 /* LibraryViewModelImportTests.swift */; }; + EBB6F00E3C34370C7C2CB369 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD065491E8CFE99188D62E09 /* ShareSheet.swift */; }; + EBF78E93F40871A091DC34B9 /* BookSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758C820FB0971EB4896ED735 /* BookSource.swift */; }; EC595D501E3CD9339A4A35AF /* PersistenceActor+Annotations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09C8CF05D0C61938AF454EDA /* PersistenceActor+Annotations.swift */; }; + EC94A16FA04EA4F5BBE10344 /* JSONExportFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A496DB7B37A1E68FA33AD5A3 /* JSONExportFormatter.swift */; }; + ED9650F4A937ED6D04E2E416 /* PDFHighlightIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B849723B3079FB8F3F4A7961 /* PDFHighlightIntegrationTests.swift */; }; + EE0F8A75700F581D5E2D1F3E /* ReaderNotificationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A579625590B25F81679F1EA0 /* ReaderNotificationModifier.swift */; }; + 2A4AB6F5EDB24297F77218DB /* TextReaderUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F7EC017FF822492219E162 /* TextReaderUIState.swift */; }; + EE8189DAD855D15887FBBCFA /* EPUBHighlightJS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44423E8976A2B27C4B14617F /* EPUBHighlightJS.swift */; }; EE83F6042EB13348B75C8BF0 /* AIConsentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B5AACE9B2A2A6B7943E4C64 /* AIConsentViewTests.swift */; }; EF1F3F42EE0664A058F2304D /* ReadingPositionPersisting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF764016CF051DDD94C586F /* ReadingPositionPersisting.swift */; }; F10FCB9E3EC6862A640BD406 /* VReaderApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605C9BAEA41B7C63D7E343B1 /* VReaderApp.swift */; }; - F1A2B3C4D5E6F7081234ABCD /* TXTServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F70812340001 /* TXTServiceTests.swift */; }; - F2B3C4D5E6F70819203142A3 /* EPUBTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2B3C4D5E6F70819203142 /* EPUBTextExtractor.swift */; }; + F14370F35DEFDB725452B5CA /* SearchWiringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C47B0077BE4937C424FFBD9 /* SearchWiringTests.swift */; }; + F14A4E744781186C72D46349 /* CustomCoverStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CB381B25E50D44505CAAB3 /* CustomCoverStore.swift */; }; + F2BD86F42ADB5A9CAF16B57A /* ReaderAICoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14088AEC6B083B2C049AB25C /* ReaderAICoordinator.swift */; }; F333DF8A66B96E51CC5CF97B /* AlertDialogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667AC3E733DFC3883BC89D39 /* AlertDialogTests.swift */; }; + F3958B26AAD50F2152E03AEB /* TTSControlBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F63868C11B04D324F09751 /* TTSControlBar.swift */; }; + F51F7B9360A990E857FE1373 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6686ECBBE526053A51CBA2 /* ChatMessage.swift */; }; F5A31837AE39AA372B31F1B5 /* LocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E8B92C301E5470AB98C87E /* LocatorTests.swift */; }; + 12EEEB491529363AA73948A6 /* foliate-bridge.js in Resources */ = {isa = PBXBuildFile; fileRef = 4715C71E34A3BC44C7D5D64F /* foliate-bridge.js */; }; F78B9D218FBB628F31479271 /* EPUBParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E742DD046F5CE970132E0C /* EPUBParser.swift */; }; + F7AD55517859473DA4E4F4D5 /* legado_multiple_sources.json in Resources */ = {isa = PBXBuildFile; fileRef = F1D3CAC117B849EE88AE0A5F /* legado_multiple_sources.json */; }; + F7D4BCC9E389D8F7956277AA /* TXTTextChunker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B189F7F32FF82BCF254923 /* TXTTextChunker.swift */; }; F827253851032F8D00E6A423 /* TOCListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE32E178C19442D8D52EBAC5 /* TOCListView.swift */; }; - F8E7D6C5B4A3210987654321 /* EPUBParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6789012345678 /* EPUBParserTests.swift */; }; + F97F59A8465B2ABA54B91E27 /* UnifiedPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34E23954103D83A7E25CC4A4 /* UnifiedPlaceholderView.swift */; }; + F9D194A8FDD4F8155C3D6CD6 /* HTTPTTSConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = E756C3235433C7EFC0BBB972 /* HTTPTTSConfig.swift */; }; + FA8BF5E0D98277BECAFB70CA /* HapticFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92676552DDABC9E3D5E7DC76 /* HapticFeedback.swift */; }; + FAD9F46E8B9BD3E87FB68287 /* RuleEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB007C93A274D2EB4EF7A3B9 /* RuleEngineTests.swift */; }; FB0BC111F33D81D4E93A031F /* HighlightListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275DFDD33FCF69E75F251F27 /* HighlightListViewModelTests.swift */; }; FBE9680C2EE09F4F1936BC5C /* PDFReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D54AC9AD2556A67C96BD52 /* PDFReaderViewModel.swift */; }; + FC95F46AC509C84F71B119DD /* BookSourceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97BCD7E2CCDE30EA6372D6C /* BookSourceListView.swift */; }; FD253FA0CEB159E2B1299BD4 /* SchemaV1Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2EA03BEFDBB65F5D7533EDE /* SchemaV1Tests.swift */; }; + FD88118ABE86FDFBA67DFF50 /* PDFPageNavigatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C39119533D269F76F970FD1 /* PDFPageNavigatorTests.swift */; }; FD9B24BBE1D852DA18A23E6F /* AITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C22F30DF9F05C20CF8DDBC /* AITypes.swift */; }; FE244DEB01C2A5C716D1B5C7 /* LibraryDynamicTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A77D3287AEC40129E6AA379F /* LibraryDynamicTypeTests.swift */; }; - SPIKEC001A1B2C3D4E5F60001 /* AnnotationAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPIKEC002A1B2C3D4E5F60002 /* AnnotationAnchor.swift */; }; - SPIKEC003A1B2C3D4E5F60003 /* SchemaV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPIKEC004A1B2C3D4E5F60004 /* SchemaV2.swift */; }; - SPIKEC005A1B2C3D4E5F60005 /* V1toV2Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPIKEC006A1B2C3D4E5F60006 /* V1toV2Migration.swift */; }; - SPIKEC007A1B2C3D4E5F60007 /* AnnotationAnchorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPIKEC008A1B2C3D4E5F60008 /* AnnotationAnchorTests.swift */; }; - 58CD1569690CE7C356C1EB08 /* HighlightAnchorStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A794708164DDDF9736D5FE7 /* HighlightAnchorStorageTests.swift */; }; - SPIKEC009A1B2C3D4E5F60009 /* V1toV2MigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPIKEC00AA1B2C3D4E5F6000A /* V1toV2MigrationTests.swift */; }; - SPIKEC00BA1B2C3D4E5F6000B /* ReaderSelectionEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPIKEC00CA1B2C3D4E5F6000C /* ReaderSelectionEventTests.swift */; }; - SPIKEC00DA1B2C3D4E5F6000D /* HighlightRecordAnchorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPIKEC00EA1B2C3D4E5F6000E /* HighlightRecordAnchorTests.swift */; }; - SPIKEC00FA1B2C3D4E5F6000F /* HighlightDedupeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WIC00F01A1B2C3D4E5F60010 /* HighlightDedupeTests.swift */; }; - WI008001A1B2C3D4E5F60001 /* PDFAnnotationBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI008002A1B2C3D4E5F60002 /* PDFAnnotationBridge.swift */; }; - WI008003A1B2C3D4E5F60003 /* PDFAnnotationBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI008004A1B2C3D4E5F60004 /* PDFAnnotationBridgeTests.swift */; }; - WI008005A1B2C3D4E5F60005 /* PDFHighlightIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI008006A1B2C3D4E5F60006 /* PDFHighlightIntegrationTests.swift */; }; - WI06000001A1B2C3D4E5F601 /* FileSizeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI06000002A1B2C3D4E5F602 /* FileSizeFormatter.swift */; }; - WI06000003A1B2C3D4E5F603 /* BookInfoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI06000004A1B2C3D4E5F604 /* BookInfoSheet.swift */; }; - WI06000005A1B2C3D4E5F605 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI06000006A1B2C3D4E5F606 /* ShareSheet.swift */; }; - WI06000007A1B2C3D4E5F607 /* LibraryContextMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI06000008A1B2C3D4E5F608 /* LibraryContextMenuTests.swift */; }; - WI07000001A1B2C3D4E5F601 /* EPUBHighlightBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI07000002A1B2C3D4E5F602 /* EPUBHighlightBridge.swift */; }; - WI07000003A1B2C3D4E5F603 /* EPUBHighlightBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI07000004A1B2C3D4E5F604 /* EPUBHighlightBridgeTests.swift */; }; - WI07000005A1B2C3D4E5F605 /* EPUBHighlightJS.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI07000006A1B2C3D4E5F606 /* EPUBHighlightJS.swift */; }; - WI07000007A1B2C3D4E5F607 /* EPUBHighlightActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI07000008A1B2C3D4E5F608 /* EPUBHighlightActions.swift */; }; - WI07000009A1B2C3D4E5F609 /* EPUBHighlightActionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI0700000AA1B2C3D4E5F60A /* EPUBHighlightActionsTests.swift */; }; - WI09000002A1B2C3D4E5F602 /* AISettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI09000001A1B2C3D4E5F601 /* AISettingsViewModel.swift */; }; - WI09000004A1B2C3D4E5F604 /* AISettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI09000003A1B2C3D4E5F603 /* AISettingsSection.swift */; }; - WI09000006A1B2C3D4E5F606 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI09000005A1B2C3D4E5F605 /* SettingsView.swift */; }; - WI09000008A1B2C3D4E5F608 /* AISettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI09000007A1B2C3D4E5F607 /* AISettingsViewModelTests.swift */; }; - WI10000002A1B2C3D4E5F602 /* AIReaderAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI10000001A1B2C3D4E5F601 /* AIReaderAvailability.swift */; }; - WI10000004A1B2C3D4E5F604 /* AIReaderPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI10000003A1B2C3D4E5F603 /* AIReaderPanel.swift */; }; - WI10000006A1B2C3D4E5F606 /* AIReaderIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI10000005A1B2C3D4E5F605 /* AIReaderIntegrationTests.swift */; }; - WI11000002A1B2C3D4E5F602 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI11000001A1B2C3D4E5F601 /* ChatMessage.swift */; }; - WI11000004A1B2C3D4E5F604 /* AIChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI11000003A1B2C3D4E5F603 /* AIChatViewModel.swift */; }; - WI11000006A1B2C3D4E5F606 /* AIChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI11000005A1B2C3D4E5F605 /* AIChatView.swift */; }; - WI11000008A1B2C3D4E5F608 /* AIChatViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI11000007A1B2C3D4E5F607 /* AIChatViewModelTests.swift */; }; - WI12000002A1B2C3D4E5F602 /* AITranslationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI12000001A1B2C3D4E5F601 /* AITranslationViewModel.swift */; }; - WI12000004A1B2C3D4E5F604 /* BilingualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI12000003A1B2C3D4E5F603 /* BilingualView.swift */; }; - WI12000006A1B2C3D4E5F606 /* TranslationPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI12000005A1B2C3D4E5F605 /* TranslationPanel.swift */; }; - WI12000008A1B2C3D4E5F608 /* AITranslationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI12000007A1B2C3D4E5F607 /* AITranslationTests.swift */; }; - WI13000002A1B2C3D4E5F602 /* AIChatGeneralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WI13000001A1B2C3D4E5F601 /* AIChatGeneralTests.swift */; }; - WID00002A1B2C3D4E5F60002 /* AIConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = WID00001A1B2C3D4E5F60001 /* AIConfiguration.swift */; }; - WID00004A1B2C3D4E5F60004 /* AIConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = WID00003A1B2C3D4E5F60003 /* AIConfigurationStore.swift */; }; - WID00006A1B2C3D4E5F60006 /* AIConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WID00005A1B2C3D4E5F60005 /* AIConfigurationTests.swift */; }; - WID00008A1B2C3D4E5F60008 /* AIRequestCacheKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WID00007A1B2C3D4E5F60007 /* AIRequestCacheKeyTests.swift */; }; - WID0000AA1B2C3D4E5F6000A /* PreferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = WID00009A1B2C3D4E5F60009 /* PreferenceStore.swift */; }; + FF584A12D5AA96B75F56AE9A /* VReaderAnnotationParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B2950791C297B18BBEC3E3 /* VReaderAnnotationParserTests.swift */; }; + U06TDVSBRAE1I2AK8NNRU71N /* BookContentCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D696HR9SIOM4CX0BL1GL91WK /* BookContentCacheTests.swift */; }; + 856D81463684114E1539B9D5 /* ReaderUnifiedDispatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805C79B33D1268AF7EA5CDBA /* ReaderUnifiedDispatch.swift */; }; + 418398FD232E9200A670F8F1 /* ReaderContainerView+Sheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 784DE4C3D42697A49F4D0DBC /* ReaderContainerView+Sheets.swift */; }; + 5533CCA4BB83C3FF5F5D5CB8 /* EPUBReaderContainerView+Highlights.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531EED0BA29E12B1336BF72A /* EPUBReaderContainerView+Highlights.swift */; }; + 824329E82E030710D3EE118B /* EPUBReaderContainerView+Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BDD567DDFEEAFDE5C1EC9AF /* EPUBReaderContainerView+Navigation.swift */; }; + BF550669B1E043DA10B9D266 /* TXTReaderContainerView+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3A1586B92D00DD884FA841 /* TXTReaderContainerView+Helpers.swift */; }; + C7C83E0CEB7EC5AA560F15C2 /* PDFReaderContainerView+Highlights.swift in Sources */ = {isa = PBXBuildFile; fileRef = F838BD23B13BE1FE9CD3E32F /* PDFReaderContainerView+Highlights.swift */; }; + AE4BEDEFF56940830655FAC2 /* PDFReaderContainerView+Overlays.swift in Sources */ = {isa = PBXBuildFile; fileRef = 073E28FACD8057856C6723D4 /* PDFReaderContainerView+Overlays.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -388,66 +617,129 @@ /* Begin PBXFileReference section */ 0069CA3A00998B13DE06E424 /* LocatorIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorIntegrationTests.swift; sourceTree = ""; }; + 00814B9C69A0E1190146095E /* PersistenceActor+Collections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceActor+Collections.swift"; sourceTree = ""; }; + 00B189F7F32FF82BCF254923 /* TXTTextChunker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextChunker.swift; sourceTree = ""; }; 00FE0912FBC85E22DF8C637F /* ReadingTimeFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingTimeFormatter.swift; sourceTree = ""; }; - 01B2C3D4E5F60718AABB0002 /* TXTAttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTAttributedStringBuilder.swift; sourceTree = ""; }; - 01B2C3D4E5F60718AABB0004 /* TXTAttributedStringBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTAttributedStringBuilderTests.swift; sourceTree = ""; }; 01E72DDEAD6225A21E973A51 /* SyncTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTypes.swift; sourceTree = ""; }; + 02DE298149C25261E2ECADA1 /* ReaderThemeCSSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderThemeCSSTests.swift; sourceTree = ""; }; + 03F1597A24B4874C1AD29043 /* WebDAVProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDAVProvider.swift; sourceTree = ""; }; 03FA3AC72012ED6686293475 /* ReadingStatsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingStatsTests.swift; sourceTree = ""; }; 0459CAA7394555E5A8E17146 /* ReaderUnsupportedFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderUnsupportedFormatTests.swift; sourceTree = ""; }; + 04C8EE35219F3BAFBDA81AAE /* VReaderAnnotationParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VReaderAnnotationParser.swift; sourceTree = ""; }; + 050AAFD290B8995258D78AC2 /* FormatCapabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatCapabilities.swift; sourceTree = ""; }; 055FBEA382F82BE342A50C8E /* LocatorFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorFactoryTests.swift; sourceTree = ""; }; 0616892213196BCF802266F8 /* ContentHasherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentHasherTests.swift; sourceTree = ""; }; 06EFFD5584526A6602FEE2E1 /* SyncTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTestHelpers.swift; sourceTree = ""; }; 08710FD2531ABF39338B56E9 /* SearchSheetPlaceholderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSheetPlaceholderTests.swift; sourceTree = ""; }; + 08B42D93C357CAAFB4261D93 /* TXTFileLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTFileLoader.swift; sourceTree = ""; }; + 0965D8213AFBB980604A7592 /* WebDAVSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDAVSettingsView.swift; sourceTree = ""; }; + 098863E9E4647225E16F2E41 /* CollectionSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionSidebar.swift; sourceTree = ""; }; 09C8CF05D0C61938AF454EDA /* PersistenceActor+Annotations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceActor+Annotations.swift"; sourceTree = ""; }; 0A2A16647F198641F11AC9C1 /* WCAGContrastTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WCAGContrastTests.swift; sourceTree = ""; }; + 0C47B0077BE4937C424FFBD9 /* SearchWiringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchWiringTests.swift; sourceTree = ""; }; + 0C49D770261B82856A40938B /* BookSourceHTTPClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceHTTPClientTests.swift; sourceTree = ""; }; + 0DFC3C0DD795D886D77A6881 /* MDTextExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDTextExtractorTests.swift; sourceTree = ""; }; 0E394527455B13D1EE9B06A9 /* MigrationFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationFixtures.swift; sourceTree = ""; }; + 0F64D33F5E4EB6B2F8F10CC8 /* OPDSCatalogListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSCatalogListView.swift; sourceTree = ""; }; + 0F7059169A9ADC70EE01420E /* TXTFileLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTFileLoaderTests.swift; sourceTree = ""; }; + 0F99442B3BA1E02CC3F2A2C1 /* ReaderPositionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPositionService.swift; sourceTree = ""; }; + 0FF60B935D0DF315B8705C2B /* PaginationCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationCacheTests.swift; sourceTree = ""; }; + 103B5BF35C8DC9FB2BE4998F /* PDFPageNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFPageNavigator.swift; sourceTree = ""; }; 10F8EE6C68FBB40F0A229AC0 /* utf16le_bom.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = utf16le_bom.txt; sourceTree = ""; }; 117731FF4D8AE414141C5ECF /* ImportJobQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportJobQueue.swift; sourceTree = ""; }; + 11D4AD419DD1123CDA21CCD0 /* ReflowableTextSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflowableTextSource.swift; sourceTree = ""; }; 11F6250C22449E7E83591620 /* SyncStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusView.swift; sourceTree = ""; }; + 138AEC5BBAD52B075096E5C8 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 13A1A428419630E618721E37 /* UnifiedTextRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedTextRenderer.swift; sourceTree = ""; }; + 14088AEC6B083B2C049AB25C /* ReaderAICoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAICoordinator.swift; sourceTree = ""; }; + 14418F18DF9D9A3B912AC090 /* SearchIndexCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchIndexCore.swift; sourceTree = ""; }; + 15C1B40888B5C8213559B111 /* AIRequestCacheKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIRequestCacheKeyTests.swift; sourceTree = ""; }; 15E22141BA785A9A62A4BE9A /* PDFIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFIntegrationTests.swift; sourceTree = ""; }; + 16E293CFD61A19BB48B38963 /* HighlightableTextViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightableTextViewTests.swift; sourceTree = ""; }; 17E7FD8CD67F19A2213DB6F5 /* PDFViewBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFViewBridge.swift; sourceTree = ""; }; 19522F65C0947EFDCF9E4D2B /* TypographySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypographySettings.swift; sourceTree = ""; }; 19AC5688AF504E6253728C73 /* ErrorMessageAuditorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageAuditorTests.swift; sourceTree = ""; }; 19EA79EB5577BF31A4096B39 /* NoOpSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoOpSessionStore.swift; sourceTree = ""; }; + 1A49E33ACBED018932A38F0C /* AddNoteSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddNoteSheet.swift; sourceTree = ""; }; + 1AE71C3AADFD978879F217BF /* WebDAVClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDAVClient.swift; sourceTree = ""; }; + 1B2B480AC630357CC08475F4 /* ReadingMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingMode.swift; sourceTree = ""; }; + 1B311C3A88BD6DC42005FE1B /* ExportedAnnotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportedAnnotation.swift; sourceTree = ""; }; + 1CF348591A8246AA1524CD10 /* EPUBLayoutPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBLayoutPreference.swift; sourceTree = ""; }; + 1D579834E6924BB521873C38 /* FormatCapabilitiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatCapabilitiesTests.swift; sourceTree = ""; }; 1F0072BB2EF5A87447A6101A /* ReaderSettingsThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSettingsThemeTests.swift; sourceTree = ""; }; 1F9542255A6791C9BCB034DB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 1FD65C0547DF264510657CA2 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 1FDACAD61BCFC3618CD18675 /* ReplacementRulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplacementRulesView.swift; sourceTree = ""; }; 20237121BB4ACF22C0818BA4 /* AIContextExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIContextExtractorTests.swift; sourceTree = ""; }; + 2026D64F5931E86FF0E34945 /* chapter_list.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = chapter_list.html; sourceTree = ""; }; + 206F4BCC7294731E8A3A82DB /* AIChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatViewModel.swift; sourceTree = ""; }; 20B35AC90256B2F4A696E3D2 /* AIAssistantStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAssistantStateTests.swift; sourceTree = ""; }; + 20EBB13D56BE43A552188D9F /* TapZoneConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapZoneConfig.swift; sourceTree = ""; }; + 21055A4DC487F0F56BA7C475 /* ThemeBackgroundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeBackgroundTests.swift; sourceTree = ""; }; + 21B3F47E988913B477EACF93 /* TranslationPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationPanel.swift; sourceTree = ""; }; 21BC5F159064D57CFAE2A676 /* MetadataExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataExtractorTests.swift; sourceTree = ""; }; + 22C20D015AD61E29BEB57DC3 /* LocatorNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorNormalizerTests.swift; sourceTree = ""; }; 22F84672A6E2EDD6E037AFD8 /* ImportSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportSource.swift; sourceTree = ""; }; 2355F0CDCE9B874D6BD148FB /* MockPositionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPositionStore.swift; sourceTree = ""; }; 23A84FE014139E7B5524445D /* BookRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookRowView.swift; sourceTree = ""; }; 24D988A4CE41E94A8A9280CC /* HighlightPersisting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightPersisting.swift; sourceTree = ""; }; 2556DC1CBB43434072B19479 /* AnnotationPersisting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationPersisting.swift; sourceTree = ""; }; + 25642951BDB16B5C59BF39CF /* ModeSwitchPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeSwitchPersistenceTests.swift; sourceTree = ""; }; 256C28A508EAD4DB73B49DD4 /* BookmarkListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListViewModelTests.swift; sourceTree = ""; }; + 25AC1B3E1E87D71E229C3EF6 /* AISettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsSection.swift; sourceTree = ""; }; + 271BAF9BD03F619061BA4D96 /* TapZoneOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapZoneOverlay.swift; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F2 /* ReaderChromeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderChromeBar.swift; sourceTree = ""; }; + + 272182B2C311AE5E968D314C /* PerBookSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerBookSettingsTests.swift; sourceTree = ""; }; + 27260FA0FED437E1EB06E0CD /* HTTPTTSSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTTSSettingsView.swift; sourceTree = ""; }; 275DFDD33FCF69E75F251F27 /* HighlightListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightListViewModelTests.swift; sourceTree = ""; }; 27C13EA240083857EA527F89 /* FileAvailabilityStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAvailabilityStateMachine.swift; sourceTree = ""; }; + 280FCCEE99306FEA6479845B /* BookSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceTests.swift; sourceTree = ""; }; 28354051023D618CC3CAE2E2 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 292131FE5DD09EAEDDD3191C /* LibraryEmptyStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryEmptyStateTests.swift; sourceTree = ""; }; + 292EB76312E500AFAC065E1F /* PreferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceStore.swift; sourceTree = ""; }; + 295669F5B358B6C7BE41952E /* NativeTextPaginatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextPaginatorTests.swift; sourceTree = ""; }; 2A513D5E8C4467B8FE45E0AC /* AnnotationRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationRecord.swift; sourceTree = ""; }; + 2B7C59839A119870BE9B6FF9 /* ReaderSelectionEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSelectionEventTests.swift; sourceTree = ""; }; 2BE1E995F0C1A8A64CF95A99 /* DocumentFingerprintTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFingerprintTests.swift; sourceTree = ""; }; 2C483C40C61CC5C3F7B66030 /* EncodingFixtureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncodingFixtureTests.swift; sourceTree = ""; }; + 2C7A2880FF5B321684FE712F /* PerBookSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerBookSettings.swift; sourceTree = ""; }; 2CB31146A831DACE67C50F08 /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = ""; }; 2D7D1FCE2E15F04329AB1978 /* ReaderSearchSheetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSearchSheetTests.swift; sourceTree = ""; }; 2ED1C14FB27E5FF826BA26AF /* vreaderUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = vreaderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2F317CDBF13A6A5673D64A8A /* TextKit2Paginator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextKit2Paginator.swift; sourceTree = ""; }; 30D00221E111683E9FF0260A /* SearchTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTextExtractor.swift; sourceTree = ""; }; 32DF5DC258E6460D4FE84706 /* SearchServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchServiceTests.swift; sourceTree = ""; }; 32F75167F586CEA5F4E9002C /* ReaderSettingsPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSettingsPanel.swift; sourceTree = ""; }; + 3323FF67F207437A97D5AB8C /* legado_single_source.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = legado_single_source.json; sourceTree = ""; }; 334D6D06A294323E149970E4 /* AIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIService.swift; sourceTree = ""; }; 3354A74B809D0D3DB13A41F7 /* ReaderSettingsTypographyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSettingsTypographyTests.swift; sourceTree = ""; }; 336495F8165F79A364CE9B09 /* AccessibilityFormattersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityFormattersTests.swift; sourceTree = ""; }; + 33CA445AA96E5EEBA05B7C36 /* OPDSModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSModels.swift; sourceTree = ""; }; + 34E23954103D83A7E25CC4A4 /* UnifiedPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedPlaceholderView.swift; sourceTree = ""; }; + 3629EA1FD0AAF0E1E903AC4E /* ReaderPositionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPositionServiceTests.swift; sourceTree = ""; }; 36686A80222AD7613951C900 /* MDAttributedStringRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDAttributedStringRenderer.swift; sourceTree = ""; }; + 3753D7CD01EA589932DF780C /* ThemeBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeBackgroundView.swift; sourceTree = ""; }; 37DF69361FD0FBED7294C43E /* ImportProvenance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportProvenance.swift; sourceTree = ""; }; + 385FF266F3625A73EC23C8BF /* HTTPTTSConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTTSConfigTests.swift; sourceTree = ""; }; + 386F15A3CC3059EC5B4E3872 /* CSSRuleEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSRuleEvaluatorTests.swift; sourceTree = ""; }; 38A104E5CBC93D0266E6C21E /* ReadingSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingSession.swift; sourceTree = ""; }; + 38E51E6D76FE0DDF87E537AB /* NativeTextPaginator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextPaginator.swift; sourceTree = ""; }; 398F1BAF549E7AC80E9CE320 /* ReadingSessionTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingSessionTrackerTests.swift; sourceTree = ""; }; + 3A13D6063551337AC540840D /* BookSourceReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceReaderView.swift; sourceTree = ""; }; 3B1563E77E28434568F45736 /* LibraryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModelTests.swift; sourceTree = ""; }; 3BAA1482C17A94A21E44EFEB /* BackgroundIndexingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIndexingCoordinator.swift; sourceTree = ""; }; + 3C39119533D269F76F970FD1 /* PDFPageNavigatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFPageNavigatorTests.swift; sourceTree = ""; }; 3C567EE93DC61BBB63CEAC20 /* Locator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locator.swift; sourceTree = ""; }; + 3D70FBE6B4F5F9DDBA035D25 /* SPIKE_RESULTS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SPIKE_RESULTS.md; sourceTree = ""; }; + 3DF47D926F78F13327F6770C /* OPDSParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSParser.swift; sourceTree = ""; }; + 3F47559B14E9DEE63DE613ED /* NativeTextPagedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextPagedView.swift; sourceTree = ""; }; 400D03ADE39639337E9993C5 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; 4078AAB96560B1BC1794472E /* DeleteConfirmationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteConfirmationTests.swift; sourceTree = ""; }; 40A2CD5F8AD4DEC1A14A255A /* SearchHitToLocatorResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHitToLocatorResolver.swift; sourceTree = ""; }; + 410123F7E79BF50A70C95A03 /* CSSRuleEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSRuleEvaluator.swift; sourceTree = ""; }; 41C3ECA5E8F6419DB347F2E4 /* BookFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookFormat.swift; sourceTree = ""; }; + 41D6AA10585EA3CE63143C50 /* BookSourceEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceEditorView.swift; sourceTree = ""; }; 41D94EE13466B0286DEA2EA7 /* ReadingSessionTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingSessionTracker.swift; sourceTree = ""; }; 41DD0013DEFFC287885D1A48 /* KeyboardInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardInteractionTests.swift; sourceTree = ""; }; 41F3E1A368720EFCB55A9582 /* SearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchService.swift; sourceTree = ""; }; @@ -456,199 +748,330 @@ 42C4804D4D5F435DF10014C5 /* AIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIError.swift; sourceTree = ""; }; 42CFB0E94DE1D37E0FD2741B /* AIContextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIContextExtractor.swift; sourceTree = ""; }; 43347208CA56C84F65B2DCF7 /* TXTTextViewBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextViewBridge.swift; sourceTree = ""; }; + 435C00E099B7F5D7A7821FDC /* EPUBHighlightBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightBridge.swift; sourceTree = ""; }; + 7A01B2C3D4E5F6A7B8C90011 /* TXTViewConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTViewConfig.swift; sourceTree = ""; }; + 7A01B2C3D4E5F6A7B8C90012 /* TXTTextViewBridgeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextViewBridgeCoordinator.swift; sourceTree = ""; }; + 7A01B2C3D4E5F6A7B8C90013 /* EPUBWebViewBridgeJS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBWebViewBridgeJS.swift; sourceTree = ""; }; + 7A01B2C3D4E5F6A7B8C90014 /* EPUBWebViewBridgeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBWebViewBridgeCoordinator.swift; sourceTree = ""; }; + 7A01B2C3D4E5F6A7B8C90015 /* TXTChunkedHighlightHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTChunkedHighlightHelper.swift; sourceTree = ""; }; 43D54AC9AD2556A67C96BD52 /* PDFReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFReaderViewModel.swift; sourceTree = ""; }; + 43DA904E79F5CF69E46ECC26 /* ReaderAuditFixTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAuditFixTests.swift; sourceTree = ""; }; 43EE33FE1EDFE9B393885110 /* FeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagsTests.swift; sourceTree = ""; }; + 44423E8976A2B27C4B14617F /* EPUBHighlightJS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightJS.swift; sourceTree = ""; }; 445A7C6C3D4466A57B29BDA8 /* ReaderSettingsSheetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSettingsSheetTests.swift; sourceTree = ""; }; + 456FDDA03D7DEF3A4AEB01DB /* TOCBuilderMDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOCBuilderMDTests.swift; sourceTree = ""; }; 4581A94B5099D15743DC02F3 /* ReaderTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTheme.swift; sourceTree = ""; }; 4598235F83FFA792CE2338F9 /* LibraryDarkModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryDarkModeTests.swift; sourceTree = ""; }; 459A646DA92A1898DF211A93 /* MockBookImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBookImporter.swift; sourceTree = ""; }; + 45B8412E88A8A21540AFCC27 /* HTTPTTSProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTTSProviderTests.swift; sourceTree = ""; }; 46221600B62F5482365B0484 /* AIAssistantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAssistantViewModel.swift; sourceTree = ""; }; + 46BCC142F6FF2ABE9B5BA64B /* PositionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionPersistenceTests.swift; sourceTree = ""; }; 46C22F30DF9F05C20CF8DDBC /* AITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AITypes.swift; sourceTree = ""; }; + 470BBE13ED7BCDD6E60D3400 /* SchemaV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaV3.swift; sourceTree = ""; }; + B1C2D3E4F5A6B7C8D9E0F1A2 /* SchemaV4.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaV4.swift; sourceTree = ""; }; + 47AA8588621686E377D9D496 /* TXTBridgeSharedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTBridgeSharedTests.swift; sourceTree = ""; }; 480E5268D197F33E8C0B6CFC /* TXTReaderPlaceholderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTReaderPlaceholderTests.swift; sourceTree = ""; }; 4846E32490F4D5FFC0A366EF /* ReaderAnnotationsPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationsPanelTests.swift; sourceTree = ""; }; + 493AACD57E158A3C3C6692B1 /* BookSourceRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceRules.swift; sourceTree = ""; }; + 49624FC3C8E1AC31E011351C /* TOCBuilderTXTTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOCBuilderTXTTests.swift; sourceTree = ""; }; + 4A3BC126A794F2C82F782E7D /* TXTTextChunkerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextChunkerTests.swift; sourceTree = ""; }; + 4A57E82A7221B36240E501FD /* AIConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConfigurationTests.swift; sourceTree = ""; }; 4A888610BD2499B817A278EB /* SearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRow.swift; sourceTree = ""; }; + 4AC68C4B5F57B57A98D3C020 /* TapZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapZoneTests.swift; sourceTree = ""; }; 4B0EDE73D4EB702686B1326E /* MDReaderContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDReaderContainerView.swift; sourceTree = ""; }; + 4B3A240BB6031B14144741FE /* PDFAnnotationBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFAnnotationBridgeTests.swift; sourceTree = ""; }; 4B81B909B41CB8C14B613D73 /* LocatorRestorer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorRestorer.swift; sourceTree = ""; }; + 4BC65A9901DDD4AC981E77E2 /* WebDAVClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDAVClientTests.swift; sourceTree = ""; }; + 4BD56854A37D8EA2B318B926 /* HighlightDedupeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDedupeTests.swift; sourceTree = ""; }; + 4C34F8D2600AE19D9C4F2E44 /* SimpTradTransformTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpTradTransformTests.swift; sourceTree = ""; }; 4C87C165AFE78571456C14D1 /* LocatorValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorValidationTests.swift; sourceTree = ""; }; + 4CC7EBBBE07E87F07CC0FE4F /* ReaderNotificationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderNotificationHandlerTests.swift; sourceTree = ""; }; + B7E3CF12A4D922873FC18E55 /* TextReaderUIStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextReaderUIStateTests.swift; sourceTree = ""; }; 4D5CDE195E585067DE4D6124 /* AnnotationListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationListViewModelTests.swift; sourceTree = ""; }; + 4D9501ED4FB49C6035FDF5BB /* MockBackupProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBackupProvider.swift; sourceTree = ""; }; 4DCA33DBE911B5B1B6425277 /* MDTypesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDTypesTests.swift; sourceTree = ""; }; 4E318ED286636BD43CD865D3 /* empty.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = empty.txt; sourceTree = ""; }; + 4E706779E64026004319957F /* AIReaderIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReaderIntegrationTests.swift; sourceTree = ""; }; 4E9F1953C017E6B1F990FB44 /* MockMDParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMDParser.swift; sourceTree = ""; }; + 4EA638212AA92F9FF855ABC2 /* JSONExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONExportTests.swift; sourceTree = ""; }; + 50AA77FDB19CB7EDA69418C8 /* ReaderUnifiedCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderUnifiedCoordinator.swift; sourceTree = ""; }; 513AE679E0A5DBFFBB0AF6BD /* MDReaderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDReaderViewModelTests.swift; sourceTree = ""; }; 5160D7D68BF1AF6654AD08B6 /* BookImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookImporter.swift; sourceTree = ""; }; 51935A413CAABF4DE2D3C488 /* AppConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigurationTests.swift; sourceTree = ""; }; + 521E495ED3C3F323D5488F1D /* TextMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMapper.swift; sourceTree = ""; }; + 533D2D4F8B5502E8D51A714E /* EPUBTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBTextExtractor.swift; sourceTree = ""; }; + 539FEE4A23AFA226048A12A4 /* AIChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatView.swift; sourceTree = ""; }; + 5401E10DDA195966ABD13F70 /* AutoPageTurner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPageTurner.swift; sourceTree = ""; }; + 544A2F3FF8BBB1C08DDCE02D /* PageNavigatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageNavigatorTests.swift; sourceTree = ""; }; + 5487566F93AB8376D1BD1F1B /* EPUBFileLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBFileLoaderTests.swift; sourceTree = ""; }; + 54EFBCCD0BA84516D28B0568 /* ReplacementTransformTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplacementTransformTests.swift; sourceTree = ""; }; + 54F63868C11B04D324F09751 /* TTSControlBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSControlBar.swift; sourceTree = ""; }; 5639E3F809343C8CE5D7A020 /* PDFPasswordTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFPasswordTests.swift; sourceTree = ""; }; + 56955DD1A478DEED93B590C9 /* search_results.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = search_results.html; sourceTree = ""; }; 576F111E93E863C656BDEC70 /* SearchViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModelTests.swift; sourceTree = ""; }; - 58456B0AB20C3D6AD39090A5 /* SwiftDataSessionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDataSessionStoreTests.swift; sourceTree = ""; }; 58E49BCEDC674BC5776103CE /* SearchTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenizer.swift; sourceTree = ""; }; 593A77413CD93AEE33F15156 /* BookImporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookImporting.swift; sourceTree = ""; }; + 5955766DF21883C0C57B71E4 /* CustomCoverStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCoverStoreTests.swift; sourceTree = ""; }; 59B2824739E67F567813198E /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; 59ECD5BE8EE959A2EF3E208E /* PersistenceActor+ReadingPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceActor+ReadingPosition.swift"; sourceTree = ""; }; + 5A53D87F16774F64922C855A /* ContentReplacementRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentReplacementRule.swift; sourceTree = ""; }; + 5A54A2C5DE8C1631C04BB2A1 /* PDFProgressHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFProgressHelper.swift; sourceTree = ""; }; + 5BB9773A4631BEDEDD187F08 /* AnnotationExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationExporterTests.swift; sourceTree = ""; }; + 5C0C66947C5376BF1D53A893 /* EPUBParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBParserTests.swift; sourceTree = ""; }; + 5C5EC86BB06D46DC9A5A4F6B /* EPUBHighlightBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightBridgeTests.swift; sourceTree = ""; }; 5C65E5FD23C2800C87ADD82A /* TOCProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOCProvider.swift; sourceTree = ""; }; 5C86F6A1C143DB6AC9187FC0 /* AnnotationListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationListView.swift; sourceTree = ""; }; 5D2BA1A05E4E36D5D7B2DCFD /* PDFReaderContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFReaderContainerView.swift; sourceTree = ""; }; + 5D3EF2FFB105C9E0DF1EDF51 /* SpeechSynthesizing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechSynthesizing.swift; sourceTree = ""; }; 5D7F4F4B985D58E03A78680D /* PDFReaderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFReaderViewModelTests.swift; sourceTree = ""; }; + 5D8A001ABD732F1E2FF24463 /* TXTTocRuleEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTocRuleEngine.swift; sourceTree = ""; }; 5DBCFDFD8D9A8634DDC1CCE2 /* TXTTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextExtractor.swift; sourceTree = ""; }; 5E1952532DDD6CD0938B0FCC /* HighlightListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightListView.swift; sourceTree = ""; }; + 5E270DE50F23F6125F3151CA /* PhaseBMediumAuditTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhaseBMediumAuditTests.swift; sourceTree = ""; }; 5E3D2050D82A39083191EDDA /* LibraryBookItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryBookItem.swift; sourceTree = ""; }; + 5EB15AD471389C6DEDDD0286 /* ReaderAuditFix3Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAuditFix3Tests.swift; sourceTree = ""; }; + 5EC76EA043F57DC3B5650C82 /* PROPFINDParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PROPFINDParser.swift; sourceTree = ""; }; + 5ED93A5DF68834883A508C16 /* AnnotationImportError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationImportError.swift; sourceTree = ""; }; + 5F90F835A126ABBB86752848 /* AIReaderAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReaderAvailability.swift; sourceTree = ""; }; 5FA7AE29709E497DBB6AF9DF /* TXTOffsetMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTOffsetMapperTests.swift; sourceTree = ""; }; 5FDB1CD71ED267C2FD85F325 /* MDMetadataExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDMetadataExtractorTests.swift; sourceTree = ""; }; 605C9BAEA41B7C63D7E343B1 /* VReaderApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VReaderApp.swift; sourceTree = ""; }; + 618A3B94C15C9AC8BC6C33C7 /* UpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateChecker.swift; sourceTree = ""; }; + 61B2950791C297B18BBEC3E3 /* VReaderAnnotationParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VReaderAnnotationParserTests.swift; sourceTree = ""; }; 61D944728B17A940A3716EA9 /* TokenSpanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenSpanTests.swift; sourceTree = ""; }; + 61E41805A45542E97AE415D9 /* WebDAVProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebDAVProviderTests.swift; sourceTree = ""; }; 61E8B92C301E5470AB98C87E /* LocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorTests.swift; sourceTree = ""; }; + 62569DC663E2BDD2DC0155C3 /* SearchHighlightDismissTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHighlightDismissTests.swift; sourceTree = ""; }; 62D7DA128D4B2AC1F362D571 /* LaunchHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchHelper.swift; sourceTree = ""; }; + 631D0375777D50E5B1EDF31C /* HighlightedSnippetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightedSnippetTests.swift; sourceTree = ""; }; 6362591D3F62B4BC84CE136A /* FileAvailabilityStateMachineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAvailabilityStateMachineTests.swift; sourceTree = ""; }; 637D35BECC7768038F474E5E /* FileAvailabilityBadgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAvailabilityBadgeTests.swift; sourceTree = ""; }; + 63E4737FA3A880C3CC2BA07D /* ReadingProgressBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBarTests.swift; sourceTree = ""; }; + 660A657BC4712219BAF7858C /* TXTTocRuleEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTocRuleEngineTests.swift; sourceTree = ""; }; 667AC3E733DFC3883BC89D39 /* AlertDialogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertDialogTests.swift; sourceTree = ""; }; 6782FA6981AE8309748D8E5D /* binary_masquerade.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = binary_masquerade.txt; sourceTree = ""; }; + 6783D1629C54A25FE65C8705 /* TTSProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSProviderProtocol.swift; sourceTree = ""; }; 67BCECCD4519E437A347DBA5 /* MDAttributedStringRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDAttributedStringRendererTests.swift; sourceTree = ""; }; 686E0EE508E85349AED791BE /* TokenSpan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenSpan.swift; sourceTree = ""; }; + 68A7FC6A70A060CD5E43602E /* ReadingProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBar.swift; sourceTree = ""; }; 68BB90FF5CA185660AAE66BD /* MDReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDReaderViewModel.swift; sourceTree = ""; }; + 69C7229E97D0F8B543683A02 /* SearchQueryExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQueryExecutor.swift; sourceTree = ""; }; + 6A3131E83F93590395D14009 /* UnifiedEPUBLoadResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedEPUBLoadResult.swift; sourceTree = ""; }; 6A5928551FF9FBB8D2E87E6F /* AIAssistantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAssistantView.swift; sourceTree = ""; }; + 6B04729AD87389C9ECEB13CE /* HighlightRecordAnchorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightRecordAnchorTests.swift; sourceTree = ""; }; + 6B393E54ECE17C3DA3969EA4 /* AnnotationAnchorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationAnchorTests.swift; sourceTree = ""; }; 6B5AACE9B2A2A6B7943E4C64 /* AIConsentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConsentViewTests.swift; sourceTree = ""; }; + 6B600146907F8D9159F2B777 /* chapter_list_page2.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = chapter_list_page2.html; sourceTree = ""; }; 6BEB6636BEBB84D528EE5DF5 /* BookCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookCardView.swift; sourceTree = ""; }; 6CFECBFD38A16DE449662507 /* MigrationFixtureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationFixtureTests.swift; sourceTree = ""; }; + 6DF113D7E83A1CE51F417258 /* AIReaderPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReaderPanel.swift; sourceTree = ""; }; 6E5D3F39FDBF4693D33D1BCB /* MDMetadataExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDMetadataExtractor.swift; sourceTree = ""; }; - 6ECD0FC78B12AED09BA2A1DF /* EPUBWebViewBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBWebViewBridgeTests.swift; sourceTree = ""; }; 6ED83105BFA1AA160A12D158 /* BookFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookFormatTests.swift; sourceTree = ""; }; + 6F3F2340D008C0F77D922091 /* CollectionTestHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionTestHelper.swift; sourceTree = ""; }; + 7008A22A4FBE1574E8B9C8A4 /* TXTStreamingOpenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTStreamingOpenTests.swift; sourceTree = ""; }; + 7024E7AEAC9AEAA028952C46 /* ScrollProgressHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollProgressHelper.swift; sourceTree = ""; }; + 7056486BA460E0BE06235F54 /* UnifiedTextRendererViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedTextRendererViewModel.swift; sourceTree = ""; }; 71DF9DC9E8F3CCA4BE321C47 /* VReaderUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VReaderUITests.swift; sourceTree = ""; }; + 7205862B286DDE2DD2233F6D /* TXTChunkedReaderBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTChunkedReaderBridge.swift; sourceTree = ""; }; 725F9036150B858160ACFF7B /* TXTReaderContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTReaderContainerView.swift; sourceTree = ""; }; 744CB6180C0CE88E75E124F3 /* ImportErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportErrorTests.swift; sourceTree = ""; }; + 758C820FB0971EB4896ED735 /* BookSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSource.swift; sourceTree = ""; }; 770B26672B9E379E795E28E7 /* LibraryBookItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryBookItemTests.swift; sourceTree = ""; }; + 77119C3681DB428BD5F1207C /* TXTAttributedStringBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTAttributedStringBuilderTests.swift; sourceTree = ""; }; + 775CED0704F1D6D39F873FF9 /* PDFProgressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFProgressTests.swift; sourceTree = ""; }; 77811B16F2CF741310C23CF5 /* EncodingDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncodingDetectorTests.swift; sourceTree = ""; }; + 78E21000C7B6029FCAD6E13E /* HTMLHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLHelper.swift; sourceTree = ""; }; + 7A691D7600EDE0812CC1E6AD /* BookSourceSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceSearchView.swift; sourceTree = ""; }; 7A980DB0017049401DAB3E93 /* AnnotationListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationListViewModel.swift; sourceTree = ""; }; 7AAC0D3FD90694D3169DB775 /* MDReaderPlaceholderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDReaderPlaceholderTests.swift; sourceTree = ""; }; + FA8BD6824D9945998A61EFB4 /* TTSHighlightCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSHighlightCoordinator.swift; sourceTree = ""; }; + 7BD36F5CC483659F962BFB3A /* TTSService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSService.swift; sourceTree = ""; }; + 7BEF5735F2121D036A4877C4 /* TextTransform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextTransform.swift; sourceTree = ""; }; 7C0A7E77EFE308BC9CF8A3FE /* ErrorMessageAuditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageAuditor.swift; sourceTree = ""; }; + 7C6CC009677C66D3AF4E5836 /* BookSourceHTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceHTTPClient.swift; sourceTree = ""; }; + 7C842F00C6B506FC831E0347 /* EPUBTextStripperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBTextStripperTests.swift; sourceTree = ""; }; 7D04AA64724C4F9A15869C20 /* BookmarkRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRecord.swift; sourceTree = ""; }; 7DAC8E20A001D7803A16AAD1 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + 7DBF9C3C1FBBCE4354F7DAAD /* DictionaryLookupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryLookupTests.swift; sourceTree = ""; }; + 7DEA283D3F2224EDC297B6E1 /* MDFileLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDFileLoader.swift; sourceTree = ""; }; 7FC8555E3C352A0C95D6BFE9 /* LocatorFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorFactory.swift; sourceTree = ""; }; 80ED96E0CBD0FFF1335B41D0 /* LibraryViewModelImportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModelImportTests.swift; sourceTree = ""; }; 815A2F870C4D8EC102254ACC /* PersistenceActor+Bookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceActor+Bookmarks.swift"; sourceTree = ""; }; 8188B21271D103070EE8CDE6 /* SyncConflictResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncConflictResolver.swift; sourceTree = ""; }; 818F6161D2855C49A12AF5A6 /* TOCBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOCBuilder.swift; sourceTree = ""; }; + 82775D9CFBD2C2E05C770BB9 /* SimpTradTransform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpTradTransform.swift; sourceTree = ""; }; + 82B992B24F86DF8CA23E1215 /* AITranslationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AITranslationViewModel.swift; sourceTree = ""; }; + 82BC782199D1750DA66D1BCC /* ReaderNotificationHandlers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderNotificationHandlers.swift; sourceTree = ""; }; 831F853E3D42A27170BB0F92 /* ReadingPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingPosition.swift; sourceTree = ""; }; 836FCCC18D880D48A10BA38A /* MockEPUBParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockEPUBParser.swift; sourceTree = ""; }; 83B60F61628D0969B18B3A9B /* AIConsentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConsentView.swift; sourceTree = ""; }; + 83F36EF81271874A13417D45 /* OPDSEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSEntryView.swift; sourceTree = ""; }; + 851277A5872045D4D935CE2B /* DictionaryLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryLookup.swift; sourceTree = ""; }; + 852FC8DBE71B720469C296C4 /* WebPageEncodingDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPageEncodingDetector.swift; sourceTree = ""; }; 864A1B050775A46ADBE3304F /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; + 8707DA3A6FC024EAC4F63E76 /* TXTTextExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextExtractorTests.swift; sourceTree = ""; }; 874E517A41CB3B9C7C5C8D3A /* NavigationFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationFlowTests.swift; sourceTree = ""; }; + 876AE3BC21A748FDD619EED2 /* WebPageEncodingDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPageEncodingDetectorTests.swift; sourceTree = ""; }; + 8781075EA7AF25572A741C40 /* BilingualView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BilingualView.swift; sourceTree = ""; }; 87DA305663C991FC6F15F80E /* ImportJobQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportJobQueueTests.swift; sourceTree = ""; }; + 889CBB149D6F4922CE90C0A8 /* AnnotationImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationImporterTests.swift; sourceTree = ""; }; 8A12A0D94CF17D48152929F0 /* ReadingStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingStats.swift; sourceTree = ""; }; 8A86CD6BD10792F5107FFB5A /* ReaderSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSettingsStoreTests.swift; sourceTree = ""; }; + 8AB2B5F77B95D3402E699DA9 /* BackupProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupProvider.swift; sourceTree = ""; }; + 8AFC46BDDED74CC58966FD7E /* LegadoImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoImporterTests.swift; sourceTree = ""; }; + 8B1AC5E9F599CDA7123547F2 /* AnnotationAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationAnchor.swift; sourceTree = ""; }; + 8C6686ECBBE526053A51CBA2 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; + 8C9E438077E6D10199BA12CE /* OPDSBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSBrowserView.swift; sourceTree = ""; }; 8CF764016CF051DDD94C586F /* ReadingPositionPersisting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingPositionPersisting.swift; sourceTree = ""; }; 8DB169F568633A25E157B4DE /* ReaderNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderNavigationTests.swift; sourceTree = ""; }; + 8E6850832765DA26713F278C /* BookSourcePipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourcePipeline.swift; sourceTree = ""; }; + 8E6E8611E23F1BE57E84E732 /* TXTTextViewBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextViewBridgeTests.swift; sourceTree = ""; }; 8EAE2660201ADC0B4272B9EE /* AnnotationEditSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationEditSheet.swift; sourceTree = ""; }; + 8F55A72D6B1A8FE2889EFB28 /* SourceSharingServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSharingServiceTests.swift; sourceTree = ""; }; + 8FA0499B50436361D13BA5D0 /* RegexRuleEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegexRuleEvaluator.swift; sourceTree = ""; }; + 8FA15EA6877E3CA9421E1B59 /* EPUBProgressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBProgressTests.swift; sourceTree = ""; }; + 8FAB86FF543782FB25FCE33C /* BookSourcePipelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourcePipelineTests.swift; sourceTree = ""; }; 907D934613DDAEA1F3055F82 /* EPUBReaderContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBReaderContainerView.swift; sourceTree = ""; }; + 61D1FFAC6D1E473B42A52C50 /* HighlightRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightRenderer.swift; sourceTree = ""; }; + D2F37065575D4351969AC869 /* TextHighlightRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextHighlightRenderer.swift; sourceTree = ""; }; + 8EBA861AF0ED9B1F194C06D0 /* EPUBHighlightRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightRenderer.swift; sourceTree = ""; }; + 940E5A743EBB62A0EEEAE70C /* PDFHighlightRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFHighlightRenderer.swift; sourceTree = ""; }; + 04C1DFA6B0FFE6606113B92E /* HighlightCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightCoordinator.swift; sourceTree = ""; }; + 910F691A853144404676CFB3 /* HighlightRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightRendererTests.swift; sourceTree = ""; }; + 68F802F6FC8A94BCA0925BEC /* EPUBHighlightRendererBug77Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightRendererBug77Tests.swift; sourceTree = ""; }; + 531A945FDC79FC3FBB5B3AD5 /* PagedModeBug82Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedModeBug82Tests.swift; sourceTree = ""; }; + 92746A75F27A7F5427FC9199 /* TransformsBug98Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransformsBug98Tests.swift; sourceTree = ""; }; + C7F837206FBB8B11B14D1EC5 /* HighlightCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightCoordinatorTests.swift; sourceTree = ""; }; 9081F5E7C359D5FB2661E7AC /* ReaderThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderThemeTests.swift; sourceTree = ""; }; - 9081F5E7C359D5FB2661E7AD /* TXTTextViewBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextViewBridgeTests.swift; sourceTree = ""; }; 90B380013D82DFFD0411633E /* EPUBReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBReaderViewModel.swift; sourceTree = ""; }; + R6LC0002A1B2C3D4E5F60002 /* ReaderLifecycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderLifecycleHelper.swift; sourceTree = ""; }; 911EF8F78991EBF40F0F6155 /* MockHighlightStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockHighlightStore.swift; sourceTree = ""; }; + 92676552DDABC9E3D5E7DC76 /* HapticFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticFeedback.swift; sourceTree = ""; }; 92AB43BED5AC7096E7278A16 /* QuoteRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteRecoveryTests.swift; sourceTree = ""; }; + 939BC09F1D771D2E22301ED3 /* V1toV2MigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1toV2MigrationTests.swift; sourceTree = ""; }; 9452FAFFDEBDF03FF6CCEBB1 /* MDParserProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDParserProtocol.swift; sourceTree = ""; }; + 9509CC40145B03A66875390C /* chapter_list_paginated.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = chapter_list_paginated.html; sourceTree = ""; }; + 9541B260A743177FAD2B96EE /* BookSourceChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceChapterListView.swift; sourceTree = ""; }; + D450ACF0A0F47929E5916B26 /* TTSHighlightCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSHighlightCoordinatorTests.swift; sourceTree = ""; }; + 96A2FE9743AF484093A21969 /* TTSServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSServiceTests.swift; sourceTree = ""; }; 96BE19585BF1F4C8174F7219 /* HighlightRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightRecord.swift; sourceTree = ""; }; 96E10DBA312E3C1934B36E63 /* MockAnnotationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAnnotationStore.swift; sourceTree = ""; }; 96E6FA59B833AC785C70A7A8 /* LibraryTouchTargetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryTouchTargetTests.swift; sourceTree = ""; }; + 96FDA9A40CE891B901F1A28F /* PersistenceActor+Stats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceActor+Stats.swift"; sourceTree = ""; }; + 9773A45B1D89408E85D126BA /* MDReflowableTextSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDReflowableTextSource.swift; sourceTree = ""; }; 982822FD76DDFB1EEE150FF0 /* ImportError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportError.swift; sourceTree = ""; }; + 9844BEF447FBDBA15ADCEFAB /* TXTAttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTAttributedStringBuilder.swift; sourceTree = ""; }; + 99782DD1A320D265D78FAA12 /* SourceSharingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSharingService.swift; sourceTree = ""; }; 99D14A41185FFD87E278E66C /* DocumentFingerprint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFingerprint.swift; sourceTree = ""; }; 9A29C79BA1C5BF852179BCBB /* LibraryPersisting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryPersisting.swift; sourceTree = ""; }; 9AC105D38A4A85CBCB79A772 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; 9B432A1C9D875A14C4E9E633 /* ReaderSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSettingsStore.swift; sourceTree = ""; }; 9B4B7084FA4C129303F52CB8 /* FileAvailabilityBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAvailabilityBadge.swift; sourceTree = ""; }; + 9B4C9FADDEE54F268F536EC5 /* legado_source_xpath.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = legado_source_xpath.json; sourceTree = ""; }; + 9C131E2BC3B226E7009FA006 /* SeriesTagPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesTagPersistenceTests.swift; sourceTree = ""; }; 9C5A2D5CFE8B719D4C8F3580 /* SchemaV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaV1.swift; sourceTree = ""; }; 9CED0BF7A8104C22C6E293BF /* ReadingTimeFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingTimeFormatterTests.swift; sourceTree = ""; }; 9DAD9A773D4AA9098981720D /* EPUBReaderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBReaderViewModelTests.swift; sourceTree = ""; }; 9DB1CCA367AE0B610F4526FC /* SearchIndexStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchIndexStore.swift; sourceTree = ""; }; + 9DB260B5C0A147C9AE9A46DC /* BookInfoSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookInfoSheet.swift; sourceTree = ""; }; 9F2D542588B3156C4A282264 /* WI11TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WI11TestHelpers.swift; sourceTree = ""; }; + 9F7F45FFFEE431C41E618EF2 /* UnifiedTextRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedTextRendererTests.swift; sourceTree = ""; }; + A054B9D6DC875E4D8E7A5F28 /* ReflowableTextSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflowableTextSourceTests.swift; sourceTree = ""; }; + A064D62C86857484454D0BE3 /* PDFAnnotationBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFAnnotationBridge.swift; sourceTree = ""; }; A069D350D0DC6B4C000E1D43 /* TXTChunkedLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTChunkedLoaderTests.swift; sourceTree = ""; }; + A0753D8C40DFE06B0323EE5B /* UnifiedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedScrollView.swift; sourceTree = ""; }; A0797CB73F5D8FDAF0B7298E /* TestSeeder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSeeder.swift; sourceTree = ""; }; A1A046B497B731C451670CED /* BookmarkPersisting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkPersisting.swift; sourceTree = ""; }; - A1B2C3D4E5F60001STATS01 /* PersistenceActor+Stats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceActor+Stats.swift"; sourceTree = ""; }; - A1B2C3D4E5F60718293A4B5C /* AddNoteSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddNoteSheet.swift; sourceTree = ""; }; - A1B2C3D4E5F6789012345678 /* EPUBParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBParserTests.swift; sourceTree = ""; }; A1E577DAF65D34544D713137 /* TombstoneStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TombstoneStoreTests.swift; sourceTree = ""; }; A43A801A0876E2437CE63808 /* EncodingDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncodingDetector.swift; sourceTree = ""; }; + A43C03327815457BD7B01409 /* TXTBridgeShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTBridgeShared.swift; sourceTree = ""; }; A457F48D22CD5B4952817701 /* EPUBTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBTypes.swift; sourceTree = ""; }; + A496DB7B37A1E68FA33AD5A3 /* JSONExportFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONExportFormatter.swift; sourceTree = ""; }; A4D9A3C4C1072DA23511265D /* PDFReaderPlaceholderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFReaderPlaceholderTests.swift; sourceTree = ""; }; - A5B6C7D8E9F01A2B3C4D5E6F /* EPUBTextExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBTextExtractorTests.swift; sourceTree = ""; }; + A4E3D87DD1EB1B18213B48C7 /* HTTPTTSProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTTSProvider.swift; sourceTree = ""; }; + A579625590B25F81679F1EA0 /* ReaderNotificationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderNotificationModifier.swift; sourceTree = ""; }; + C6F7EC017FF822492219E162 /* TextReaderUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextReaderUIState.swift; sourceTree = ""; }; + A6F1C998AAACA679A10A86D2 /* BookCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookCollection.swift; sourceTree = ""; }; A77D3287AEC40129E6AA379F /* LibraryDynamicTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryDynamicTypeTests.swift; sourceTree = ""; }; + 4715C71E34A3BC44C7D5D64F /* foliate-bridge.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = foliate-bridge.js; sourceTree = ""; }; A7E742DD046F5CE970132E0C /* EPUBParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBParser.swift; sourceTree = ""; }; A84BF17CCCD4B376BDDF8CD1 /* utf8_bom.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = utf8_bom.txt; sourceTree = ""; }; + A8B682E216B5A24055B696F0 /* SwiftDataSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDataSessionStore.swift; sourceTree = ""; }; + A97BCD7E2CCDE30EA6372D6C /* BookSourceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceListView.swift; sourceTree = ""; }; + A983D06F916C51795A2223E7 /* ReaderBottomOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderBottomOverlay.swift; sourceTree = ""; }; A9EE79C451D86828387A1BEF /* MDIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDIntegrationTests.swift; sourceTree = ""; }; + AB0C783DCFD9CFDD8F488F80 /* AISettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsViewModel.swift; sourceTree = ""; }; AB12A029D167735B92A38BA7 /* AccessibilityAuditHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityAuditHelper.swift; sourceTree = ""; }; + AB8FC6D57843EAC26DB980D3 /* SearchResultHighlightTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultHighlightTests.swift; sourceTree = ""; }; + ABBA16F9071C94660C6AB1EB /* SimpTradDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpTradDictionary.swift; sourceTree = ""; }; ABC4CE4DBCD7E5A52BC4E511 /* AccessibilityFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityFormatters.swift; sourceTree = ""; }; ABF63E3EE60CC06C5650C3AD /* AnnotationNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationNote.swift; sourceTree = ""; }; + ABFBA14606BD14D14A8D5500 /* EPUBWebViewBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBWebViewBridgeTests.swift; sourceTree = ""; }; + CA0DB65C8E6609BD73825B37 /* ChangeTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeTokenStore.swift; sourceTree = ""; }; + 338D1B6319B2BB8DF3BA6E99 /* CloudKitClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitClient.swift; sourceTree = ""; }; + 0D9AAF8DAE58F885C9741413 /* CloudKitRecordMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitRecordMapper.swift; sourceTree = ""; }; + A1F3129242478B2D5F49C0D5 /* DeviceIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentity.swift; sourceTree = ""; }; + 35237A69147FB81A638492C7 /* DurableTombstoneStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurableTombstoneStore.swift; sourceTree = ""; }; + 6AB651A97BA8CE8B5BD6E97B /* NSUKVSBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSUKVSBridge.swift; sourceTree = ""; }; + 555BC3B182915D99AE4D6E83 /* SyncOutboundQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncOutboundQueue.swift; sourceTree = ""; }; + 6CF51FC0C60A1FF5E749759B /* SyncPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPipeline.swift; sourceTree = ""; }; + DB3F0345BFE29A78AF0467BC /* SyncRecordDTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncRecordDTOs.swift; sourceTree = ""; }; AC12F17B1F2EDD25E25B114C /* SyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncService.swift; sourceTree = ""; }; + AC38E517D1EE7098DCB96426 /* MarkdownExportFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownExportFormatter.swift; sourceTree = ""; }; AC517B8E3581F795DEDEC934 /* PersistenceActor+Highlights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceActor+Highlights.swift"; sourceTree = ""; }; + AD065491E8CFE99188D62E09 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; AE0CDD04120BFF39B61E8418 /* BackgroundIndexingCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIndexingCoordinatorTests.swift; sourceTree = ""; }; + AE29B97304B59C88E3F3F9CF /* AITranslationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AITranslationTests.swift; sourceTree = ""; }; + AEE844A76B8AFC7B1DC2E840 /* HighlightAnchorStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightAnchorStorageTests.swift; sourceTree = ""; }; AF495D7A9D6F8F137DD42CE0 /* SyncConflictResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncConflictResolverTests.swift; sourceTree = ""; }; - AUDIT5000002 /* HapticFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticFeedback.swift; sourceTree = ""; }; - AUDIT5000004 /* BookmarkFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkFeedbackTests.swift; sourceTree = ""; }; - AUDIT7000002 /* SearchHighlightDismissTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHighlightDismissTests.swift; sourceTree = ""; }; - AUDIT8000002 /* ReaderAuditFixTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAuditFixTests.swift; sourceTree = ""; }; - AUDIT9000002 /* ReaderAuditFix2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAuditFix2Tests.swift; sourceTree = ""; }; - AUDITA000002 /* ReaderAuditFix3Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAuditFix3Tests.swift; sourceTree = ""; }; + AF4E488AD7274100802E64AD /* HighlightedSnippet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightedSnippet.swift; sourceTree = ""; }; B10A08E980C92248337462DF /* LocatorRestorerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorRestorerTests.swift; sourceTree = ""; }; + B16D420A03BD524C247A78FB /* TXTReflowableTextSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTReflowableTextSource.swift; sourceTree = ""; }; B1DBBFF061B96088FFE84194 /* TXTService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTService.swift; sourceTree = ""; }; - B2C3D4E5F6071829A3B4C5D6 /* SearchWiringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchWiringTests.swift; sourceTree = ""; }; B2DB7F421D9D2E7492E12F89 /* ZIPReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZIPReader.swift; sourceTree = ""; }; B34C87EDF46A4EFC99012268 /* AnnotationsPanelPlaceholderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationsPanelPlaceholderTests.swift; sourceTree = ""; }; B3987200016FB6CA3D063E44 /* LocatorCanonicalHashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorCanonicalHashTests.swift; sourceTree = ""; }; B3CB57338FD24289DAC8ABE4 /* WI9TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WI9TestHelpers.swift; sourceTree = ""; }; - B54B02A280C0D89C54FB9967 /* SwiftDataSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDataSessionStore.swift; sourceTree = ""; }; + B442FC7A6A6ED344AB5C1FC9 /* CollectionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionPersistenceTests.swift; sourceTree = ""; }; + B501E24B36BF00B609B04BF3 /* ReaderSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSearchCoordinator.swift; sourceTree = ""; }; + B5099FDCB9D68C511D5C59FA /* LegadoRuleParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoRuleParser.swift; sourceTree = ""; }; + B5EE95205EE2494B9343343F /* LegadoBookSourceDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoBookSourceDTO.swift; sourceTree = ""; }; B6CE6EA4B82CC966077E656F /* BookmarkListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListView.swift; sourceTree = ""; }; B6DAD680DB86CF1A65D34F3F /* LibraryRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryRefreshServiceTests.swift; sourceTree = ""; }; + B6F8130B082FB1C006A2F711 /* LegadoRuleParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoRuleParserTests.swift; sourceTree = ""; }; + B74CC275250E1FEFD4D1A72B /* ReplacementTransform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplacementTransform.swift; sourceTree = ""; }; B811BD48F552B167D438BFCF /* BookModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookModelTests.swift; sourceTree = ""; }; + B849723B3079FB8F3F4A7961 /* PDFHighlightIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFHighlightIntegrationTests.swift; sourceTree = ""; }; B84D0E1452F211B986E8328A /* ContentHasher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentHasher.swift; sourceTree = ""; }; + B90F4EB83CC68406DA14DD94 /* AIChatGeneralTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatGeneralTests.swift; sourceTree = ""; }; B925BE5683D3296D77D3503B /* MockPersistenceActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPersistenceActor.swift; sourceTree = ""; }; + B9500033AC714D470A3024F8 /* EPUBPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBPaginationTests.swift; sourceTree = ""; }; BB7031C26EB38B7B1D2A0BEF /* SearchIndexStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchIndexStoreTests.swift; sourceTree = ""; }; + BC42F137A3776DEED18D9044 /* EPUBComplexityClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBComplexityClassifierTests.swift; sourceTree = ""; }; + BD6D19741098BC82E294F1E1 /* PageNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageNavigator.swift; sourceTree = ""; }; + BD9F0676ACCEE6F37D547E72 /* EPUBHighlightActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightActionsTests.swift; sourceTree = ""; }; BDC254C94AE749FFA8AACCE4 /* LibraryRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryRefreshService.swift; sourceTree = ""; }; + C00E5921EA2B5FE88656FED6 /* AnnotationsPanelViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationsPanelViewTests.swift; sourceTree = ""; }; + C0B1246EC0EE34D326231F60 /* SearchQueryExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQueryExecutorTests.swift; sourceTree = ""; }; C0E536B950D178C97842DF52 /* EPUBWebViewBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBWebViewBridge.swift; sourceTree = ""; }; C0FBDC41C60328ED4FB8A197 /* BookImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookImporterTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB0002 /* TXTTextChunker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextChunker.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB0004 /* TXTTextChunkerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextChunkerTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB0006 /* TXTChunkedReaderBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTChunkedReaderBridge.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB1002 /* HighlightableTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightableTextView.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB1004 /* HighlightableTextViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightableTextViewTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB2002 /* ReaderNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderNotifications.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB2004 /* TXTBridgeShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTBridgeShared.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB2006 /* TXTBridgeSharedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTBridgeSharedTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB3002 /* ReaderNotificationHandlers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderNotificationHandlers.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB3004 /* ReaderNotificationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderNotificationModifier.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB3006 /* NoOpPersistenceStores.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoOpPersistenceStores.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB3008 /* ReaderNotificationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderNotificationHandlerTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB4002 /* AnnotationsPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationsPanelView.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB4004 /* ReaderFormatHosts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderFormatHosts.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB4006 /* AnnotationsPanelViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationsPanelViewTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB5002 /* ReaderBottomOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderBottomOverlay.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB5004 /* ReaderBottomOverlayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderBottomOverlayTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB5012 /* ReadingProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBar.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB5014 /* ReadingProgressBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBarTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB5016 /* PDFProgressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFProgressTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB5018 /* PDFProgressHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFProgressHelper.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB501A /* ScrollProgressHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollProgressHelper.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB501C /* TXTMDProgressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTMDProgressTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB501E /* EPUBProgressCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBProgressCalculator.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB5020 /* EPUBProgressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBProgressTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB6002 /* ReaderPositionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPositionService.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB6004 /* ReaderPositionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPositionServiceTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB7E02 /* SearchIndexCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchIndexCore.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB7E04 /* SearchQueryExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQueryExecutor.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB7E06 /* SearchQueryExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQueryExecutorTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB8B02 /* EPUBFileLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBFileLoader.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB8B04 /* EPUBFileLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBFileLoaderTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB8C02 /* MDFileLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDFileLoader.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB8C04 /* MDFileLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDFileLoaderTests.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB8D02 /* TXTFileLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTFileLoader.swift; sourceTree = ""; }; - C1A2B3C4D5E60001AABB8D04 /* TXTFileLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTFileLoaderTests.swift; sourceTree = ""; }; C1DE5531A63EA492C5D91BEE /* PersistenceActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceActor.swift; sourceTree = ""; }; + C2780A3796872F31F1666DA7 /* ReaderTOCBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTOCBuilder.swift; sourceTree = ""; }; C2D49361893EE9956D6EC5DB /* TXTOffsetMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTOffsetMapper.swift; sourceTree = ""; }; C3C15E361FF460BCE57B8675 /* EPUBParserProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBParserProtocol.swift; sourceTree = ""; }; + C42FD55371CD8FA98699541E /* LibraryContextMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryContextMenuTests.swift; sourceTree = ""; }; + C52103AEAF5528A9DD58625E /* AIConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConfiguration.swift; sourceTree = ""; }; + C5959972E9775E5B52E5C840 /* SwiftDataSessionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDataSessionStoreTests.swift; sourceTree = ""; }; C5CB5C659CC573EF448F0992 /* AIResponseCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIResponseCacheTests.swift; sourceTree = ""; }; + C68E9908FEE2CDE00C6EB279 /* AISettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsViewModelTests.swift; sourceTree = ""; }; C775619D3C0E4641505CE2B8 /* Highlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Highlight.swift; sourceTree = ""; }; - C7D8E9F01A2B3C4D5E6F7081 /* MDTextExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDTextExtractorTests.swift; sourceTree = ""; }; + C85205257E8103AA80C08BAB /* BookmarkFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkFeedbackTests.swift; sourceTree = ""; }; C8E7C46539D19C4B3CFCD766 /* vreader.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = vreader.app; sourceTree = BUILT_PRODUCTS_DIR; }; C9AB5E0256EC0FE97B68DE5D /* TombstoneStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TombstoneStore.swift; sourceTree = ""; }; + CAEDD9E567FC32F217A07CAF /* ZIPWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZIPWriter.swift; sourceTree = ""; }; CB65B98019C814421DDB0668 /* ZIPReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZIPReaderTests.swift; sourceTree = ""; }; CB6F33EB7EFB0D7C9001E3A2 /* LibraryEdgeCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryEdgeCaseTests.swift; sourceTree = ""; }; + CB7EBD49DAE8D5F5BC4C7207 /* PageTurnAnimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTurnAnimatorTests.swift; sourceTree = ""; }; CC4425D7764DA53AD595FF93 /* ReduceMotionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReduceMotionHelper.swift; sourceTree = ""; }; CD3507D70A2497BA857E5A49 /* plain_utf8.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = plain_utf8.txt; sourceTree = ""; }; CEED7A8EE9DAF9388DE79212 /* ScreenSpaceDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSpaceDemo.swift; sourceTree = ""; }; @@ -656,100 +1079,135 @@ CF5C12635DFA6BEE42EBB1CE /* KeychainServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainServiceTests.swift; sourceTree = ""; }; D02AA769AEDBC25CEA896348 /* TXTChunkedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTChunkedLoader.swift; sourceTree = ""; }; D02B540992E1F3C96A00723F /* AIProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIProvider.swift; sourceTree = ""; }; + D06CC002A1B2C3D4E5F60002 /* ChapterCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterCache.swift; sourceTree = ""; }; + D06CC004A1B2C3D4E5F60004 /* ChapterCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterCacheTests.swift; sourceTree = ""; }; + D0966F97359B41E9AB958A02 /* legado_source_js.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = legado_source_js.json; sourceTree = ""; }; D14E222357346107CB34B750 /* SmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmokeTests.swift; sourceTree = ""; }; D2AEB48E4BF208D97EEF397C /* QuoteRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteRecovery.swift; sourceTree = ""; }; D2EA03BEFDBB65F5D7533EDE /* SchemaV1Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaV1Tests.swift; sourceTree = ""; }; - D3E4F5A6B7C8091A2B3C4D5E /* MDTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDTextExtractor.swift; sourceTree = ""; }; D432C9B43D1B6662B4605664 /* BookmarkListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListViewModel.swift; sourceTree = ""; }; D4B4E4FB28FD82376AE20A4F /* DocumentFingerprintValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFingerprintValidationTests.swift; sourceTree = ""; }; + D52DADD2C2FB6330070EA6DE /* FileSizeFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSizeFormatter.swift; sourceTree = ""; }; + D5C26DE0705F29A16D1919F5 /* OPDSParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSParserTests.swift; sourceTree = ""; }; + D5CE4AD339F8C68B973D9C88 /* NativeTextPagedIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextPagedIntegrationTests.swift; sourceTree = ""; }; + D696HR9SIOM4CX0BL1GL91WK /* BookContentCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookContentCacheTests.swift; sourceTree = ""; }; + D6C080C3D62DBA36CA1E88C5 /* AnnotationImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationImporter.swift; sourceTree = ""; }; D83492717235FB856C8A06ED /* TOCProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOCProviderTests.swift; sourceTree = ""; }; - D93492717235FB856C8A06EE /* TOCBuilderMDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOCBuilderMDTests.swift; sourceTree = ""; }; D998048CE6DE8DC3BC77C284 /* TXTReaderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTReaderViewModelTests.swift; sourceTree = ""; }; + D9E867C06CA165E731435125 /* HighlightableTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightableTextView.swift; sourceTree = ""; }; + DADECFF5C347B6D68C1A8529 /* EPUBTextExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBTextExtractorTests.swift; sourceTree = ""; }; + DB16317C72EB6BBB61D77030 /* V1toV2Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1toV2Migration.swift; sourceTree = ""; }; + DBBB555BA86F7648ACBC780F /* EPUBProgressCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBProgressCalculator.swift; sourceTree = ""; }; + DBD586268986FD19FFE2271A /* OffsetMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetMap.swift; sourceTree = ""; }; DC606D4AF30DF956E04C13DF /* LibrarySortOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySortOrder.swift; sourceTree = ""; }; + DCA9065CD56F7F0C7AF165FE /* EPUBComplexityClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBComplexityClassifier.swift; sourceTree = ""; }; + DD76366E51B98FEE9E53DB3C /* ReaderAuditFix2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAuditFix2Tests.swift; sourceTree = ""; }; + DDB7C7EC41A96F5D4B53E983 /* ReaderNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderNotifications.swift; sourceTree = ""; }; + DDC5E430511CD7491C543A15 /* chapter_content.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = chapter_content.html; sourceTree = ""; }; DDD7F2C7E93907B97A730010 /* HighlightListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightListViewModel.swift; sourceTree = ""; }; DE2038A4D36C4355AC5C7BF5 /* SearchLocatorSliceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocatorSliceTests.swift; sourceTree = ""; }; + DE5CAE41C429B6868957C540 /* ExportTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportTestFixtures.swift; sourceTree = ""; }; + E026EDF24B39D1CD50B39389 /* CollectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionTests.swift; sourceTree = ""; }; + E07FB35EC9805F51CAD10444 /* ThemeBackgroundStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeBackgroundStore.swift; sourceTree = ""; }; E11E9DBFB16DA26DD0659851 /* AnnotationModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationModelTests.swift; sourceTree = ""; }; + E122212D54348149A32DC51B /* RuleEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleEngine.swift; sourceTree = ""; }; + E12EBCB8CD58F740D9042C32 /* TXTTocRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTocRule.swift; sourceTree = ""; }; E19A1FE14FDE4829AF0F5913 /* TXTReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTReaderViewModel.swift; sourceTree = ""; }; - E1A2B3C4D5E6F70819203142 /* EPUBTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBTextExtractor.swift; sourceTree = ""; }; E1C9AB72079AF7B2ACCAB516 /* AIConsentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConsentManager.swift; sourceTree = ""; }; + E1EF16A1A352B2C6BAE84556 /* UnifiedMDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedMDTests.swift; sourceTree = ""; }; + E2396FE1BB03C2F3CEFAB8E2 /* AutoPageTurnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPageTurnerTests.swift; sourceTree = ""; }; E24D3FC7488318277468A7F0 /* LibraryAccessibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryAccessibilityTests.swift; sourceTree = ""; }; + E25EC6B4A507BE08D646A4AD /* PersistentSearchIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentSearchIndexTests.swift; sourceTree = ""; }; + E28AEE54347E9EC752286A2A /* EPUBHighlightActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightActions.swift; sourceTree = ""; }; + E368FCB9544C39958CDC31CE /* TextKit2PaginatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextKit2PaginatorTests.swift; sourceTree = ""; }; E3D8B3D17D6551C053F12355 /* MockTXTService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTXTService.swift; sourceTree = ""; }; E46A786B20AA87E763D00F45 /* PersistenceActor+Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceActor+Library.swift"; sourceTree = ""; }; E46AADA707F0E3AF7E8AB297 /* vreaderTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = vreaderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E50B0954852C3621D008EE07 /* TXTBridgeOffsetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTBridgeOffsetTests.swift; sourceTree = ""; }; E54B97E5C67533291971CA1D /* SearchTextNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTextNormalizer.swift; sourceTree = ""; }; + E5CB381B25E50D44505CAAB3 /* CustomCoverStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCoverStore.swift; sourceTree = ""; }; + E5EC9B0FFB09D08F65F205F3 /* EPUBPaginationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBPaginationHelper.swift; sourceTree = ""; }; E68D7B5F0EC86B4E39725EC9 /* LibraryPopulatedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryPopulatedTests.swift; sourceTree = ""; }; + E6AEAC075B9C38B3783D207A /* EPUBFileLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBFileLoader.swift; sourceTree = ""; }; + E6D45B144AFD2D20CAEACC48 /* NoOpPersistenceStores.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoOpPersistenceStores.swift; sourceTree = ""; }; + E756C3235433C7EFC0BBB972 /* HTTPTTSConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTTSConfig.swift; sourceTree = ""; }; + E7D86F739CC4D19AF019F728 /* LocatorNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorNormalizer.swift; sourceTree = ""; }; + E861A379A620B769CEA36300 /* AIChatViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatViewModelTests.swift; sourceTree = ""; }; + E9D4D088D34AA8A5692A991B /* PageTurnAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTurnAnimator.swift; sourceTree = ""; }; + EA0C50C474AA2F96C39AAC94 /* PaginationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationCache.swift; sourceTree = ""; }; EA401D8FC3B4F17213528B27 /* AIAssistantViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAssistantViewModelTests.swift; sourceTree = ""; }; + EA41D8643B42B4B25CB6D96A /* PipelineTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipelineTypes.swift; sourceTree = ""; }; + EA7540DF541EE961F4442A67 /* TXTServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTServiceTests.swift; sourceTree = ""; }; EAB42EEEFFCAD8D654D57AE7 /* ReaderContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderContainerView.swift; sourceTree = ""; }; EAE6C27BECED96A8DA016439 /* MetadataExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataExtractor.swift; sourceTree = ""; }; + EB007C93A274D2EB4EF7A3B9 /* RuleEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleEngineTests.swift; sourceTree = ""; }; + EB0A4899D0F08DEDB20D068C /* MarkdownExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownExportTests.swift; sourceTree = ""; }; + A62E09E73F40E15B8AE10F61 /* ChangeTokenStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeTokenStoreTests.swift; sourceTree = ""; }; + E10009F6F0D32B9A9E2D16A9 /* CloudKitRecordMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitRecordMapperTests.swift; sourceTree = ""; }; + E2C99AB2454A14C2424347F5 /* DeviceIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentityTests.swift; sourceTree = ""; }; + 2FC2642E3A5DC9FDFE1C4111 /* DurableTombstoneStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurableTombstoneStoreTests.swift; sourceTree = ""; }; + 516DF5B639FADAB9842388B7 /* NSUKVSBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSUKVSBridgeTests.swift; sourceTree = ""; }; + 14D74EB30AD402C81A7F3E56 /* SyncOutboundQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncOutboundQueueTests.swift; sourceTree = ""; }; + EDE7986F0EE66546C2CB5751 /* SyncPipelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPipelineTests.swift; sourceTree = ""; }; EB81DF32070BCFB6D8653800 /* SyncServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncServiceTests.swift; sourceTree = ""; }; + EC4FE169F0AC369FB30F888F /* ReadingModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingModeTests.swift; sourceTree = ""; }; ECD12F6574178C9287A93CA6 /* Book.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Book.swift; sourceTree = ""; }; + ED150D276C082FEC194F2F31 /* BackupProviderContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupProviderContractTests.swift; sourceTree = ""; }; ED80D7FC768F4B87E7DB036A /* VoiceOverAuditTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceOverAuditTests.swift; sourceTree = ""; }; EE41196788F697BB4ACD4B06 /* ReadingSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingSessionTests.swift; sourceTree = ""; }; + EE99E42CBCBD4C1784CBDBA1 /* LegadoCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoCompatibility.swift; sourceTree = ""; }; EEF87AF7EE6B0FB8E4CC9332 /* TypographySettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypographySettingsTests.swift; sourceTree = ""; }; + EF9547D23D813327B536EAD5 /* ReaderBottomOverlayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderBottomOverlayTests.swift; sourceTree = ""; }; EFB9DBE73613A6499D43B18C /* AIServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIServiceTests.swift; sourceTree = ""; }; - F01A2B3C4D5E6F7081920304 /* TXTTextExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTTextExtractorTests.swift; sourceTree = ""; }; F069A328AA628585D86B52B2 /* SyncStatusViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusViewTests.swift; sourceTree = ""; }; - F1A2B3C4D5E6F70812340001 /* TXTServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTServiceTests.swift; sourceTree = ""; }; + F099DED45D1C192D6F99A194 /* search_no_results.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = search_no_results.html; sourceTree = ""; }; + F0C394C4CBC74CD9F661A403 /* UpdateCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCheckerTests.swift; sourceTree = ""; }; + F16AF7EAA6EC1F1D0D126E75 /* BasePageNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasePageNavigator.swift; sourceTree = ""; }; + F197589BA55261B91DBFFEE0 /* TextMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMapperTests.swift; sourceTree = ""; }; + F1D3CAC117B849EE88AE0A5F /* legado_multiple_sources.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = legado_multiple_sources.json; sourceTree = ""; }; + F213D0F6EFBD7D7088AAC40D /* TXTMDProgressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTMDProgressTests.swift; sourceTree = ""; }; F2EFEE7A0EC5352A0BB1A994 /* utf16be_bom.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = utf16be_bom.txt; sourceTree = ""; }; + F379B3EEC02DC574C73F4323 /* SchemaV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaV2.swift; sourceTree = ""; }; F3B26374749D7626349FB9E0 /* LibraryTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryTestHelpers.swift; sourceTree = ""; }; F4C18F1E19149F8DBAE36A31 /* AIResponseCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIResponseCache.swift; sourceTree = ""; }; F560EA65913036C78284C138 /* ErrorScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorScreenTests.swift; sourceTree = ""; }; + F59ACCAF30F5092968415855 /* AnnotationsPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationsPanelView.swift; sourceTree = ""; }; + F5DFC4A77EA2740C79B2EC34 /* book_detail.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = book_detail.html; sourceTree = ""; }; F693921BA233745D77EC0037 /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = ""; }; + F77371DBD389CE1F1153E56E /* OPDSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSClient.swift; sourceTree = ""; }; + F8038AAB18412F30C09CBDD9 /* AIConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConfigurationStore.swift; sourceTree = ""; }; + F82AD6F50703DBCA984EBAAC /* UnifiedPagedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedPagedView.swift; sourceTree = ""; }; F8DEE3B767FC8F7457067C11 /* MutationDriftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutationDriftTests.swift; sourceTree = ""; }; F9102277C4126793229AEEB9 /* SearchHitToLocatorResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHitToLocatorResolverTests.swift; sourceTree = ""; }; + F97894D9D7A3FC8227521C6E /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = ""; }; + F9D9F31A3F96B73C8E3653E6 /* MDTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDTextExtractor.swift; sourceTree = ""; }; + FB17C932D5188B36F01F024A /* AnnotationExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationExporter.swift; sourceTree = ""; }; FB82BDFCDB76725A5586D5E0 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = ""; }; FBC4E25069345D28610C64EC /* SearchTextNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTextNormalizerTests.swift; sourceTree = ""; }; + FBCB98531364449BB44DDC00 /* legado_source_with_unknown_fields.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = legado_source_with_unknown_fields.json; sourceTree = ""; }; + FBD54F8887091F46753F09A4 /* DictionarySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionarySheet.swift; sourceTree = ""; }; FC614F4D61859721C71EC447 /* MDParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDParser.swift; sourceTree = ""; }; + FC729E5B1BA7DC69B40D4929 /* NativeTextPageNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextPageNavigator.swift; sourceTree = ""; }; + FCB2E5176B454D4A8C82E893 /* legado_source_minimal.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = legado_source_minimal.json; sourceTree = ""; }; FCDA968F8186B11859D8CCFE /* MDTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDTypes.swift; sourceTree = ""; }; FDF40785A923791B0241CF75 /* GlobalAccessibilityAuditTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalAccessibilityAuditTests.swift; sourceTree = ""; }; FDFAF8770F91837F0B3793E1 /* ImportProvenanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportProvenanceTests.swift; sourceTree = ""; }; + FE0AB24552C147A6A2D56EE3 /* LegadoImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoImporter.swift; sourceTree = ""; }; FE32E178C19442D8D52EBAC5 /* TOCListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOCListView.swift; sourceTree = ""; }; + FE6974D0F73862058FC97358 /* MDFileLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDFileLoaderTests.swift; sourceTree = ""; }; FE6E48CFA0BB789114F9CE19 /* MockBookmarkStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBookmarkStore.swift; sourceTree = ""; }; + FE7F4B7C906FB04E87EE16F5 /* EPUBTextStripper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBTextStripper.swift; sourceTree = ""; }; + FF23B1A0CC0BE35DF685C5FA /* ReaderFormatHosts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderFormatHosts.swift; sourceTree = ""; }; FFD1AE78FBFF38B3616352FF /* AIConsentManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConsentManagerTests.swift; sourceTree = ""; }; FFE1146B1851B4122B5187A1 /* TXTServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TXTServiceProtocol.swift; sourceTree = ""; }; - SPIKEC002A1B2C3D4E5F60002 /* AnnotationAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationAnchor.swift; sourceTree = ""; }; - SPIKEC004A1B2C3D4E5F60004 /* SchemaV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaV2.swift; sourceTree = ""; }; - SPIKEC006A1B2C3D4E5F60006 /* V1toV2Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1toV2Migration.swift; sourceTree = ""; }; - SPIKEC008A1B2C3D4E5F60008 /* AnnotationAnchorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationAnchorTests.swift; sourceTree = ""; }; - 6A794708164DDDF9736D5FE7 /* HighlightAnchorStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightAnchorStorageTests.swift; sourceTree = ""; }; - SPIKEC00AA1B2C3D4E5F6000A /* V1toV2MigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1toV2MigrationTests.swift; sourceTree = ""; }; - SPIKEC00CA1B2C3D4E5F6000C /* ReaderSelectionEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSelectionEventTests.swift; sourceTree = ""; }; - SPIKEC00EA1B2C3D4E5F6000E /* HighlightRecordAnchorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightRecordAnchorTests.swift; sourceTree = ""; }; - WI008002A1B2C3D4E5F60002 /* PDFAnnotationBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFAnnotationBridge.swift; sourceTree = ""; }; - WI008004A1B2C3D4E5F60004 /* PDFAnnotationBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFAnnotationBridgeTests.swift; sourceTree = ""; }; - WI008006A1B2C3D4E5F60006 /* PDFHighlightIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFHighlightIntegrationTests.swift; sourceTree = ""; }; - WI06000002A1B2C3D4E5F602 /* FileSizeFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSizeFormatter.swift; sourceTree = ""; }; - WI06000004A1B2C3D4E5F604 /* BookInfoSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookInfoSheet.swift; sourceTree = ""; }; - WI06000006A1B2C3D4E5F606 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; - WI06000008A1B2C3D4E5F608 /* LibraryContextMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryContextMenuTests.swift; sourceTree = ""; }; - WI07000002A1B2C3D4E5F602 /* EPUBHighlightBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightBridge.swift; sourceTree = ""; }; - WI07000004A1B2C3D4E5F604 /* EPUBHighlightBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightBridgeTests.swift; sourceTree = ""; }; - WI07000006A1B2C3D4E5F606 /* EPUBHighlightJS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightJS.swift; sourceTree = ""; }; - WI07000008A1B2C3D4E5F608 /* EPUBHighlightActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightActions.swift; sourceTree = ""; }; - WI0700000AA1B2C3D4E5F60A /* EPUBHighlightActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBHighlightActionsTests.swift; sourceTree = ""; }; - WI09000001A1B2C3D4E5F601 /* AISettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsViewModel.swift; sourceTree = ""; }; - WI09000003A1B2C3D4E5F603 /* AISettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsSection.swift; sourceTree = ""; }; - WI09000005A1B2C3D4E5F605 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - WI09000007A1B2C3D4E5F607 /* AISettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsViewModelTests.swift; sourceTree = ""; }; - WI10000001A1B2C3D4E5F601 /* AIReaderAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReaderAvailability.swift; sourceTree = ""; }; - WI10000003A1B2C3D4E5F603 /* AIReaderPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReaderPanel.swift; sourceTree = ""; }; - WI10000005A1B2C3D4E5F605 /* AIReaderIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReaderIntegrationTests.swift; sourceTree = ""; }; - WI11000001A1B2C3D4E5F601 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; - WI11000003A1B2C3D4E5F603 /* AIChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatViewModel.swift; sourceTree = ""; }; - WI11000005A1B2C3D4E5F605 /* AIChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatView.swift; sourceTree = ""; }; - WI11000007A1B2C3D4E5F607 /* AIChatViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatViewModelTests.swift; sourceTree = ""; }; - WI12000001A1B2C3D4E5F601 /* AITranslationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AITranslationViewModel.swift; sourceTree = ""; }; - WI12000003A1B2C3D4E5F603 /* BilingualView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BilingualView.swift; sourceTree = ""; }; - WI12000005A1B2C3D4E5F605 /* TranslationPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationPanel.swift; sourceTree = ""; }; - WI12000007A1B2C3D4E5F607 /* AITranslationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AITranslationTests.swift; sourceTree = ""; }; - WI13000001A1B2C3D4E5F601 /* AIChatGeneralTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatGeneralTests.swift; sourceTree = ""; }; - WIC00F01A1B2C3D4E5F60010 /* HighlightDedupeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDedupeTests.swift; sourceTree = ""; }; - WID00001A1B2C3D4E5F60001 /* AIConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConfiguration.swift; sourceTree = ""; }; - WID00003A1B2C3D4E5F60003 /* AIConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConfigurationStore.swift; sourceTree = ""; }; - WID00005A1B2C3D4E5F60005 /* AIConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConfigurationTests.swift; sourceTree = ""; }; - WID00007A1B2C3D4E5F60007 /* AIRequestCacheKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIRequestCacheKeyTests.swift; sourceTree = ""; }; - WID00009A1B2C3D4E5F60009 /* PreferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceStore.swift; sourceTree = ""; }; + I1LZNT3Z7HY2RSP3O90OFDPF /* BookContentCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookContentCache.swift; sourceTree = ""; }; + 805C79B33D1268AF7EA5CDBA /* ReaderUnifiedDispatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderUnifiedDispatch.swift; sourceTree = ""; }; + 784DE4C3D42697A49F4D0DBC /* ReaderContainerView+Sheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderContainerView+Sheets.swift"; sourceTree = ""; }; + 531EED0BA29E12B1336BF72A /* EPUBReaderContainerView+Highlights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EPUBReaderContainerView+Highlights.swift"; sourceTree = ""; }; + 2BDD567DDFEEAFDE5C1EC9AF /* EPUBReaderContainerView+Navigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EPUBReaderContainerView+Navigation.swift"; sourceTree = ""; }; + FC3A1586B92D00DD884FA841 /* TXTReaderContainerView+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TXTReaderContainerView+Helpers.swift"; sourceTree = ""; }; + F838BD23B13BE1FE9CD3E32F /* PDFReaderContainerView+Highlights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PDFReaderContainerView+Highlights.swift"; sourceTree = ""; }; + 073E28FACD8057856C6723D4 /* PDFReaderContainerView+Overlays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PDFReaderContainerView+Overlays.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -758,19 +1216,44 @@ children = ( DF8A355857F679FF9E0583AF /* Encoding */, 87DEE4E969E5546C29BF06E4 /* Migration */, + 48274A6BA7254678BC185584 /* BookSource */, ); path = Fixtures; sourceTree = ""; }; + 0998F86FF3DCADF316D3BBE0 /* TextMapping */ = { + isa = PBXGroup; + children = ( + 7BEF5735F2121D036A4877C4 /* TextTransform.swift */, + DBD586268986FD19FFE2271A /* OffsetMap.swift */, + 521E495ED3C3F323D5488F1D /* TextMapper.swift */, + 82775D9CFBD2C2E05C770BB9 /* SimpTradTransform.swift */, + ABBA16F9071C94660C6AB1EB /* SimpTradDictionary.swift */, + B74CC275250E1FEFD4D1A72B /* ReplacementTransform.swift */, + ); + path = TextMapping; + sourceTree = ""; + }; + 0A674A1C945C15048247CC09 /* TextKit2Spike */ = { + isa = PBXGroup; + children = ( + E368FCB9544C39958CDC31CE /* TextKit2PaginatorTests.swift */, + ); + path = TextKit2Spike; + sourceTree = ""; + }; 0B012D36F10FD0A92C516160 /* Models */ = { isa = PBXGroup; children = ( + 6B393E54ECE17C3DA3969EA4 /* AnnotationAnchorTests.swift */, E11E9DBFB16DA26DD0659851 /* AnnotationModelTests.swift */, - SPIKEC008A1B2C3D4E5F60008 /* AnnotationAnchorTests.swift */, 6ED83105BFA1AA160A12D158 /* BookFormatTests.swift */, B811BD48F552B167D438BFCF /* BookModelTests.swift */, + 280FCCEE99306FEA6479845B /* BookSourceTests.swift */, + E026EDF24B39D1CD50B39389 /* CollectionTests.swift */, 2BE1E995F0C1A8A64CF95A99 /* DocumentFingerprintTests.swift */, D4B4E4FB28FD82376AE20A4F /* DocumentFingerprintValidationTests.swift */, + 1D579834E6924BB521873C38 /* FormatCapabilitiesTests.swift */, FDFAF8770F91837F0B3793E1 /* ImportProvenanceTests.swift */, 770B26672B9E379E795E28E7 /* LibraryBookItemTests.swift */, B3987200016FB6CA3D063E44 /* LocatorCanonicalHashTests.swift */, @@ -778,13 +1261,14 @@ 4C87C165AFE78571456C14D1 /* LocatorValidationTests.swift */, F8DEE3B767FC8F7457067C11 /* MutationDriftTests.swift */, 9081F5E7C359D5FB2661E7AC /* ReaderThemeTests.swift */, - 9081F5E7C359D5FB2661E7AD /* TXTTextViewBridgeTests.swift */, + EC4FE169F0AC369FB30F888F /* ReadingModeTests.swift */, EE41196788F697BB4ACD4B06 /* ReadingSessionTests.swift */, 03FA3AC72012ED6686293475 /* ReadingStatsTests.swift */, D2EA03BEFDBB65F5D7533EDE /* SchemaV1Tests.swift */, 61D944728B17A940A3716EA9 /* TokenSpanTests.swift */, + 8E6E8611E23F1BE57E84E732 /* TXTTextViewBridgeTests.swift */, EEF87AF7EE6B0FB8E4CC9332 /* TypographySettingsTests.swift */, - SPIKEC010A1B2C3D4E5F60010 /* Migration */, + AD90252EC7BC13BB04C75119 /* Migration */, ); path = Models; sourceTree = ""; @@ -805,6 +1289,15 @@ 27C13EA240083857EA527F89 /* FileAvailabilityStateMachine.swift */, 8188B21271D103070EE8CDE6 /* SyncConflictResolver.swift */, AC12F17B1F2EDD25E25B114C /* SyncService.swift */, + DB3F0345BFE29A78AF0467BC /* SyncRecordDTOs.swift */, + CA0DB65C8E6609BD73825B37 /* ChangeTokenStore.swift */, + 338D1B6319B2BB8DF3BA6E99 /* CloudKitClient.swift */, + 0D9AAF8DAE58F885C9741413 /* CloudKitRecordMapper.swift */, + A1F3129242478B2D5F49C0D5 /* DeviceIdentity.swift */, + 35237A69147FB81A638492C7 /* DurableTombstoneStore.swift */, + 6AB651A97BA8CE8B5BD6E97B /* NSUKVSBridge.swift */, + 555BC3B182915D99AE4D6E83 /* SyncOutboundQueue.swift */, + 6CF51FC0C60A1FF5E749759B /* SyncPipeline.swift */, F693921BA233745D77EC0037 /* SyncStatusMonitor.swift */, 01E72DDEAD6225A21E973A51 /* SyncTypes.swift */, C9AB5E0256EC0FE97B68DE5D /* TombstoneStore.swift */, @@ -819,8 +1312,9 @@ B84D0E1452F211B986E8328A /* ContentHasher.swift */, A43A801A0876E2437CE63808 /* EncodingDetector.swift */, 7C0A7E77EFE308BC9CF8A3FE /* ErrorMessageAuditor.swift */, + D52DADD2C2FB6330070EA6DE /* FileSizeFormatter.swift */, + AF4E488AD7274100802E64AD /* HighlightedSnippet.swift */, D2AEB48E4BF208D97EEF397C /* QuoteRecovery.swift */, - WI06000002A1B2C3D4E5F602 /* FileSizeFormatter.swift */, 00FE0912FBC85E22DF8C637F /* ReadingTimeFormatter.swift */, CC4425D7764DA53AD595FF93 /* ReduceMotionHelper.swift */, ); @@ -839,7 +1333,7 @@ C89089F8D64EB38CF661EAB4 /* Services */, 3EC42569191D945E8426907A /* Utils */, 31E042EB986C2221B3740C56 /* ViewModels */, - C1A2B3C4D5E60001AABB1010 /* Views */, + 255C46B94F558C0D47C58F15 /* Views */, ); path = vreaderTests; sourceTree = ""; @@ -854,6 +1348,48 @@ name = Products; sourceTree = ""; }; + 1F91A1066C385178070AEA40 /* Settings */ = { + isa = PBXGroup; + children = ( + C68E9908FEE2CDE00C6EB279 /* AISettingsViewModelTests.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 232456552E53357A5363638A /* Backup */ = { + isa = PBXGroup; + children = ( + 8AB2B5F77B95D3402E699DA9 /* BackupProvider.swift */, + 5EC76EA043F57DC3B5650C82 /* PROPFINDParser.swift */, + 1AE71C3AADFD978879F217BF /* WebDAVClient.swift */, + 03F1597A24B4874C1AD29043 /* WebDAVProvider.swift */, + CAEDD9E567FC32F217A07CAF /* ZIPWriter.swift */, + ); + path = Backup; + sourceTree = ""; + }; + 255C46B94F558C0D47C58F15 /* Views */ = { + isa = PBXGroup; + children = ( + BDA2CABDFE8AC4CE645BAD05 /* Library */, + 3FEBC4F34FA9A312FFFA1513 /* Reader */, + 1F91A1066C385178070AEA40 /* Settings */, + ); + path = Views; + sourceTree = ""; + }; + 2738D73A0AE7DCF6486B429D /* BookSource */ = { + isa = PBXGroup; + children = ( + 41D6AA10585EA3CE63143C50 /* BookSourceEditorView.swift */, + A97BCD7E2CCDE30EA6372D6C /* BookSourceListView.swift */, + 7A691D7600EDE0812CC1E6AD /* BookSourceSearchView.swift */, + 9541B260A743177FAD2B96EE /* BookSourceChapterListView.swift */, + 3A13D6063551337AC540840D /* BookSourceReaderView.swift */, + ); + path = BookSource; + sourceTree = ""; + }; 282786F5712B97081F2285ED /* Keyboard */ = { isa = PBXGroup; children = ( @@ -865,11 +1401,11 @@ 31E042EB986C2221B3740C56 /* ViewModels */ = { isa = PBXGroup; children = ( - WI12000007A1B2C3D4E5F607 /* AITranslationTests.swift */, - WI10000005A1B2C3D4E5F605 /* AIReaderIntegrationTests.swift */, EA401D8FC3B4F17213528B27 /* AIAssistantViewModelTests.swift */, - WI11000007A1B2C3D4E5F607 /* AIChatViewModelTests.swift */, - WI13000001A1B2C3D4E5F601 /* AIChatGeneralTests.swift */, + B90F4EB83CC68406DA14DD94 /* AIChatGeneralTests.swift */, + E861A379A620B769CEA36300 /* AIChatViewModelTests.swift */, + 4E706779E64026004319957F /* AIReaderIntegrationTests.swift */, + AE29B97304B59C88E3F3F9CF /* AITranslationTests.swift */, 4D5CDE195E585067DE4D6124 /* AnnotationListViewModelTests.swift */, 256C28A508EAD4DB73B49DD4 /* BookmarkListViewModelTests.swift */, 275DFDD33FCF69E75F251F27 /* HighlightListViewModelTests.swift */, @@ -903,6 +1439,36 @@ path = vreaderUITests; sourceTree = ""; }; + 38A5EAA412AB13E9A5DB6C10 /* BookSource */ = { + isa = PBXGroup; + children = ( + 7C6CC009677C66D3AF4E5836 /* BookSourceHTTPClient.swift */, + 852FC8DBE71B720469C296C4 /* WebPageEncodingDetector.swift */, + FE0AB24552C147A6A2D56EE3 /* LegadoImporter.swift */, + 99782DD1A320D265D78FAA12 /* SourceSharingService.swift */, + EE99E42CBCBD4C1784CBDBA1 /* LegadoCompatibility.swift */, + E122212D54348149A32DC51B /* RuleEngine.swift */, + 410123F7E79BF50A70C95A03 /* CSSRuleEvaluator.swift */, + 8FA0499B50436361D13BA5D0 /* RegexRuleEvaluator.swift */, + B5099FDCB9D68C511D5C59FA /* LegadoRuleParser.swift */, + 78E21000C7B6029FCAD6E13E /* HTMLHelper.swift */, + EA41D8643B42B4B25CB6D96A /* PipelineTypes.swift */, + 8E6850832765DA26713F278C /* BookSourcePipeline.swift */, + D06CC002A1B2C3D4E5F60002 /* ChapterCache.swift */, + 618A3B94C15C9AC8BC6C33C7 /* UpdateChecker.swift */, + ); + path = BookSource; + sourceTree = ""; + }; + 39C1AC75099796E288B434A2 /* Import */ = { + isa = PBXGroup; + children = ( + 889CBB149D6F4922CE90C0A8 /* AnnotationImporterTests.swift */, + 61B2950791C297B18BBEC3E3 /* VReaderAnnotationParserTests.swift */, + ); + path = Import; + sourceTree = ""; + }; 3C10281F62243C7F619315EA /* Navigation */ = { isa = PBXGroup; children = ( @@ -924,12 +1490,57 @@ children = ( 336495F8165F79A364CE9B09 /* AccessibilityFormattersTests.swift */, 19AC5688AF504E6253728C73 /* ErrorMessageAuditorTests.swift */, + 631D0375777D50E5B1EDF31C /* HighlightedSnippetTests.swift */, 92AB43BED5AC7096E7278A16 /* QuoteRecoveryTests.swift */, 9CED0BF7A8104C22C6E293BF /* ReadingTimeFormatterTests.swift */, ); path = Utils; sourceTree = ""; }; + 3FEBC4F34FA9A312FFFA1513 /* Reader */ = { + isa = PBXGroup; + children = ( + C00E5921EA2B5FE88656FED6 /* AnnotationsPanelViewTests.swift */, + C85205257E8103AA80C08BAB /* BookmarkFeedbackTests.swift */, + BD9F0676ACCEE6F37D547E72 /* EPUBHighlightActionsTests.swift */, + 5C5EC86BB06D46DC9A5A4F6B /* EPUBHighlightBridgeTests.swift */, + 910F691A853144404676CFB3 /* HighlightRendererTests.swift */, + 68F802F6FC8A94BCA0925BEC /* EPUBHighlightRendererBug77Tests.swift */, + 531A945FDC79FC3FBB5B3AD5 /* PagedModeBug82Tests.swift */, + 92746A75F27A7F5427FC9199 /* TransformsBug98Tests.swift */, + C7F837206FBB8B11B14D1EC5 /* HighlightCoordinatorTests.swift */, + B9500033AC714D470A3024F8 /* EPUBPaginationTests.swift */, + 8FA15EA6877E3CA9421E1B59 /* EPUBProgressTests.swift */, + ABFBA14606BD14D14A8D5500 /* EPUBWebViewBridgeTests.swift */, + 16E293CFD61A19BB48B38963 /* HighlightableTextViewTests.swift */, + D5CE4AD339F8C68B973D9C88 /* NativeTextPagedIntegrationTests.swift */, + 295669F5B358B6C7BE41952E /* NativeTextPaginatorTests.swift */, + CB7EBD49DAE8D5F5BC4C7207 /* PageTurnAnimatorTests.swift */, + 4B3A240BB6031B14144741FE /* PDFAnnotationBridgeTests.swift */, + B849723B3079FB8F3F4A7961 /* PDFHighlightIntegrationTests.swift */, + 3C39119533D269F76F970FD1 /* PDFPageNavigatorTests.swift */, + 775CED0704F1D6D39F873FF9 /* PDFProgressTests.swift */, + 5E270DE50F23F6125F3151CA /* PhaseBMediumAuditTests.swift */, + DD76366E51B98FEE9E53DB3C /* ReaderAuditFix2Tests.swift */, + 5EB15AD471389C6DEDDD0286 /* ReaderAuditFix3Tests.swift */, + 43DA904E79F5CF69E46ECC26 /* ReaderAuditFixTests.swift */, + EF9547D23D813327B536EAD5 /* ReaderBottomOverlayTests.swift */, + 4CC7EBBBE07E87F07CC0FE4F /* ReaderNotificationHandlerTests.swift */, + B7E3CF12A4D922873FC18E55 /* TextReaderUIStateTests.swift */, + 2B7C59839A119870BE9B6FF9 /* ReaderSelectionEventTests.swift */, + 63E4737FA3A880C3CC2BA07D /* ReadingProgressBarTests.swift */, + 62569DC663E2BDD2DC0155C3 /* SearchHighlightDismissTests.swift */, + AB8FC6D57843EAC26DB980D3 /* SearchResultHighlightTests.swift */, + 4AC68C4B5F57B57A98D3C020 /* TapZoneTests.swift */, + 47AA8588621686E377D9D496 /* TXTBridgeSharedTests.swift */, + F213D0F6EFBD7D7088AAC40D /* TXTMDProgressTests.swift */, + E1EF16A1A352B2C6BAE84556 /* UnifiedMDTests.swift */, + 9F7F45FFFEE431C41E618EF2 /* UnifiedTextRendererTests.swift */, + D696HR9SIOM4CX0BL1GL91WK /* BookContentCacheTests.swift */, + ); + path = Reader; + sourceTree = ""; + }; 426A9C0082A8466F8713D3A3 /* Bookmarks */ = { isa = PBXGroup; children = ( @@ -939,6 +1550,26 @@ path = Bookmarks; sourceTree = ""; }; + 48274A6BA7254678BC185584 /* BookSource */ = { + isa = PBXGroup; + children = ( + 3323FF67F207437A97D5AB8C /* legado_single_source.json */, + F1D3CAC117B849EE88AE0A5F /* legado_multiple_sources.json */, + FBCB98531364449BB44DDC00 /* legado_source_with_unknown_fields.json */, + 9B4C9FADDEE54F268F536EC5 /* legado_source_xpath.json */, + D0966F97359B41E9AB958A02 /* legado_source_js.json */, + FCB2E5176B454D4A8C82E893 /* legado_source_minimal.json */, + 56955DD1A478DEED93B590C9 /* search_results.html */, + F5DFC4A77EA2740C79B2EC34 /* book_detail.html */, + 2026D64F5931E86FF0E34945 /* chapter_list.html */, + DDC5E430511CD7491C543A15 /* chapter_content.html */, + F099DED45D1C192D6F99A194 /* search_no_results.html */, + 9509CC40145B03A66875390C /* chapter_list_paginated.html */, + 6B600146907F8D9159F2B777 /* chapter_list_page2.html */, + ); + path = BookSource; + sourceTree = ""; + }; 4A2763D1CAC2BDF0F9A60D00 /* Helpers */ = { isa = PBXGroup; children = ( @@ -949,6 +1580,15 @@ path = Helpers; sourceTree = ""; }; + 4E1FFF75D59C48ECF6498EA5 /* TextKit2Spike */ = { + isa = PBXGroup; + children = ( + 3D70FBE6B4F5F9DDBA035D25 /* SPIKE_RESULTS.md */, + 2F317CDBF13A6A5673D64A8A /* TextKit2Paginator.swift */, + ); + path = TextKit2Spike; + sourceTree = ""; + }; 53B51FC470B475497769270A /* AI */ = { isa = PBXGroup; children = ( @@ -962,6 +1602,7 @@ isa = PBXGroup; children = ( 7FC8555E3C352A0C95D6BFE9 /* LocatorFactory.swift */, + E7D86F739CC4D19AF019F728 /* LocatorNormalizer.swift */, 4B81B909B41CB8C14B613D73 /* LocatorRestorer.swift */, ); path = Locator; @@ -971,7 +1612,7 @@ isa = PBXGroup; children = ( 67BCECCD4519E437A347DBA5 /* MDAttributedStringRendererTests.swift */, - C1A2B3C4D5E60001AABB8C04 /* MDFileLoaderTests.swift */, + FE6974D0F73862058FC97358 /* MDFileLoaderTests.swift */, 5FDB1CD71ED267C2FD85F325 /* MDMetadataExtractorTests.swift */, 4DCA33DBE911B5B1B6425277 /* MDTypesTests.swift */, 4E9F1953C017E6B1F990FB44 /* MockMDParser.swift */, @@ -983,6 +1624,7 @@ isa = PBXGroup; children = ( 055FBEA382F82BE342A50C8E /* LocatorFactoryTests.swift */, + 22C20D015AD61E29BEB57DC3 /* LocatorNormalizerTests.swift */, B10A08E980C92248337462DF /* LocatorRestorerTests.swift */, ); path = Locator; @@ -1001,13 +1643,15 @@ isa = PBXGroup; children = ( E3D8B3D17D6551C053F12355 /* MockTXTService.swift */, - 01B2C3D4E5F60718AABB0004 /* TXTAttributedStringBuilderTests.swift */, + 77119C3681DB428BD5F1207C /* TXTAttributedStringBuilderTests.swift */, A069D350D0DC6B4C000E1D43 /* TXTChunkedLoaderTests.swift */, - C1A2B3C4D5E60001AABB8D04 /* TXTFileLoaderTests.swift */, + 0F7059169A9ADC70EE01420E /* TXTFileLoaderTests.swift */, 5FA7AE29709E497DBB6AF9DF /* TXTOffsetMapperTests.swift */, - C1A2B3C4D5E60001AABB0004 /* TXTTextChunkerTests.swift */, D998048CE6DE8DC3BC77C284 /* TXTReaderViewModelTests.swift */, - F1A2B3C4D5E6F70812340001 /* TXTServiceTests.swift */, + EA7540DF541EE961F4442A67 /* TXTServiceTests.swift */, + 7008A22A4FBE1574E8B9C8A4 /* TXTStreamingOpenTests.swift */, + 4A3BC126A794F2C82F782E7D /* TXTTextChunkerTests.swift */, + 660A657BC4712219BAF7858C /* TXTTocRuleEngineTests.swift */, ); path = TXT; sourceTree = ""; @@ -1016,12 +1660,50 @@ isa = PBXGroup; children = ( 9C5A2D5CFE8B719D4C8F3580 /* SchemaV1.swift */, - SPIKEC004A1B2C3D4E5F60004 /* SchemaV2.swift */, - SPIKEC006A1B2C3D4E5F60006 /* V1toV2Migration.swift */, + F379B3EEC02DC574C73F4323 /* SchemaV2.swift */, + 470BBE13ED7BCDD6E60D3400 /* SchemaV3.swift */, + B1C2D3E4F5A6B7C8D9E0F1A2 /* SchemaV4.swift */, + DB16317C72EB6BBB61D77030 /* V1toV2Migration.swift */, ); path = Migration; sourceTree = ""; }; + 6357F7D5E65FD0E7D03AC85B /* OPDS */ = { + isa = PBXGroup; + children = ( + D5C26DE0705F29A16D1919F5 /* OPDSParserTests.swift */, + ); + path = OPDS; + sourceTree = ""; + }; + 6B8B8F34AB2CC69F7875AEF8 /* Unified */ = { + isa = PBXGroup; + children = ( + 0FF60B935D0DF315B8705C2B /* PaginationCacheTests.swift */, + ); + path = Unified; + sourceTree = ""; + }; + 6C040A57F55B7CB815C0F614 /* TextMapping */ = { + isa = PBXGroup; + children = ( + F197589BA55261B91DBFFEE0 /* TextMapperTests.swift */, + 4C34F8D2600AE19D9C4F2E44 /* SimpTradTransformTests.swift */, + 54EFBCCD0BA84516D28B0568 /* ReplacementTransformTests.swift */, + ); + path = TextMapping; + sourceTree = ""; + }; + 6F08819FD1F3F1436E8B755C /* Library */ = { + isa = PBXGroup; + children = ( + 9DB260B5C0A147C9AE9A46DC /* BookInfoSheet.swift */, + 098863E9E4647225E16F2E41 /* CollectionSidebar.swift */, + AD065491E8CFE99188D62E09 /* ShareSheet.swift */, + ); + path = Library; + sourceTree = ""; + }; 7D99185EA50E0F1BCFB67881 /* Annotations */ = { isa = PBXGroup; children = ( @@ -1034,7 +1716,7 @@ isa = PBXGroup; children = ( 6A5928551FF9FBB8D2E87E6F /* AIAssistantView.swift */, - WI11000005A1B2C3D4E5F605 /* AIChatView.swift */, + 539FEE4A23AFA226048A12A4 /* AIChatView.swift */, 83B60F61628D0969B18B3A9B /* AIConsentView.swift */, ); path = AI; @@ -1052,37 +1734,74 @@ 8D2F333DAEB9758BF44807FC /* Reader */ = { isa = PBXGroup; children = ( - WI10000003A1B2C3D4E5F603 /* AIReaderPanel.swift */, - WI12000003A1B2C3D4E5F603 /* BilingualView.swift */, - WI12000005A1B2C3D4E5F605 /* TranslationPanel.swift */, + 6DF113D7E83A1CE51F417258 /* AIReaderPanel.swift */, + F59ACCAF30F5092968415855 /* AnnotationsPanelView.swift */, + 8781075EA7AF25572A741C40 /* BilingualView.swift */, + FBD54F8887091F46753F09A4 /* DictionarySheet.swift */, + E28AEE54347E9EC752286A2A /* EPUBHighlightActions.swift */, + 435C00E099B7F5D7A7821FDC /* EPUBHighlightBridge.swift */, + 44423E8976A2B27C4B14617F /* EPUBHighlightJS.swift */, + E5EC9B0FFB09D08F65F205F3 /* EPUBPaginationHelper.swift */, + DBBB555BA86F7648ACBC780F /* EPUBProgressCalculator.swift */, 907D934613DDAEA1F3055F82 /* EPUBReaderContainerView.swift */, + 531EED0BA29E12B1336BF72A /* EPUBReaderContainerView+Highlights.swift */, + 2BDD567DDFEEAFDE5C1EC9AF /* EPUBReaderContainerView+Navigation.swift */, + 61D1FFAC6D1E473B42A52C50 /* HighlightRenderer.swift */, + D2F37065575D4351969AC869 /* TextHighlightRenderer.swift */, + 8EBA861AF0ED9B1F194C06D0 /* EPUBHighlightRenderer.swift */, + 940E5A743EBB62A0EEEAE70C /* PDFHighlightRenderer.swift */, + 04C1DFA6B0FFE6606113B92E /* HighlightCoordinator.swift */, C0E536B950D178C97842DF52 /* EPUBWebViewBridge.swift */, - C1A2B3C4D5E60001AABB4002 /* AnnotationsPanelView.swift */, - C1A2B3C4D5E60001AABB1002 /* HighlightableTextView.swift */, - C1A2B3C4D5E60001AABB5002 /* ReaderBottomOverlay.swift */, - C1A2B3C4D5E60001AABB5012 /* ReadingProgressBar.swift */, - C1A2B3C4D5E60001AABB5018 /* PDFProgressHelper.swift */, - C1A2B3C4D5E60001AABB501A /* ScrollProgressHelper.swift */, - C1A2B3C4D5E60001AABB501E /* EPUBProgressCalculator.swift */, - WI07000002A1B2C3D4E5F602 /* EPUBHighlightBridge.swift */, - WI07000006A1B2C3D4E5F606 /* EPUBHighlightJS.swift */, - WI07000008A1B2C3D4E5F608 /* EPUBHighlightActions.swift */, + 7A01B2C3D4E5F6A7B8C90013 /* EPUBWebViewBridgeJS.swift */, + 7A01B2C3D4E5F6A7B8C90014 /* EPUBWebViewBridgeCoordinator.swift */, + D9E867C06CA165E731435125 /* HighlightableTextView.swift */, 4B0EDE73D4EB702686B1326E /* MDReaderContainerView.swift */, - C1A2B3C4D5E60001AABB3006 /* NoOpPersistenceStores.swift */, + 3F47559B14E9DEE63DE613ED /* NativeTextPagedView.swift */, + FC729E5B1BA7DC69B40D4929 /* NativeTextPageNavigator.swift */, + 38E51E6D76FE0DDF87E537AB /* NativeTextPaginator.swift */, + E6D45B144AFD2D20CAEACC48 /* NoOpPersistenceStores.swift */, + E9D4D088D34AA8A5692A991B /* PageTurnAnimator.swift */, + A064D62C86857484454D0BE3 /* PDFAnnotationBridge.swift */, + 103B5BF35C8DC9FB2BE4998F /* PDFPageNavigator.swift */, 425829C48779CD64EB0C5A05 /* PDFPasswordPromptView.swift */, + 5A54A2C5DE8C1631C04BB2A1 /* PDFProgressHelper.swift */, 5D2BA1A05E4E36D5D7B2DCFD /* PDFReaderContainerView.swift */, - WI008002A1B2C3D4E5F60002 /* PDFAnnotationBridge.swift */, + F838BD23B13BE1FE9CD3E32F /* PDFReaderContainerView+Highlights.swift */, + 073E28FACD8057856C6723D4 /* PDFReaderContainerView+Overlays.swift */, 17E7FD8CD67F19A2213DB6F5 /* PDFViewBridge.swift */, + 14088AEC6B083B2C049AB25C /* ReaderAICoordinator.swift */, + A983D06F916C51795A2223E7 /* ReaderBottomOverlay.swift */, EAB42EEEFFCAD8D654D57AE7 /* ReaderContainerView.swift */, - C1A2B3C4D5E60001AABB4004 /* ReaderFormatHosts.swift */, - C1A2B3C4D5E60001AABB3002 /* ReaderNotificationHandlers.swift */, - C1A2B3C4D5E60001AABB3004 /* ReaderNotificationModifier.swift */, - C1A2B3C4D5E60001AABB2002 /* ReaderNotifications.swift */, + 784DE4C3D42697A49F4D0DBC /* ReaderContainerView+Sheets.swift */, + 805C79B33D1268AF7EA5CDBA /* ReaderUnifiedDispatch.swift */, + FF23B1A0CC0BE35DF685C5FA /* ReaderFormatHosts.swift */, + 82BC782199D1750DA66D1BCC /* ReaderNotificationHandlers.swift */, + A579625590B25F81679F1EA0 /* ReaderNotificationModifier.swift */, + C6F7EC017FF822492219E162 /* TextReaderUIState.swift */, + DDB7C7EC41A96F5D4B53E983 /* ReaderNotifications.swift */, + B501E24B36BF00B609B04BF3 /* ReaderSearchCoordinator.swift */, 32F75167F586CEA5F4E9002C /* ReaderSettingsPanel.swift */, - C1A2B3C4D5E60001AABB2004 /* TXTBridgeShared.swift */, - C1A2B3C4D5E60001AABB0006 /* TXTChunkedReaderBridge.swift */, + C2780A3796872F31F1666DA7 /* ReaderTOCBuilder.swift */, + 50AA77FDB19CB7EDA69418C8 /* ReaderUnifiedCoordinator.swift */, + 68A7FC6A70A060CD5E43602E /* ReadingProgressBar.swift */, + 7024E7AEAC9AEAA028952C46 /* ScrollProgressHelper.swift */, + A1B2C3D4E5F6A7B8C9D0E1F2 /* ReaderChromeBar.swift */, + 271BAF9BD03F619061BA4D96 /* TapZoneOverlay.swift */, + 3753D7CD01EA589932DF780C /* ThemeBackgroundView.swift */, + 21B3F47E988913B477EACF93 /* TranslationPanel.swift */, + 54F63868C11B04D324F09751 /* TTSControlBar.swift */, + A43C03327815457BD7B01409 /* TXTBridgeShared.swift */, + 7205862B286DDE2DD2233F6D /* TXTChunkedReaderBridge.swift */, + 7A01B2C3D4E5F6A7B8C90015 /* TXTChunkedHighlightHelper.swift */, 725F9036150B858160ACFF7B /* TXTReaderContainerView.swift */, + FC3A1586B92D00DD884FA841 /* TXTReaderContainerView+Helpers.swift */, + 7A01B2C3D4E5F6A7B8C90011 /* TXTViewConfig.swift */, 43347208CA56C84F65B2DCF7 /* TXTTextViewBridge.swift */, + 7A01B2C3D4E5F6A7B8C90012 /* TXTTextViewBridgeCoordinator.swift */, + F82AD6F50703DBCA984EBAAC /* UnifiedPagedView.swift */, + 34E23954103D83A7E25CC4A4 /* UnifiedPlaceholderView.swift */, + A0753D8C40DFE06B0323EE5B /* UnifiedScrollView.swift */, + 13A1A428419630E618721E37 /* UnifiedTextRenderer.swift */, ); path = Reader; sourceTree = ""; @@ -1103,24 +1822,46 @@ isa = PBXGroup; children = ( AE0CDD04120BFF39B61E8418 /* BackgroundIndexingCoordinatorTests.swift */, - A5B6C7D8E9F01A2B3C4D5E6F /* EPUBTextExtractorTests.swift */, - C7D8E9F01A2B3C4D5E6F7081 /* MDTextExtractorTests.swift */, + DADECFF5C347B6D68C1A8529 /* EPUBTextExtractorTests.swift */, + 0DFC3C0DD795D886D77A6881 /* MDTextExtractorTests.swift */, + E25EC6B4A507BE08D646A4AD /* PersistentSearchIndexTests.swift */, F9102277C4126793229AEEB9 /* SearchHitToLocatorResolverTests.swift */, - F01A2B3C4D5E6F7081920304 /* TXTTextExtractorTests.swift */, BB7031C26EB38B7B1D2A0BEF /* SearchIndexStoreTests.swift */, - C1A2B3C4D5E60001AABB7E06 /* SearchQueryExecutorTests.swift */, + C0B1246EC0EE34D326231F60 /* SearchQueryExecutorTests.swift */, 32DF5DC258E6460D4FE84706 /* SearchServiceTests.swift */, FBC4E25069345D28610C64EC /* SearchTextNormalizerTests.swift */, + 8707DA3A6FC024EAC4F63E76 /* TXTTextExtractorTests.swift */, ); path = Search; sourceTree = ""; }; + 9449AF5290B43E062FF6882D /* Settings */ = { + isa = PBXGroup; + children = ( + 25AC1B3E1E87D71E229C3EF6 /* AISettingsSection.swift */, + AB0C783DCFD9CFDD8F488F80 /* AISettingsViewModel.swift */, + 138AEC5BBAD52B075096E5C8 /* SettingsView.swift */, + 0965D8213AFBB980604A7592 /* WebDAVSettingsView.swift */, + 1FDACAD61BCFC3618CD18675 /* ReplacementRulesView.swift */, + 27260FA0FED437E1EB06E0CD /* HTTPTTSSettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 947C994C5F48818BEAA51792 /* Unified */ = { + isa = PBXGroup; + children = ( + EA0C50C474AA2F96C39AAC94 /* PaginationCache.swift */, + ); + path = Unified; + sourceTree = ""; + }; 94D06942F90AF64D53FF08E9 /* ViewModels */ = { isa = PBXGroup; children = ( 46221600B62F5482365B0484 /* AIAssistantViewModel.swift */, - WI12000001A1B2C3D4E5F601 /* AITranslationViewModel.swift */, - WI11000003A1B2C3D4E5F603 /* AIChatViewModel.swift */, + 206F4BCC7294731E8A3A82DB /* AIChatViewModel.swift */, + 82B992B24F86DF8CA23E1215 /* AITranslationViewModel.swift */, 7A980DB0017049401DAB3E93 /* AnnotationListViewModel.swift */, D432C9B43D1B6662B4605664 /* BookmarkListViewModel.swift */, 90B380013D82DFFD0411633E /* EPUBReaderViewModel.swift */, @@ -1128,12 +1869,34 @@ CF473C04CE58CE0887DAA5A1 /* LibraryViewModel.swift */, 68BB90FF5CA185660AAE66BD /* MDReaderViewModel.swift */, 43D54AC9AD2556A67C96BD52 /* PDFReaderViewModel.swift */, + R6LC0002A1B2C3D4E5F60002 /* ReaderLifecycleHelper.swift */, 864A1B050775A46ADBE3304F /* SearchViewModel.swift */, E19A1FE14FDE4829AF0F5913 /* TXTReaderViewModel.swift */, + 7056486BA460E0BE06235F54 /* UnifiedTextRendererViewModel.swift */, ); path = ViewModels; sourceTree = ""; }; + 9A27269DBB24004990DDA77D /* OPDS */ = { + isa = PBXGroup; + children = ( + 8C9E438077E6D10199BA12CE /* OPDSBrowserView.swift */, + 0F64D33F5E4EB6B2F8F10CC8 /* OPDSCatalogListView.swift */, + 83F36EF81271874A13417D45 /* OPDSEntryView.swift */, + ); + path = OPDS; + sourceTree = ""; + }; + A4FCC2B159B04F516D5E542E /* OPDS */ = { + isa = PBXGroup; + children = ( + F77371DBD389CE1F1153E56E /* OPDSClient.swift */, + 33CA445AA96E5EEBA05B7C36 /* OPDSModels.swift */, + 3DF47D926F78F13327F6770C /* OPDSParser.swift */, + ); + path = OPDS; + sourceTree = ""; + }; A7EE43B11B0E83A5DCC7E9D2 /* Sync */ = { isa = PBXGroup; children = ( @@ -1143,6 +1906,25 @@ path = Sync; sourceTree = ""; }; + AABB001122334455AABB0011 /* TTS */ = { + isa = PBXGroup; + children = ( + 385FF266F3625A73EC23C8BF /* HTTPTTSConfigTests.swift */, + 45B8412E88A8A21540AFCC27 /* HTTPTTSProviderTests.swift */, + F97894D9D7A3FC8227521C6E /* MockURLSession.swift */, + ); + path = TTS; + sourceTree = ""; + }; + AD90252EC7BC13BB04C75119 /* Migration */ = { + isa = PBXGroup; + children = ( + AEE844A76B8AFC7B1DC2E840 /* HighlightAnchorStorageTests.swift */, + 939BC09F1D771D2E22301ED3 /* V1toV2MigrationTests.swift */, + ); + path = Migration; + sourceTree = ""; + }; AE4E61A680128143BD32AA91 /* Views */ = { isa = PBXGroup; children = ( @@ -1154,10 +1936,12 @@ 80635BFEACEBE8B6FF3653D4 /* AI */, E38F2C520CBAABE13CDFD35A /* Annotations */, 426A9C0082A8466F8713D3A3 /* Bookmarks */, - WI0600000AA1B2C3D4E5F60A /* Library */, + 6F08819FD1F3F1436E8B755C /* Library */, + 9A27269DBB24004990DDA77D /* OPDS */, + 2738D73A0AE7DCF6486B429D /* BookSource */, 8D2F333DAEB9758BF44807FC /* Reader */, CCD72732DFB3705741783948 /* Search */, - WI0900000AA1B2C3D4E5F60A /* Settings */, + 9449AF5290B43E062FF6882D /* Settings */, A7EE43B11B0E83A5DCC7E9D2 /* Sync */, ); path = Views; @@ -1176,14 +1960,23 @@ children = ( 0069CA3A00998B13DE06E424 /* LocatorIntegrationTests.swift */, A9EE79C451D86828387A1BEF /* MDIntegrationTests.swift */, + 25642951BDB16B5C59BF39CF /* ModeSwitchPersistenceTests.swift */, 15E22141BA785A9A62A4BE9A /* PDFIntegrationTests.swift */, DE2038A4D36C4355AC5C7BF5 /* SearchLocatorSliceTests.swift */, - B2C3D4E5F6071829A3B4C5D6 /* SearchWiringTests.swift */, + 0C47B0077BE4937C424FFBD9 /* SearchWiringTests.swift */, E50B0954852C3621D008EE07 /* TXTBridgeOffsetTests.swift */, ); path = Integration; sourceTree = ""; }; + BDA2CABDFE8AC4CE645BAD05 /* Library */ = { + isa = PBXGroup; + children = ( + C42FD55371CD8FA98699541E /* LibraryContextMenuTests.swift */, + ); + path = Library; + sourceTree = ""; + }; C021FB2F3866A0572AA413C7 /* Library */ = { isa = PBXGroup; children = ( @@ -1202,65 +1995,48 @@ C0B6C8014BAA5AFC1F7476A3 /* TXT */ = { isa = PBXGroup; children = ( - 01B2C3D4E5F60718AABB0002 /* TXTAttributedStringBuilder.swift */, + 9844BEF447FBDBA15ADCEFAB /* TXTAttributedStringBuilder.swift */, D02AA769AEDBC25CEA896348 /* TXTChunkedLoader.swift */, - C1A2B3C4D5E60001AABB8D02 /* TXTFileLoader.swift */, + 08B42D93C357CAAFB4261D93 /* TXTFileLoader.swift */, C2D49361893EE9956D6EC5DB /* TXTOffsetMapper.swift */, + B16D420A03BD524C247A78FB /* TXTReflowableTextSource.swift */, B1DBBFF061B96088FFE84194 /* TXTService.swift */, FFE1146B1851B4122B5187A1 /* TXTServiceProtocol.swift */, - C1A2B3C4D5E60001AABB0002 /* TXTTextChunker.swift */, + 00B189F7F32FF82BCF254923 /* TXTTextChunker.swift */, + E12EBCB8CD58F740D9042C32 /* TXTTocRule.swift */, + 5D8A001ABD732F1E2FF24463 /* TXTTocRuleEngine.swift */, ); path = TXT; sourceTree = ""; }; - C1A2B3C4D5E60001AABB1010 /* Views */ = { + C1590617A7AA8A9252EAE3CA /* BookSource */ = { isa = PBXGroup; children = ( - WI0600000BA1B2C3D4E5F60B /* Library */, - C1A2B3C4D5E60001AABB1011 /* Reader */, - WI0900000BA1B2C3D4E5F60B /* Settings */, - ); - path = Views; - sourceTree = ""; - }; - C1A2B3C4D5E60001AABB1011 /* Reader */ = { - isa = PBXGroup; - children = ( - C1A2B3C4D5E60001AABB1004 /* HighlightableTextViewTests.swift */, - C1A2B3C4D5E60001AABB4006 /* AnnotationsPanelViewTests.swift */, - C1A2B3C4D5E60001AABB5004 /* ReaderBottomOverlayTests.swift */, - C1A2B3C4D5E60001AABB5014 /* ReadingProgressBarTests.swift */, - C1A2B3C4D5E60001AABB5016 /* PDFProgressTests.swift */, - C1A2B3C4D5E60001AABB501C /* TXTMDProgressTests.swift */, - C1A2B3C4D5E60001AABB5020 /* EPUBProgressTests.swift */, - 6ECD0FC78B12AED09BA2A1DF /* EPUBWebViewBridgeTests.swift */, - WI07000004A1B2C3D4E5F604 /* EPUBHighlightBridgeTests.swift */, - WI0700000AA1B2C3D4E5F60A /* EPUBHighlightActionsTests.swift */, - C1A2B3C4D5E60001AABB3008 /* ReaderNotificationHandlerTests.swift */, - C1A2B3C4D5E60001AABB2006 /* TXTBridgeSharedTests.swift */, - SPIKEC00CA1B2C3D4E5F6000C /* ReaderSelectionEventTests.swift */, - WI008004A1B2C3D4E5F60004 /* PDFAnnotationBridgeTests.swift */, - WI008006A1B2C3D4E5F60006 /* PDFHighlightIntegrationTests.swift */, - AUDIT8000002 /* ReaderAuditFixTests.swift */, - AUDIT9000002 /* ReaderAuditFix2Tests.swift */, - AUDITA000002 /* ReaderAuditFix3Tests.swift */, - AUDIT5000004 /* BookmarkFeedbackTests.swift */, - AUDIT7000002 /* SearchHighlightDismissTests.swift */, - ); - path = Reader; + 0C49D770261B82856A40938B /* BookSourceHTTPClientTests.swift */, + D06CC004A1B2C3D4E5F60004 /* ChapterCacheTests.swift */, + 876AE3BC21A748FDD619EED2 /* WebPageEncodingDetectorTests.swift */, + 8AFC46BDDED74CC58966FD7E /* LegadoImporterTests.swift */, + 8F55A72D6B1A8FE2889EFB28 /* SourceSharingServiceTests.swift */, + EB007C93A274D2EB4EF7A3B9 /* RuleEngineTests.swift */, + 386F15A3CC3059EC5B4E3872 /* CSSRuleEvaluatorTests.swift */, + B6F8130B082FB1C006A2F711 /* LegadoRuleParserTests.swift */, + 8FAB86FF543782FB25FCE33C /* BookSourcePipelineTests.swift */, + F0C394C4CBC74CD9F661A403 /* UpdateCheckerTests.swift */, + ); + path = BookSource; sourceTree = ""; }; C31B38FD3E940430CFB54754 /* Search */ = { isa = PBXGroup; children = ( 3BAA1482C17A94A21E44EFEB /* BackgroundIndexingCoordinator.swift */, - E1A2B3C4D5E6F70819203142 /* EPUBTextExtractor.swift */, - D3E4F5A6B7C8091A2B3C4D5E /* MDTextExtractor.swift */, + 533D2D4F8B5502E8D51A714E /* EPUBTextExtractor.swift */, + F9D9F31A3F96B73C8E3653E6 /* MDTextExtractor.swift */, 428A4370B8DB59563F9EE768 /* PDFTextExtractor.swift */, 40A2CD5F8AD4DEC1A14A255A /* SearchHitToLocatorResolver.swift */, - C1A2B3C4D5E60001AABB7E02 /* SearchIndexCore.swift */, + 14418F18DF9D9A3B912AC090 /* SearchIndexCore.swift */, 9DB1CCA367AE0B610F4526FC /* SearchIndexStore.swift */, - C1A2B3C4D5E60001AABB7E04 /* SearchQueryExecutor.swift */, + 69C7229E97D0F8B543683A02 /* SearchQueryExecutor.swift */, 41F3E1A368720EFCB55A9582 /* SearchService.swift */, 30D00221E111683E9FF0260A /* SearchTextExtractor.swift */, E54B97E5C67533291971CA1D /* SearchTextNormalizer.swift */, @@ -1276,6 +2052,13 @@ 6362591D3F62B4BC84CE136A /* FileAvailabilityStateMachineTests.swift */, AF495D7A9D6F8F137DD42CE0 /* SyncConflictResolverTests.swift */, EB81DF32070BCFB6D8653800 /* SyncServiceTests.swift */, + A62E09E73F40E15B8AE10F61 /* ChangeTokenStoreTests.swift */, + E10009F6F0D32B9A9E2D16A9 /* CloudKitRecordMapperTests.swift */, + E2C99AB2454A14C2424347F5 /* DeviceIdentityTests.swift */, + 2FC2642E3A5DC9FDFE1C4111 /* DurableTombstoneStoreTests.swift */, + 516DF5B639FADAB9842388B7 /* NSUKVSBridgeTests.swift */, + 14D74EB30AD402C81A7F3E56 /* SyncOutboundQueueTests.swift */, + EDE7986F0EE66546C2CB5751 /* SyncPipelineTests.swift */, 06EFFD5584526A6602FEE2E1 /* SyncTestHelpers.swift */, A1E577DAF65D34544D713137 /* TombstoneStoreTests.swift */, ); @@ -1285,32 +2068,55 @@ C89089F8D64EB38CF661EAB4 /* Services */ = { isa = PBXGroup; children = ( + E2396FE1BB03C2F3CEFAB8E2 /* AutoPageTurnerTests.swift */, C0FBDC41C60328ED4FB8A197 /* BookImporterTests.swift */, + B442FC7A6A6ED344AB5C1FC9 /* CollectionPersistenceTests.swift */, + 6F3F2340D008C0F77D922091 /* CollectionTestHelper.swift */, + 9C131E2BC3B226E7009FA006 /* SeriesTagPersistenceTests.swift */, 0616892213196BCF802266F8 /* ContentHasherTests.swift */, + 5955766DF21883C0C57B71E4 /* CustomCoverStoreTests.swift */, + 7DBF9C3C1FBBCE4354F7DAAD /* DictionaryLookupTests.swift */, 77811B16F2CF741310C23CF5 /* EncodingDetectorTests.swift */, 43EE33FE1EDFE9B393885110 /* FeatureFlagsTests.swift */, + 4BD56854A37D8EA2B318B926 /* HighlightDedupeTests.swift */, + 6B04729AD87389C9ECEB13CE /* HighlightRecordAnchorTests.swift */, 744CB6180C0CE88E75E124F3 /* ImportErrorTests.swift */, 87DA305663C991FC6F15F80E /* ImportJobQueueTests.swift */, CF5C12635DFA6BEE42EBB1CE /* KeychainServiceTests.swift */, B6DAD680DB86CF1A65D34F3F /* LibraryRefreshServiceTests.swift */, 21BC5F159064D57CFAE2A676 /* MetadataExtractorTests.swift */, - C1A2B3C4D5E60001AABB6004 /* ReaderPositionServiceTests.swift */, + 544A2F3FF8BBB1C08DDCE02D /* PageNavigatorTests.swift */, + 272182B2C311AE5E968D314C /* PerBookSettingsTests.swift */, + 3629EA1FD0AAF0E1E903AC4E /* ReaderPositionServiceTests.swift */, 8A86CD6BD10792F5107FFB5A /* ReaderSettingsStoreTests.swift */, + 02DE298149C25261E2ECADA1 /* ReaderThemeCSSTests.swift */, 398F1BAF549E7AC80E9CE320 /* ReadingSessionTrackerTests.swift */, - 58456B0AB20C3D6AD39090A5 /* SwiftDataSessionStoreTests.swift */, - D93492717235FB856C8A06EE /* TOCBuilderMDTests.swift */, + A054B9D6DC875E4D8E7A5F28 /* ReflowableTextSourceTests.swift */, + C5959972E9775E5B52E5C840 /* SwiftDataSessionStoreTests.swift */, + 21055A4DC487F0F56BA7C475 /* ThemeBackgroundTests.swift */, + 456FDDA03D7DEF3A4AEB01DB /* TOCBuilderMDTests.swift */, + 49624FC3C8E1AC31E011351C /* TOCBuilderTXTTests.swift */, D83492717235FB856C8A06ED /* TOCProviderTests.swift */, + 96A2FE9743AF484093A21969 /* TTSServiceTests.swift */, + D450ACF0A0F47929E5916B26 /* TTSHighlightCoordinatorTests.swift */, 0A2A16647F198641F11AC9C1 /* WCAGContrastTests.swift */, - SPIKEC00EA1B2C3D4E5F6000E /* HighlightRecordAnchorTests.swift */, - WIC00F01A1B2C3D4E5F60010 /* HighlightDedupeTests.swift */, D90CA6776A077CBDC255F35C /* AI */, + D18FC2F3DB2B1B40D884B7A1 /* Backup */, EF527EE1B64863AD6FA373B4 /* EPUB */, + ED7D6CFBFC3C6AF82E21EA90 /* Export */, + 39C1AC75099796E288B434A2 /* Import */, 5F8FFECE27C7EBAE6B16B555 /* Locator */, 5D4C46833F42CC54A2204930 /* MD */, 8D9655BC6E5498F95CB02833 /* Mocks */, + 6357F7D5E65FD0E7D03AC85B /* OPDS */, 92C615A31566B39FC62EE928 /* Search */, C76C72E65151C7C62DB901C2 /* Sync */, + 0A674A1C945C15048247CC09 /* TextKit2Spike */, 60B87C16019C31ED0DAABBBC /* TXT */, + 6B8B8F34AB2CC69F7875AEF8 /* Unified */, + C1590617A7AA8A9252EAE3CA /* BookSource */, + 6C040A57F55B7CB815C0F614 /* TextMapping */, + AABB001122334455AABB0011 /* TTS */, ); path = Services; sourceTree = ""; @@ -1324,14 +2130,48 @@ path = Search; sourceTree = ""; }; + D18FC2F3DB2B1B40D884B7A1 /* Backup */ = { + isa = PBXGroup; + children = ( + ED150D276C082FEC194F2F31 /* BackupProviderContractTests.swift */, + 4D9501ED4FB49C6035FDF5BB /* MockBackupProvider.swift */, + 4BC65A9901DDD4AC981E77E2 /* WebDAVClientTests.swift */, + 61E41805A45542E97AE415D9 /* WebDAVProviderTests.swift */, + ); + path = Backup; + sourceTree = ""; + }; + D544B1FCA1D99EBC7EAE9F25 /* TTS */ = { + isa = PBXGroup; + children = ( + E756C3235433C7EFC0BBB972 /* HTTPTTSConfig.swift */, + A4E3D87DD1EB1B18213B48C7 /* HTTPTTSProvider.swift */, + 5D3EF2FFB105C9E0DF1EDF51 /* SpeechSynthesizing.swift */, + 6783D1629C54A25FE65C8705 /* TTSProviderProtocol.swift */, + 7BD36F5CC483659F962BFB3A /* TTSService.swift */, + FA8BD6824D9945998A61EFB4 /* TTSHighlightCoordinator.swift */, + ); + path = TTS; + sourceTree = ""; + }; D5F7C95CAE9759233D092954 /* Models */ = { isa = PBXGroup; children = ( + 8B1AC5E9F599CDA7123547F2 /* AnnotationAnchor.swift */, ABF63E3EE60CC06C5650C3AD /* AnnotationNote.swift */, + 5A53D87F16774F64922C855A /* ContentReplacementRule.swift */, ECD12F6574178C9287A93CA6 /* Book.swift */, + 758C820FB0971EB4896ED735 /* BookSource.swift */, + B5EE95205EE2494B9343343F /* LegadoBookSourceDTO.swift */, + 493AACD57E158A3C3C6692B1 /* BookSourceRules.swift */, + A6F1C998AAACA679A10A86D2 /* BookCollection.swift */, 41C3ECA5E8F6419DB347F2E4 /* BookFormat.swift */, FB82BDFCDB76725A5586D5E0 /* Bookmark.swift */, + 8C6686ECBBE526053A51CBA2 /* ChatMessage.swift */, 99D14A41185FFD87E278E66C /* DocumentFingerprint.swift */, + 1CF348591A8246AA1524CD10 /* EPUBLayoutPreference.swift */, + 1B311C3A88BD6DC42005FE1B /* ExportedAnnotation.swift */, + 050AAFD290B8995258D78AC2 /* FormatCapabilities.swift */, C775619D3C0E4641505CE2B8 /* Highlight.swift */, 37DF69361FD0FBED7294C43E /* ImportProvenance.swift */, 22F84672A6E2EDD6E037AFD8 /* ImportSource.swift */, @@ -1339,25 +2179,36 @@ DC606D4AF30DF956E04C13DF /* LibrarySortOrder.swift */, 3C567EE93DC61BBB63CEAC20 /* Locator.swift */, 4581A94B5099D15743DC02F3 /* ReaderTheme.swift */, + 1B2B480AC630357CC08475F4 /* ReadingMode.swift */, 831F853E3D42A27170BB0F92 /* ReadingPosition.swift */, 38A104E5CBC93D0266E6C21E /* ReadingSession.swift */, 8A12A0D94CF17D48152929F0 /* ReadingStats.swift */, + 20EBB13D56BE43A552188D9F /* TapZoneConfig.swift */, 686E0EE508E85349AED791BE /* TokenSpan.swift */, 19522F65C0947EFDCF9E4D2B /* TypographySettings.swift */, - SPIKEC002A1B2C3D4E5F60002 /* AnnotationAnchor.swift */, - WI11000001A1B2C3D4E5F601 /* ChatMessage.swift */, + 6A3131E83F93590395D14009 /* UnifiedEPUBLoadResult.swift */, 62043F4A2E7370252FDB1685 /* Migration */, ); path = Models; sourceTree = ""; }; + D6AC8160470E6C79DAEE5D74 /* Export */ = { + isa = PBXGroup; + children = ( + FB17C932D5188B36F01F024A /* AnnotationExporter.swift */, + A496DB7B37A1E68FA33AD5A3 /* JSONExportFormatter.swift */, + AC38E517D1EE7098DCB96426 /* MarkdownExportFormatter.swift */, + ); + path = Export; + sourceTree = ""; + }; D90CA6776A077CBDC255F35C /* AI */ = { isa = PBXGroup; children = ( - WID00005A1B2C3D4E5F60005 /* AIConfigurationTests.swift */, + 4A57E82A7221B36240E501FD /* AIConfigurationTests.swift */, FFD1AE78FBFF38B3616352FF /* AIConsentManagerTests.swift */, 20237121BB4ACF22C0818BA4 /* AIContextExtractorTests.swift */, - WID00007A1B2C3D4E5F60007 /* AIRequestCacheKeyTests.swift */, + 15C1B40888B5C8213559B111 /* AIRequestCacheKeyTests.swift */, C5CB5C659CC573EF448F0992 /* AIResponseCacheTests.swift */, EFB9DBE73613A6499D43B18C /* AIServiceTests.swift */, 9F2D542588B3156C4A282264 /* WI11TestHelpers.swift */, @@ -1371,6 +2222,7 @@ 7AAC0D3FD90694D3169DB775 /* MDReaderPlaceholderTests.swift */, 5639E3F809343C8CE5D7A020 /* PDFPasswordTests.swift */, A4D9A3C4C1072DA23511265D /* PDFReaderPlaceholderTests.swift */, + 46BCC142F6FF2ABE9B5BA64B /* PositionPersistenceTests.swift */, 4846E32490F4D5FFC0A366EF /* ReaderAnnotationsPanelTests.swift */, 8DB169F568633A25E157B4DE /* ReaderNavigationTests.swift */, 2D7D1FCE2E15F04329AB1978 /* ReaderSearchSheetTests.swift */, @@ -1416,42 +2268,61 @@ children = ( 2556DC1CBB43434072B19479 /* AnnotationPersisting.swift */, 2A513D5E8C4467B8FE45E0AC /* AnnotationRecord.swift */, + 5401E10DDA195966ABD13F70 /* AutoPageTurner.swift */, + F16AF7EAA6EC1F1D0D126E75 /* BasePageNavigator.swift */, 5160D7D68BF1AF6654AD08B6 /* BookImporter.swift */, + I1LZNT3Z7HY2RSP3O90OFDPF /* BookContentCache.swift */, 593A77413CD93AEE33F15156 /* BookImporting.swift */, A1A046B497B731C451670CED /* BookmarkPersisting.swift */, 7D04AA64724C4F9A15869C20 /* BookmarkRecord.swift */, + E5CB381B25E50D44505CAAB3 /* CustomCoverStore.swift */, + 851277A5872045D4D935CE2B /* DictionaryLookup.swift */, 400D03ADE39639337E9993C5 /* FeatureFlags.swift */, + 92676552DDABC9E3D5E7DC76 /* HapticFeedback.swift */, 24D988A4CE41E94A8A9280CC /* HighlightPersisting.swift */, 96BE19585BF1F4C8174F7219 /* HighlightRecord.swift */, 982822FD76DDFB1EEE150FF0 /* ImportError.swift */, 117731FF4D8AE414141C5ECF /* ImportJobQueue.swift */, 9AC105D38A4A85CBCB79A772 /* KeychainService.swift */, - WID00009A1B2C3D4E5F60009 /* PreferenceStore.swift */, 9A29C79BA1C5BF852179BCBB /* LibraryPersisting.swift */, BDC254C94AE749FFA8AACCE4 /* LibraryRefreshService.swift */, EAE6C27BECED96A8DA016439 /* MetadataExtractor.swift */, 19EA79EB5577BF31A4096B39 /* NoOpSessionStore.swift */, - B54B02A280C0D89C54FB9967 /* SwiftDataSessionStore.swift */, + BD6D19741098BC82E294F1E1 /* PageNavigator.swift */, + 2C7A2880FF5B321684FE712F /* PerBookSettings.swift */, C1DE5531A63EA492C5D91BEE /* PersistenceActor.swift */, 09C8CF05D0C61938AF454EDA /* PersistenceActor+Annotations.swift */, 815A2F870C4D8EC102254ACC /* PersistenceActor+Bookmarks.swift */, + 00814B9C69A0E1190146095E /* PersistenceActor+Collections.swift */, AC517B8E3581F795DEDEC934 /* PersistenceActor+Highlights.swift */, E46A786B20AA87E763D00F45 /* PersistenceActor+Library.swift */, - A1B2C3D4E5F60001STATS01 /* PersistenceActor+Stats.swift */, 59ECD5BE8EE959A2EF3E208E /* PersistenceActor+ReadingPosition.swift */, + 96FDA9A40CE891B901F1A28F /* PersistenceActor+Stats.swift */, + 292EB76312E500AFAC065E1F /* PreferenceStore.swift */, + 0F99442B3BA1E02CC3F2A2C1 /* ReaderPositionService.swift */, 9B432A1C9D875A14C4E9E633 /* ReaderSettingsStore.swift */, - C1A2B3C4D5E60001AABB6002 /* ReaderPositionService.swift */, 41D94EE13466B0286DEA2EA7 /* ReadingSessionTracker.swift */, + 11D4AD419DD1123CDA21CCD0 /* ReflowableTextSource.swift */, + A8B682E216B5A24055B696F0 /* SwiftDataSessionStore.swift */, + E07FB35EC9805F51CAD10444 /* ThemeBackgroundStore.swift */, 818F6161D2855C49A12AF5A6 /* TOCBuilder.swift */, 5C65E5FD23C2800C87ADD82A /* TOCProvider.swift */, F1A2DC49F84E40DE8F921733 /* AI */, + 232456552E53357A5363638A /* Backup */, FDAD081C3FE054319EF94E4A /* EPUB */, + D6AC8160470E6C79DAEE5D74 /* Export */, 5CF05FDFCFCF1A5110783282 /* Locator */, E90FBCF83CA21869224CA665 /* MD */, + FE95CE57F609BE6ED90C0674 /* Import */, + A4FCC2B159B04F516D5E542E /* OPDS */, C31B38FD3E940430CFB54754 /* Search */, 193A7CF46EE48B365E0A6079 /* Sync */, + 4E1FFF75D59C48ECF6498EA5 /* TextKit2Spike */, + D544B1FCA1D99EBC7EAE9F25 /* TTS */, C0B6C8014BAA5AFC1F7476A3 /* TXT */, - AUDIT5000002 /* HapticFeedback.swift */, + 947C994C5F48818BEAA51792 /* Unified */, + 38A5EAA412AB13E9A5DB6C10 /* BookSource */, + 0998F86FF3DCADF316D3BBE0 /* TextMapping */, ); path = Services; sourceTree = ""; @@ -1459,7 +2330,7 @@ E38F2C520CBAABE13CDFD35A /* Annotations */ = { isa = PBXGroup; children = ( - A1B2C3D4E5F60718293A4B5C /* AddNoteSheet.swift */, + 1A49E33ACBED018932A38F0C /* AddNoteSheet.swift */, 8EAE2660201ADC0B4272B9EE /* AnnotationEditSheet.swift */, 5C86F6A1C143DB6AC9187FC0 /* AnnotationListView.swift */, 5E1952532DDD6CD0938B0FCC /* HighlightListView.swift */, @@ -1481,21 +2352,35 @@ isa = PBXGroup; children = ( 36686A80222AD7613951C900 /* MDAttributedStringRenderer.swift */, - C1A2B3C4D5E60001AABB8C02 /* MDFileLoader.swift */, + 7DEA283D3F2224EDC297B6E1 /* MDFileLoader.swift */, 6E5D3F39FDBF4693D33D1BCB /* MDMetadataExtractor.swift */, FC614F4D61859721C71EC447 /* MDParser.swift */, 9452FAFFDEBDF03FF6CCEBB1 /* MDParserProtocol.swift */, + 9773A45B1D89408E85D126BA /* MDReflowableTextSource.swift */, FCDA968F8186B11859D8CCFE /* MDTypes.swift */, ); path = MD; sourceTree = ""; }; + ED7D6CFBFC3C6AF82E21EA90 /* Export */ = { + isa = PBXGroup; + children = ( + 5BB9773A4631BEDEDD187F08 /* AnnotationExporterTests.swift */, + DE5CAE41C429B6868957C540 /* ExportTestFixtures.swift */, + 4EA638212AA92F9FF855ABC2 /* JSONExportTests.swift */, + EB0A4899D0F08DEDB20D068C /* MarkdownExportTests.swift */, + ); + path = Export; + sourceTree = ""; + }; EF527EE1B64863AD6FA373B4 /* EPUB */ = { isa = PBXGroup; children = ( - C1A2B3C4D5E60001AABB8B04 /* EPUBFileLoaderTests.swift */, - A1B2C3D4E5F6789012345678 /* EPUBParserTests.swift */, + BC42F137A3776DEED18D9044 /* EPUBComplexityClassifierTests.swift */, + 5487566F93AB8376D1BD1F1B /* EPUBFileLoaderTests.swift */, + 5C0C66947C5376BF1D53A893 /* EPUBParserTests.swift */, 9DAD9A773D4AA9098981720D /* EPUBReaderViewModelTests.swift */, + 7C842F00C6B506FC831E0347 /* EPUBTextStripperTests.swift */, 836FCCC18D880D48A10BA38A /* MockEPUBParser.swift */, 2355F0CDCE9B874D6BD148FB /* MockPositionStore.swift */, CB65B98019C814421DDB0668 /* ZIPReaderTests.swift */, @@ -1506,13 +2391,13 @@ F1A2DC49F84E40DE8F921733 /* AI */ = { isa = PBXGroup; children = ( - WID00001A1B2C3D4E5F60001 /* AIConfiguration.swift */, - WID00003A1B2C3D4E5F60003 /* AIConfigurationStore.swift */, + C52103AEAF5528A9DD58625E /* AIConfiguration.swift */, + F8038AAB18412F30C09CBDD9 /* AIConfigurationStore.swift */, E1C9AB72079AF7B2ACCAB516 /* AIConsentManager.swift */, 42CFB0E94DE1D37E0FD2741B /* AIContextExtractor.swift */, 42C4804D4D5F435DF10014C5 /* AIError.swift */, D02B540992E1F3C96A00723F /* AIProvider.swift */, - WI10000001A1B2C3D4E5F601 /* AIReaderAvailability.swift */, + 5F90F835A126ABBB86752848 /* AIReaderAvailability.swift */, F4C18F1E19149F8DBAE36A31 /* AIResponseCache.swift */, 334D6D06A294323E149970E4 /* AIService.swift */, 46C22F30DF9F05C20CF8DDBC /* AITypes.swift */, @@ -1540,9 +2425,12 @@ FDAD081C3FE054319EF94E4A /* EPUB */ = { isa = PBXGroup; children = ( - C1A2B3C4D5E60001AABB8B02 /* EPUBFileLoader.swift */, + DCA9065CD56F7F0C7AF165FE /* EPUBComplexityClassifier.swift */, + E6AEAC075B9C38B3783D207A /* EPUBFileLoader.swift */, A7E742DD046F5CE970132E0C /* EPUBParser.swift */, + 4715C71E34A3BC44C7D5D64F /* foliate-bridge.js */, C3C15E361FF460BCE57B8675 /* EPUBParserProtocol.swift */, + FE7F4B7C906FB04E87EE16F5 /* EPUBTextStripper.swift */, A457F48D22CD5B4952817701 /* EPUBTypes.swift */, 8CF764016CF051DDD94C586F /* ReadingPositionPersisting.swift */, B2DB7F421D9D2E7492E12F89 /* ZIPReader.swift */, @@ -1558,48 +2446,14 @@ path = App; sourceTree = ""; }; - SPIKEC010A1B2C3D4E5F60010 /* Migration */ = { + FE95CE57F609BE6ED90C0674 /* Import */ = { isa = PBXGroup; children = ( - 6A794708164DDDF9736D5FE7 /* HighlightAnchorStorageTests.swift */, - SPIKEC00AA1B2C3D4E5F6000A /* V1toV2MigrationTests.swift */, + D6C080C3D62DBA36CA1E88C5 /* AnnotationImporter.swift */, + 5ED93A5DF68834883A508C16 /* AnnotationImportError.swift */, + 04C8EE35219F3BAFBDA81AAE /* VReaderAnnotationParser.swift */, ); - path = Migration; - sourceTree = ""; - }; - WI0600000AA1B2C3D4E5F60A /* Library */ = { - isa = PBXGroup; - children = ( - WI06000004A1B2C3D4E5F604 /* BookInfoSheet.swift */, - WI06000006A1B2C3D4E5F606 /* ShareSheet.swift */, - ); - path = Library; - sourceTree = ""; - }; - WI0600000BA1B2C3D4E5F60B /* Library */ = { - isa = PBXGroup; - children = ( - WI06000008A1B2C3D4E5F608 /* LibraryContextMenuTests.swift */, - ); - path = Library; - sourceTree = ""; - }; - WI0900000AA1B2C3D4E5F60A /* Settings */ = { - isa = PBXGroup; - children = ( - WI09000001A1B2C3D4E5F601 /* AISettingsViewModel.swift */, - WI09000003A1B2C3D4E5F603 /* AISettingsSection.swift */, - WI09000005A1B2C3D4E5F605 /* SettingsView.swift */, - ); - path = Settings; - sourceTree = ""; - }; - WI0900000BA1B2C3D4E5F60B /* Settings */ = { - isa = PBXGroup; - children = ( - WI09000007A1B2C3D4E5F607 /* AISettingsViewModelTests.swift */, - ); - path = Settings; + path = Import; sourceTree = ""; }; /* End PBXGroup section */ @@ -1675,7 +2529,6 @@ }; }; buildConfigurationList = AF56623EDCCEFB646F4F50CC /* Build configuration list for PBXProject "vreader" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -1684,6 +2537,8 @@ ); mainGroup = E630D3048A0AF78632A66A2B; minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 1DC89D462A6E6000C5FE89F8 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -1700,6 +2555,7 @@ buildActionMask = 2147483647; files = ( 10835FE4B33B176F7668ADF7 /* Assets.xcassets in Resources */, + 95D2D252390D26AFDBBAF43C /* SPIKE_RESULTS.md in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1711,6 +2567,19 @@ 1196930F82189AC76D8DCD2F /* empty.txt in Resources */, C18C9598D8ECE749889D544A /* plain_utf8.txt in Resources */, 00AA9871B88FE39518AC1320 /* utf16be_bom.txt in Resources */, + B9E51AB8A1444F8BBD0F2521 /* legado_single_source.json in Resources */, + F7AD55517859473DA4E4F4D5 /* legado_multiple_sources.json in Resources */, + E6AD9376444C4D5EA1933701 /* legado_source_with_unknown_fields.json in Resources */, + 7BF30BFBA55B4D40A38A03F4 /* legado_source_xpath.json in Resources */, + 63F0FFBB206E429287E293C3 /* legado_source_js.json in Resources */, + AF5A3B3C7744485DB376E5C8 /* legado_source_minimal.json in Resources */, + 18EC97E247FEF2212C90B5E1 /* search_results.html in Resources */, + BC0E362C5EA8C36CB648B34E /* book_detail.html in Resources */, + D4BE2B581F2B7372BA6390AF /* chapter_list.html in Resources */, + 989E5E5ACA5EB8FFD6A3B74A /* chapter_content.html in Resources */, + 5DDD5217EFD8A53C4C5DD152 /* search_no_results.html in Resources */, + 212D635EB0A8088D74729C2C /* chapter_list_paginated.html in Resources */, + 8C858D4E771BCCAC02F2D1E2 /* chapter_list_page2.html in Resources */, 4176B17F6A64ED68E53B016E /* utf16le_bom.txt in Resources */, 08F7E1DE72FF450114142960 /* utf8_bom.txt in Resources */, ); @@ -1723,53 +2592,97 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - SPIKEC007A1B2C3D4E5F60007 /* AnnotationAnchorTests.swift in Sources */, - 58CD1569690CE7C356C1EB08 /* HighlightAnchorStorageTests.swift in Sources */, - SPIKEC009A1B2C3D4E5F60009 /* V1toV2MigrationTests.swift in Sources */, - SPIKEC00BA1B2C3D4E5F6000B /* ReaderSelectionEventTests.swift in Sources */, - WI008003A1B2C3D4E5F60003 /* PDFAnnotationBridgeTests.swift in Sources */, - WI008005A1B2C3D4E5F60005 /* PDFHighlightIntegrationTests.swift in Sources */, - SPIKEC00DA1B2C3D4E5F6000D /* HighlightRecordAnchorTests.swift in Sources */, - SPIKEC00FA1B2C3D4E5F6000F /* HighlightDedupeTests.swift in Sources */, - WI09000008A1B2C3D4E5F608 /* AISettingsViewModelTests.swift in Sources */, - WI12000008A1B2C3D4E5F608 /* AITranslationTests.swift in Sources */, - WI10000006A1B2C3D4E5F606 /* AIReaderIntegrationTests.swift in Sources */, - WI11000008A1B2C3D4E5F608 /* AIChatViewModelTests.swift in Sources */, - WI13000002A1B2C3D4E5F602 /* AIChatGeneralTests.swift in Sources */, + 0F278FD5678087467BEC0E2C /* TextMapperTests.swift in Sources */, + U06TDVSBRAE1I2AK8NNRU71N /* BookContentCacheTests.swift in Sources */, + D4DBF7581572109E9CA3D5AB /* SimpTradTransformTests.swift in Sources */, + BDA5A0ADF7496048B73FE610 /* ReplacementTransformTests.swift in Sources */, + 7495B58258DD9BE8624D27C9 /* AnnotationImporterTests.swift in Sources */, + FF584A12D5AA96B75F56AE9A /* VReaderAnnotationParserTests.swift in Sources */, + C0A9D0F52A965B62B61B6A4E /* BookSourceHTTPClientTests.swift in Sources */, + D81A72060F55E3808BA4992D /* WebPageEncodingDetectorTests.swift in Sources */, + FAD9F46E8B9BD3E87FB68287 /* RuleEngineTests.swift in Sources */, + 0DDF1B72D9B712C6B0D327D3 /* CSSRuleEvaluatorTests.swift in Sources */, + 3D4FD29B8D09BBB8C02241D5 /* LegadoRuleParserTests.swift in Sources */, + 47B8BA0B880F22752CD21308 /* BookSourcePipelineTests.swift in Sources */, + D06CC003A1B2C3D4E5F60003 /* ChapterCacheTests.swift in Sources */, + 013DEEAD10005949F5BF2271 /* UpdateCheckerTests.swift in Sources */, 8044960A1B045C48AAE88736 /* AIAssistantViewModelTests.swift in Sources */, + 369D003DCE5F36A64E5C1C06 /* AIChatGeneralTests.swift in Sources */, + 05BE70789318FA085B9A735E /* AIChatViewModelTests.swift in Sources */, + C81C50DAC16681379E93FC9D /* AIConfigurationTests.swift in Sources */, CD104D016ED7015A7F8B42C7 /* AIConsentManagerTests.swift in Sources */, - WID00006A1B2C3D4E5F60006 /* AIConfigurationTests.swift in Sources */, 891E1E70B176150B071E464F /* AIContextExtractorTests.swift in Sources */, - WID00008A1B2C3D4E5F60008 /* AIRequestCacheKeyTests.swift in Sources */, + 4E2207CD7F48BCE945A0971C /* AIReaderIntegrationTests.swift in Sources */, + E92CBF70F353E737625B557F /* AIRequestCacheKeyTests.swift in Sources */, 9092232FBB529C5C6D9A53DB /* AIResponseCacheTests.swift in Sources */, 7E14E9F4DB1451B47A4DDC9E /* AIServiceTests.swift in Sources */, + 9E2DE265BDC2515E4572714B /* AISettingsViewModelTests.swift in Sources */, + 5C20BECE1338802F60F3E7B7 /* AITranslationTests.swift in Sources */, 65C927CBE6855076656719CE /* AccessibilityFormattersTests.swift in Sources */, + 13EC9440F35FF01443E4FABF /* AnnotationAnchorTests.swift in Sources */, + 058C2F104DE6D10D81F907D9 /* AnnotationExporterTests.swift in Sources */, CC774F69EFD8E1BD204DD515 /* AnnotationListViewModelTests.swift in Sources */, 762B3F65978080FE7D26BF4C /* AnnotationModelTests.swift in Sources */, + 66EF1425722B3E84E4902AEC /* AnnotationsPanelViewTests.swift in Sources */, A2B7E3D8BAEC3C9A9375D3E4 /* AppConfigurationTests.swift in Sources */, + 5BCD69B7FFD787F33D6E0C8D /* AutoPageTurnerTests.swift in Sources */, C9D2AE1A81222E67A05CA05C /* BackgroundIndexingCoordinatorTests.swift in Sources */, + 4F2FEC62B6D23F67263EDF34 /* BackupProviderContractTests.swift in Sources */, + C8B22FD567407EEA30EF3BE7 /* WebDAVClientTests.swift in Sources */, + 83F569CBFEB2E90AC8B57E5F /* WebDAVProviderTests.swift in Sources */, 2B9E39AC289E006A1A8B25AE /* BookFormatTests.swift in Sources */, 1AC5FD48311C93B5CEB3702E /* BookImporterTests.swift in Sources */, 3C6784421BC6B3DD6F1D3C16 /* BookModelTests.swift in Sources */, + 4DD834AD725B80F1CB92DEF3 /* BookSourceTests.swift in Sources */, + 7E5E743E7A274E009CF942B3 /* LegadoImporterTests.swift in Sources */, + AFB4E04161F7BD0D7FB0C706 /* SourceSharingServiceTests.swift in Sources */, + DD711E614DD2743B1CB0088E /* BookmarkFeedbackTests.swift in Sources */, 6225EFF6A5A33D3F2FD4DABF /* BookmarkListViewModelTests.swift in Sources */, + E400E164FA809CC3AB0AC796 /* CollectionPersistenceTests.swift in Sources */, + 070817D1986BE6BCF7208912 /* CollectionTestHelper.swift in Sources */, + 6CE796C0D4A118EF12FC79D2 /* SeriesTagPersistenceTests.swift in Sources */, + 8F280281FE847272C7E64547 /* CollectionTests.swift in Sources */, 21E6733005B6B4894ECCFEAB /* ContentHasherTests.swift in Sources */, + ACE4A8A746F082AEC08BB273 /* CustomCoverStoreTests.swift in Sources */, + 61F1501169ECAB4537B2C930 /* DictionaryLookupTests.swift in Sources */, 0DCC70724F193A55D2B254AE /* DocumentFingerprintTests.swift in Sources */, 01FEDA4DD8F5A3BFA56F275D /* DocumentFingerprintValidationTests.swift in Sources */, - C1A2B3C4D5E60001AABB8B03 /* EPUBFileLoaderTests.swift in Sources */, - F8E7D6C5B4A3210987654321 /* EPUBParserTests.swift in Sources */, + D19881EF60E15DFDCFF74173 /* EPUBComplexityClassifierTests.swift in Sources */, + 860C6626A5AC805B4C622E70 /* EPUBFileLoaderTests.swift in Sources */, + 9301FA74B29BDCD8C3FF55DB /* EPUBHighlightActionsTests.swift in Sources */, + D0FB5FB63B24803C9ADA5E1A /* EPUBHighlightBridgeTests.swift in Sources */, + 23CDCE3DBA89CD94BF5E5CBA /* HighlightRendererTests.swift in Sources */, + 7C95E7E78AD1A5CECC235092 /* EPUBHighlightRendererBug77Tests.swift in Sources */, + 7829D659A1E978F705D9FA38 /* PagedModeBug82Tests.swift in Sources */, + 4282ED0255055BBC0814F4D3 /* TransformsBug98Tests.swift in Sources */, + 04443847C0FAFF314FF4CCB3 /* HighlightCoordinatorTests.swift in Sources */, + 5BA867B4591F0916810C91B8 /* EPUBPaginationTests.swift in Sources */, + EB3D180641036C8A6FA00030 /* EPUBParserTests.swift in Sources */, + 83DAEE23928C668DA378F086 /* EPUBProgressTests.swift in Sources */, C320E44AF92161F0A556FDA7 /* EPUBReaderViewModelTests.swift in Sources */, + 1EE68B75A44789E6789BA6EB /* EPUBTextExtractorTests.swift in Sources */, + 65EB03B9632EFDE8EAF49ACC /* EPUBTextStripperTests.swift in Sources */, + 1D762D01C68B70790B8C2DE9 /* EPUBWebViewBridgeTests.swift in Sources */, 7C168089FE12D0A6B34DDEA1 /* EncodingDetectorTests.swift in Sources */, 8CAAA8CE24E5701C76A9A55F /* EncodingFixtureTests.swift in Sources */, E7493CC6D24CE5FD1922F9D0 /* ErrorMessageAuditorTests.swift in Sources */, + 700CFFEC7C74E0532A0271F4 /* ExportTestFixtures.swift in Sources */, 982FDBDA893DC7DA931711C7 /* FeatureFlagsTests.swift in Sources */, 2DB8779FF53587C400452428 /* FileAvailabilityStateMachineTests.swift in Sources */, - C1A2B3C4D5E60001AABB1003 /* HighlightableTextViewTests.swift in Sources */, + 67307D96FA4FE6F86F92B988 /* FormatCapabilitiesTests.swift in Sources */, + 9D982FAFD79829613C2EFECB /* HighlightAnchorStorageTests.swift in Sources */, + 2509B7983AE76F5C85B71634 /* HighlightDedupeTests.swift in Sources */, FB0BC111F33D81D4E93A031F /* HighlightListViewModelTests.swift in Sources */, + 1DBB5BA587B2EBF08A7F3C85 /* HighlightRecordAnchorTests.swift in Sources */, + 3B62997EF7304D0ACFBF141D /* HighlightableTextViewTests.swift in Sources */, + 45EA62BA74F57E9AC57B1BA4 /* HighlightedSnippetTests.swift in Sources */, 08C05E320FF9F6EFAAE34DA3 /* ImportErrorTests.swift in Sources */, B409ED069E31C39F7DCE6726 /* ImportJobQueueTests.swift in Sources */, 5128BF72998C4F697D2A6A84 /* ImportProvenanceTests.swift in Sources */, + 298EB8447E1427B7FE4CE0F9 /* JSONExportTests.swift in Sources */, 53DEE85CF4BDE11891B0E07A /* KeychainServiceTests.swift in Sources */, EB3FFD36A03AA194F33246D4 /* LibraryBookItemTests.swift in Sources */, + 73319DBA75C0F4352DDCD858 /* LibraryContextMenuTests.swift in Sources */, 879E269189DBE24A5AF6095B /* LibraryRefreshServiceTests.swift in Sources */, C6D9CCA699EBFFBE7A5479F1 /* LibraryTestHelpers.swift in Sources */, EBA5AEC161A4C9D055955BB4 /* LibraryViewModelImportTests.swift in Sources */, @@ -1777,19 +2690,23 @@ 34B285C323BF6E55A25CC148 /* LocatorCanonicalHashTests.swift in Sources */, 2FA04FC59FECB40C45FAD4D8 /* LocatorFactoryTests.swift in Sources */, 87A61AC432B6116973B7D291 /* LocatorIntegrationTests.swift in Sources */, + 5FE7F04D448C49D466F641FB /* LocatorNormalizerTests.swift in Sources */, DD273808CA81E259EAC3F7C5 /* LocatorRestorerTests.swift in Sources */, F5A31837AE39AA372B31F1B5 /* LocatorTests.swift in Sources */, 545CCE5FED3565C9BF15EF78 /* LocatorValidationTests.swift in Sources */, E81E9ED3618B9C4D37BCCAEF /* MDAttributedStringRendererTests.swift in Sources */, + 3CAC33209031F93DC4692879 /* MDFileLoaderTests.swift in Sources */, 018E696CD9238CE5A290D9AF /* MDIntegrationTests.swift in Sources */, - C1A2B3C4D5E60001AABB8C03 /* MDFileLoaderTests.swift in Sources */, 689A68666A4FDCD57304AC8E /* MDMetadataExtractorTests.swift in Sources */, 18A64CDBD49535E2C27EA116 /* MDReaderViewModelTests.swift in Sources */, + 201D1474F216D8F16D76F1B6 /* MDTextExtractorTests.swift in Sources */, 4222752F69DF72644596BAD9 /* MDTypesTests.swift in Sources */, + DEA5FF1E01245842DB54B8D7 /* MarkdownExportTests.swift in Sources */, D08EB57C5EBC9C9542476015 /* MetadataExtractorTests.swift in Sources */, 1FC25DD5163814F8A1DF4EBF /* MigrationFixtureTests.swift in Sources */, 4930168887D85648F1EDA897 /* MigrationFixtures.swift in Sources */, E057330B701161FF1AD06DB4 /* MockAnnotationStore.swift in Sources */, + 7B9FDFE7688C6E45D59916CD /* MockBackupProvider.swift in Sources */, A3B284AC778E4DC971B5E0A5 /* MockBookImporter.swift in Sources */, A52C405D0597E4DC53370789 /* MockBookmarkStore.swift in Sources */, 73CB49F24C1650EA99E2A5B5 /* MockEPUBParser.swift in Sources */, @@ -1798,71 +2715,101 @@ 47A1B7844CF41A92813CD002 /* MockPersistenceActor.swift in Sources */, 80CABDCB3569E07FE3AD3FD0 /* MockPositionStore.swift in Sources */, 129358092754DD095B2008B2 /* MockTXTService.swift in Sources */, + 6C4BDAADD228F84C65CE3CE6 /* ModeSwitchPersistenceTests.swift in Sources */, 0CAEEF69ACEA07AE0DEC346E /* MutationDriftTests.swift in Sources */, + 036A7AA1F02D9219384C777A /* NativeTextPagedIntegrationTests.swift in Sources */, + 4C5F7C805D7702035CEA5837 /* NativeTextPaginatorTests.swift in Sources */, + 32C58BAAF2DB529049921D4D /* OPDSParserTests.swift in Sources */, + CBF9C34C3E23A55FD82B726D /* PDFAnnotationBridgeTests.swift in Sources */, + ED9650F4A937ED6D04E2E416 /* PDFHighlightIntegrationTests.swift in Sources */, C89BA415E1DB53FD1AF65604 /* PDFIntegrationTests.swift in Sources */, + FD88118ABE86FDFBA67DFF50 /* PDFPageNavigatorTests.swift in Sources */, + 6B30D9E131580FD96CF77D20 /* PDFProgressTests.swift in Sources */, 250865E2A2A7BC3DE436183F /* PDFReaderViewModelTests.swift in Sources */, + 13AA54A125EC714F17846B6B /* PageNavigatorTests.swift in Sources */, + DE92D6FD10FC38811A32D3F5 /* PageTurnAnimatorTests.swift in Sources */, + 468BC9EB876942D28F071E57 /* PaginationCacheTests.swift in Sources */, + 0ADA2F00E5ABFEEF5328CE2F /* PerBookSettingsTests.swift in Sources */, + 49E7475E7118E6366C0530E5 /* PersistentSearchIndexTests.swift in Sources */, + 4107A78DB0996F371F4B3C6F /* PhaseBMediumAuditTests.swift in Sources */, 3DE8687C45492DB6E076D65E /* QuoteRecoveryTests.swift in Sources */, + 2D8D59A6A41D0A2D5AB16741 /* ReaderAuditFix2Tests.swift in Sources */, + 48501D19A13066218A1D529B /* ReaderAuditFix3Tests.swift in Sources */, + 43BD02365D537972791DF4D5 /* ReaderAuditFixTests.swift in Sources */, + 505E3728FE1C69A31079DA9B /* ReaderBottomOverlayTests.swift in Sources */, + 32F4E36941EDCA2C0D457777 /* ReaderNotificationHandlerTests.swift in Sources */, + D1C8E2F09B3E47AB62F5A710 /* TextReaderUIStateTests.swift in Sources */, + A3B091692BEA8453C7246A12 /* ReaderPositionServiceTests.swift in Sources */, + 82152E9125D5620CACFCEFF3 /* ReaderSelectionEventTests.swift in Sources */, B5114E58420F95EFB408CA82 /* ReaderSettingsStoreTests.swift in Sources */, + 6173290A06E9E4303D363AE8 /* ReaderThemeCSSTests.swift in Sources */, CB432F27C324A4EBC3D1F327 /* ReaderThemeTests.swift in Sources */, - CB432F27C324A4EBC3D1F328 /* TXTTextViewBridgeTests.swift in Sources */, + 41E1AE7987CC61A4C9B29FFE /* ReadingModeTests.swift in Sources */, + 726DA1204DBFE8EBE5852764 /* ReadingProgressBarTests.swift in Sources */, B1DFA851C827F5BB877DD2B8 /* ReadingSessionTests.swift in Sources */, 7E4274201E53904CDE46FC16 /* ReadingSessionTrackerTests.swift in Sources */, - 716E28B2D0AF6A439D3748DC /* SwiftDataSessionStoreTests.swift in Sources */, 7D0B1A054D3897CAD9D768A6 /* ReadingStatsTests.swift in Sources */, 800F472661AB1030CC54DB46 /* ReadingTimeFormatterTests.swift in Sources */, + 6B7F71BDB5ECEA83F19496FC /* ReflowableTextSourceTests.swift in Sources */, FD253FA0CEB159E2B1299BD4 /* SchemaV1Tests.swift in Sources */, + 0681EC94635E9BBB798AAB77 /* SearchHighlightDismissTests.swift in Sources */, 3821F3BE76B9BE588B9FA995 /* SearchHitToLocatorResolverTests.swift in Sources */, 9760CE8C8811986D6EEECF61 /* SearchIndexStoreTests.swift in Sources */, - C1A2B3C4D5E60001AABB7E05 /* SearchQueryExecutorTests.swift in Sources */, 099933954CB74FAE04C6B877 /* SearchLocatorSliceTests.swift in Sources */, + 59B8E65739F0BDF9F099D348 /* SearchQueryExecutorTests.swift in Sources */, + 11B285AD42721118145AE416 /* SearchResultHighlightTests.swift in Sources */, 68B750CB7D82E8E4DE0DA393 /* SearchServiceTests.swift in Sources */, - A1B2C3D4E5F60718192A3B4C /* SearchWiringTests.swift in Sources */, - B6C7D8E9F01A2B3C4D5E6F70 /* EPUBTextExtractorTests.swift in Sources */, - D8E9F01A2B3C4D5E6F708192 /* MDTextExtractorTests.swift in Sources */, - E9F01A2B3C4D5E6F70819203 /* TXTTextExtractorTests.swift in Sources */, 8CB7B605DFC452B422BBEC4F /* SearchTextNormalizerTests.swift in Sources */, 8BBE50E626A6524E8C6DB59D /* SearchViewModelTests.swift in Sources */, + F14370F35DEFDB725452B5CA /* SearchWiringTests.swift in Sources */, 30F629136C9EED9FCD557222 /* SmokeTests.swift in Sources */, + 1316119F5F616DBE405F7E38 /* SwiftDataSessionStoreTests.swift in Sources */, BF9767FD9DB988B04F1B27D5 /* SyncConflictResolverTests.swift in Sources */, 32D0866BC61D79BCAEF0A525 /* SyncServiceTests.swift in Sources */, + 9D4C2D2D74BD1C463344244F /* ChangeTokenStoreTests.swift in Sources */, + 3EEAADD077DC64CA56321F88 /* CloudKitRecordMapperTests.swift in Sources */, + 9D7069502ECFFA59E181CA85 /* DeviceIdentityTests.swift in Sources */, + 81DB77D7FC6B57F00EB82A1B /* DurableTombstoneStoreTests.swift in Sources */, + 6A83534950489DA0D635B625 /* NSUKVSBridgeTests.swift in Sources */, + 6ADF2E9A64B5325648EC83B7 /* SyncOutboundQueueTests.swift in Sources */, + 66A08D073DF12A5015075202 /* SyncPipelineTests.swift in Sources */, C68AC74F688C3FE8CD3546F0 /* SyncTestHelpers.swift in Sources */, - 987AC8640BA33F3235A89D82 /* TOCBuilderMDTests.swift in Sources */, + 80CA0E4FF773CBEC357DF93D /* TOCBuilderMDTests.swift in Sources */, + 5365C69DAECEFAE3481247B3 /* TOCBuilderTXTTests.swift in Sources */, 986AC8640BA33F3235A89D81 /* TOCProviderTests.swift in Sources */, + DF587005A7C4257AD28C42A0 /* TTSServiceTests.swift in Sources */, + 6F32A23CC48EDCE28C65E792 /* TTSHighlightCoordinatorTests.swift in Sources */, + CA6CDE39A3E09EED5F14447A /* HTTPTTSProviderTests.swift in Sources */, + 094D24D5B1E3CBF90739BE42 /* HTTPTTSConfigTests.swift in Sources */, + DC206C60545521442E0326E3 /* MockURLSession.swift in Sources */, + CC46DEE722313420D6F150ED /* TXTAttributedStringBuilderTests.swift in Sources */, CD8C26CB53B0BE57CE214F00 /* TXTBridgeOffsetTests.swift in Sources */, - 01B2C3D4E5F60718AABB0003 /* TXTAttributedStringBuilderTests.swift in Sources */, - C1A2B3C4D5E60001AABB3007 /* ReaderNotificationHandlerTests.swift in Sources */, - C1A2B3C4D5E60001AABB4005 /* AnnotationsPanelViewTests.swift in Sources */, - WI06000007A1B2C3D4E5F607 /* LibraryContextMenuTests.swift in Sources */, - C1A2B3C4D5E60001AABB5003 /* ReaderBottomOverlayTests.swift in Sources */, - C1A2B3C4D5E60001AABB5013 /* ReadingProgressBarTests.swift in Sources */, - C1A2B3C4D5E60001AABB5015 /* PDFProgressTests.swift in Sources */, - C1A2B3C4D5E60001AABB501B /* TXTMDProgressTests.swift in Sources */, - C1A2B3C4D5E60001AABB501F /* EPUBProgressTests.swift in Sources */, - 82464599BDEC3715DA80E83F /* EPUBWebViewBridgeTests.swift in Sources */, - WI07000003A1B2C3D4E5F603 /* EPUBHighlightBridgeTests.swift in Sources */, - WI07000009A1B2C3D4E5F609 /* EPUBHighlightActionsTests.swift in Sources */, - AUDIT8000001 /* ReaderAuditFixTests.swift in Sources */, - AUDIT9000001 /* ReaderAuditFix2Tests.swift in Sources */, - AUDITA000001 /* ReaderAuditFix3Tests.swift in Sources */, - C1A2B3C4D5E60001AABB6003 /* ReaderPositionServiceTests.swift in Sources */, - C1A2B3C4D5E60001AABB2005 /* TXTBridgeSharedTests.swift in Sources */, + 85C11C4F2CA49FF77D34BBFB /* TXTBridgeSharedTests.swift in Sources */, E9177AEFF47DA3EFEAC13C50 /* TXTChunkedLoaderTests.swift in Sources */, - C1A2B3C4D5E60001AABB8D03 /* TXTFileLoaderTests.swift in Sources */, - C1A2B3C4D5E60001AABB0003 /* TXTTextChunkerTests.swift in Sources */, - F1A2B3C4D5E6F7081234ABCD /* TXTServiceTests.swift in Sources */, + C81A31B52218576A75210100 /* TXTFileLoaderTests.swift in Sources */, + 7406A7805B98779AF9AA2F63 /* TXTMDProgressTests.swift in Sources */, 3548ECB80E9BAC95250F69E5 /* TXTOffsetMapperTests.swift in Sources */, 4D4A49E8738329EC2B336683 /* TXTReaderViewModelTests.swift in Sources */, + B3E77B47634165E42FA68E11 /* TXTServiceTests.swift in Sources */, + 631E89297E6BE25DF74556B7 /* TXTStreamingOpenTests.swift in Sources */, + 01450E848C5A2110A56DDD21 /* TXTTextChunkerTests.swift in Sources */, + B1B9E936492D58B0768C0785 /* TXTTextExtractorTests.swift in Sources */, + E276113ABA6A03C93EE62848 /* TXTTextViewBridgeTests.swift in Sources */, + E9FB5CFE42A28D671A7DE83A /* TXTTocRuleEngineTests.swift in Sources */, + AA366A9AF508E95B64F9A5E0 /* TapZoneTests.swift in Sources */, + 7D8D8CAA59DB81F80A9BEE72 /* TextKit2PaginatorTests.swift in Sources */, + 381D47129E7564BFBE0B26EB /* ThemeBackgroundTests.swift in Sources */, 49D992E6F2DAB74CCD4FDC68 /* TokenSpanTests.swift in Sources */, C65D7B190AC53E15002CBF0C /* TombstoneStoreTests.swift in Sources */, 34E33F3534FA3EB044406F2D /* TypographySettingsTests.swift in Sources */, + B086340BE996C111A4B374BD /* UnifiedMDTests.swift in Sources */, + 58F98A74EEAB6D0C39616B5D /* UnifiedTextRendererTests.swift in Sources */, + 6070A8AF12AADF388F7C1383 /* V1toV2MigrationTests.swift in Sources */, 8C5EFF0A113773C9FA1153E1 /* VoiceOverAuditTests.swift in Sources */, 59C50A731FA0022482A38792 /* WCAGContrastTests.swift in Sources */, C08E9C36FF3ED5C05E74F52B /* WI11TestHelpers.swift in Sources */, E44BC8CE480C18F6469C62DD /* WI9TestHelpers.swift in Sources */, E153024C836519993C468665 /* ZIPReaderTests.swift in Sources */, - AUDIT5000003 /* BookmarkFeedbackTests.swift in Sources */, - AUDIT7000001 /* SearchHighlightDismissTests.swift in Sources */, - AUDIT8000001 /* ReaderAuditFixTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1870,83 +2817,150 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - AUDIT5000001 /* HapticFeedback.swift in Sources */, - SPIKEC001A1B2C3D4E5F60001 /* AnnotationAnchor.swift in Sources */, - SPIKEC003A1B2C3D4E5F60003 /* SchemaV2.swift in Sources */, - SPIKEC005A1B2C3D4E5F60005 /* V1toV2Migration.swift in Sources */, - WI09000002A1B2C3D4E5F602 /* AISettingsViewModel.swift in Sources */, - WI09000004A1B2C3D4E5F604 /* AISettingsSection.swift in Sources */, - WI09000006A1B2C3D4E5F606 /* SettingsView.swift in Sources */, - WI10000002A1B2C3D4E5F602 /* AIReaderAvailability.swift in Sources */, - WI10000004A1B2C3D4E5F604 /* AIReaderPanel.swift in Sources */, - WI12000002A1B2C3D4E5F602 /* AITranslationViewModel.swift in Sources */, - WI12000004A1B2C3D4E5F604 /* BilingualView.swift in Sources */, - WI12000006A1B2C3D4E5F606 /* TranslationPanel.swift in Sources */, - WI11000002A1B2C3D4E5F602 /* ChatMessage.swift in Sources */, - WI11000004A1B2C3D4E5F604 /* AIChatViewModel.swift in Sources */, - WI11000006A1B2C3D4E5F606 /* AIChatView.swift in Sources */, + 822D2DE8366AE763FAA0A424 /* TextTransform.swift in Sources */, + A2038806633CEB1515E27008 /* OffsetMap.swift in Sources */, + 44C7CAB1B9EAAD4F33BAD7AF /* TextMapper.swift in Sources */, + D5062FEAC5E9C9FB902C5BCC /* SimpTradTransform.swift in Sources */, + 8C233C75BA4991C7C1A52E27 /* SimpTradDictionary.swift in Sources */, + CF68A850F824408573A71BF8 /* ContentReplacementRule.swift in Sources */, + 11654293CCE901059BA9F941 /* ReplacementTransform.swift in Sources */, + 9CF8425987E95B557DB67B8F /* ReplacementRulesView.swift in Sources */, + 22EE9DC0642A27F8292E0CE9 /* AnnotationImporter.swift in Sources */, + 6018C875BCDA469C84DE3BC2 /* VReaderAnnotationParser.swift in Sources */, + A7C1E32F52A70A3BC79C7C11 /* AnnotationImportError.swift in Sources */, + 24A18369157C0FE60E879A7E /* BookSourceHTTPClient.swift in Sources */, + 619448A4D6264FCD5522806C /* WebPageEncodingDetector.swift in Sources */, + CD75C2144A26B8307DFC1143 /* RuleEngine.swift in Sources */, + 56A8491DE672A28A32B64620 /* CSSRuleEvaluator.swift in Sources */, + A6D4A208825821F4077F90A0 /* RegexRuleEvaluator.swift in Sources */, + 47534D81F01962F43C11E9B5 /* LegadoRuleParser.swift in Sources */, + 5BC84DECAA7A019DAB762F29 /* HTMLHelper.swift in Sources */, + 275C851E9115056F4AF6657B /* PipelineTypes.swift in Sources */, + 70679EB33203CA5BD78A43C9 /* BookSourcePipeline.swift in Sources */, + D06CC001A1B2C3D4E5F60001 /* ChapterCache.swift in Sources */, + C2C16B2F8A50D252F62F5E52 /* UpdateChecker.swift in Sources */, + 87A6858F82E1DE2F31A592C3 /* BookSourceSearchView.swift in Sources */, + 1F55B2D70EC99A6E77AACFD8 /* BookSourceChapterListView.swift in Sources */, + 60E22AD64A6DF63A0CFBFE70 /* BookSourceReaderView.swift in Sources */, 384E9916435C82876752D9D9 /* AIAssistantView.swift in Sources */, 99456D2FCC39AA83E0B43C65 /* AIAssistantViewModel.swift in Sources */, - WID00002A1B2C3D4E5F60002 /* AIConfiguration.swift in Sources */, - WID00004A1B2C3D4E5F60004 /* AIConfigurationStore.swift in Sources */, + 8E936BF32CF0850398C9D742 /* AIChatView.swift in Sources */, + C8AC5FD914F26F89DA69AA8C /* AIChatViewModel.swift in Sources */, + 4CC9567091E0CABFDD800F6C /* AIConfiguration.swift in Sources */, + 2AD43547691478569AA638EB /* AIConfigurationStore.swift in Sources */, 26285A9C2309CC149C5372DC /* AIConsentManager.swift in Sources */, 9E163090F48B7DA58DBF7EE1 /* AIConsentView.swift in Sources */, B272D2C56AF8559115BBD8E3 /* AIContextExtractor.swift in Sources */, D0E3C146DC0F8D0978B419E7 /* AIError.swift in Sources */, 5BF55452ABA60AB492B54C6D /* AIProvider.swift in Sources */, + B6390E651DCC967457775D96 /* AIReaderAvailability.swift in Sources */, + 81498F908FAF97F421A3C35F /* AIReaderPanel.swift in Sources */, 04759BE2CD3CA39424689484 /* AIResponseCache.swift in Sources */, 8E153D7C3B713EDDBF484A24 /* AIService.swift in Sources */, + 21864806F4268E51A9E589A6 /* AISettingsSection.swift in Sources */, + B463893D3C3558483F25BDCF /* AISettingsViewModel.swift in Sources */, + 7EE8C91043F4F20D39AF326B /* AITranslationViewModel.swift in Sources */, FD9B24BBE1D852DA18A23E6F /* AITypes.swift in Sources */, 8A3AE73DD5935F4E45882E38 /* AccessibilityFormatters.swift in Sources */, - D6E7F8091A2B3C4D5E6F7A8B /* AddNoteSheet.swift in Sources */, + 19A48F5C5BC46818F3CC10BB /* AddNoteSheet.swift in Sources */, + 21145D281B373D2D3262E65F /* AnnotationAnchor.swift in Sources */, 7872FD996BEB1009EFF26413 /* AnnotationEditSheet.swift in Sources */, + C7963EB4DD0ACFD3A87D97CC /* AnnotationExporter.swift in Sources */, 7E88B70492874A1D1C0416D5 /* AnnotationListView.swift in Sources */, BBF57D9DB0812B5253D353A5 /* AnnotationListViewModel.swift in Sources */, D32E57C07E8D41055084129C /* AnnotationNote.swift in Sources */, 6C56C40C260D289E023BCEE9 /* AnnotationPersisting.swift in Sources */, 8F002C822102F0A088F31A06 /* AnnotationRecord.swift in Sources */, + 25B106D034C16291C104D69E /* AnnotationsPanelView.swift in Sources */, 8E7482D250AFDBEF1803780A /* AppConfiguration.swift in Sources */, + 64709D1B5C006D3299C8ABAF /* AutoPageTurner.swift in Sources */, 6274548A4C4DDC1FCA13397C /* BackgroundIndexingCoordinator.swift in Sources */, + 238CEDFC273E8AD0026B77AB /* BackupProvider.swift in Sources */, + 19A67133FBCA83C048797762 /* PROPFINDParser.swift in Sources */, + 7C6C782D2152DDE6DCEDF6B8 /* WebDAVClient.swift in Sources */, + BB11590E610D82808A5C7641 /* WebDAVProvider.swift in Sources */, + 58C75DAF7234376B96CEF824 /* ZIPWriter.swift in Sources */, + 4C19B2381D5EDB9E64C88807 /* WebDAVSettingsView.swift in Sources */, + 986EFB28F7203E56F853A0FD /* BasePageNavigator.swift in Sources */, + D1F6F2B6287F6C78E947FFAE /* BilingualView.swift in Sources */, 96F610A12CED33CA6B82C142 /* Book.swift in Sources */, + EBF78E93F40871A091DC34B9 /* BookSource.swift in Sources */, + B24559AB9CC24274B6835749 /* LegadoBookSourceDTO.swift in Sources */, + 6AC23BE43C5A46609BFFB118 /* LegadoImporter.swift in Sources */, + A1D75F4B8C2EF1EB714CDD40 /* SourceSharingService.swift in Sources */, + 05C3CA2FC89C4976ACFA43AA /* LegadoCompatibility.swift in Sources */, + 3D839D7370FF42B7A426FAD4 /* BookSourceRules.swift in Sources */, + FC95F46AC509C84F71B119DD /* BookSourceListView.swift in Sources */, + 3C289C7DFA69A28D8AAFD86B /* BookSourceEditorView.swift in Sources */, 12EE1BD6335013980EFA3EC0 /* BookCardView.swift in Sources */, - WI06000003A1B2C3D4E5F603 /* BookInfoSheet.swift in Sources */, - WI06000005A1B2C3D4E5F605 /* ShareSheet.swift in Sources */, + AF32B348898F4110FED715F5 /* BookCollection.swift in Sources */, 33B874FB4BB17A21ACA4468E /* BookFormat.swift in Sources */, DF36D73AC9B53845BF561CBC /* BookImporter.swift in Sources */, + 003HF5GN00VW59KVM4CQV0SD /* BookContentCache.swift in Sources */, 429316840ADE46912C2A89E9 /* BookImporting.swift in Sources */, + 7CCEBC99BB8B9A70D65163FC /* BookInfoSheet.swift in Sources */, + 070C52BEBC5753727B555585 /* CollectionSidebar.swift in Sources */, 7388501251356E2DE780DC22 /* BookRowView.swift in Sources */, A51705EB5AB296DECFDADEB4 /* Bookmark.swift in Sources */, 4FC1839FA29FE323CF45B3B2 /* BookmarkListView.swift in Sources */, 2775DDF52321F468CB58F795 /* BookmarkListViewModel.swift in Sources */, 07D023FAB657EDAED583D009 /* BookmarkPersisting.swift in Sources */, 9ADB90C99DECE18FE058ECD9 /* BookmarkRecord.swift in Sources */, + F51F7B9360A990E857FE1373 /* ChatMessage.swift in Sources */, 78A8B8FE91360F619F57DDB1 /* ContentHasher.swift in Sources */, 72BBA56325CAC43C8F4CC3EB /* ContentView.swift in Sources */, + F14A4E744781186C72D46349 /* CustomCoverStore.swift in Sources */, + A5820C3CDA46B108F9923560 /* DictionaryLookup.swift in Sources */, + 6254C228981BFCF2AC50B719 /* DictionarySheet.swift in Sources */, 55E8CDBFFC9EC1C49EAC47EE /* DocumentFingerprint.swift in Sources */, - C1A2B3C4D5E60001AABB8B01 /* EPUBFileLoader.swift in Sources */, + 542FF947F7F993A2904D56C2 /* EPUBComplexityClassifier.swift in Sources */, + 2206E24712AFD54F00761207 /* EPUBFileLoader.swift in Sources */, + A11E272093433A3733A66FE6 /* EPUBHighlightActions.swift in Sources */, + 50214665FE48786605E6CF7E /* EPUBHighlightBridge.swift in Sources */, + EE8189DAD855D15887FBBCFA /* EPUBHighlightJS.swift in Sources */, + 1D0B7D11E74A0E06F27A9F8B /* EPUBLayoutPreference.swift in Sources */, + 39699408A91BBF24E36FCE53 /* EPUBPaginationHelper.swift in Sources */, F78B9D218FBB628F31479271 /* EPUBParser.swift in Sources */, 2F558CF136B69212E668F2D3 /* EPUBParserProtocol.swift in Sources */, + 06C8E85FDBC83E56C5BF3B64 /* EPUBProgressCalculator.swift in Sources */, 7D0D6E22B259A33A6B27BAE9 /* EPUBReaderContainerView.swift in Sources */, + 5533CCA4BB83C3FF5F5D5CB8 /* EPUBReaderContainerView+Highlights.swift in Sources */, + 824329E82E030710D3EE118B /* EPUBReaderContainerView+Navigation.swift in Sources */, + 06BFC1681027392CD6A52ABA /* HighlightRenderer.swift in Sources */, + D339ED97E8CC0ED654A02C03 /* TextHighlightRenderer.swift in Sources */, + 51CB9D4AA85B58B85F414D6D /* EPUBHighlightRenderer.swift in Sources */, + FF40E774910C0E799FD13C8B /* PDFHighlightRenderer.swift in Sources */, + F4442C4CA80CE9B9B0A0865E /* HighlightCoordinator.swift in Sources */, 08F6B888EBC4D1ADDA3CC360 /* EPUBReaderViewModel.swift in Sources */, + R6LC0001A1B2C3D4E5F60001 /* ReaderLifecycleHelper.swift in Sources */, + E1863B0320B22A4E53575FBD /* EPUBTextExtractor.swift in Sources */, + C60601B9EF202F0983235144 /* EPUBTextStripper.swift in Sources */, E8353217B517055A09014AE7 /* EPUBTypes.swift in Sources */, D4332566CDFE7329E3709381 /* EPUBWebViewBridge.swift in Sources */, + 7A01B2C3D4E5F6A7B8C90003 /* EPUBWebViewBridgeJS.swift in Sources */, + 7A01B2C3D4E5F6A7B8C90004 /* EPUBWebViewBridgeCoordinator.swift in Sources */, C731DA5F2D3885D918F1640A /* EncodingDetector.swift in Sources */, C3E08FC456AC81388D905F7F /* ErrorMessageAuditor.swift in Sources */, - WI06000001A1B2C3D4E5F601 /* FileSizeFormatter.swift in Sources */, + 4701E8DB7FB81F6690AA729D /* ExportedAnnotation.swift in Sources */, 98CC47D9875A2A96F5098AF3 /* FeatureFlags.swift in Sources */, D36EEE178AE4BC41B7B4C650 /* FileAvailabilityBadge.swift in Sources */, 97D9F118063EF7E6328A4E14 /* FileAvailabilityStateMachine.swift in Sources */, + 095C1FEFA8400DD2F1A61CFF /* FileSizeFormatter.swift in Sources */, + DCB3DF75803B93E9AFB05F1D /* FormatCapabilities.swift in Sources */, + FA8BF5E0D98277BECAFB70CA /* HapticFeedback.swift in Sources */, A957D0C3F823092026646570 /* Highlight.swift in Sources */, 0C04B6441DD521F888F59DBD /* HighlightListView.swift in Sources */, 4331E31295D932065A8E3DCB /* HighlightListViewModel.swift in Sources */, 1F3F0A67EB5D7AEC3B11D3C3 /* HighlightPersisting.swift in Sources */, 1BD4CC8621C43917629D4728 /* HighlightRecord.swift in Sources */, + 53F990254493D95BE25D6BFD /* HighlightableTextView.swift in Sources */, + 76272EACDDB7D292C6CA8C9E /* HighlightedSnippet.swift in Sources */, C72258E7A1E6477E89E27D6E /* ImportError.swift in Sources */, 36B37715BB3494F635566157 /* ImportJobQueue.swift in Sources */, 98E08494B40D86923451655E /* ImportProvenance.swift in Sources */, 4C47F172233199432FC289E3 /* ImportSource.swift in Sources */, + EC94A16FA04EA4F5BBE10344 /* JSONExportFormatter.swift in Sources */, 6D073A0311A72B82FEF65852 /* KeychainService.swift in Sources */, - WID0000AA1B2C3D4E5F6000A /* PreferenceStore.swift in Sources */, CFF2F7127E363B96F2B6429B /* LibraryBookItem.swift in Sources */, A2518618C1F5EAD7C8FD4EE0 /* LibraryPersisting.swift in Sources */, 2DDEE520E3606572AED3598F /* LibraryRefreshService.swift in Sources */, @@ -1955,66 +2969,92 @@ 0A6A74D96B7763878D13CFDD /* LibraryViewModel.swift in Sources */, 6E5022EE67C6ACC27F614E77 /* Locator.swift in Sources */, 09122777AF5FD739850888CA /* LocatorFactory.swift in Sources */, + E648A7D0DA601378CD08D521 /* LocatorNormalizer.swift in Sources */, 9FCA583CDA52A7FB584260FA /* LocatorRestorer.swift in Sources */, 86AB97CD6CC05A3EEADE5F00 /* MDAttributedStringRenderer.swift in Sources */, + EB8290C909F46C2189E07D4B /* MDFileLoader.swift in Sources */, 265B4C9C4B99A0500F0EC6B7 /* MDMetadataExtractor.swift in Sources */, E25C2E1B2AD3CB697D37166D /* MDParser.swift in Sources */, - C1A2B3C4D5E60001AABB8C01 /* MDFileLoader.swift in Sources */, 07DCB26CA703C2A38E135473 /* MDParserProtocol.swift in Sources */, B9676CF3333F44711ABD70DB /* MDReaderContainerView.swift in Sources */, E815D5B8FBB84953D2887AAA /* MDReaderViewModel.swift in Sources */, + 18B415B48942ADDF3FDE479A /* MDReflowableTextSource.swift in Sources */, + C243AD36AE83E921EA70EEDD /* MDTextExtractor.swift in Sources */, 41337E423B4F0C5CCE5B6785 /* MDTypes.swift in Sources */, + 15C15DFE4818EDE663481250 /* MarkdownExportFormatter.swift in Sources */, AF7D99D9C0CEA266BFD976B8 /* MetadataExtractor.swift in Sources */, + 7621CBAFA9E36EEA60633869 /* NativeTextPageNavigator.swift in Sources */, + 9322D9DEBD7A9423DACA12DF /* NativeTextPagedView.swift in Sources */, + 243DB71360738DB06D5813BF /* NativeTextPaginator.swift in Sources */, + D2997E6E5680E58471DAA777 /* NoOpPersistenceStores.swift in Sources */, 2C05C1DC5E7C1B7C7B438C2B /* NoOpSessionStore.swift in Sources */, - DA1E49AE4E12924D8E4503DE /* SwiftDataSessionStore.swift in Sources */, + 99B6840F5C6B54842C465F64 /* OPDSBrowserView.swift in Sources */, + 07827134E24965F0D27435AC /* OPDSCatalogListView.swift in Sources */, + 2051294D05C42778582C8172 /* OPDSClient.swift in Sources */, + BFFFDD0F1A6208FCCA9BBA75 /* OPDSEntryView.swift in Sources */, + 44A1BF88B48D717D89913706 /* OPDSModels.swift in Sources */, + C545A7D59A2D63CC5B1DF45B /* OPDSParser.swift in Sources */, + 8E2E64928835A3533FDE10FA /* PDFAnnotationBridge.swift in Sources */, + 71397E1DB2920A02C5CEE39E /* PDFPageNavigator.swift in Sources */, 50837395A5CDCDFC14CF2B64 /* PDFPasswordPromptView.swift in Sources */, + 34E5034ADB5725781C9CE5C8 /* PDFProgressHelper.swift in Sources */, 320025CDBC69B31CC1B4DEAA /* PDFReaderContainerView.swift in Sources */, + C7C83E0CEB7EC5AA560F15C2 /* PDFReaderContainerView+Highlights.swift in Sources */, + AE4BEDEFF56940830655FAC2 /* PDFReaderContainerView+Overlays.swift in Sources */, FBE9680C2EE09F4F1936BC5C /* PDFReaderViewModel.swift in Sources */, C2CB65A50C711B49AAB672AE /* PDFTextExtractor.swift in Sources */, - F2B3C4D5E6F70819203142A3 /* EPUBTextExtractor.swift in Sources */, - C4D5E6F7A8B9011A2B3C4D6F /* MDTextExtractor.swift in Sources */, - WI008001A1B2C3D4E5F60001 /* PDFAnnotationBridge.swift in Sources */, B3395550D16A4B05AB62ADB2 /* PDFViewBridge.swift in Sources */, + B8B529FEB01F674FC01A38F5 /* PageNavigator.swift in Sources */, + CCC8728E1750053B1F454A32 /* PageTurnAnimator.swift in Sources */, + 453E682BDF07AEB9D4DA6294 /* PaginationCache.swift in Sources */, + 905543EBFD153235D8C3BF00 /* PerBookSettings.swift in Sources */, EC595D501E3CD9339A4A35AF /* PersistenceActor+Annotations.swift in Sources */, E7CA24CCF3F1D348E85E0C37 /* PersistenceActor+Bookmarks.swift in Sources */, + CC5A3B33193938B59254FD8A /* PersistenceActor+Collections.swift in Sources */, 38661CFAC1618DB7526ACB28 /* PersistenceActor+Highlights.swift in Sources */, CD9751D76A3A9DBF872CA5D7 /* PersistenceActor+Library.swift in Sources */, - A1B2C3D4E5F60002STATS02 /* PersistenceActor+Stats.swift in Sources */, E1646FDF9A47255E94758A54 /* PersistenceActor+ReadingPosition.swift in Sources */, + D451E5FB27521C48F0A54FEA /* PersistenceActor+Stats.swift in Sources */, A190FE66621610AD2D04739D /* PersistenceActor.swift in Sources */, + 39ED0E66F0AC131788A16FEB /* PreferenceStore.swift in Sources */, B64CAC947A1951E5ED22009C /* QuoteRecovery.swift in Sources */, + F2BD86F42ADB5A9CAF16B57A /* ReaderAICoordinator.swift in Sources */, + 252CB71020888B6952C2F69E /* ReaderBottomOverlay.swift in Sources */, 4CCBF4F6E186A7363A995303 /* ReaderContainerView.swift in Sources */, - C1A2B3C4D5E60001AABB4001 /* AnnotationsPanelView.swift in Sources */, - C1A2B3C4D5E60001AABB5001 /* ReaderBottomOverlay.swift in Sources */, - C1A2B3C4D5E60001AABB5011 /* ReadingProgressBar.swift in Sources */, - C1A2B3C4D5E60001AABB5017 /* PDFProgressHelper.swift in Sources */, - C1A2B3C4D5E60001AABB5019 /* ScrollProgressHelper.swift in Sources */, - C1A2B3C4D5E60001AABB501D /* EPUBProgressCalculator.swift in Sources */, - WI07000001A1B2C3D4E5F601 /* EPUBHighlightBridge.swift in Sources */, - WI07000005A1B2C3D4E5F605 /* EPUBHighlightJS.swift in Sources */, - WI07000007A1B2C3D4E5F607 /* EPUBHighlightActions.swift in Sources */, - C1A2B3C4D5E60001AABB6001 /* ReaderPositionService.swift in Sources */, - C1A2B3C4D5E60001AABB4003 /* ReaderFormatHosts.swift in Sources */, - C1A2B3C4D5E60001AABB3001 /* ReaderNotificationHandlers.swift in Sources */, - C1A2B3C4D5E60001AABB3003 /* ReaderNotificationModifier.swift in Sources */, - C1A2B3C4D5E60001AABB2001 /* ReaderNotifications.swift in Sources */, - C1A2B3C4D5E60001AABB3005 /* NoOpPersistenceStores.swift in Sources */, + 418398FD232E9200A670F8F1 /* ReaderContainerView+Sheets.swift in Sources */, + 856D81463684114E1539B9D5 /* ReaderUnifiedDispatch.swift in Sources */, + 818D42F1D3D6548605297F83 /* ReaderFormatHosts.swift in Sources */, + B69C0AB6A9AECC0E9A1A8692 /* ReaderNotificationHandlers.swift in Sources */, + EE0F8A75700F581D5E2D1F3E /* ReaderNotificationModifier.swift in Sources */, + 2A4AB6F5EDB24297F77218DB /* TextReaderUIState.swift in Sources */, + 5A94BE236411F0F7268F803F /* ReaderNotifications.swift in Sources */, + A138F46DE7229925D7AC22EF /* ReaderPositionService.swift in Sources */, + 8727C5619EABD0DD355565C0 /* ReaderSearchCoordinator.swift in Sources */, 076CA91860D0CAF278F65B50 /* ReaderSettingsPanel.swift in Sources */, 05007F5C2D7687A1173C48CB /* ReaderSettingsStore.swift in Sources */, + E6D1587B7798C34CCA0E37F6 /* ReaderTOCBuilder.swift in Sources */, 792681509B48600C93D01C39 /* ReaderTheme.swift in Sources */, + 6EA1E4475CD190B3E9F9D370 /* ReaderUnifiedCoordinator.swift in Sources */, + 69D8922795B7525A06038A49 /* ReadingMode.swift in Sources */, 16E0E8B88F2913E822EA56C3 /* ReadingPosition.swift in Sources */, EF1F3F42EE0664A058F2304D /* ReadingPositionPersisting.swift in Sources */, + 0EFA98D7C252D06AD3A254A9 /* ReadingProgressBar.swift in Sources */, A232BB96700FAD0583896DE3 /* ReadingSession.swift in Sources */, E03FE4F3997CC67281E84F1E /* ReadingSessionTracker.swift in Sources */, D49983BE0A1E6A803BF58B2E /* ReadingStats.swift in Sources */, 38F1AB473618B5EB717D05DE /* ReadingTimeFormatter.swift in Sources */, 432C40433346C054B7EC5D07 /* ReduceMotionHelper.swift in Sources */, + A84E7CDEED71FF118D2DD7DC /* ReflowableTextSource.swift in Sources */, 689521FED3BECF363AF06049 /* SchemaV1.swift in Sources */, + 209CA5025D6BC048CCAE4012 /* SchemaV2.swift in Sources */, + 7517791F2112F0367A462A10 /* SchemaV3.swift in Sources */, + A2F1E0D9C8B7A6F5E4D3C2B1 /* SchemaV4.swift in Sources */, 97731990DD3F91A22A6C9038 /* ScreenSpaceDemo.swift in Sources */, + 793A39541D8253B1CA8984A5 /* ScrollProgressHelper.swift in Sources */, 01440A60BDBE08FC56500DA7 /* SearchHitToLocatorResolver.swift in Sources */, - C1A2B3C4D5E60001AABB7E01 /* SearchIndexCore.swift in Sources */, + 42C12085597D305DE974B0B1 /* SearchIndexCore.swift in Sources */, 1E114184ED4E99E9EB1FE43C /* SearchIndexStore.swift in Sources */, - C1A2B3C4D5E60001AABB7E03 /* SearchQueryExecutor.swift in Sources */, + E9AAB1E1CE8C74F9517CBEC3 /* SearchQueryExecutor.swift in Sources */, 21C14E9FBD7B7373B56A365C /* SearchResultRow.swift in Sources */, 5D3C554CE5388769AA455D1E /* SearchService.swift in Sources */, 2B2BB1E5BCB1E74F360BE9F2 /* SearchTextExtractor.swift in Sources */, @@ -2022,32 +3062,73 @@ 74BEA01C5E4B3E080D2BF2FD /* SearchTokenizer.swift in Sources */, 1C9B4E21A97013A7CC409925 /* SearchView.swift in Sources */, 2F69DF71FDE5AF4FF920C3B2 /* SearchViewModel.swift in Sources */, + 8FFE1DA9C8F17FC318BE81BD /* SettingsView.swift in Sources */, + EBB6F00E3C34370C7C2CB369 /* ShareSheet.swift in Sources */, + 65E4EDF452B2195BA78BA7A3 /* SpeechSynthesizing.swift in Sources */, + 84A03EDA45DB68CE01456609 /* SwiftDataSessionStore.swift in Sources */, 4FDDEB10D3E2014D806618AD /* SyncConflictResolver.swift in Sources */, 8F202DA6CCCB6E1D83E7DC01 /* SyncService.swift in Sources */, + 8D76452BF88CA5D2714EAB77 /* SyncRecordDTOs.swift in Sources */, + 680388290CA3707893F7085D /* ChangeTokenStore.swift in Sources */, + A7D1190541B235BE4894AEA1 /* CloudKitClient.swift in Sources */, + E19C8C3656F85AC8B7E45B92 /* CloudKitRecordMapper.swift in Sources */, + A1C30093363094C1C79FA1BE /* DeviceIdentity.swift in Sources */, + 11879AEE84DFD84E7A1E35D9 /* DurableTombstoneStore.swift in Sources */, + 8CB032376D95F12AE86E7B24 /* NSUKVSBridge.swift in Sources */, + 4D84426071C2283D632870F2 /* SyncOutboundQueue.swift in Sources */, + 1F67AE198719DAB2ED869FE1 /* SyncPipeline.swift in Sources */, C59B87F85C20B1B2D9DDC387 /* SyncStatusMonitor.swift in Sources */, 5895F86BFE58FBFBAA7D8424 /* SyncStatusView.swift in Sources */, 884CC3C2EDE6FC428A666910 /* SyncTypes.swift in Sources */, 752B9949AB27FC69C8F017AE /* TOCBuilder.swift in Sources */, F827253851032F8D00E6A423 /* TOCListView.swift in Sources */, C9CBB4436EA4DDE7757EA3F4 /* TOCProvider.swift in Sources */, - 01B2C3D4E5F60718AABB0001 /* TXTAttributedStringBuilder.swift in Sources */, + F3958B26AAD50F2152E03AEB /* TTSControlBar.swift in Sources */, + C941DFD16C7CE7CF5ACC770D /* TTSService.swift in Sources */, + F5C562AE59FD6A2B923524AA /* TTSHighlightCoordinator.swift in Sources */, + F9D194A8FDD4F8155C3D6CD6 /* HTTPTTSConfig.swift in Sources */, + 53B3E8A2D0A66FB2A9E968E3 /* HTTPTTSProvider.swift in Sources */, + 2B6913E770A92CDDB1991B84 /* TTSProviderProtocol.swift in Sources */, + 1AB09C7743582725CDCE0EC7 /* HTTPTTSSettingsView.swift in Sources */, + B4230822C3A709F2E72DEF80 /* TXTAttributedStringBuilder.swift in Sources */, + E05A4A7DA74E241520EB2F0E /* TXTBridgeShared.swift in Sources */, BF7CA157DAD1516A4667641D /* TXTChunkedLoader.swift in Sources */, - C1A2B3C4D5E60001AABB8D01 /* TXTFileLoader.swift in Sources */, - C1A2B3C4D5E60001AABB0001 /* TXTTextChunker.swift in Sources */, - C1A2B3C4D5E60001AABB2003 /* TXTBridgeShared.swift in Sources */, - C1A2B3C4D5E60001AABB0005 /* TXTChunkedReaderBridge.swift in Sources */, + 98D1ABF430790E35869AAB0E /* TXTChunkedReaderBridge.swift in Sources */, + 7A01B2C3D4E5F6A7B8C90005 /* TXTChunkedHighlightHelper.swift in Sources */, + 1D61E44D67F37555FDEA9B6C /* TXTFileLoader.swift in Sources */, 947AC899C38470795EF51F2E /* TXTOffsetMapper.swift in Sources */, 454342CEF3A2152B1EDD2455 /* TXTReaderContainerView.swift in Sources */, + BF550669B1E043DA10B9D266 /* TXTReaderContainerView+Helpers.swift in Sources */, 0D65E679657B901DB2AE7CBB /* TXTReaderViewModel.swift in Sources */, + 070F22DA080E6D84CC1EF73D /* TXTReflowableTextSource.swift in Sources */, 0AF2C077EAD177EE3AF2985A /* TXTService.swift in Sources */, 01A53D2CA4B291030B55F5F6 /* TXTServiceProtocol.swift in Sources */, + F7D4BCC9E389D8F7956277AA /* TXTTextChunker.swift in Sources */, E7EFBA25B7F2E347F466C5BF /* TXTTextExtractor.swift in Sources */, - C1A2B3C4D5E60001AABB1001 /* HighlightableTextView.swift in Sources */, + 7A01B2C3D4E5F6A7B8C90001 /* TXTViewConfig.swift in Sources */, BA758603C760FDE615B4E6CD /* TXTTextViewBridge.swift in Sources */, + 7A01B2C3D4E5F6A7B8C90002 /* TXTTextViewBridgeCoordinator.swift in Sources */, + 116A16DB9D20D6B20F77016A /* TXTTocRule.swift in Sources */, + A6E44E99068166200DADB131 /* TXTTocRuleEngine.swift in Sources */, + A6AD2E80D9CF312EF3AB962C /* TapZoneConfig.swift in Sources */, + 354E24A0B0A690869F8EFC5A /* TapZoneOverlay.swift in Sources */, + F2E1D0C9B8A7F6E5D4C3B2A1 /* ReaderChromeBar.swift in Sources */, + 6344AC26417E72E251541FE3 /* TestSeeder.swift in Sources */, + 044A57EF6D114FA998DB29FA /* TextKit2Paginator.swift in Sources */, + 08D7E1736817CB7AA0695C3D /* ThemeBackgroundStore.swift in Sources */, + 9DB7FDDADD640EDC37004402 /* ThemeBackgroundView.swift in Sources */, DB49A43B0C365D8308D5D1BB /* TokenSpan.swift in Sources */, 7C55E0AC6A420DF9B2B81DDB /* TombstoneStore.swift in Sources */, + 3DF1A5D2E40DE8AAF521BB8C /* TranslationPanel.swift in Sources */, CE3D74414B1983EB35589EFD /* TypographySettings.swift in Sources */, + B401D6506E193DB12661B33E /* UnifiedEPUBLoadResult.swift in Sources */, + 39D1A8383CEBC235DDAA552F /* UnifiedPagedView.swift in Sources */, + F97F59A8465B2ABA54B91E27 /* UnifiedPlaceholderView.swift in Sources */, + DD1020F3F0A2D5BA7FFCB279 /* UnifiedScrollView.swift in Sources */, + 432FAED1CA14525CAB953BC3 /* UnifiedTextRenderer.swift in Sources */, + 9AF587978D1D58F58C7E8A23 /* UnifiedTextRendererViewModel.swift in Sources */, + 1CB7C39AC6FE3B715D4B4305 /* V1toV2Migration.swift in Sources */, F10FCB9E3EC6862A640BD406 /* VReaderApp.swift in Sources */, AEFC819574E845429DFC9D78 /* ZIPReader.swift in Sources */, ); @@ -2079,6 +3160,7 @@ 563B878DD14F1699FFC52439 /* NavigationFlowTests.swift in Sources */, 67F279A51B09B3CDD7BBA3A9 /* PDFPasswordTests.swift in Sources */, A95CA2A8C9841860265A7FF3 /* PDFReaderPlaceholderTests.swift in Sources */, + AAEA9983AA0492366955FB9B /* PositionPersistenceTests.swift in Sources */, 378FF0B45CF9F05579644D1B /* ReaderAnnotationsPanelTests.swift in Sources */, 43E5608C73F29F783F64BE8A /* ReaderNavigationTests.swift in Sources */, 18F17DCE91707A996AFA35F1 /* ReaderSearchSheetTests.swift in Sources */, diff --git a/vreader/App/VReaderApp.swift b/vreader/App/VReaderApp.swift index bbcbcc7..01d8642 100644 --- a/vreader/App/VReaderApp.swift +++ b/vreader/App/VReaderApp.swift @@ -34,7 +34,7 @@ struct VReaderApp: App { #endif do { - let schema = Schema(SchemaV2.models) + let schema = Schema(SchemaV4.models) #if DEBUG // Use in-memory store for UI testing to ensure clean state @@ -102,7 +102,8 @@ struct VReaderApp: App { self.contentView = ContentView( viewModel: LibraryViewModel( persistence: persistence, - importer: importer + importer: importer, + preferenceStore: UserDefaultsPreferenceStore() ), syncMonitor: syncMonitor ) diff --git a/vreader/Models/Book.swift b/vreader/Models/Book.swift index 8b32b2a..80dac00 100644 --- a/vreader/Models/Book.swift +++ b/vreader/Models/Book.swift @@ -12,6 +12,8 @@ // to avoid memory/store pressure with large images. // - detectedEncoding stores the IANA name of the encoding detected at import // for TXT files, enabling consistent reopen without re-detection. +// - seriesName/seriesIndex are inline fields for series grouping (no separate entity). +// - bookCollections is a many-to-many relationship with BookCollection (inverse managed by BookCollection). import Foundation import SwiftData @@ -58,6 +60,15 @@ final class Book { var isFavorite: Bool var tags: [String] + // MARK: - Series + + /// Series name for grouping books. Nil if not part of a series. + var seriesName: String? + + /// Position within the series (1-based). Nil if not part of a series + /// or position is unknown. + var seriesIndex: Int? + // MARK: - Indexing-Produced Metadata /// Total word count, populated by background indexer after import. @@ -76,6 +87,9 @@ final class Book { @Relationship(deleteRule: .cascade) var highlights: [Highlight] @Relationship(deleteRule: .cascade) var annotations: [AnnotationNote] + /// Collections this book belongs to. Managed by BookCollection's inverse relationship. + var bookCollections: [BookCollection] + // MARK: - Init init( @@ -104,6 +118,7 @@ final class Book { self.bookmarks = [] self.highlights = [] self.annotations = [] + self.bookCollections = [] } // MARK: - Explicit Sync diff --git a/vreader/Models/BookCollection.swift b/vreader/Models/BookCollection.swift new file mode 100644 index 0000000..97d945e --- /dev/null +++ b/vreader/Models/BookCollection.swift @@ -0,0 +1,50 @@ +// Purpose: Collection model for organizing books into user-created groups. +// Named BookCollection to avoid conflict with Swift's Collection protocol. +// +// Key decisions: +// - Separate @Model entity (not inline on Book) because collections need +// their own identity, name, creation date, and many-to-many relationship. +// - Name is validated: non-empty, trimmed, max 100 characters, unique. +// - @Relationship with inverse on Book.bookCollections for bidirectional link. +// - Delete rule .nullify: deleting a collection does NOT delete books. +// +// @coordinates-with: Book.swift, PersistenceActor+Collections.swift + +import Foundation +import SwiftData + +@Model +final class BookCollection { + // MARK: - Identity + + /// Unique collection name. Enforced at the application layer + /// (SwiftData cannot enforce unique on non-primitive Codable). + var name: String + + /// When the collection was created. + var createdAt: Date + + // MARK: - Relationships + + /// Books in this collection. Nullify on delete: removing a collection + /// does not remove its books. + @Relationship(deleteRule: .nullify, inverse: \Book.bookCollections) + var books: [Book] + + // MARK: - Init + + init(name: String, createdAt: Date = Date()) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + self.name = String(trimmed.prefix(100)) + self.createdAt = createdAt + self.books = [] + } + + // MARK: - Validation + + /// Validates that the collection name is non-empty after trimming. + static func validateName(_ name: String) -> Bool { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmed.isEmpty + } +} diff --git a/vreader/Models/BookFormat.swift b/vreader/Models/BookFormat.swift index 3216f49..8fb04d2 100644 --- a/vreader/Models/BookFormat.swift +++ b/vreader/Models/BookFormat.swift @@ -1,5 +1,7 @@ // Purpose: Defines supported book formats for the reader. // .md is importable as of WI-6B. +// +// @coordinates-with: FormatCapabilities.swift — `capabilities` convenience property /// Supported document formats for the reader. enum BookFormat: String, Codable, Hashable, Sendable, CaseIterable { @@ -27,4 +29,9 @@ enum BookFormat: String, Codable, Hashable, Sendable, CaseIterable { case .md: return ["md", "markdown"] } } + + /// Default capabilities for this format (assumes simple EPUB). + var capabilities: FormatCapabilities { + FormatCapabilities.capabilities(for: self) + } } diff --git a/vreader/Models/BookSource.swift b/vreader/Models/BookSource.swift new file mode 100644 index 0000000..93a0c21 --- /dev/null +++ b/vreader/Models/BookSource.swift @@ -0,0 +1,159 @@ +// Purpose: SwiftData model for a web content source with configurable extraction rules. +// Compatible with Legado's BookSource JSON format for import/export. +// +// Key decisions: +// - sourceURL is the unique key (@Attribute(.unique)), matching Legado's bookSourceUrl PK. +// - Rules are stored as Data? (JSON blobs) with computed properties for typed access, +// following the same safe pattern as Highlight.anchorData. This avoids SwiftData +// Codable enum decode crashes on schema evolution. +// - sourceType uses Int (0=text, 1=audio, 2=image, 3=file) matching Legado convention. +// - No built-in sources — all user-imported. +// +// @coordinates-with: BookSourceRules.swift, BookSourceListView.swift, +// BookSourceEditorView.swift, LegadoImporter.swift, LegadoBookSourceDTO.swift + +import Foundation +import SwiftData + +@Model +final class BookSource { + // MARK: - Identity + + /// Unique source identifier — the base URL of the content source. + @Attribute(.unique) var sourceURL: String + + /// Human-readable display name for the source. + var sourceName: String + + /// Optional grouping label for organizing sources. + var sourceGroup: String? + + /// Source content type: 0=text, 1=audio, 2=image, 3=file. + var sourceType: Int + + /// Whether this source is active for searches. + var enabled: Bool + + // MARK: - Configuration + + /// URL template for search. Uses `{{key}}` as keyword placeholder. + var searchURL: String? + + /// JSON string for custom HTTP headers (User-Agent, Cookie, etc.). + var header: String? + + // MARK: - Rule Data (JSON blobs) + + /// Raw JSON bytes for search extraction rules. + var ruleSearchData: Data? + + /// Raw JSON bytes for book info extraction rules. + var ruleBookInfoData: Data? + + /// Raw JSON bytes for TOC extraction rules. + var ruleTocData: Data? + + /// Raw JSON bytes for content extraction rules. + var ruleContentData: Data? + + // MARK: - Compatibility + + /// Compatibility level with VReader's rule engine: "Full", "Limited", "Unsupported". + /// - Full: CSS selectors + regex only (all rules supported). + /// - Limited: Contains XPath-only rules (deferred; source imported but flagged). + /// - Unsupported: Requires JS execution (imported but marked non-functional). + var compatibilityLevel: String? + + // MARK: - Metadata + + /// When this source was last updated (import or edit). + var lastUpdateTime: Date? + + /// User-defined ordering position. + var customOrder: Int + + // MARK: - Computed Rule Accessors + + /// Decoded search rule. Returns nil when data is missing, empty, or corrupted. + @Transient var ruleSearch: BSSearchRule? { + decodeRule(ruleSearchData) + } + + /// Decoded book info rule. Returns nil when data is missing, empty, or corrupted. + @Transient var ruleBookInfo: BSBookInfoRule? { + decodeRule(ruleBookInfoData) + } + + /// Decoded TOC rule. Returns nil when data is missing, empty, or corrupted. + @Transient var ruleToc: BSTocRule? { + decodeRule(ruleTocData) + } + + /// Decoded content rule. Returns nil when data is missing, empty, or corrupted. + @Transient var ruleContent: BSContentRule? { + decodeRule(ruleContentData) + } + + // MARK: - Init + + init( + sourceURL: String, + sourceName: String, + sourceGroup: String? = nil, + sourceType: Int = 0, + enabled: Bool = true, + searchURL: String? = nil, + header: String? = nil, + customOrder: Int = 0 + ) { + self.sourceURL = sourceURL + self.sourceName = sourceName + self.sourceGroup = sourceGroup + self.sourceType = sourceType + self.enabled = enabled + self.searchURL = searchURL + self.header = header + self.customOrder = customOrder + } + + // MARK: - Rule Update Methods + + /// Updates the search rule. Encodes to JSON bytes for safe SwiftData storage. + func updateSearchRule(_ rule: BSSearchRule?) { + ruleSearchData = encodeRule(rule) + } + + /// Updates the book info rule. + func updateBookInfoRule(_ rule: BSBookInfoRule?) { + ruleBookInfoData = encodeRule(rule) + } + + /// Updates the TOC rule. + func updateTocRule(_ rule: BSTocRule?) { + ruleTocData = encodeRule(rule) + } + + /// Updates the content rule. + func updateContentRule(_ rule: BSContentRule?) { + ruleContentData = encodeRule(rule) + } + + // MARK: - Validation + + /// Validates that a source URL is non-empty and non-whitespace. + static func validateSourceURL(_ url: String) -> Bool { + !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // MARK: - Private Helpers + + private func decodeRule(_ data: Data?) -> T? { + guard let data, !data.isEmpty else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + private func encodeRule(_ rule: T?) -> Data? { + guard let rule else { return nil } + return try? JSONEncoder().encode(rule) + } +} diff --git a/vreader/Models/BookSourceRules.swift b/vreader/Models/BookSourceRules.swift new file mode 100644 index 0000000..f15f1a6 --- /dev/null +++ b/vreader/Models/BookSourceRules.swift @@ -0,0 +1,125 @@ +// Purpose: Codable rule sub-models for BookSource extraction rules. +// Matches Legado's SearchRule, BookInfoRule, TocRule, ContentRule structure. +// Stored as JSON Data blobs on BookSource for safe SwiftData storage. +// +// Key decisions: +// - All fields are optional — sources may only define some rules. +// - Sendable for safe concurrent use in pipeline stages. +// - Equatable for testing and diff detection. +// +// @coordinates-with: BookSource.swift, LegadoImporter.swift, LegadoBookSourceDTO.swift + +import Foundation + +/// Extraction rules for search results parsing. +struct BSSearchRule: Codable, Sendable, Equatable { + var bookList: String? + var name: String? + var author: String? + var bookUrl: String? + var coverUrl: String? + + init( + bookList: String? = nil, + name: String? = nil, + author: String? = nil, + bookUrl: String? = nil, + coverUrl: String? = nil + ) { + self.bookList = bookList + self.name = name + self.author = author + self.bookUrl = bookUrl + self.coverUrl = coverUrl + } +} + +/// Extraction rules for book detail page parsing. +struct BSBookInfoRule: Codable, Sendable, Equatable { + var name: String? + var author: String? + var intro: String? + var coverUrl: String? + var tocUrl: String? + + init( + name: String? = nil, + author: String? = nil, + intro: String? = nil, + coverUrl: String? = nil, + tocUrl: String? = nil + ) { + self.name = name + self.author = author + self.intro = intro + self.coverUrl = coverUrl + self.tocUrl = tocUrl + } +} + +/// Extraction rules for table of contents parsing. +struct BSTocRule: Codable, Sendable, Equatable { + var chapterList: String? + var chapterName: String? + var chapterUrl: String? + var nextTocUrl: String? + + init( + chapterList: String? = nil, + chapterName: String? = nil, + chapterUrl: String? = nil, + nextTocUrl: String? = nil + ) { + self.chapterList = chapterList + self.chapterName = chapterName + self.chapterUrl = chapterUrl + self.nextTocUrl = nextTocUrl + } +} + +/// Extraction rules for chapter content parsing. +struct BSContentRule: Codable, Sendable, Equatable { + var content: String? + var nextContentUrl: String? + var replaceRegex: String? + + init( + content: String? = nil, + nextContentUrl: String? = nil, + replaceRegex: String? = nil + ) { + self.content = content + self.nextContentUrl = nextContentUrl + self.replaceRegex = replaceRegex + } +} + +// MARK: - hasAnyField Helpers + +extension BSSearchRule { + /// True if at least one extraction field is set. + var hasAnyField: Bool { + [bookList, name, author, bookUrl, coverUrl].contains(where: { $0 != nil }) + } +} + +extension BSBookInfoRule { + /// True if at least one extraction field is set. + var hasAnyField: Bool { + [name, author, intro, coverUrl, tocUrl].contains(where: { $0 != nil }) + } +} + +extension BSTocRule { + /// True if at least one extraction field is set. + var hasAnyField: Bool { + [chapterList, chapterName, chapterUrl, nextTocUrl].contains(where: { $0 != nil }) + } +} + +extension BSContentRule { + /// True if at least one extraction field is set. + var hasAnyField: Bool { + [content, nextContentUrl, replaceRegex].contains(where: { $0 != nil }) + } +} diff --git a/vreader/Models/ContentReplacementRule.swift b/vreader/Models/ContentReplacementRule.swift new file mode 100644 index 0000000..e36e446 --- /dev/null +++ b/vreader/Models/ContentReplacementRule.swift @@ -0,0 +1,78 @@ +// Purpose: SwiftData model for content replacement rules. +// Users can define string or regex find/replace rules to fix OCR errors, +// remove watermarks, or customize display text. +// +// Key decisions: +// - SwiftData @Model for persistence. +// - isRegex flag distinguishes plain string vs regex patterns. +// - scope: .global applies to all books, .perBook(fingerprint) to one. +// - order: lower number = applied first. +// - enabled: toggle without deleting. +// +// @coordinates-with: ReplacementTransform.swift, ReplacementRulesView.swift + +import Foundation +import SwiftData + +@Model +final class ContentReplacementRule { + @Attribute(.unique) var ruleId: UUID + + /// The search pattern (plain string or regex). + var pattern: String + + /// The replacement string. Supports regex group references ($1, $2) when isRegex. + var replacement: String + + /// Whether the pattern is a regular expression. + var isRegex: Bool + + /// Scope: empty string = global, non-empty = book fingerprint key. + var scopeKey: String + + /// Whether this rule is active. + var enabled: Bool + + /// Sort order — lower runs first. + var order: Int + + /// User-visible label/note. + var label: String + + /// Creation date. + var createdAt: Date + + init( + ruleId: UUID = UUID(), + pattern: String, + replacement: String, + isRegex: Bool = false, + scopeKey: String = "", + enabled: Bool = true, + order: Int = 0, + label: String = "", + createdAt: Date = Date() + ) { + self.ruleId = ruleId + self.pattern = pattern + self.replacement = replacement + self.isRegex = isRegex + self.scopeKey = scopeKey + self.enabled = enabled + self.order = order + self.label = label + self.createdAt = createdAt + } +} + +// MARK: - Scope Helpers + +extension ContentReplacementRule { + /// Whether this is a global rule (applies to all books). + var isGlobal: Bool { scopeKey.isEmpty } + + /// Whether this rule applies to a given book fingerprint key. + func appliesTo(bookKey: String) -> Bool { + isGlobal || scopeKey == bookKey + } +} diff --git a/vreader/Models/EPUBLayoutPreference.swift b/vreader/Models/EPUBLayoutPreference.swift new file mode 100644 index 0000000..a1d69bc --- /dev/null +++ b/vreader/Models/EPUBLayoutPreference.swift @@ -0,0 +1,20 @@ +// Purpose: User-facing layout preference for EPUB rendering — scroll or paged. +// Distinct from EPUBLayout (which describes the publication's intrinsic layout type). +// +// Key decisions: +// - String-backed RawRepresentable for UserDefaults persistence. +// - Default is .scroll (preserves existing behavior). +// - .paged enables CSS multi-column pagination in WKWebView. +// +// @coordinates-with: ReaderSettingsStore.swift, EPUBWebViewBridge.swift, +// EPUBPaginationHelper.swift + +import Foundation + +/// User preference for EPUB reading layout mode. +enum EPUBLayoutPreference: String, Codable, Sendable, Hashable, CaseIterable { + /// Traditional vertical scrolling (default). + case scroll + /// Horizontal paged layout using CSS multi-column. + case paged +} diff --git a/vreader/Models/ExportedAnnotation.swift b/vreader/Models/ExportedAnnotation.swift new file mode 100644 index 0000000..6fe783f --- /dev/null +++ b/vreader/Models/ExportedAnnotation.swift @@ -0,0 +1,41 @@ +// Purpose: Codable DTO for annotation export. Captures highlights, bookmarks, +// and notes in a format-agnostic structure suitable for JSON/Markdown/PDF export. +// +// Key decisions: +// - All date fields use ISO 8601 encoding for interoperability. +// - Chapter is optional — annotations without chapter info go to "Ungrouped". +// - Color is stored as-is (hex or name) from the source HighlightRecord. +// - Sendable for cross-actor safety. +// +// @coordinates-with: AnnotationExporter.swift, MarkdownExportFormatter.swift, +// JSONExportFormatter.swift + +import Foundation + +/// The kind of annotation being exported. +enum ExportedAnnotationType: String, Codable, Sendable { + case highlight + case bookmark + case note +} + +/// A single exported annotation, combining data from highlights, bookmarks, and notes. +struct ExportedAnnotation: Codable, Sendable, Equatable { + let id: UUID + let type: ExportedAnnotationType + let chapter: String? + let selectedText: String? + let note: String? + let color: String? + let title: String? + let createdAt: Date + let updatedAt: Date +} + +/// Container for a full annotation export, including book metadata. +struct AnnotationExportPayload: Codable, Sendable, Equatable { + let bookTitle: String + let bookAuthor: String? + let exportedAt: Date + let annotations: [ExportedAnnotation] +} diff --git a/vreader/Models/FormatCapabilities.swift b/vreader/Models/FormatCapabilities.swift new file mode 100644 index 0000000..d0b9b0e --- /dev/null +++ b/vreader/Models/FormatCapabilities.swift @@ -0,0 +1,73 @@ +// Purpose: Declares what each BookFormat can do at runtime. +// A context-aware factory accounts for EPUB complexity. +// +// @coordinates-with: BookFormat.swift — convenience `capabilities` property + +/// Feature flags describing what a format's reader engine supports. +struct FormatCapabilities: OptionSet, Sendable, Hashable { + let rawValue: UInt16 + + static let textSelection = FormatCapabilities(rawValue: 1 << 0) + static let highlights = FormatCapabilities(rawValue: 1 << 1) + static let bookmarks = FormatCapabilities(rawValue: 1 << 2) + static let search = FormatCapabilities(rawValue: 1 << 3) + static let tts = FormatCapabilities(rawValue: 1 << 4) + static let nativePagination = FormatCapabilities(rawValue: 1 << 5) + static let unifiedReflow = FormatCapabilities(rawValue: 1 << 6) + static let toc = FormatCapabilities(rawValue: 1 << 7) + static let annotations = FormatCapabilities(rawValue: 1 << 8) + + // MARK: - Presets + + /// Capabilities shared by every format. + private static let universal: FormatCapabilities = [.search, .bookmarks] + + /// Base capabilities for reflowable text formats (TXT, MD). + private static let reflowableBase: FormatCapabilities = [ + .textSelection, .highlights, .tts, + .nativePagination, .unifiedReflow, .annotations, + ] + + // MARK: - Context-Aware Factory + + /// Returns the capability set for `format`, optionally considering + /// whether an EPUB has complex layout (fixed-layout, heavy CSS, SVG pages). + /// + /// - Parameters: + /// - format: The book format. + /// - isComplexEPUB: When `true` the EPUB loses `.unifiedReflow`. + /// Ignored for non-EPUB formats. + /// - Returns: The resolved capability set. + static func capabilities( + for format: BookFormat, + isComplexEPUB: Bool = false + ) -> FormatCapabilities { + switch format { + case .txt: + return universal.union(reflowableBase) + + case .md: + return universal.union(reflowableBase).union(.toc) + + case .epub: + var caps: FormatCapabilities = [ + .textSelection, .highlights, .tts, + .nativePagination, .toc, .annotations, + ] + caps.formUnion(universal) + if !isComplexEPUB { + caps.insert(.unifiedReflow) + } + return caps + + case .pdf: + var caps: FormatCapabilities = [ + .textSelection, .highlights, + .nativePagination, .annotations, + ] + caps.formUnion(universal) + // PDF never gets TTS or unifiedReflow. + return caps + } + } +} diff --git a/vreader/Models/LegadoBookSourceDTO.swift b/vreader/Models/LegadoBookSourceDTO.swift new file mode 100644 index 0000000..ca4e76e --- /dev/null +++ b/vreader/Models/LegadoBookSourceDTO.swift @@ -0,0 +1,120 @@ +// Purpose: Codable DTO matching Legado's BookSource JSON format exactly. +// Used as an intermediary for import/export — maps to/from VReader's BookSource model. +// +// Key decisions: +// - All fields optional for forward compatibility (unknown fields ignored). +// - Field names match Legado's camelCase JSON keys exactly. +// - Rule sub-objects use separate DTO structs with flexible decoding. +// - lastUpdateTime is Int64 (milliseconds since epoch) in Legado format. +// +// @coordinates-with: LegadoImporter.swift, BookSource.swift, BookSourceRules.swift + +import Foundation + +/// DTO matching Legado's BookSource JSON format for import/export. +/// Uses flexible decoding: unknown fields are silently ignored. +struct LegadoBookSourceDTO: Codable, Sendable { + + // MARK: - Identity + + var bookSourceUrl: String? + var bookSourceName: String? + var bookSourceGroup: String? + var bookSourceType: Int? + var enabled: Bool? + + // MARK: - Configuration + + var searchUrl: String? + var header: String? + var loginUrl: String? + var concurrentRate: String? + + // MARK: - Rules + + var ruleSearch: LegadoSearchRuleDTO? + var ruleBookInfo: LegadoBookInfoRuleDTO? + var ruleToc: LegadoTocRuleDTO? + var ruleContent: LegadoContentRuleDTO? + + // MARK: - Metadata + + var lastUpdateTime: Int64? + var customOrder: Int? + var weight: Int? + var bookSourceComment: String? +} + +/// Legado search rule DTO — all fields optional, unknown keys ignored. +struct LegadoSearchRuleDTO: Codable, Sendable { + var bookList: String? + var name: String? + var author: String? + var bookUrl: String? + var coverUrl: String? + var kind: String? + var wordCount: String? + var intro: String? + var lastChapter: String? + + /// Collects all non-nil rule strings for compatibility analysis. + var allRuleStrings: [String] { + [bookList, name, author, bookUrl, coverUrl, + kind, wordCount, intro, lastChapter].compactMap { $0 } + } +} + +/// Legado book info rule DTO. +struct LegadoBookInfoRuleDTO: Codable, Sendable { + var name: String? + var author: String? + var intro: String? + var coverUrl: String? + var tocUrl: String? + var kind: String? + var wordCount: String? + var lastChapter: String? + + var allRuleStrings: [String] { + [name, author, intro, coverUrl, tocUrl, + kind, wordCount, lastChapter].compactMap { $0 } + } +} + +/// Legado TOC rule DTO. +struct LegadoTocRuleDTO: Codable, Sendable { + var chapterList: String? + var chapterName: String? + var chapterUrl: String? + var nextTocUrl: String? + var isVip: String? + var isPay: String? + var updateTime: String? + + var allRuleStrings: [String] { + [chapterList, chapterName, chapterUrl, nextTocUrl, + isVip, isPay, updateTime].compactMap { $0 } + } +} + +/// Legado content rule DTO. +struct LegadoContentRuleDTO: Codable, Sendable { + var content: String? + var nextContentUrl: String? + var replaceRegex: String? + var webJs: String? + var sourceRegex: String? + + var allRuleStrings: [String] { + [content, nextContentUrl, replaceRegex, + webJs, sourceRegex].compactMap { $0 } + } +} + +// MARK: - Flexible Decoding (ignore unknown keys) + +extension LegadoBookSourceDTO { + /// Custom CodingKeys not needed — Codable already ignores unknown keys + /// when no explicit CodingKeys enum is provided. + /// (Swift's default synthesized Codable skips unknown keys.) +} diff --git a/vreader/Models/Migration/SchemaV1.swift b/vreader/Models/Migration/SchemaV1.swift index ec53178..9359d6c 100644 --- a/vreader/Models/Migration/SchemaV1.swift +++ b/vreader/Models/Migration/SchemaV1.swift @@ -77,7 +77,7 @@ enum SchemaV1: VersionedSchema { /// infers the column addition automatically. enum VReaderMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { - [SchemaV1.self, SchemaV2.self] + [SchemaV1.self, SchemaV2.self, SchemaV3.self, SchemaV4.self] } static var stages: [MigrationStage] { diff --git a/vreader/Models/Migration/SchemaV3.swift b/vreader/Models/Migration/SchemaV3.swift new file mode 100644 index 0000000..90f7b7b --- /dev/null +++ b/vreader/Models/Migration/SchemaV3.swift @@ -0,0 +1,29 @@ +// Purpose: Schema version 3 — adds BookCollection model and series fields to Book. +// +// Changes from V2: +// - New BookCollection @Model (name, createdAt, books relationship). +// - Book gains seriesName: String?, seriesIndex: Int?, bookCollections: [BookCollection]. +// - All new fields are optional/defaulted, so lightweight migration applies. +// +// @coordinates-with: SchemaV2.swift, BookCollection.swift, Book.swift + +import Foundation +import SwiftData + +/// Schema version 3: adds BookCollection entity and series fields to Book. +enum SchemaV3: VersionedSchema { + static let versionIdentifier = Schema.Version(3, 0, 0) + + static var models: [any PersistentModel.Type] { + [ + Book.self, + ReadingPosition.self, + Bookmark.self, + Highlight.self, + AnnotationNote.self, + ReadingSession.self, + ReadingStats.self, + BookCollection.self, + ] + } +} diff --git a/vreader/Models/Migration/SchemaV4.swift b/vreader/Models/Migration/SchemaV4.swift new file mode 100644 index 0000000..841f9a6 --- /dev/null +++ b/vreader/Models/Migration/SchemaV4.swift @@ -0,0 +1,32 @@ +// Purpose: Schema version 4 — adds BookSource and ContentReplacementRule models. +// +// Changes from V3: +// - New BookSource @Model (web novel source definitions). +// - New ContentReplacementRule @Model (text replacement rules). +// - Both are independent entities — no relationship changes to existing models. +// - All new fields are optional/defaulted, so lightweight migration applies. +// +// @coordinates-with: SchemaV3.swift, BookSource.swift, ContentReplacementRule.swift + +import Foundation +import SwiftData + +/// Schema version 4: adds BookSource and ContentReplacementRule entities. +enum SchemaV4: VersionedSchema { + static let versionIdentifier = Schema.Version(4, 0, 0) + + static var models: [any PersistentModel.Type] { + [ + Book.self, + ReadingPosition.self, + Bookmark.self, + Highlight.self, + AnnotationNote.self, + ReadingSession.self, + ReadingStats.self, + BookCollection.self, + BookSource.self, + ContentReplacementRule.self, + ] + } +} diff --git a/vreader/Models/ReaderTheme.swift b/vreader/Models/ReaderTheme.swift index 9897253..68f3ed5 100644 --- a/vreader/Models/ReaderTheme.swift +++ b/vreader/Models/ReaderTheme.swift @@ -72,17 +72,73 @@ enum ReaderTheme: String, Codable, CaseIterable, Sendable { /// Issue 10: Uses `body *` wildcard instead of a hard-coded tag allowlist so that /// all elements (including `pre`, `code`, etc.) inherit the user-chosen font size. /// Headings (`h1`-`h6`) are excluded via `revert` so they keep their relative sizing. - func epubOverrideCSS(fontSize: CGFloat) -> String { + func epubOverrideCSS(fontSize: CGFloat, lineHeight: CGFloat = 1.6, letterSpacing: CGFloat = 0) -> String { let bg = cssColor(backgroundColor) let fg = cssColor(textColor) let secondary = cssColor(secondaryTextColor) let size = String(format: "%.1f", fontSize) + let lh = String(format: "%.2f", lineHeight) + let ls = letterSpacing > 0 ? String(format: "%.2fem", letterSpacing) : "normal" + let linkColor = self == .dark ? "rgb(120,170,255)" : "rgb(0,90,180)" return """ """ } diff --git a/vreader/Models/ReadingMode.swift b/vreader/Models/ReadingMode.swift new file mode 100644 index 0000000..f0c70c0 --- /dev/null +++ b/vreader/Models/ReadingMode.swift @@ -0,0 +1,18 @@ +// Purpose: Defines the reading mode toggle — Native vs Unified rendering engine. +// Native dispatches to format-specific readers; Unified uses a shared reflow engine (Phase B). +// +// Key decisions: +// - String-backed RawRepresentable for UserDefaults persistence. +// - Default is .native (all existing readers are native). +// - .unified uses TextKit 2 reflow engine for TXT/MD (WI-B04). EPUB unified is placeholder. +// +// @coordinates-with: ReaderSettingsStore.swift, ReaderContainerView.swift + +/// Reading engine mode: native per-format readers vs unified reflow engine. +enum ReadingMode: String, Codable, Sendable, Hashable, CaseIterable { + /// Per-format native reader (EPUB WebView, PDF PDFKit, TXT/MD attributed string). + case native + /// Unified reflow engine for reflowable formats (TXT, MD, simple EPUB). + /// TXT/MD use TextKit 2 reflow engine (WI-B04). EPUB unified is placeholder. + case unified +} diff --git a/vreader/Models/TapZoneConfig.swift b/vreader/Models/TapZoneConfig.swift new file mode 100644 index 0000000..380a6fb --- /dev/null +++ b/vreader/Models/TapZoneConfig.swift @@ -0,0 +1,100 @@ +// Purpose: Defines tap zone configuration for reader screens. +// The reader divides the screen into three horizontal zones (left/center/right), +// each mapped to a configurable action (previous page, toggle chrome, next page, none). +// +// Key decisions: +// - Zones are percentage-based: 33.33% / 33.33% / 33.33%. +// - Zone detection is a static pure function for easy testing. +// - All types are Codable + Sendable for persistence and thread safety. +// - previousPage/nextPage dispatch via NotificationCenter; PDF wired via PDFPageNavigator (WI-B09). +// - TapZoneStore provides @Observable persistence independent of ReaderSettingsStore. +// +// @coordinates-with TapZoneOverlay.swift, ReaderContainerView.swift + +import Foundation +import SwiftUI + +/// Identifies which horizontal third of the screen a tap landed in. +enum TapZone: String, Codable, Sendable { + case left + case center + case right +} + +/// Actions that can be assigned to a tap zone. +enum TapAction: String, Codable, Sendable, CaseIterable { + case previousPage + case nextPage + case toggleChrome + case none +} + +/// Configurable mapping of tap zones to actions. +struct TapZoneConfig: Codable, Sendable, Equatable { + var leftAction: TapAction + var centerAction: TapAction + var rightAction: TapAction + + static let `default` = TapZoneConfig( + leftAction: .previousPage, + centerAction: .toggleChrome, + rightAction: .nextPage + ) + + init( + leftAction: TapAction = .previousPage, + centerAction: TapAction = .toggleChrome, + rightAction: TapAction = .nextPage + ) { + self.leftAction = leftAction + self.centerAction = centerAction + self.rightAction = rightAction + } + + func action(for zone: TapZone) -> TapAction { + switch zone { + case .left: return leftAction + case .center: return centerAction + case .right: return rightAction + } + } + + static func zone(atX x: CGFloat, totalWidth: CGFloat) -> TapZone { + guard totalWidth > 0 else { return .center } + let fraction = x / totalWidth + if fraction < 1.0 / 3.0 { + return .left + } else if fraction < 2.0 / 3.0 { + return .center + } else { + return .right + } + } +} + +/// Observable store for tap zone configuration. +@Observable +@MainActor +final class TapZoneStore { + static let key = "readerTapZoneConfig" + + var config: TapZoneConfig { + didSet { + if let data = try? JSONEncoder().encode(config) { + defaults.set(data, forKey: Self.key) + } + } + } + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + if let data = defaults.data(forKey: Self.key), + let decoded = try? JSONDecoder().decode(TapZoneConfig.self, from: data) { + self.config = decoded + } else { + self.config = .default + } + } +} diff --git a/vreader/Models/UnifiedEPUBLoadResult.swift b/vreader/Models/UnifiedEPUBLoadResult.swift new file mode 100644 index 0000000..5163114 --- /dev/null +++ b/vreader/Models/UnifiedEPUBLoadResult.swift @@ -0,0 +1,41 @@ +// Purpose: Result type for unified EPUB loading (Issue 10). +// Tracks loaded text/attributed text, skipped chapter count, and total chapter count. +// Used by ReaderContainerView.loadUnifiedEPUBContent() to report loading quality. +// +// @coordinates-with ReaderContainerView.swift, EPUBTextStripper.swift + +import Foundation + +/// Captures the result of loading EPUB chapters into the unified reflow engine. +struct UnifiedEPUBLoadResult { + /// The combined plain text from all loaded chapters, or nil if all failed. + let text: String? + /// The combined attributed text from all loaded chapters, or nil if all failed. + let attributedText: NSAttributedString? + /// Number of chapters that could not be loaded (failed to read or parse). + let skippedChapterCount: Int + /// Total number of spine chapters in the EPUB. + let totalChapterCount: Int + + /// Whether any chapters were skipped during loading. + var hasSkippedChapters: Bool { + skippedChapterCount > 0 + } + + /// Whether all chapters failed to load (book is unreadable in unified mode). + var allChaptersFailed: Bool { + totalChapterCount == 0 || skippedChapterCount >= totalChapterCount + } + + /// Warning message for partial loading, or nil if no chapters were skipped. + var warningMessage: String? { + guard hasSkippedChapters, !allChaptersFailed else { return nil } + return "\(skippedChapterCount) of \(totalChapterCount) chapters could not be loaded" + } + + /// Error message when all chapters failed, or nil otherwise. + var errorMessage: String? { + guard allChaptersFailed, totalChapterCount > 0 else { return nil } + return "All \(totalChapterCount) chapters failed to load" + } +} diff --git a/vreader/Services/AutoPageTurner.swift b/vreader/Services/AutoPageTurner.swift new file mode 100644 index 0000000..fea93ad --- /dev/null +++ b/vreader/Services/AutoPageTurner.swift @@ -0,0 +1,105 @@ +// Purpose: Timer-based auto page turning for the reader. +// Calls navigator.nextPage() at a configurable interval. +// Stops at the last page. Pauses on user interaction. +// +// Key decisions: +// - @MainActor + @Observable for SwiftUI binding. +// - Interval clamped to 1...60 seconds. +// - Uses Task.sleep for timer to allow clean cancellation. +// - Auto-stops when navigator reaches last page. +// +// @coordinates-with PageNavigator.swift, BasePageNavigator.swift + +import Foundation + +/// Timer-based auto page turning service. +@MainActor @Observable +final class AutoPageTurner { + + // MARK: - Types + + enum State: Sendable, Equatable { + case idle + case running + case paused + } + + // MARK: - Public State + + private(set) var state: State = .idle + + /// Seconds between page turns, clamped to 1...60. + var interval: TimeInterval = 5.0 { + didSet { interval = Self.clampInterval(interval) } + } + + // MARK: - Private + + private var timerTask: Task? + private weak var navigator: (any PageNavigator)? + + // MARK: - Public API + + /// Start auto page turning using the given navigator. + /// If already running, restarts with the current interval. + func start(navigator: any PageNavigator) { + stop() + self.navigator = navigator + state = .running + scheduleTimer() + } + + /// Pause auto page turning. Timer is suspended but state is retained. + func pause() { + guard state == .running else { return } + timerTask?.cancel() + timerTask = nil + state = .paused + } + + /// Resume auto page turning from paused state. + func resume() { + guard state == .paused else { return } + state = .running + scheduleTimer() + } + + /// Stop auto page turning completely. Resets to idle. + func stop() { + timerTask?.cancel() + timerTask = nil + navigator = nil + state = .idle + } + + // MARK: - Private + + private func scheduleTimer() { + timerTask?.cancel() + timerTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + let sleepInterval = self.interval + try? await Task.sleep(for: .seconds(sleepInterval)) + guard !Task.isCancelled else { return } + + guard let nav = self.navigator else { + self.stop() + return + } + + // Check if at last page + if nav.currentPage >= nav.totalPages - 1 { + self.stop() + return + } + + nav.nextPage() + } + } + } + + private static func clampInterval(_ value: TimeInterval) -> TimeInterval { + max(1.0, min(60.0, value)) + } +} diff --git a/vreader/Services/Backup/BackupProvider.swift b/vreader/Services/Backup/BackupProvider.swift new file mode 100644 index 0000000..4054d83 --- /dev/null +++ b/vreader/Services/Backup/BackupProvider.swift @@ -0,0 +1,82 @@ +// Purpose: Protocol for backup/restore operations against any storage backend. +// Defines the contract that concrete providers (WebDAV, iCloud) must satisfy. +// Phase E will add concrete implementations; this file is protocol-only. +// +// @coordinates-with: BackupMetadata (below), BackupError (below) + +import Foundation + +// MARK: - BackupMetadata + +/// Metadata describing a single backup snapshot. +/// +/// Codable for local persistence (e.g., caching backup lists). +/// Sendable for safe cross-actor transfer. +struct BackupMetadata: Codable, Sendable, Identifiable, Equatable { + /// Unique identifier for this backup. + let id: UUID + /// When the backup was created. + let createdAt: Date + /// Human-readable device name (e.g., "iPhone 17 Pro"). + let deviceName: String + /// App version that created this backup. + let appVersion: String + /// Number of books included in the backup. + let bookCount: Int + /// Total uncompressed size of all backup data in bytes. + let totalSizeBytes: Int64 +} + +// MARK: - BackupError + +/// Errors that backup/restore operations can produce. +enum BackupError: Error, Sendable, Equatable { + /// The backup archive could not be created. + case archiveCreationFailed(String) + /// The backup archive is corrupted or unreadable. + case archiveCorrupted(String) + /// The storage backend is unreachable or unavailable. + case storageUnavailable(String) + /// No backup exists with the given ID. + case backupNotFound(UUID) + /// The operation was cancelled by the user or system. + case cancelled +} + +// MARK: - BackupProvider Protocol + +/// Contract for backup storage backends. +/// +/// Concrete implementations (WebDAV, iCloud Drive) will be added in Phase E. +/// All methods are async and report progress via a callback. +/// +/// - Important: Implementations must be `Sendable` for safe use across actors. +protocol BackupProvider: Sendable { + /// Creates a new backup of the current library. + /// + /// - Parameter progress: Called with values in `[0, 1]` as the backup proceeds. + /// Values are non-decreasing and the final call is always `1.0`. + /// - Returns: Metadata describing the completed backup. + /// - Throws: `BackupError` on failure. + func backup(progress: @Sendable (Double) -> Void) async throws -> BackupMetadata + + /// Restores the library from a previous backup. + /// + /// - Parameters: + /// - backupId: The `id` of the backup to restore (from `listBackups()`). + /// - progress: Called with values in `[0, 1]` as the restore proceeds. + /// - Throws: `BackupError.backupNotFound` if `backupId` is unknown. + func restore(backupId: UUID, progress: @Sendable (Double) -> Void) async throws + + /// Lists all available backups, sorted newest first. + /// + /// - Returns: An array of `BackupMetadata`, sorted by `createdAt` descending. + /// Returns an empty array if no backups exist. + func listBackups() async throws -> [BackupMetadata] + + /// Deletes a backup. + /// + /// - Parameter id: The `id` of the backup to delete. + /// - Throws: `BackupError.backupNotFound` if `id` is unknown. + func deleteBackup(id: UUID) async throws +} diff --git a/vreader/Services/Backup/PROPFINDParser.swift b/vreader/Services/Backup/PROPFINDParser.swift new file mode 100644 index 0000000..8dfcbf5 --- /dev/null +++ b/vreader/Services/Backup/PROPFINDParser.swift @@ -0,0 +1,119 @@ +// Purpose: Parses WebDAV PROPFIND multi-status XML responses into WebDAVEntry values. +// Extracted from WebDAVClient.swift to keep files under 300 lines. +// +// Key decisions: +// - Uses Foundation XMLParser (no third-party deps). +// - Handles DAV: namespace with various prefix conventions (D:, d:, no prefix) +// by stripping prefixes and matching local element names. +// - HTTP date formatter uses en_US_POSIX locale for reliable parsing. +// +// @coordinates-with: WebDAVClient.swift + +import Foundation + +/// Parses WebDAV PROPFIND multi-status XML responses. +/// +/// Handles DAV: namespace with various prefix conventions (D:, d:, no prefix). +final class PROPFINDParser: NSObject, XMLParserDelegate { + + private let data: Data + private var entries: [WebDAVEntry] = [] + + // Current parsing state + private var currentHref: String? + private var currentContentLength: Int64 = 0 + private var currentLastModified: Date? + private var currentIsDirectory = false + private var currentText = "" + private var inResponse = false + private var parseError: Error? + + /// HTTP date formatter for getlastmodified values. + private static let httpDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return formatter + }() + + init(data: Data) { + self.data = data + super.init() + } + + func parse() throws -> [WebDAVEntry] { + let parser = XMLParser(data: data) + parser.delegate = self + parser.shouldProcessNamespaces = true + let success = parser.parse() + + if let error = parseError { + throw error + } + if !success { + throw WebDAVError.invalidResponse( + parser.parserError?.localizedDescription ?? "Unknown XML parse error" + ) + } + return entries + } + + // MARK: - XMLParserDelegate + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] + ) { + let localName = elementName.components(separatedBy: ":").last ?? elementName + currentText = "" + + if localName == "response" { + inResponse = true + currentHref = nil + currentContentLength = 0 + currentLastModified = nil + currentIsDirectory = false + } else if localName == "collection" && inResponse { + currentIsDirectory = true + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + currentText += string + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + let localName = elementName.components(separatedBy: ":").last ?? elementName + let trimmed = currentText.trimmingCharacters(in: .whitespacesAndNewlines) + + if localName == "href" && inResponse { + currentHref = trimmed + } else if localName == "getcontentlength" && inResponse { + currentContentLength = Int64(trimmed) ?? 0 + } else if localName == "getlastmodified" && inResponse { + currentLastModified = Self.httpDateFormatter.date(from: trimmed) + } else if localName == "response" { + if let href = currentHref { + entries.append(WebDAVEntry( + href: href, + contentLength: currentContentLength, + lastModified: currentLastModified, + isDirectory: currentIsDirectory + )) + } + inResponse = false + } + } + + func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { + self.parseError = WebDAVError.invalidResponse(parseError.localizedDescription) + } +} diff --git a/vreader/Services/Backup/WebDAVClient.swift b/vreader/Services/Backup/WebDAVClient.swift new file mode 100644 index 0000000..6086e3c --- /dev/null +++ b/vreader/Services/Backup/WebDAVClient.swift @@ -0,0 +1,289 @@ +// Purpose: URLSession-based WebDAV HTTP operations (PROPFIND, PUT, GET, DELETE, MKCOL). +// Provides request building, response parsing, and HTTP status validation. +// Transport protocol enables testing without real HTTP calls. +// +// Key decisions: +// - Stateless struct: each method builds a URLRequest independently. +// - Basic authentication via Authorization header. +// - PROPFIND XML parsed with Foundation's XMLParser (no third-party deps). +// - Transport protocol abstracts actual HTTP calls for testability. +// - Server URL normalized with trailing slash at init. +// +// @coordinates-with: WebDAVProvider.swift, KeychainService.swift + +import Foundation + +// MARK: - WebDAVError + +/// Errors from WebDAV operations. +enum WebDAVError: Error, Sendable, Equatable { + /// Server returned 401/403. + case authenticationFailed + /// Resource not found (404). + case notFound(String) + /// Server is unreachable or returned a connection error. + case connectionFailed(String) + /// Server returned an unexpected HTTP status code. + case httpError(Int) + /// Server is out of storage space (507). + case quotaExceeded + /// The PROPFIND response XML could not be parsed. + case invalidResponse(String) +} + +// MARK: - WebDAVEntry + +/// A single entry returned by a PROPFIND directory listing. +struct WebDAVEntry: Sendable, Equatable { + /// The href (path) of the resource. + let href: String + /// Content length in bytes (0 if unknown or directory). + let contentLength: Int64 + /// Last modified date, if available. + let lastModified: Date? + /// Whether this entry is a collection (directory). + let isDirectory: Bool +} + +// MARK: - WebDAVTransport Protocol + +/// Abstracts WebDAV HTTP operations for testability. +/// Production uses URLSession; tests use MockWebDAVTransport. +protocol WebDAVTransport: Sendable { + func upload(data: Data, toPath path: String) async throws + func download(fromPath path: String) async throws -> Data + func delete(path: String) async throws + func listDirectory(path: String) async throws -> [WebDAVEntry] + func createDirectory(path: String) async throws + func testConnection() async throws +} + +// MARK: - WebDAVClient + +/// URLSession-based WebDAV client. +/// +/// Provides both direct request-building methods (for unit testing request +/// construction) and a `WebDAVTransport` conformance (for integration use). +struct WebDAVClient: Sendable { + + /// The base server URL (with trailing slash). + let serverURL: URL + + /// The username for Basic auth. + let username: String + + /// The password for Basic auth (not exposed publicly). + private let password: String + + /// URLSession for HTTP operations. + private let session: URLSession + + // MARK: - Init + + /// Creates a WebDAV client. + /// + /// - Parameters: + /// - serverURL: Base WebDAV URL. Trailing slash is added if missing. + /// - username: Auth username. + /// - password: Auth password. + /// - session: URLSession to use (default: `.shared`). + init( + serverURL: URL, + username: String, + password: String, + session: URLSession = .shared + ) { + // Normalize trailing slash + var urlString = serverURL.absoluteString + if !urlString.hasSuffix("/") { + urlString += "/" + } + self.serverURL = URL(string: urlString)! + self.username = username + self.password = password + self.session = session + } + + // MARK: - URL Building + + /// Builds a full URL by appending a relative path to the server URL. + func buildURL(path: String) -> URL { + serverURL.appendingPathComponent(path) + } + + // MARK: - Auth + + /// The Basic auth Authorization header value. + var authorizationHeader: String { + let credentials = "\(username):\(password)" + let encoded = Data(credentials.utf8).base64EncodedString() + return "Basic \(encoded)" + } + + // MARK: - Request Building + + /// Builds a PROPFIND request for directory listing. + func buildPROPFINDRequest(path: String) -> URLRequest { + var request = URLRequest(url: buildURL(path: path)) + request.httpMethod = "PROPFIND" + request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization") + request.setValue("1", forHTTPHeaderField: "Depth") + request.setValue("application/xml", forHTTPHeaderField: "Content-Type") + request.httpBody = Data(Self.propfindBody.utf8) + return request + } + + /// Builds a PUT request for file upload. + func buildPUTRequest(path: String, data: Data) -> URLRequest { + var request = URLRequest(url: buildURL(path: path)) + request.httpMethod = "PUT" + request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization") + request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") + request.httpBody = data + return request + } + + /// Builds a GET request for file download. + func buildGETRequest(path: String) -> URLRequest { + var request = URLRequest(url: buildURL(path: path)) + request.httpMethod = "GET" + request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization") + return request + } + + /// Builds a DELETE request. + func buildDELETERequest(path: String) -> URLRequest { + var request = URLRequest(url: buildURL(path: path)) + request.httpMethod = "DELETE" + request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization") + return request + } + + /// Builds a MKCOL request for directory creation. + func buildMKCOLRequest(path: String) -> URLRequest { + var request = URLRequest(url: buildURL(path: path)) + request.httpMethod = "MKCOL" + request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization") + return request + } + + // MARK: - HTTP Status Validation + + /// Checks an HTTP status code and throws appropriate WebDAVError for failures. + /// + /// - Parameters: + /// - statusCode: The HTTP response status code. + /// - url: The URL that was requested (for error messages). + /// - Throws: `WebDAVError` for non-success status codes. + static func checkHTTPStatus(_ statusCode: Int, url: URL) throws { + switch statusCode { + case 200...299, 207: + return // Success + case 401, 403: + throw WebDAVError.authenticationFailed + case 404: + throw WebDAVError.notFound(url.absoluteString) + case 507: + throw WebDAVError.quotaExceeded + case 400...499: + throw WebDAVError.httpError(statusCode) + case 500...599: + throw WebDAVError.httpError(statusCode) + default: + throw WebDAVError.httpError(statusCode) + } + } + + // MARK: - PROPFIND XML Parsing + + /// Parses a PROPFIND XML response into WebDAVEntry values. + /// + /// - Parameter data: The raw XML response body. + /// - Returns: Array of parsed entries. + /// - Throws: `WebDAVError.invalidResponse` if XML is malformed. + static func parsePROPFINDResponse(_ data: Data) throws -> [WebDAVEntry] { + let parser = PROPFINDParser(data: data) + return try parser.parse() + } + + // MARK: - Private + + /// Standard PROPFIND request body asking for common properties. + private static let propfindBody = """ + + + + + + + + + """ +} + +// MARK: - WebDAVTransport Conformance + +extension WebDAVClient: WebDAVTransport { + + func upload(data: Data, toPath path: String) async throws { + let request = buildPUTRequest(path: path, data: data) + let (_, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw WebDAVError.connectionFailed("Invalid response") + } + try Self.checkHTTPStatus(httpResponse.statusCode, url: request.url!) + } + + func download(fromPath path: String) async throws -> Data { + let request = buildGETRequest(path: path) + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw WebDAVError.connectionFailed("Invalid response") + } + try Self.checkHTTPStatus(httpResponse.statusCode, url: request.url!) + return data + } + + func delete(path: String) async throws { + let request = buildDELETERequest(path: path) + let (_, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw WebDAVError.connectionFailed("Invalid response") + } + try Self.checkHTTPStatus(httpResponse.statusCode, url: request.url!) + } + + func listDirectory(path: String) async throws -> [WebDAVEntry] { + let request = buildPROPFINDRequest(path: path) + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw WebDAVError.connectionFailed("Invalid response") + } + try Self.checkHTTPStatus(httpResponse.statusCode, url: request.url!) + return try Self.parsePROPFINDResponse(data) + } + + func createDirectory(path: String) async throws { + let request = buildMKCOLRequest(path: path) + let (_, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw WebDAVError.connectionFailed("Invalid response") + } + // 405 = already exists, treat as success + let statusCode = httpResponse.statusCode + if statusCode != 405 { + try Self.checkHTTPStatus(statusCode, url: request.url!) + } + } + + func testConnection() async throws { + let request = buildPROPFINDRequest(path: "") + let (_, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw WebDAVError.connectionFailed("Invalid response") + } + try Self.checkHTTPStatus(httpResponse.statusCode, url: request.url!) + } +} + +// MARK: - PROPFIND XML Parser (see PROPFINDParser.swift) diff --git a/vreader/Services/Backup/WebDAVProvider.swift b/vreader/Services/Backup/WebDAVProvider.swift new file mode 100644 index 0000000..5ab8050 --- /dev/null +++ b/vreader/Services/Backup/WebDAVProvider.swift @@ -0,0 +1,319 @@ +// Purpose: BackupProvider conformance for WebDAV storage backends. +// Creates ZIP backups of app data and uploads/downloads via WebDAVTransport. +// Restore delegates to BackupDataRestoring protocol for persistence-layer writes. +// +// Key decisions: +// - Uses WebDAVTransport protocol for testability. +// - Backup format: ZIP with JSON files. Path: VReader/backups/_.vreader.zip +// - Progress: collect (40%), archive (10%), upload (50%). +// - Restore extracts ZIP entries and delegates to BackupDataRestoring for import. +// - Missing entries during restore are silently skipped (forward compatibility). +// +// @coordinates-with: BackupProvider.swift, WebDAVClient.swift, ZIPWriter.swift + +import Foundation + +// MARK: - BackupDataCollecting Protocol + +/// Abstracts data collection from the persistence layer for testability. +protocol BackupDataCollecting: Sendable { + func collectAnnotations() async throws -> Data + func collectPositions() async throws -> Data + func collectSettings() async throws -> Data + func collectCollections() async throws -> Data + func collectBookSources() async throws -> Data + func collectPerBookSettings() async throws -> Data + func collectReplacementRules() async throws -> Data + func getBookCount() async -> Int +} + +// MARK: - BackupDataRestoring Protocol + +/// Abstracts data restoration into the persistence layer for testability. +/// Mirrors BackupDataCollecting for the restore path. +protocol BackupDataRestoring: Sendable { + func restoreAnnotations(from data: Data) async throws + func restorePositions(from data: Data) async throws + func restoreSettings(from data: Data) async throws + func restoreCollections(from data: Data) async throws + func restoreBookSources(from data: Data) async throws + func restorePerBookSettings(from data: Data) async throws + func restoreReplacementRules(from data: Data) async throws +} + +// MARK: - WebDAVProvider + +/// BackupProvider that stores backups on a WebDAV server. +final class WebDAVProvider: BackupProvider, @unchecked Sendable { + + private let transport: WebDAVTransport + private let dataCollector: BackupDataCollecting + private let dataRestorer: BackupDataRestoring + private let deviceName: String + private let appVersion: String + private let basePath = "VReader/backups" + + /// In-memory cache of known backup metadata, keyed by ID. + private var metadataCache: [UUID: (metadata: BackupMetadata, remotePath: String)] = [:] + + init( + transport: WebDAVTransport, + dataCollector: BackupDataCollecting, + dataRestorer: BackupDataRestoring, + deviceName: String, + appVersion: String + ) { + self.transport = transport + self.dataCollector = dataCollector + self.dataRestorer = dataRestorer + self.deviceName = deviceName + self.appVersion = appVersion + } + + // MARK: - BackupProvider + + func backup(progress: @Sendable (Double) -> Void) async throws -> BackupMetadata { + progress(0.0) + + // Phase 1: Collect data (0.0 → 0.4) + let collected: [(String, Data)] + let bookCount: Int + do { + let a = try await dataCollector.collectAnnotations(); progress(0.06) + let p = try await dataCollector.collectPositions(); progress(0.12) + let s = try await dataCollector.collectSettings(); progress(0.18) + let c = try await dataCollector.collectCollections(); progress(0.24) + let bs = try await dataCollector.collectBookSources(); progress(0.30) + let pbs = try await dataCollector.collectPerBookSettings(); progress(0.35) + let rr = try await dataCollector.collectReplacementRules(); progress(0.38) + bookCount = await dataCollector.getBookCount(); progress(0.40) + collected = [ + ("annotations.json", a), ("positions.json", p), ("settings.json", s), + ("collections.json", c), ("book-sources.json", bs), ("per-book-settings.json", pbs), + ("replacement-rules.json", rr), + ] + } catch { + throw BackupError.archiveCreationFailed("Failed to collect data: \(error.localizedDescription)") + } + + // Phase 2: Create metadata and ZIP (0.4 → 0.5) + let backupId = UUID() + let now = Date() + let totalSize = Int64(collected.reduce(0) { $0 + $1.1.count }) + let metadata = BackupMetadata( + id: backupId, createdAt: now, deviceName: deviceName, + appVersion: appVersion, bookCount: bookCount, totalSizeBytes: totalSize + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let metadataJSON = try? encoder.encode(metadata) else { + throw BackupError.archiveCreationFailed("Failed to encode metadata") + } + + var zipEntries = [ZIPWriter.Entry(name: "metadata.json", data: metadataJSON)] + zipEntries += collected.map { ZIPWriter.Entry(name: $0.0, data: $0.1) } + + guard let zipData = try? ZIPWriter.createArchive(entries: zipEntries) else { + throw BackupError.archiveCreationFailed("Failed to create ZIP archive") + } + progress(0.50) + + // Phase 3: Upload to WebDAV (0.5 → 1.0) + let remotePath = makeRemotePath(id: backupId, date: now) + do { + try await transport.createDirectory(path: basePath); progress(0.55) + try await transport.upload(data: zipData, toPath: remotePath); progress(0.95) + } catch let error as WebDAVError { + throw BackupError.storageUnavailable("WebDAV upload failed: \(error)") + } catch { + throw BackupError.storageUnavailable("Upload failed: \(error.localizedDescription)") + } + + metadataCache[backupId] = (metadata, remotePath) + progress(1.0) + return metadata + } + + func restore(backupId: UUID, progress: @Sendable (Double) -> Void) async throws { + progress(0.0) + + // Find the remote path for this backup + let remotePath: String + if let cached = metadataCache[backupId] { + remotePath = cached.remotePath + } else { + // Refresh cache from server + _ = try await listBackups() + guard let cached = metadataCache[backupId] else { + throw BackupError.backupNotFound(backupId) + } + remotePath = cached.remotePath + } + + progress(0.10) + + // Download the ZIP + let zipData: Data + do { + zipData = try await transport.download(fromPath: remotePath) + } catch let error as WebDAVError { + if case .notFound = error { + throw BackupError.backupNotFound(backupId) + } + throw BackupError.storageUnavailable( + "Download failed: \(error)" + ) + } + + progress(0.50) + + // Validate the archive contains metadata + do { + let names = try ZIPWriter.listEntryNames(in: zipData) + guard names.contains("metadata.json") else { + throw BackupError.archiveCorrupted("Missing metadata.json in archive") + } + } catch let error as BackupError { + throw error + } catch { + throw BackupError.archiveCorrupted( + "Failed to read archive: \(error.localizedDescription)" + ) + } + + progress(0.55) + + // Apply restored data to local database via BackupDataRestoring delegate. + // Each file is optional — missing entries are silently skipped (forward compatibility). + let restoreFiles: [(String, (Data) async throws -> Void)] = [ + ("annotations.json", dataRestorer.restoreAnnotations), + ("positions.json", dataRestorer.restorePositions), + ("settings.json", dataRestorer.restoreSettings), + ("collections.json", dataRestorer.restoreCollections), + ("book-sources.json", dataRestorer.restoreBookSources), + ("per-book-settings.json", dataRestorer.restorePerBookSettings), + ("replacement-rules.json", dataRestorer.restoreReplacementRules), + ] + + let totalFiles = Double(restoreFiles.count) + for (index, (filename, restoreFunc)) in restoreFiles.enumerated() { + if let entryData = try? ZIPWriter.extractEntry(named: filename, from: zipData) { + try await restoreFunc(entryData) + } + progress(0.55 + 0.40 * Double(index + 1) / totalFiles) + } + + progress(1.0) + } + + func listBackups() async throws -> [BackupMetadata] { + let entries: [WebDAVEntry] + do { + entries = try await transport.listDirectory(path: basePath + "/") + } catch let error as WebDAVError { + if case .notFound = error { + return [] // Directory doesn't exist yet — no backups + } + throw BackupError.storageUnavailable( + "Failed to list backups: \(error)" + ) + } + + // Filter to .vreader.zip files + let zipEntries = entries.filter { + !$0.isDirectory && $0.href.hasSuffix(".vreader.zip") + } + + // Download and parse metadata from each backup + var results: [BackupMetadata] = [] + for entry in zipEntries { + let path = extractRelativePath(from: entry.href) + do { + let zipData = try await transport.download(fromPath: path) + let metadataData = try ZIPWriter.extractEntry( + named: "metadata.json", from: zipData + ) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let metadata = try decoder.decode(BackupMetadata.self, from: metadataData) + results.append(metadata) + metadataCache[metadata.id] = (metadata, path) + } catch { + // Skip corrupted backups + continue + } + } + + // Sort newest first + results.sort { $0.createdAt > $1.createdAt } + return results + } + + func deleteBackup(id: UUID) async throws { + // Find the remote path + let remotePath: String + if let cached = metadataCache[id] { + remotePath = cached.remotePath + } else { + _ = try await listBackups() + guard let cached = metadataCache[id] else { + throw BackupError.backupNotFound(id) + } + remotePath = cached.remotePath + } + + do { + try await transport.delete(path: remotePath) + } catch let error as WebDAVError { + if case .notFound = error { + throw BackupError.backupNotFound(id) + } + throw BackupError.storageUnavailable( + "Failed to delete backup: \(error)" + ) + } + + metadataCache.removeValue(forKey: id) + } + + // MARK: - Connection Test + + /// Tests the connection to the WebDAV server. + /// Throws if connection or authentication fails. + func testConnection() async throws { + try await transport.testConnection() + } + + // MARK: - Private Helpers + + /// Creates a remote path for a backup file. + private func makeRemotePath(id: UUID, date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate, .withFullTime, .withDashSeparatorInDate] + let timestamp = formatter.string(from: date) + .replacingOccurrences(of: ":", with: "-") + let shortId = id.uuidString.prefix(8).lowercased() + return "\(basePath)/\(timestamp)_\(shortId).vreader.zip" + } + + /// Extracts a relative path from a full href. + /// Handles various server path formats. + private func extractRelativePath(from href: String) -> String { + // If href is already a relative path starting with basePath, use it directly + if href.hasPrefix(basePath) { + return href + } + // Otherwise, find the basePath portion within the href + if let range = href.range(of: basePath) { + return String(href[range.lowerBound...]) + } + // Fallback: try to extract just the filename + let components = href.components(separatedBy: "/") + if let filename = components.last, filename.hasSuffix(".vreader.zip") { + return "\(basePath)/\(filename)" + } + return href + } +} diff --git a/vreader/Services/Backup/ZIPWriter.swift b/vreader/Services/Backup/ZIPWriter.swift new file mode 100644 index 0000000..6cb6e3c --- /dev/null +++ b/vreader/Services/Backup/ZIPWriter.swift @@ -0,0 +1,289 @@ +// Purpose: Minimal write-only ZIP archive creator using Foundation. +// Creates uncompressed (Stored) ZIP archives for backup data. +// Also provides extraction helpers for reading entries back. +// +// Key decisions: +// - Uses Stored (method 0) for simplicity and reliability. +// JSON backup data compresses poorly relative to archive size, +// and the upload channel (WebDAV) often handles compression. +// - CRC32 computed using the zlib crc32() function via Compression. +// - Matches ZIP format structures used by ZIPReader for round-trip compatibility. +// - Static methods only — no mutable state. +// +// @coordinates-with: ZIPReader.swift, WebDAVProvider.swift + +import Foundation +import Compression + +// MARK: - ZIPWriter + +/// Creates ZIP archives in memory from named data entries. +enum ZIPWriter { + + /// A single file to include in the archive. + struct Entry: Sendable { + /// Relative path within the archive (e.g., "metadata.json"). + let name: String + /// File contents. + let data: Data + } + + // MARK: - Archive Creation + + /// Creates a ZIP archive containing the given entries. + /// + /// Uses Stored (no compression) method for simplicity. + /// The resulting Data can be written to disk or uploaded directly. + /// + /// - Parameter entries: Files to include in the archive. + /// - Returns: Complete ZIP archive as Data. + /// - Throws: If entry names cannot be encoded as UTF-8. + static func createArchive(entries: [Entry]) throws -> Data { + var archive = Data() + var centralDirectory = Data() + var localOffsets: [Int] = [] + + for entry in entries { + guard let nameData = entry.name.data(using: .utf8) else { + throw ZIPWriterError.invalidEntryName(entry.name) + } + + let crc = crc32Checksum(entry.data) + let fileSize = UInt32(entry.data.count) + let nameLength = UInt16(nameData.count) + + // Record offset for central directory + localOffsets.append(archive.count) + + // --- Local File Header --- + archive.appendUInt32LE(0x04034b50) // signature + archive.appendUInt16LE(20) // version needed (2.0) + archive.appendUInt16LE(0) // general purpose bit flag + archive.appendUInt16LE(0) // compression method (stored) + archive.appendUInt16LE(0) // last mod time + archive.appendUInt16LE(0) // last mod date + archive.appendUInt32LE(crc) // CRC-32 + archive.appendUInt32LE(fileSize) // compressed size + archive.appendUInt32LE(fileSize) // uncompressed size + archive.appendUInt16LE(nameLength) // filename length + archive.appendUInt16LE(0) // extra field length + archive.append(nameData) // filename + archive.append(entry.data) // file data + + // --- Central Directory Entry --- + centralDirectory.appendUInt32LE(0x02014b50) // signature + centralDirectory.appendUInt16LE(20) // version made by + centralDirectory.appendUInt16LE(20) // version needed + centralDirectory.appendUInt16LE(0) // general purpose bit flag + centralDirectory.appendUInt16LE(0) // compression method + centralDirectory.appendUInt16LE(0) // last mod time + centralDirectory.appendUInt16LE(0) // last mod date + centralDirectory.appendUInt32LE(crc) // CRC-32 + centralDirectory.appendUInt32LE(fileSize) // compressed size + centralDirectory.appendUInt32LE(fileSize) // uncompressed size + centralDirectory.appendUInt16LE(nameLength) // filename length + centralDirectory.appendUInt16LE(0) // extra field length + centralDirectory.appendUInt16LE(0) // file comment length + centralDirectory.appendUInt16LE(0) // disk number start + centralDirectory.appendUInt16LE(0) // internal file attributes + centralDirectory.appendUInt32LE(0) // external file attributes + centralDirectory.appendUInt32LE(UInt32(localOffsets.last!)) // offset + centralDirectory.append(nameData) // filename + } + + let cdOffset = UInt32(archive.count) + let cdSize = UInt32(centralDirectory.count) + archive.append(centralDirectory) + + // --- End of Central Directory Record --- + archive.appendUInt32LE(0x06054b50) // signature + archive.appendUInt16LE(0) // disk number + archive.appendUInt16LE(0) // disk with CD + archive.appendUInt16LE(UInt16(entries.count)) // entries on this disk + archive.appendUInt16LE(UInt16(entries.count)) // total entries + archive.appendUInt32LE(cdSize) // CD size + archive.appendUInt32LE(cdOffset) // CD offset + archive.appendUInt16LE(0) // comment length + + return archive + } + + // MARK: - Extraction Helpers + + /// Lists all entry names in a ZIP archive. + /// + /// - Parameter data: Raw ZIP archive data. + /// - Returns: Array of entry name strings. + /// - Throws: If the archive is malformed. + static func listEntryNames(in data: Data) throws -> [String] { + let entries = try parseEntries(data) + return entries.map(\.name) + } + + /// Extracts a single entry's data from a ZIP archive. + /// + /// - Parameters: + /// - name: The entry name to extract. + /// - data: Raw ZIP archive data. + /// - Returns: The uncompressed entry data. + /// - Throws: If the entry is not found or archive is malformed. + static func extractEntry(named name: String, from data: Data) throws -> Data { + let entries = try parseEntries(data) + guard let entry = entries.first(where: { $0.name == name }) else { + throw ZIPWriterError.entryNotFound(name) + } + let start = entry.dataOffset + let end = start + entry.dataSize + guard end <= data.count else { + throw ZIPWriterError.corruptArchive + } + return Data(data[start.. [ParsedEntry] { + guard let eocdOffset = findEOCD(in: data) else { + throw ZIPWriterError.corruptArchive + } + + let cdOffset = Int(readUInt32LE(data, at: eocdOffset + 16)) + let entryCount = Int(readUInt16LE(data, at: eocdOffset + 10)) + var entries: [ParsedEntry] = [] + var offset = cdOffset + + for _ in 0.. Int? { + guard data.count >= 22 else { return nil } + let maxSearch = min(data.count, 65536 + 22) + let start = max(0, data.count - maxSearch) + for i in stride(from: data.count - 22, through: start, by: -1) { + if data[i] == 0x50 && data[i + 1] == 0x4B + && data[i + 2] == 0x05 && data[i + 3] == 0x06 { + return i + } + } + return nil + } + + // MARK: - CRC32 + + /// Computes CRC32 checksum using zlib. + private static func crc32Checksum(_ data: Data) -> UInt32 { + data.withUnsafeBytes { buffer -> UInt32 in + guard let baseAddress = buffer.baseAddress else { return 0 } + var crc: UInt32 = 0 + crc = crc ^ 0xFFFFFFFF + let bytes = baseAddress.assumingMemoryBound(to: UInt8.self) + for i in 0..> 8) + } + return crc ^ 0xFFFFFFFF + } + } + + /// CRC32 lookup table (polynomial 0xEDB88320). + private static let crc32Table: [UInt32] = { + var table = [UInt32](repeating: 0, count: 256) + for i in 0..<256 { + var crc = UInt32(i) + for _ in 0..<8 { + if crc & 1 != 0 { + crc = 0xEDB88320 ^ (crc >> 1) + } else { + crc = crc >> 1 + } + } + table[i] = crc + } + return table + }() + + // MARK: - Binary Helpers + + private static func readUInt16LE(_ data: Data, at offset: Int) -> UInt16 { + UInt16(data[offset]) | UInt16(data[offset + 1]) << 8 + } + + private static func readUInt32LE(_ data: Data, at offset: Int) -> UInt32 { + UInt32(data[offset]) + | UInt32(data[offset + 1]) << 8 + | UInt32(data[offset + 2]) << 16 + | UInt32(data[offset + 3]) << 24 + } +} + +// MARK: - ZIPWriterError + +enum ZIPWriterError: Error, Sendable { + case invalidEntryName(String) + case entryNotFound(String) + case corruptArchive +} + +// MARK: - Data Helpers + +private extension Data { + mutating func appendUInt16LE(_ value: UInt16) { + append(UInt8(value & 0xFF)) + append(UInt8((value >> 8) & 0xFF)) + } + + mutating func appendUInt32LE(_ value: UInt32) { + append(UInt8(value & 0xFF)) + append(UInt8((value >> 8) & 0xFF)) + append(UInt8((value >> 16) & 0xFF)) + append(UInt8((value >> 24) & 0xFF)) + } +} diff --git a/vreader/Services/BasePageNavigator.swift b/vreader/Services/BasePageNavigator.swift new file mode 100644 index 0000000..977ec84 --- /dev/null +++ b/vreader/Services/BasePageNavigator.swift @@ -0,0 +1,74 @@ +// Purpose: Default implementation of PageNavigator with clamping logic. +// Phase B reader VMs will subclass or compose this to get standard +// page navigation behavior (next/prev/jump with clamping + delegate). +// +// Key decisions: +// - currentPage auto-clamps when totalPages is reduced below it. +// - Delegate is only notified when currentPage actually changes. +// - jumpToPage with the same page index is a no-op (no delegate call). +// - progression uses (totalPages - 1) as denominator for correct 0.0..1.0 range. +// +// @coordinates-with PageNavigator.swift + +import Foundation + +/// Base implementation of PageNavigator with boundary clamping. +@MainActor +class BasePageNavigator: PageNavigator { + + // MARK: - State + + private(set) var currentPage: Int = 0 + + var totalPages: Int = 0 { + didSet { + // Clamp currentPage when totalPages shrinks + let maxPage = max(totalPages - 1, 0) + if currentPage > maxPage { + currentPage = maxPage + delegate?.pageNavigator(self, didNavigateToPage: currentPage) + } + } + } + + weak var delegate: (any PageNavigatorDelegate)? + + // MARK: - Computed + + var progression: Double { + guard totalPages > 1 else { return 0.0 } + return Double(currentPage) / Double(totalPages - 1) + } + + // MARK: - Navigation + + func nextPage() { + let maxPage = max(totalPages - 1, 0) + let target = currentPage + 1 + guard target <= maxPage, target != currentPage else { return } + currentPage = target + delegate?.pageNavigator(self, didNavigateToPage: currentPage) + } + + func previousPage() { + let target = currentPage - 1 + guard target >= 0 else { return } + currentPage = target + delegate?.pageNavigator(self, didNavigateToPage: currentPage) + } + + func jumpToPage(_ page: Int) { + let maxPage = max(totalPages - 1, 0) + let clamped = max(0, min(page, maxPage)) + guard clamped != currentPage else { return } + currentPage = clamped + delegate?.pageNavigator(self, didNavigateToPage: currentPage) + } + + /// Reset the navigator to initial state. Used when content changes + /// (e.g., chapter navigation) to avoid stale page state from the previous content. + func reset() { + currentPage = 0 + totalPages = 0 + } +} diff --git a/vreader/Services/BookContentCache.swift b/vreader/Services/BookContentCache.swift new file mode 100644 index 0000000..d4821b2 --- /dev/null +++ b/vreader/Services/BookContentCache.swift @@ -0,0 +1,69 @@ +// Purpose: Shared cache for loaded book text content. +// Ensures each book file is parsed only once and shared across coordinators +// (AI, search, TTS, unified reader). +// +// Key decisions: +// - @MainActor for safe access from SwiftUI views. +// - Caches by file URL path to avoid redundant loads. +// - Supports TXT and MD formats via direct file reading. +// EPUB and PDF are loaded by their respective coordinators. +// - invalidate() clears cache for a specific file URL. +// +// @coordinates-with: ReaderContainerView.swift, ReaderAICoordinator.swift, +// ReaderUnifiedCoordinator.swift, ReaderSearchCoordinator.swift + +import Foundation + +/// Shared cache that loads book text content once and serves it to all consumers. +@MainActor +final class BookContentCache { + + /// Cached text content keyed by file URL path. + private var cache: [String: String] = [:] + + /// Returns cached text content for a book file URL, loading it on first access. + /// Returns nil if the file cannot be read or is empty. + func getText(for fileURL: URL, format: String) async -> String? { + let key = fileURL.path + + if let cached = cache[key] { + return cached + } + + let url = fileURL + let text: String? = await Task.detached { + switch format.lowercased() { + case "txt", "md": + // Use sample-based encoding detection to match TXTService decode path (bug #92) + guard let data = try? Data(contentsOf: url, options: .mappedIfSafe) else { + return nil + } + let hintName = TXTService.detectEncodingFromSample(data) + if let enc = TXTService.encodingFromName(hintName), + let decoded = String(data: data, encoding: enc) { + return decoded + } + return try? String(contentsOf: url, encoding: .utf8) + default: + return nil + } + }.value + + guard let text, !text.isEmpty else { + return nil + } + + cache[key] = text + return text + } + + /// Clears cached content for a specific file URL. + func invalidate(for fileURL: URL) { + cache.removeValue(forKey: fileURL.path) + } + + /// Clears all cached content. + func invalidateAll() { + cache.removeAll() + } +} diff --git a/vreader/Services/BookSource/BookSourceHTTPClient.swift b/vreader/Services/BookSource/BookSourceHTTPClient.swift new file mode 100644 index 0000000..51cecc6 --- /dev/null +++ b/vreader/Services/BookSource/BookSourceHTTPClient.swift @@ -0,0 +1,192 @@ +// Purpose: HTTP client for BookSource web scraping. +// URLSession-based with encoding detection, custom headers, and rate limiting. +// +// Key decisions: +// - Actor-isolated for thread safety (concurrent scraping from multiple sources). +// - Encoding detection: HTTP Content-Type → HTML meta charset → BOM → UTF-8. +// - Rate limiting via async sleep between requests (configurable per-source). +// - Custom headers from BookSource.header field (User-Agent, Referer, etc.). +// - Default User-Agent to avoid bot detection. +// +// @coordinates-with: WebPageEncodingDetector.swift, BookSourcePipeline.swift + +import Foundation + +/// Errors from BookSource HTTP operations. +enum HTTPClientError: Error, Equatable { + /// HTTP response with non-2xx status code. + case httpError(Int) + + /// Network-level error (timeout, DNS, connection refused). + case networkError(String) + + /// Failed to decode response body with detected encoding. + case decodingFailed(String) +} + +/// HTTP client for fetching web pages and downloading files for BookSource scraping. +/// +/// Actor-isolated to safely handle concurrent requests and rate limiting state. +/// Uses URLSession (not WKWebView) for headless scraping. +actor BookSourceHTTPClient { + + // MARK: - Properties + + private let session: URLSession + private let rateLimitDelay: TimeInterval + private let defaultTimeout: TimeInterval + + /// Timestamp of the last request, for rate limiting. + private var lastRequestTime: ContinuousClock.Instant? + + /// Default User-Agent string to reduce bot detection. + private static let defaultUserAgent = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) VReader/1.0 Mobile/15E148 Safari/604.1" + + // MARK: - Init + + /// Creates a new HTTP client. + /// + /// - Parameters: + /// - session: URLSession to use (injectable for testing). + /// - rateLimitDelay: Minimum seconds between requests (default 0 = no limit). + /// - timeout: Request timeout in seconds (default 30). + init( + session: URLSession = .shared, + rateLimitDelay: TimeInterval = 0, + timeout: TimeInterval = 30 + ) { + self.session = session + self.rateLimitDelay = rateLimitDelay + self.defaultTimeout = timeout + } + + // MARK: - Fetch Page + + /// Fetches a web page and returns decoded HTML string. + /// + /// Encoding detection order: + /// 1. Explicit `encoding` parameter (if provided, skips auto-detection) + /// 2. HTTP Content-Type charset header + /// 3. HTML `` tag + /// 4. BOM (Byte Order Mark) + /// 5. Default: UTF-8 + /// + /// - Parameters: + /// - url: Page URL to fetch. + /// - headers: Custom HTTP headers (User-Agent, Referer, etc.). + /// - encoding: Explicit encoding override. If nil, auto-detects. + /// - Returns: Decoded HTML string. + /// - Throws: `HTTPClientError` on failure. + func fetchPage( + url: URL, + headers: [String: String]? = nil, + encoding: String.Encoding? = nil + ) async throws -> String { + // Rate limiting + await enforceRateLimit() + + // Build request + var request = URLRequest(url: url, timeoutInterval: defaultTimeout) + request.setValue(Self.defaultUserAgent, forHTTPHeaderField: "User-Agent") + + // Apply custom headers (may override default User-Agent) + if let headers { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + + // Execute request + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw HTTPClientError.networkError(error.localizedDescription) + } + + // Record request time for rate limiting + lastRequestTime = .now + + // Check HTTP status + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) { + throw HTTPClientError.httpError(httpResponse.statusCode) + } + + // Empty response + if data.isEmpty { return "" } + + // Determine encoding + let resolvedEncoding: String.Encoding + if let explicit = encoding { + resolvedEncoding = explicit + } else { + let contentType = (response as? HTTPURLResponse)? + .value(forHTTPHeaderField: "Content-Type") + resolvedEncoding = WebPageEncodingDetector.detect( + data: data, + contentTypeHeader: contentType + ) + } + + // Decode + guard let text = WebPageEncodingDetector.decode( + data: data, + encoding: resolvedEncoding + ) else { + throw HTTPClientError.decodingFailed( + "Failed to decode response from \(url) with encoding \(resolvedEncoding)" + ) + } + + return text + } + + // MARK: - Download File + + /// Downloads a file from the given URL and saves it to the destination path. + /// + /// - Parameters: + /// - url: File URL to download. + /// - destination: Local file URL to save to. + /// - Throws: `HTTPClientError` on failure. + func downloadFile(url: URL, to destination: URL) async throws { + await enforceRateLimit() + + var request = URLRequest(url: url, timeoutInterval: defaultTimeout * 4) + request.setValue(Self.defaultUserAgent, forHTTPHeaderField: "User-Agent") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw HTTPClientError.networkError(error.localizedDescription) + } + + lastRequestTime = .now + + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) { + throw HTTPClientError.httpError(httpResponse.statusCode) + } + + try data.write(to: destination, options: .atomic) + } + + // MARK: - Private: Rate Limiting + + /// Waits if needed to respect the rate limit delay between requests. + private func enforceRateLimit() async { + guard rateLimitDelay > 0, let last = lastRequestTime else { return } + + let elapsed = ContinuousClock.now - last + let required = Duration.seconds(rateLimitDelay) + + if elapsed < required { + let remaining = required - elapsed + try? await Task.sleep(for: remaining) + } + } +} diff --git a/vreader/Services/BookSource/BookSourcePipeline.swift b/vreader/Services/BookSource/BookSourcePipeline.swift new file mode 100644 index 0000000..5fab499 --- /dev/null +++ b/vreader/Services/BookSource/BookSourcePipeline.swift @@ -0,0 +1,313 @@ +// Purpose: Four-stage scraping pipeline connecting BookSource model (D01), +// HTTP client (D02), and rule engine (D03) into an end-to-end flow. +// Stages: Search -> BookInfo -> TOC -> Content. +// +// Key decisions: +// - Actor-isolated for thread safety during concurrent scraping. +// - HTTP fetching abstracted via HTMLFetchProvider closure for testability. +// - Progress callback reports current stage to UI. +// - Pagination support for TOC (nextTocUrl) and content (nextContentUrl). +// - Max pagination depth to prevent infinite loops. +// +// @coordinates-with: PipelineTypes.swift, BookSource.swift, BookSourceRules.swift, +// RuleEngine.swift, BookSourceHTTPClient.swift, ChapterCache.swift + +import Foundation + +/// Four-stage scraping pipeline for BookSource web scraping. +/// +/// Stages: +/// 1. **Search**: Replace `{{key}}` in searchURL, fetch, apply ruleSearch. +/// 2. **BookInfo**: Fetch bookUrl, apply ruleBookInfo, extract metadata + tocUrl. +/// 3. **TOC**: Fetch tocUrl, apply ruleToc, extract chapter list. Handle nextTocUrl. +/// 4. **Content**: Fetch chapterUrl, apply ruleContent, clean text. Handle nextContentUrl. +actor BookSourcePipeline { + + /// Maximum number of pagination pages to follow (prevents infinite loops). + private let maxPaginationDepth: Int + + /// The HTML fetch provider (injectable for testing). + private let fetchHTML: HTMLFetchProvider + + /// Optional chapter cache for offline reading (D06). + private let chapterCache: ChapterCache? + + /// Creates a pipeline with the given fetch provider. + /// + /// - Parameters: + /// - fetchHTML: Closure to fetch HTML from a URL. + /// - maxPaginationDepth: Max pages to follow for pagination (default 50). + /// - chapterCache: Optional cache for chapter content (default nil). + init( + fetchHTML: @escaping HTMLFetchProvider, + maxPaginationDepth: Int = 50, + chapterCache: ChapterCache? = nil + ) { + self.fetchHTML = fetchHTML + self.maxPaginationDepth = maxPaginationDepth + self.chapterCache = chapterCache + } + + // MARK: - Stage 1: Search + + /// Searches for books using the source's search rules. + /// + /// Replaces `{{key}}` in the source's searchURL with the keyword, + /// fetches the page, and applies ruleSearch to extract book results. + func search( + source: BookSourceSnapshot, + keyword: String, + progress: (@Sendable (PipelineStage) -> Void)? = nil + ) async throws -> [BookSearchResult] { + try Task.checkCancellation() + progress?(.search) + + guard let searchRule = source.ruleSearch else { + throw PipelineError.missingSearchRule + } + guard let searchURLTemplate = source.searchURL, + !searchURLTemplate.isEmpty else { + throw PipelineError.missingSearchURL + } + + let encoded = keyword.addingPercentEncoding( + withAllowedCharacters: .urlQueryAllowed + ) ?? keyword + let urlString = searchURLTemplate.replacingOccurrences( + of: "{{key}}", with: encoded + ) + guard let url = URL(string: urlString) else { + throw PipelineError.invalidURL(urlString) + } + + let html = try await fetchHTML(url, source.parsedHeaders) + + guard let bookListRule = searchRule.bookList, !bookListRule.isEmpty else { + return [] + } + + let bookElements = RuleEngine.evaluateRawHTML( + rule: bookListRule, html: html, baseURL: url + ) + guard !bookElements.isEmpty else { return [] } + + return bookElements.map { elementHTML in + BookSearchResult( + name: extractField(searchRule.name, html: elementHTML, baseURL: url), + author: extractField(searchRule.author, html: elementHTML, baseURL: url), + bookUrl: extractField(searchRule.bookUrl, html: elementHTML, baseURL: url), + coverUrl: extractField(searchRule.coverUrl, html: elementHTML, baseURL: url) + ) + } + } + + // MARK: - Stage 2: Book Info + + /// Extracts book detail information from a book's detail page. + func bookInfo( + source: BookSourceSnapshot, + bookUrl: String, + progress: (@Sendable (PipelineStage) -> Void)? = nil + ) async throws -> BookDetail { + try Task.checkCancellation() + progress?(.bookInfo) + + guard let infoRule = source.ruleBookInfo else { + throw PipelineError.missingBookInfoRule + } + + let resolved = resolveURL(bookUrl, against: source.sourceURL) + guard let url = URL(string: resolved) else { + throw PipelineError.invalidURL(resolved) + } + + let html = try await fetchHTML(url, source.parsedHeaders) + + return BookDetail( + name: extractField(infoRule.name, html: html, baseURL: url), + author: extractField(infoRule.author, html: html, baseURL: url), + intro: extractField(infoRule.intro, html: html, baseURL: url), + coverUrl: extractField(infoRule.coverUrl, html: html, baseURL: url), + tocUrl: extractField(infoRule.tocUrl, html: html, baseURL: url) + ) + } + + // MARK: - Stage 3: Chapters (TOC) + + /// Extracts the chapter list from a book's table of contents page. + /// Handles pagination via nextTocUrl for multi-page chapter lists. + func chapters( + source: BookSourceSnapshot, + tocUrl: String, + progress: (@Sendable (PipelineStage) -> Void)? = nil + ) async throws -> [ChapterInfo] { + try Task.checkCancellation() + progress?(.toc) + + guard let tocRule = source.ruleToc else { + throw PipelineError.missingTocRule + } + + var allChapters: [ChapterInfo] = [] + var currentUrlString: String? = tocUrl + var pagesFollowed = 0 + + while let urlStr = currentUrlString, + pagesFollowed < maxPaginationDepth { + try Task.checkCancellation() + + let resolved = resolveURL(urlStr, against: source.sourceURL) + guard let url = URL(string: resolved) else { break } + + let html = try await fetchHTML(url, source.parsedHeaders) + + let chapterElements: [String] + if let listRule = tocRule.chapterList, !listRule.isEmpty { + chapterElements = RuleEngine.evaluateRawHTML( + rule: listRule, html: html, baseURL: url + ) + } else { + chapterElements = [html] + } + + for elementHTML in chapterElements { + let name = extractField( + tocRule.chapterName, html: elementHTML, baseURL: url + ) ?? "" + let chapterUrl = extractField( + tocRule.chapterUrl, html: elementHTML, baseURL: url + ) ?? "" + + if !name.isEmpty || !chapterUrl.isEmpty { + allChapters.append(ChapterInfo( + name: name.isEmpty ? "Untitled" : name, + url: chapterUrl + )) + } + } + + // Check for next page + if let nextRule = tocRule.nextTocUrl, !nextRule.isEmpty { + currentUrlString = RuleEngine.evaluateSingle( + rule: nextRule, html: html, baseURL: url + ) + } else { + currentUrlString = nil + } + + pagesFollowed += 1 + } + + return allChapters + } + + // MARK: - Stage 4: Chapter Content + + /// Extracts the text content of a single chapter. + /// Checks the chapter cache first; on hit, returns cached content without network. + /// On miss, fetches via network, applies rules, and caches the result. + /// Handles pagination via nextContentUrl for multi-page chapters. + /// Applies replaceRegex cleanup if defined. + func chapterContent( + source: BookSourceSnapshot, + chapterUrl: String, + progress: (@Sendable (PipelineStage) -> Void)? = nil + ) async throws -> String { + try Task.checkCancellation() + progress?(.content) + + guard let contentRule = source.ruleContent, + let contentSelector = contentRule.content, + !contentSelector.isEmpty else { + throw PipelineError.missingContentRule + } + + // Check cache first (D06) + if let cache = chapterCache, + let cached = await cache.get( + sourceURL: source.sourceURL, chapterURL: chapterUrl + ) { + return cached + } + + var allText: [String] = [] + var currentUrlString: String? = chapterUrl + var pagesFollowed = 0 + + while let urlStr = currentUrlString, + pagesFollowed < maxPaginationDepth { + try Task.checkCancellation() + + let resolved = resolveURL(urlStr, against: source.sourceURL) + guard let url = URL(string: resolved) else { break } + + let html = try await fetchHTML(url, source.parsedHeaders) + + let texts = RuleEngine.evaluate( + rule: contentSelector, html: html, baseURL: url + ) + allText.append(contentsOf: texts) + + // Check for next content page + if let nextRule = contentRule.nextContentUrl, !nextRule.isEmpty { + currentUrlString = RuleEngine.evaluateSingle( + rule: nextRule, html: html, baseURL: url + ) + } else { + currentUrlString = nil + } + + pagesFollowed += 1 + } + + guard !allText.isEmpty else { + throw PipelineError.emptyContent + } + + var result = allText.joined(separator: "\n") + + if let replaceRegex = contentRule.replaceRegex, !replaceRegex.isEmpty { + result = RegexRuleEvaluator.replace( + pattern: replaceRegex, replacement: "", in: result + ) + } + + // Cache the result (D06) + if let cache = chapterCache { + await cache.set( + sourceURL: source.sourceURL, + chapterURL: chapterUrl, + content: result + ) + } + + return result + } + + // MARK: - Private Helpers + + /// Extracts a single field value using an optional rule string. + private func extractField( + _ rule: String?, + html: String, + baseURL: URL + ) -> String? { + guard let rule, !rule.isEmpty else { return nil } + return RuleEngine.evaluateSingle(rule: rule, html: html, baseURL: baseURL) + } + + /// Resolves a potentially relative URL against a base URL string. + private func resolveURL( + _ urlString: String, + against baseURLString: String + ) -> String { + if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") { + return urlString + } + if let base = URL(string: baseURLString), + let resolved = URL(string: urlString, relativeTo: base) { + return resolved.absoluteString + } + return urlString + } +} diff --git a/vreader/Services/BookSource/CSSRuleEvaluator.swift b/vreader/Services/BookSource/CSSRuleEvaluator.swift new file mode 100644 index 0000000..d26ad58 --- /dev/null +++ b/vreader/Services/BookSource/CSSRuleEvaluator.swift @@ -0,0 +1,399 @@ +// Purpose: Evaluates CSS-like selectors against HTML using Foundation regex. +// No external dependencies (no SwiftSoup). Handles common selectors: +// tag, .class, #id, tag.class, and single-level descendant selectors. +// +// Key decisions: +// - Enum namespace (stateless, all static). +// - Uses NSRegularExpression for HTML tag matching (not a full DOM parser). +// - Delegates attribute parsing, URL resolution, and HTML stripping to HTMLHelper. +// - Self-closing tags (img, br, hr, input, meta, link) handled specially. +// +// Limitations: +// - Not a full CSS selector engine. Handles tag, .class, #id, tag.class, +// and one-level descendant selectors (e.g., "div.x p"). +// - Does not support combinators like >, +, ~. +// +// @coordinates-with: RuleEngine.swift, LegadoRuleParser.swift, HTMLHelper.swift + +import Foundation + +/// Evaluates CSS-like selectors against HTML content using Foundation regex. +enum CSSRuleEvaluator { + + // MARK: - Self-closing Tags + + private static let selfClosingTags: Set = [ + "img", "br", "hr", "input", "meta", "link", "area", + "base", "col", "embed", "source", "track", "wbr" + ] + + // MARK: - Evaluate + + /// Evaluates a CSS selector against HTML and returns extracted values. + static func evaluate( + selector: String, + attribute: String?, + index: Int?, + html: String, + baseURL: URL? + ) -> [String] { + guard !selector.isEmpty, !html.isEmpty else { return [] } + + let parts = parseDescendantSelector(selector) + let results: [String] + + if parts.count > 1 { + results = evaluateDescendant( + parts: parts, attribute: attribute, + html: html, baseURL: baseURL + ) + } else { + results = evaluateSimple( + simple: parts[0], attribute: attribute, + html: html, baseURL: baseURL + ) + } + + return HTMLHelper.applyIndex(results: results, index: index) + } + + // MARK: - Descendant Selector + + private static func parseDescendantSelector( + _ selector: String + ) -> [SimpleSelector] { + selector + .split(separator: " ", omittingEmptySubsequences: true) + .map { parseSimpleSelector(String($0)) } + } + + private static func evaluateDescendant( + parts: [SimpleSelector], + attribute: String?, + html: String, + baseURL: URL? + ) -> [String] { + guard let first = parts.first else { return [] } + + let outerMatches = findElements(matching: first, in: html) + + if parts.count == 1 { + return outerMatches.map { + extractValue(from: $0, attribute: attribute, baseURL: baseURL) + } + } + + let remaining = Array(parts.dropFirst()) + var results: [String] = [] + + for match in outerMatches { + let inner: [String] + if remaining.count == 1 { + inner = evaluateSimple( + simple: remaining[0], attribute: attribute, + html: match.innerHTML, baseURL: baseURL + ) + } else { + inner = evaluateDescendant( + parts: remaining, attribute: attribute, + html: match.innerHTML, baseURL: baseURL + ) + } + results.append(contentsOf: inner) + } + + return results + } + + // MARK: - Simple Selector + + private static func evaluateSimple( + simple: SimpleSelector, + attribute: String?, + html: String, + baseURL: URL? + ) -> [String] { + findElements(matching: simple, in: html).map { + extractValue(from: $0, attribute: attribute, baseURL: baseURL) + } + } + + // MARK: - Element Finding + + private struct ElementMatch { + let fullMatch: String + let tagAttributes: String + let innerHTML: String + } + + private static func findElements( + matching selector: SimpleSelector, + in html: String + ) -> [ElementMatch] { + if let tag = selector.tag { + return findTagElements( + tag: tag, className: selector.className, + id: selector.id, in: html + ) + } else { + return findAnyTagElements( + className: selector.className, + id: selector.id, in: html + ) + } + } + + /// Finds elements by a specific tag name. + private static func findTagElements( + tag: String, + className: String?, + id: String?, + in html: String + ) -> [ElementMatch] { + let pattern: String + if selfClosingTags.contains(tag.lowercased()) { + pattern = "<\(tag)(\\s[^>]*)?\\/?>(?:([\\s\\S]*?)<\\/\(tag)>)?" + } else { + pattern = "<\(tag)(\\s[^>]*)?>([\\s\\S]*?)<\\/\(tag)>" + } + + guard let regex = try? NSRegularExpression( + pattern: pattern, options: [.caseInsensitive] + ) else { return [] } + + let nsHTML = html as NSString + let range = NSRange(location: 0, length: nsHTML.length) + + return regex.matches(in: html, range: range).compactMap { match in + let fullMatch = nsHTML.substring(with: match.range) + let attrs = HTMLHelper.safeSubstring(nsHTML, match: match, group: 1) + let content = HTMLHelper.safeSubstring(nsHTML, match: match, group: 2) + + if let className, + !HTMLHelper.parseClasses(from: attrs).contains(className) { + return nil + } + if let id, HTMLHelper.parseAttribute(named: "id", from: attrs) != id { + return nil + } + + return ElementMatch( + fullMatch: fullMatch, + tagAttributes: attrs, + innerHTML: content + ) + } + } + + /// Finds elements of any tag name, filtering by class or ID. + private static func findAnyTagElements( + className: String?, + id: String?, + in html: String + ) -> [ElementMatch] { + let openPattern = + "<([a-zA-Z][a-zA-Z0-9]*)(\\s[^>]*)?>|<([a-zA-Z][a-zA-Z0-9]*)>" + guard let openRegex = try? NSRegularExpression( + pattern: openPattern, options: [.caseInsensitive] + ) else { return [] } + + let nsHTML = html as NSString + let fullRange = NSRange(location: 0, length: nsHTML.length) + let openMatches = openRegex.matches(in: html, range: fullRange) + + var results: [ElementMatch] = [] + + for openMatch in openMatches { + let tagName: String + let attrs: String + + if openMatch.range(at: 1).location != NSNotFound { + tagName = nsHTML.substring(with: openMatch.range(at: 1)) + attrs = HTMLHelper.safeSubstring( + nsHTML, match: openMatch, group: 2 + ) + } else if openMatch.range(at: 3).location != NSNotFound { + tagName = nsHTML.substring(with: openMatch.range(at: 3)) + attrs = "" + } else { + continue + } + + if selfClosingTags.contains(tagName.lowercased()) { continue } + + if let className, + !HTMLHelper.parseClasses(from: attrs).contains(className) { + continue + } + if let id, + HTMLHelper.parseAttribute(named: "id", from: attrs) != id { + continue + } + + let contentStart = + openMatch.range.location + openMatch.range.length + let closingTag = "" + let searchRange = NSRange( + location: contentStart, + length: nsHTML.length - contentStart + ) + let closeRange = nsHTML.range( + of: closingTag, + options: [.caseInsensitive], + range: searchRange + ) + + guard closeRange.location != NSNotFound else { continue } + + let innerRange = NSRange( + location: contentStart, + length: closeRange.location - contentStart + ) + let innerHTML = nsHTML.substring(with: innerRange) + + let fullMatchRange = NSRange( + location: openMatch.range.location, + length: closeRange.location + closeRange.length + - openMatch.range.location + ) + let fullMatch = nsHTML.substring(with: fullMatchRange) + + results.append(ElementMatch( + fullMatch: fullMatch, + tagAttributes: attrs, + innerHTML: innerHTML + )) + } + + return results + } + + // MARK: - Evaluate Raw HTML + + /// Evaluates a CSS selector and returns the full HTML of each match. + /// + /// Used for container-level rules (bookList, chapterList) where + /// sub-rules are applied to each element's HTML. + static func evaluateRawHTML( + selector: String, + index: Int?, + html: String + ) -> [String] { + guard !selector.isEmpty, !html.isEmpty else { return [] } + + let parts = parseDescendantSelector(selector) + let matches: [ElementMatch] + + if parts.count > 1 { + matches = findDescendantMatches(parts: parts, html: html) + } else { + matches = findElements(matching: parts[0], in: html) + } + + let rawHTMLs = matches.map { $0.fullMatch } + return HTMLHelper.applyIndex(results: rawHTMLs, index: index) + } + + /// Finds descendant matches, returning ElementMatch objects. + private static func findDescendantMatches( + parts: [SimpleSelector], + html: String + ) -> [ElementMatch] { + guard let first = parts.first else { return [] } + + let outerMatches = findElements(matching: first, in: html) + if parts.count == 1 { return outerMatches } + + let remaining = Array(parts.dropFirst()) + var results: [ElementMatch] = [] + + for match in outerMatches { + if remaining.count == 1 { + results.append( + contentsOf: findElements( + matching: remaining[0], in: match.innerHTML + ) + ) + } else { + results.append( + contentsOf: findDescendantMatches( + parts: remaining, html: match.innerHTML + ) + ) + } + } + + return results + } + + // MARK: - Value Extraction + + private static func extractValue( + from element: ElementMatch, + attribute: String?, + baseURL: URL? + ) -> String { + if let attr = attribute { + let value = + HTMLHelper.parseAttribute( + named: attr, from: element.tagAttributes + ) + ?? HTMLHelper.parseAttribute( + named: attr, from: element.fullMatch + ) + ?? "" + return HTMLHelper.resolveURL( + value, attribute: attr, baseURL: baseURL + ) + } else { + return HTMLHelper.stripHTMLTags(element.innerHTML) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + // MARK: - Selector Parsing + + struct SimpleSelector { + let tag: String? + let className: String? + let id: String? + } + + private static func parseSimpleSelector( + _ selector: String + ) -> SimpleSelector { + if selector.hasPrefix("#") { + return SimpleSelector( + tag: nil, className: nil, + id: String(selector.dropFirst()) + ) + } + if selector.hasPrefix(".") { + return SimpleSelector( + tag: nil, className: String(selector.dropFirst()), + id: nil + ) + } + if let dotIdx = selector.firstIndex(of: ".") { + let tag = String(selector[..//.txt +// Uses an in-memory LRU index with JSON manifest persistence per source. +// +// Key decisions: +// - Actor-isolated for thread safety during concurrent reads/writes. +// - Disk-based persistence survives app restarts. +// - LRU eviction when total size exceeds configurable max (default 500MB). +// - Empty content is never cached. +// - Corrupted files are cleaned up on read. +// - Static hash function exposed for test verification. +// +// @coordinates-with: BookSourcePipeline.swift, PipelineTypes.swift + +import Foundation +import CryptoKit + +/// Disk-based LRU cache for chapter content. +/// +/// Files are stored at `//.txt`. +/// An in-memory LRU index tracks access order for eviction. +/// The index is rebuilt from disk on first access (lazy initialization). +actor ChapterCache { + + /// Root directory for cache files. + private let directory: URL + + /// Maximum total cache size in bytes before LRU eviction. + private let maxSizeBytes: Int64 + + /// In-memory LRU index: ordered list of cache entries (oldest first). + private var lruEntries: [CacheEntry] = [] + + /// Current total size of all cached files. + private var currentSizeBytes: Int64 = 0 + + /// Whether the index has been loaded from disk. + private var indexLoaded = false + + // MARK: - Types + + /// Metadata for a single cached chapter. + private struct CacheEntry: Equatable { + let sourceHash: String + let chapterHash: String + let sizeBytes: Int64 + var lastAccess: Date + + /// Unique key combining source and chapter. + var key: String { "\(sourceHash)/\(chapterHash)" } + } + + // MARK: - Init + + /// Creates a chapter cache backed by the given directory. + /// + /// - Parameters: + /// - directory: Root directory for cache files. + /// - maxSizeBytes: Maximum cache size before eviction (default 500MB). + init(directory: URL, maxSizeBytes: Int64 = 500 * 1024 * 1024) { + self.directory = directory + self.maxSizeBytes = maxSizeBytes + } + + // MARK: - Public API + + /// Retrieves cached chapter content, or nil on miss/corruption. + /// + /// On cache hit, updates LRU access time. + /// On corruption (non-UTF8 data), deletes the file and returns nil. + func get(sourceURL: String, chapterURL: String) -> String? { + ensureIndexLoaded() + + let sHash = Self.hash(for: sourceURL) + let cHash = Self.hash(for: chapterURL) + let key = "\(sHash)/\(cHash)" + let filePath = directory + .appendingPathComponent(sHash) + .appendingPathComponent(cHash + ".txt") + + guard FileManager.default.fileExists(atPath: filePath.path) else { + // Remove stale index entry if any + removeFromIndex(key: key) + return nil + } + + do { + let data = try Data(contentsOf: filePath) + guard let content = String(data: data, encoding: .utf8) else { + // Corrupted: non-UTF8 data + try? FileManager.default.removeItem(at: filePath) + removeFromIndex(key: key) + return nil + } + + if content.isEmpty { + try? FileManager.default.removeItem(at: filePath) + removeFromIndex(key: key) + return nil + } + + // Update LRU access time + touchEntry(key: key) + + return content + } catch { + // Read error — treat as corruption + try? FileManager.default.removeItem(at: filePath) + removeFromIndex(key: key) + return nil + } + } + + /// Caches chapter content to disk. Empty content is silently ignored. + /// + /// After writing, triggers LRU eviction if total exceeds maxSizeBytes. + func set(sourceURL: String, chapterURL: String, content: String) { + guard !content.isEmpty else { return } + + ensureIndexLoaded() + + let sHash = Self.hash(for: sourceURL) + let cHash = Self.hash(for: chapterURL) + let key = "\(sHash)/\(cHash)" + + let sourceDir = directory.appendingPathComponent(sHash) + let filePath = sourceDir.appendingPathComponent(cHash + ".txt") + + // Remove old entry size if overwriting + let oldSize = removeFromIndex(key: key) + currentSizeBytes -= oldSize + + do { + try FileManager.default.createDirectory( + at: sourceDir, withIntermediateDirectories: true + ) + let data = Data(content.utf8) + try data.write(to: filePath, options: .atomic) + + let size = Int64(data.count) + lruEntries.append(CacheEntry( + sourceHash: sHash, + chapterHash: cHash, + sizeBytes: size, + lastAccess: Date() + )) + currentSizeBytes += size + + evictIfNeeded() + } catch { + // Write failed silently — cache is best-effort + } + } + + /// Removes all cached chapters for a given source. + func clear(sourceURL: String) { + ensureIndexLoaded() + + let sHash = Self.hash(for: sourceURL) + let sourceDir = directory.appendingPathComponent(sHash) + + // Remove from index + let removed = lruEntries.filter { $0.sourceHash == sHash } + lruEntries.removeAll { $0.sourceHash == sHash } + currentSizeBytes -= removed.reduce(0) { $0 + $1.sizeBytes } + + // Remove from disk + try? FileManager.default.removeItem(at: sourceDir) + } + + /// Removes all cached chapters for all sources. + func clearAll() { + lruEntries.removeAll() + currentSizeBytes = 0 + indexLoaded = true + + // Remove all contents of the cache directory + if let contents = try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil + ) { + for item in contents { + try? FileManager.default.removeItem(at: item) + } + } + } + + /// The current total size of all cached files in bytes. + var totalSizeBytes: Int64 { + ensureIndexLoaded() + return currentSizeBytes + } + + // MARK: - Hashing (exposed for test verification) + + /// Computes a SHA-256 hash string for a given input. + /// Exposed as static for test verification of file paths. + static func hash(for input: String) -> String { + let digest = SHA256.hash(data: Data(input.utf8)) + return digest.prefix(16).map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Private Helpers + + /// Loads the LRU index from disk by scanning the cache directory. + private func ensureIndexLoaded() { + guard !indexLoaded else { return } + indexLoaded = true + rebuildIndex() + } + + /// Scans the cache directory and rebuilds the in-memory LRU index. + private func rebuildIndex() { + lruEntries.removeAll() + currentSizeBytes = 0 + + let fm = FileManager.default + guard let sourceDirs = try? fm.contentsOfDirectory( + at: directory, includingPropertiesForKeys: [.isDirectoryKey] + ) else { return } + + for sourceDir in sourceDirs { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: sourceDir.path, isDirectory: &isDir), + isDir.boolValue else { continue } + + let sourceHash = sourceDir.lastPathComponent + guard let files = try? fm.contentsOfDirectory( + at: sourceDir, includingPropertiesForKeys: [ + .fileSizeKey, .contentModificationDateKey + ] + ) else { continue } + + for file in files where file.pathExtension == "txt" { + let chapterHash = file.deletingPathExtension().lastPathComponent + let attrs = try? file.resourceValues( + forKeys: [.fileSizeKey, .contentModificationDateKey] + ) + let size = Int64(attrs?.fileSize ?? 0) + let modDate = attrs?.contentModificationDate ?? Date.distantPast + + lruEntries.append(CacheEntry( + sourceHash: sourceHash, + chapterHash: chapterHash, + sizeBytes: size, + lastAccess: modDate + )) + currentSizeBytes += size + } + } + + // Sort by access time: oldest first + lruEntries.sort { $0.lastAccess < $1.lastAccess } + } + + /// Updates the access time for a cache entry (moves it to the end). + private func touchEntry(key: String) { + guard let idx = lruEntries.firstIndex(where: { $0.key == key }) else { + return + } + var entry = lruEntries.remove(at: idx) + entry.lastAccess = Date() + lruEntries.append(entry) + + // Also touch the file modification date + let filePath = directory + .appendingPathComponent(entry.sourceHash) + .appendingPathComponent(entry.chapterHash + ".txt") + try? FileManager.default.setAttributes( + [.modificationDate: entry.lastAccess], + ofItemAtPath: filePath.path + ) + } + + /// Removes an entry from the index and returns its size (0 if not found). + @discardableResult + private func removeFromIndex(key: String) -> Int64 { + guard let idx = lruEntries.firstIndex(where: { $0.key == key }) else { + return 0 + } + let entry = lruEntries.remove(at: idx) + return entry.sizeBytes + } + + /// Evicts the oldest entries until totalSize is within maxSizeBytes. + private func evictIfNeeded() { + while currentSizeBytes > maxSizeBytes, !lruEntries.isEmpty { + let oldest = lruEntries.removeFirst() + currentSizeBytes -= oldest.sizeBytes + + let filePath = directory + .appendingPathComponent(oldest.sourceHash) + .appendingPathComponent(oldest.chapterHash + ".txt") + try? FileManager.default.removeItem(at: filePath) + + // Clean up empty source directory + let sourceDir = directory.appendingPathComponent(oldest.sourceHash) + if let contents = try? FileManager.default.contentsOfDirectory( + at: sourceDir, includingPropertiesForKeys: nil + ), contents.isEmpty { + try? FileManager.default.removeItem(at: sourceDir) + } + } + } +} diff --git a/vreader/Services/BookSource/HTMLHelper.swift b/vreader/Services/BookSource/HTMLHelper.swift new file mode 100644 index 0000000..0874db0 --- /dev/null +++ b/vreader/Services/BookSource/HTMLHelper.swift @@ -0,0 +1,137 @@ +// Purpose: Low-level HTML parsing utilities for the BookSource rule engine. +// Provides attribute extraction, HTML tag stripping, entity decoding, +// URL resolution, and index application. +// +// Key decisions: +// - Enum namespace (stateless, all static). +// - Uses NSRegularExpression (no external dependencies). +// - Supports double-quoted, single-quoted, and unquoted HTML attributes. +// - Decodes the most common HTML entities (not exhaustive). +// +// @coordinates-with: CSSRuleEvaluator.swift, RuleEngine.swift + +import Foundation + +/// Low-level HTML parsing utilities used by CSSRuleEvaluator. +enum HTMLHelper { + + // MARK: - Attribute Parsing + + /// Parses the value of a named attribute from an HTML attributes string. + /// Handles `attr="value"`, `attr='value'`, and `attr=value`. + static func parseAttribute(named name: String, from attrs: String) -> String? { + let escaped = NSRegularExpression.escapedPattern(for: name) + let pattern = "\\b\(escaped)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))" + guard let regex = try? NSRegularExpression( + pattern: pattern, options: [.caseInsensitive] + ), + let match = regex.firstMatch( + in: attrs, range: NSRange(attrs.startIndex..., in: attrs) + ) + else { return nil } + + for g in 1...3 { + if g < match.numberOfRanges, + match.range(at: g).location != NSNotFound, + let r = Range(match.range(at: g), in: attrs) { + return String(attrs[r]) + } + } + return nil + } + + /// Parses the class attribute and returns individual class names. + static func parseClasses(from attrs: String) -> Set { + guard let cls = parseAttribute(named: "class", from: attrs) else { + return [] + } + return Set( + cls.split(separator: " ", omittingEmptySubsequences: true) + .map(String.init) + ) + } + + // MARK: - HTML Tag Stripping + + /// Removes HTML tags from a string, returning plain text with decoded entities. + static func stripHTMLTags(_ html: String) -> String { + guard let regex = try? NSRegularExpression( + pattern: "<[^>]+>", options: [] + ) else { return html } + + let nsHTML = html as NSString + let range = NSRange(location: 0, length: nsHTML.length) + let stripped = regex.stringByReplacingMatches( + in: html, range: range, withTemplate: "" + ) + return decodeHTMLEntities(stripped) + } + + /// Decodes common HTML entities to their character equivalents. + static func decodeHTMLEntities(_ text: String) -> String { + var result = text + let entities: [(String, String)] = [ + ("&", "&"), ("<", "<"), (">", ">"), + (""", "\""), ("'", "'"), ("'", "'"), + (" ", " "), + ] + for (entity, replacement) in entities { + result = result.replacingOccurrences(of: entity, with: replacement) + } + return result + } + + // MARK: - URL Resolution + + /// Resolves a URL value against a base URL for href/src-type attributes. + /// Returns the original value if the attribute is not URL-typed or already absolute. + static func resolveURL( + _ value: String, + attribute: String, + baseURL: URL? + ) -> String { + let urlAttrs: Set = ["href", "src", "action", "data"] + guard urlAttrs.contains(attribute.lowercased()), + let base = baseURL, !value.isEmpty else { + return value + } + + if value.hasPrefix("http://") || value.hasPrefix("https://") + || value.hasPrefix("//") { + return value + } + + if let resolved = URL(string: value, relativeTo: base) { + return resolved.absoluteString + } + return value + } + + // MARK: - Index Application + + /// Applies index selection to a results array. + /// Negative indices count from the end (-1 = last element). + static func applyIndex(results: [String], index: Int?) -> [String] { + guard let idx = index else { return results } + guard !results.isEmpty else { return [] } + + let resolved = idx < 0 ? results.count + idx : idx + guard resolved >= 0, resolved < results.count else { return [] } + return [results[resolved]] + } + + // MARK: - NSTextCheckingResult Helper + + /// Safely extracts a capture group substring from a regex match. + static func safeSubstring( + _ nsString: NSString, + match: NSTextCheckingResult, + group: Int + ) -> String { + guard group < match.numberOfRanges, + match.range(at: group).location != NSNotFound else { + return "" + } + return nsString.substring(with: match.range(at: group)) + } +} diff --git a/vreader/Services/BookSource/LegadoCompatibility.swift b/vreader/Services/BookSource/LegadoCompatibility.swift new file mode 100644 index 0000000..e2bbd1e --- /dev/null +++ b/vreader/Services/BookSource/LegadoCompatibility.swift @@ -0,0 +1,62 @@ +// Purpose: Compatibility classification for Legado book sources. +// Scans rule strings to determine if a source uses CSS-only (Full), +// XPath (Limited), or JavaScript (Unsupported) extraction rules. +// +// @coordinates-with: LegadoImporter.swift, LegadoBookSourceDTO.swift + +import Foundation + +extension LegadoImporter { + + // MARK: - Compatibility Classification + + /// Classifies a source's rule compatibility level by scanning all rule strings. + /// - "Unsupported" if any rule uses `` or `{{` (JavaScript execution). + /// - "Limited" if any rule uses `//` prefix (XPath). + /// - "Full" if all rules are CSS selectors / regex only. + static func classifyCompatibility( + _ dto: LegadoBookSourceDTO + ) -> String { + let allRules = collectAllRuleStrings(dto) + + // Empty rules = Full (nothing incompatible) + guard !allRules.isEmpty else { return "Full" } + + var hasXPath = false + + for rule in allRules { + // Check for JS execution markers + if containsJSMarkers(rule) { + return "Unsupported" // worst case, return immediately + } + // Check for XPath + if containsXPathMarkers(rule) { + hasXPath = true + } + } + + return hasXPath ? "Limited" : "Full" + } + + /// Collects all rule strings from all rule sections of a DTO. + static func collectAllRuleStrings( + _ dto: LegadoBookSourceDTO + ) -> [String] { + var strings: [String] = [] + if let r = dto.ruleSearch { strings.append(contentsOf: r.allRuleStrings) } + if let r = dto.ruleBookInfo { strings.append(contentsOf: r.allRuleStrings) } + if let r = dto.ruleToc { strings.append(contentsOf: r.allRuleStrings) } + if let r = dto.ruleContent { strings.append(contentsOf: r.allRuleStrings) } + return strings + } + + /// Checks if a rule string contains JavaScript execution markers. + static func containsJSMarkers(_ rule: String) -> Bool { + rule.contains("") || rule.contains("{{") + } + + /// Checks if a rule string contains XPath markers. + static func containsXPathMarkers(_ rule: String) -> Bool { + rule.hasPrefix("//") + } +} diff --git a/vreader/Services/BookSource/LegadoImporter.swift b/vreader/Services/BookSource/LegadoImporter.swift new file mode 100644 index 0000000..6e7aef0 --- /dev/null +++ b/vreader/Services/BookSource/LegadoImporter.swift @@ -0,0 +1,284 @@ +// Purpose: Import/export BookSource in Legado's JSON format for ecosystem compatibility. +// Handles field mapping, compatibility classification, and duplicate deduplication. +// +// Key decisions: +// - Accepts both single-object and array JSON (Legado exports both forms). +// - Unknown fields are silently ignored via flexible DTO decoding. +// - Sources with empty/whitespace-only URLs are skipped (not errors). +// - Duplicate URLs are deduplicated (first occurrence wins). +// - Compatibility classification: Full (CSS/regex), Limited (XPath), Unsupported (JS). +// +// @coordinates-with: LegadoBookSourceDTO.swift, BookSource.swift, BookSourceRules.swift + +import Foundation + +// MARK: - Error Types + +/// Errors that can occur during Legado JSON import. +enum LegadoImportError: Error, Sendable, Equatable { + /// The input data is not valid JSON. + case invalidJSON + /// The JSON structure doesn't match the expected format. + case unexpectedFormat +} + +// MARK: - LegadoImporter + +/// Imports and exports BookSource objects in Legado's JSON format. +enum LegadoImporter { + + // MARK: - Import + + /// Imports book sources from Legado JSON data. + /// Accepts both a single object `{...}` and an array `[{...}, ...]`. + /// - Parameter jsonData: Raw JSON bytes in Legado format. + /// - Returns: Array of BookSource objects (deduplicated by URL). + /// - Throws: `LegadoImportError` on invalid JSON. + static func importSources(from jsonData: Data) throws -> [BookSource] { + let dtos = try parseDTOs(from: jsonData) + return convertAndDeduplicate(dtos) + } + + // MARK: - Export + + /// Exports book sources to Legado-compatible JSON data. + /// Always exports as an array (even for a single source). + /// - Parameter sources: Array of BookSource objects to export. + /// - Returns: JSON data in Legado format. + static func exportSources(_ sources: [BookSource]) throws -> Data { + let dtos = sources.map { convertToDTO($0) } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(dtos) + } + + // MARK: - Parse DTOs + + /// Parses JSON data into an array of DTOs. + /// Handles both single-object and array forms. + private static func parseDTOs(from data: Data) throws -> [LegadoBookSourceDTO] { + let decoder = JSONDecoder() + + // Try array first (most common) + if let array = try? decoder.decode( + [LegadoBookSourceDTO].self, + from: data + ) { + return array + } + + // Try single object + if let single = try? decoder.decode( + LegadoBookSourceDTO.self, + from: data + ) { + return [single] + } + + throw LegadoImportError.invalidJSON + } + + // MARK: - Convert and Deduplicate + + /// Converts DTOs to BookSource models, deduplicating by URL. + private static func convertAndDeduplicate( + _ dtos: [LegadoBookSourceDTO] + ) -> [BookSource] { + var seenURLs = Set() + var results: [BookSource] = [] + + for dto in dtos { + guard let url = dto.bookSourceUrl, + BookSource.validateSourceURL(url) else { + continue // skip empty/missing URLs + } + + guard !seenURLs.contains(url) else { + continue // skip duplicates + } + seenURLs.insert(url) + + let source = convertToBookSource(dto) + source.compatibilityLevel = classifyCompatibility(dto) + results.append(source) + } + + return results + } + + // MARK: - DTO → BookSource + + /// Converts a Legado DTO to a VReader BookSource model. + private static func convertToBookSource( + _ dto: LegadoBookSourceDTO + ) -> BookSource { + let url = dto.bookSourceUrl ?? "" + let name = dto.bookSourceName + ?? (url.isEmpty ? "Unknown" : url) + + let source = BookSource( + sourceURL: url, + sourceName: name, + sourceGroup: dto.bookSourceGroup, + sourceType: dto.bookSourceType ?? 0, + enabled: dto.enabled ?? true, + searchURL: dto.searchUrl, + header: dto.header, + customOrder: dto.customOrder ?? 0 + ) + + // Convert lastUpdateTime from Legado ms epoch to Date + if let ms = dto.lastUpdateTime { + source.lastUpdateTime = Date( + timeIntervalSince1970: Double(ms) / 1000.0 + ) + } + + // Convert rules + if let searchDTO = dto.ruleSearch { + source.updateSearchRule(convertSearchRule(searchDTO)) + } + if let bookInfoDTO = dto.ruleBookInfo { + source.updateBookInfoRule(convertBookInfoRule(bookInfoDTO)) + } + if let tocDTO = dto.ruleToc { + source.updateTocRule(convertTocRule(tocDTO)) + } + if let contentDTO = dto.ruleContent { + source.updateContentRule(convertContentRule(contentDTO)) + } + + return source + } + + // MARK: - BookSource → DTO + + /// Converts a VReader BookSource to a Legado DTO for export. + private static func convertToDTO(_ source: BookSource) -> LegadoBookSourceDTO { + var dto = LegadoBookSourceDTO() + dto.bookSourceUrl = source.sourceURL + dto.bookSourceName = source.sourceName + dto.bookSourceGroup = source.sourceGroup + dto.bookSourceType = source.sourceType + dto.enabled = source.enabled + dto.searchUrl = source.searchURL + dto.header = source.header + dto.customOrder = source.customOrder + + // Convert Date to Legado ms epoch + if let date = source.lastUpdateTime { + dto.lastUpdateTime = Int64(date.timeIntervalSince1970 * 1000) + } + + // Convert rules + if let rule = source.ruleSearch { + dto.ruleSearch = exportSearchRule(rule) + } + if let rule = source.ruleBookInfo { + dto.ruleBookInfo = exportBookInfoRule(rule) + } + if let rule = source.ruleToc { + dto.ruleToc = exportTocRule(rule) + } + if let rule = source.ruleContent { + dto.ruleContent = exportContentRule(rule) + } + + return dto + } + + // MARK: - Rule Conversion (Legado DTO → VReader) + + private static func convertSearchRule( + _ dto: LegadoSearchRuleDTO + ) -> BSSearchRule { + BSSearchRule( + bookList: dto.bookList, + name: dto.name, + author: dto.author, + bookUrl: dto.bookUrl, + coverUrl: dto.coverUrl + ) + } + + private static func convertBookInfoRule( + _ dto: LegadoBookInfoRuleDTO + ) -> BSBookInfoRule { + BSBookInfoRule( + name: dto.name, + author: dto.author, + intro: dto.intro, + coverUrl: dto.coverUrl, + tocUrl: dto.tocUrl + ) + } + + private static func convertTocRule( + _ dto: LegadoTocRuleDTO + ) -> BSTocRule { + BSTocRule( + chapterList: dto.chapterList, + chapterName: dto.chapterName, + chapterUrl: dto.chapterUrl, + nextTocUrl: dto.nextTocUrl + ) + } + + private static func convertContentRule( + _ dto: LegadoContentRuleDTO + ) -> BSContentRule { + BSContentRule( + content: dto.content, + nextContentUrl: dto.nextContentUrl, + replaceRegex: dto.replaceRegex + ) + } + + // MARK: - Rule Conversion (VReader → Legado DTO) + + private static func exportSearchRule( + _ rule: BSSearchRule + ) -> LegadoSearchRuleDTO { + LegadoSearchRuleDTO( + bookList: rule.bookList, + name: rule.name, + author: rule.author, + bookUrl: rule.bookUrl, + coverUrl: rule.coverUrl + ) + } + + private static func exportBookInfoRule( + _ rule: BSBookInfoRule + ) -> LegadoBookInfoRuleDTO { + LegadoBookInfoRuleDTO( + name: rule.name, + author: rule.author, + intro: rule.intro, + coverUrl: rule.coverUrl, + tocUrl: rule.tocUrl + ) + } + + private static func exportTocRule( + _ rule: BSTocRule + ) -> LegadoTocRuleDTO { + LegadoTocRuleDTO( + chapterList: rule.chapterList, + chapterName: rule.chapterName, + chapterUrl: rule.chapterUrl, + nextTocUrl: rule.nextTocUrl + ) + } + + private static func exportContentRule( + _ rule: BSContentRule + ) -> LegadoContentRuleDTO { + LegadoContentRuleDTO( + content: rule.content, + nextContentUrl: rule.nextContentUrl, + replaceRegex: rule.replaceRegex + ) + } + +} diff --git a/vreader/Services/BookSource/LegadoRuleParser.swift b/vreader/Services/BookSource/LegadoRuleParser.swift new file mode 100644 index 0000000..7d069ae --- /dev/null +++ b/vreader/Services/BookSource/LegadoRuleParser.swift @@ -0,0 +1,143 @@ +// Purpose: Parses Legado rule syntax strings into structured components. +// Handles CSS selectors with Legado-specific operators (@, !) and detects +// rule types (CSS, regex, XPath). +// +// Key decisions: +// - Enum namespace (no instances needed, all static). +// - @ splits selector from attribute name. +// - ! splits from index (0-based, negative for from-end). +// - :regex: prefix triggers regex mode. +// - /... or //... prefix triggers xpath detection (deferred to D08). +// +// @coordinates-with: RuleEngine.swift, CSSRuleEvaluator.swift, RegexRuleEvaluator.swift + +import Foundation + +/// The type of rule extraction to perform. +enum RuleType: Equatable, Sendable { + /// CSS selector-based extraction (default). + case css + /// Regular expression extraction (prefixed with `:regex:`). + case regex + /// XPath extraction (prefixed with `/` or `//`). Deferred to D08. + case xpath +} + +/// Result of parsing a Legado rule string. +struct ParsedRule: Equatable, Sendable { + /// The detected rule type. + let type: RuleType + /// CSS selector string (for CSS rules). Empty if not applicable. + let selector: String + /// Attribute to extract (e.g., "href" from `a@href`). Nil = extract text content. + let attribute: String? + /// Index to select from matches (e.g., 0 from `li!0`). Nil = all matches. + let index: Int? + /// Regex pattern (for regex rules). Nil if not a regex rule. + let regexPattern: String? +} + +/// Parses Legado-format rule strings into structured components. +/// +/// Legado rule syntax: +/// - `selector@attribute!index` -- CSS with attribute and index operators +/// - `:regex:pattern` -- regex extraction +/// - `//xpath` or `/xpath` -- XPath (detected but deferred) +enum LegadoRuleParser { + + // MARK: - Parse + + /// Parses a raw rule string into a structured `ParsedRule`. + /// + /// Examples: + /// - `"a@href"` -> CSS, selector="a", attribute="href" + /// - `"li!0"` -> CSS, selector="li", index=0 + /// - `"a@href!1"` -> CSS, selector="a", attribute="href", index=1 + /// - `":regex:([^<]+)"` -> regex, pattern=`([^<]+)` + /// - `"//div[@class='result']"` -> xpath (deferred) + static func parse(_ rule: String) -> ParsedRule { + let trimmed = rule.trimmingCharacters(in: .whitespaces) + + // Detect regex + if trimmed.hasPrefix(":regex:") { + let pattern = String(trimmed.dropFirst(7)) + return ParsedRule( + type: .regex, + selector: "", + attribute: nil, + index: nil, + regexPattern: pattern + ) + } + + // Detect XPath + if trimmed.hasPrefix("//") || (trimmed.hasPrefix("/") && !trimmed.isEmpty) { + return ParsedRule( + type: .xpath, + selector: trimmed, + attribute: nil, + index: nil, + regexPattern: nil + ) + } + + // CSS with Legado operators + return parseCSS(trimmed) + } + + // MARK: - Private: CSS Parsing + + /// Parses a CSS selector with optional `@attribute` and `!index` operators. + /// + /// Parse order: extract `!index` from the end first, then `@attribute`, + /// leaving the selector as the remainder. + private static func parseCSS(_ rule: String) -> ParsedRule { + var remaining = rule + var index: Int? + var attribute: String? + + // Extract !index from the end + if let bangRange = findBangIndex(in: remaining) { + let indexStr = String(remaining[bangRange.upperBound...]) + if let parsed = Int(indexStr) { + index = parsed + remaining = String(remaining[.. Range? { + guard let bangIdx = text.lastIndex(of: "!") else { return nil } + let after = String(text[text.index(after: bangIdx)...]) + guard Int(after) != nil else { return nil } + return bangIdx.. String.Index? { + guard let atIdx = text.lastIndex(of: "@") else { return nil } + let after = String(text[text.index(after: atIdx)...]) + guard !after.isEmpty, + after.allSatisfy({ $0.isLetter || $0 == "-" || $0 == "_" }) else { + return nil + } + return atIdx + } +} diff --git a/vreader/Services/BookSource/PipelineTypes.swift b/vreader/Services/BookSource/PipelineTypes.swift new file mode 100644 index 0000000..b450d02 --- /dev/null +++ b/vreader/Services/BookSource/PipelineTypes.swift @@ -0,0 +1,141 @@ +// Purpose: Shared data types for the BookSource scraping pipeline. +// Separated from BookSourcePipeline.swift to keep files under 300 lines. +// +// Key decisions: +// - All types are Sendable for safe use across actor boundaries. +// - BookSourceSnapshot is a value-type mirror of the SwiftData @Model BookSource. +// - PipelineError is Equatable for easy test assertions. +// +// @coordinates-with: BookSourcePipeline.swift, BookSource.swift, BookSourceRules.swift + +import Foundation + +// MARK: - Pipeline Stage + +/// Represents the current stage of the scraping pipeline. +enum PipelineStage: String, Sendable { + case search + case bookInfo + case toc + case content +} + +// MARK: - Pipeline Data Types + +/// A single book result from a search query. +struct BookSearchResult: Sendable, Equatable { + var name: String? + var author: String? + var bookUrl: String? + var coverUrl: String? +} + +/// Detailed information about a book. +struct BookDetail: Sendable, Equatable { + var name: String? + var author: String? + var intro: String? + var coverUrl: String? + var tocUrl: String? +} + +/// A single chapter entry from a table of contents. +struct ChapterInfo: Sendable, Equatable { + var name: String + var url: String +} + +// MARK: - Pipeline Errors + +/// Errors that can occur during pipeline execution. +enum PipelineError: Error, Equatable { + /// No search rule defined on the source. + case missingSearchRule + /// No book info rule defined on the source. + case missingBookInfoRule + /// No TOC rule defined on the source. + case missingTocRule + /// No content rule defined on the source. + case missingContentRule + /// The search URL template is missing from the source. + case missingSearchURL + /// The URL string could not be parsed. + case invalidURL(String) + /// The fetched content was empty after rule extraction. + case emptyContent + /// The pipeline was cancelled. + case cancelled + /// A fetch error occurred. + case fetchFailed(String) +} + +// MARK: - Fetch Provider + +/// A closure type that fetches HTML from a URL. Abstracted for testability. +/// - Parameters: +/// - url: The URL to fetch. +/// - headers: Optional HTTP headers. +/// - Returns: The HTML string. +typealias HTMLFetchProvider = @Sendable (URL, [String: String]?) async throws -> String + +// MARK: - BookSourceSnapshot + +/// A Sendable, value-type snapshot of BookSource for use across actor boundaries. +/// BookSource is a SwiftData @Model (reference type, not Sendable), so the pipeline +/// works with this snapshot instead. +struct BookSourceSnapshot: Sendable { + let sourceURL: String + let sourceName: String + let searchURL: String? + let header: String? + let ruleSearch: BSSearchRule? + let ruleBookInfo: BSBookInfoRule? + let ruleToc: BSTocRule? + let ruleContent: BSContentRule? + + /// Parses the JSON header string into a dictionary. + var parsedHeaders: [String: String]? { + guard let header, !header.isEmpty, + let data = header.data(using: .utf8), + let dict = try? JSONDecoder().decode( + [String: String].self, from: data + ) else { + return nil + } + return dict + } + + /// Creates a snapshot from a BookSource model object. + /// Call this on the main actor or within a SwiftData context. + init(from source: BookSource) { + self.sourceURL = source.sourceURL + self.sourceName = source.sourceName + self.searchURL = source.searchURL + self.header = source.header + self.ruleSearch = source.ruleSearch + self.ruleBookInfo = source.ruleBookInfo + self.ruleToc = source.ruleToc + self.ruleContent = source.ruleContent + } + + /// Direct initializer for testing without a SwiftData model. + init( + sourceURL: String, + sourceName: String, + searchURL: String? = nil, + header: String? = nil, + ruleSearch: BSSearchRule? = nil, + ruleBookInfo: BSBookInfoRule? = nil, + ruleToc: BSTocRule? = nil, + ruleContent: BSContentRule? = nil + ) { + self.sourceURL = sourceURL + self.sourceName = sourceName + self.searchURL = searchURL + self.header = header + self.ruleSearch = ruleSearch + self.ruleBookInfo = ruleBookInfo + self.ruleToc = ruleToc + self.ruleContent = ruleContent + } +} diff --git a/vreader/Services/BookSource/RegexRuleEvaluator.swift b/vreader/Services/BookSource/RegexRuleEvaluator.swift new file mode 100644 index 0000000..a155328 --- /dev/null +++ b/vreader/Services/BookSource/RegexRuleEvaluator.swift @@ -0,0 +1,81 @@ +// Purpose: Evaluates regex rules against text content. +// Supports capture group extraction and pattern replacement. +// +// Key decisions: +// - Enum namespace (no instances needed, all static). +// - If the pattern has capture groups, returns the first group match. +// - If no capture groups, returns the full match. +// - Returns all matches (not just the first). +// - Replacement is a separate method for content cleanup pipelines. +// +// @coordinates-with: RuleEngine.swift, LegadoRuleParser.swift + +import Foundation + +/// Evaluates regex patterns against text content. +enum RegexRuleEvaluator { + + // MARK: - Extract + + /// Extracts matches from text using a regex pattern. + /// + /// If the pattern contains capture groups, returns the first capture group + /// from each match. If no capture groups, returns the full match text. + /// + /// - Parameters: + /// - pattern: The regex pattern string. + /// - text: The text to search. + /// - Returns: Array of matched strings. Empty if no matches or invalid pattern. + static func extract(pattern: String, from text: String) -> [String] { + guard !pattern.isEmpty, !text.isEmpty else { return [] } + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return [] + } + + let nsText = text as NSString + let range = NSRange(location: 0, length: nsText.length) + let matches = regex.matches(in: text, range: range) + + guard !matches.isEmpty else { return [] } + + let hasGroups = regex.numberOfCaptureGroups > 0 + + return matches.compactMap { match in + if hasGroups && match.numberOfRanges > 1 { + let groupRange = match.range(at: 1) + guard groupRange.location != NSNotFound else { return nil } + return nsText.substring(with: groupRange) + } else { + let matchRange = match.range(at: 0) + guard matchRange.location != NSNotFound else { return nil } + return nsText.substring(with: matchRange) + } + } + } + + // MARK: - Replace + + /// Replaces all occurrences of a regex pattern in the input string. + /// + /// - Parameters: + /// - pattern: The regex pattern to match. + /// - replacement: The replacement string. + /// - input: The string to modify. + /// - Returns: The modified string, or the original if the pattern is invalid. + static func replace(pattern: String, replacement: String, in input: String) -> String { + guard !pattern.isEmpty else { return input } + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return input + } + + let nsInput = input as NSString + let range = NSRange(location: 0, length: nsInput.length) + return regex.stringByReplacingMatches( + in: input, + range: range, + withTemplate: replacement + ) + } +} diff --git a/vreader/Services/BookSource/RuleEngine.swift b/vreader/Services/BookSource/RuleEngine.swift new file mode 100644 index 0000000..3f26524 --- /dev/null +++ b/vreader/Services/BookSource/RuleEngine.swift @@ -0,0 +1,118 @@ +// Purpose: Main dispatcher for BookSource rule evaluation. +// Auto-detects rule type (CSS/regex/XPath) and delegates to the appropriate evaluator. +// +// Key decisions: +// - Enum namespace (stateless, all static). +// - XPath rules are detected but return empty (deferred to D08). +// - Empty/whitespace rules return empty arrays (not errors). +// - evaluateSingle convenience for single-value extraction. +// +// @coordinates-with: LegadoRuleParser.swift, CSSRuleEvaluator.swift, +// RegexRuleEvaluator.swift, BookSourcePipeline.swift + +import Foundation + +/// Main dispatcher for evaluating BookSource rules against HTML content. +/// +/// Supports: +/// - CSS selectors with Legado syntax (`selector@attribute!index`) +/// - Regex extraction (`:regex:pattern`) +/// - XPath detection (deferred to D08, returns empty) +enum RuleEngine { + + // MARK: - Evaluate (Multiple Results) + + /// Evaluates a rule against HTML and returns all matching values. + /// + /// Rule type is auto-detected: + /// - `:regex:` prefix -> regex extraction + /// - `//` or `/` prefix -> XPath (deferred, returns empty) + /// - Otherwise -> CSS selector with optional Legado operators (@, !) + /// + /// - Parameters: + /// - rule: The rule string (CSS selector, regex, or XPath). + /// - html: The HTML content to evaluate against. + /// - baseURL: Base URL for resolving relative URLs. + /// - Returns: Array of extracted strings. Empty if no matches. + static func evaluate(rule: String, html: String, baseURL: URL?) -> [String] { + let trimmed = rule.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let parsed = LegadoRuleParser.parse(trimmed) + + switch parsed.type { + case .css: + return CSSRuleEvaluator.evaluate( + selector: parsed.selector, + attribute: parsed.attribute, + index: parsed.index, + html: html, + baseURL: baseURL + ) + + case .regex: + guard let pattern = parsed.regexPattern, !pattern.isEmpty else { + return [] + } + return RegexRuleEvaluator.extract(pattern: pattern, from: html) + + case .xpath: + // XPath deferred to D08 + return [] + } + } + + // MARK: - Evaluate (Single Result) + + /// Evaluates a rule and returns only the first matching value. + /// + /// Convenience for fields that expect a single value (e.g., book title). + /// + /// - Parameters: + /// - rule: The rule string. + /// - html: The HTML content to evaluate against. + /// - baseURL: Base URL for resolving relative URLs. + /// - Returns: First matching value, or nil if no matches. + static func evaluateSingle( + rule: String, + html: String, + baseURL: URL? + ) -> String? { + evaluate(rule: rule, html: html, baseURL: baseURL).first + } + + // MARK: - Evaluate Raw HTML (Container Extraction) + + /// Evaluates a CSS rule and returns the full HTML of each matched element. + /// + /// Used by the pipeline for container rules (e.g., bookList, chapterList) + /// where sub-rules need to be applied against each element's HTML. + /// Regex and XPath rules fall back to normal `evaluate`. + /// + /// - Parameters: + /// - rule: The CSS selector rule string. + /// - html: The HTML content to evaluate against. + /// - baseURL: Base URL for resolving relative URLs. + /// - Returns: Array of raw HTML strings for each matched element. + static func evaluateRawHTML( + rule: String, + html: String, + baseURL: URL? + ) -> [String] { + let trimmed = rule.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let parsed = LegadoRuleParser.parse(trimmed) + + switch parsed.type { + case .css: + return CSSRuleEvaluator.evaluateRawHTML( + selector: parsed.selector, + index: parsed.index, + html: html + ) + case .regex, .xpath: + return evaluate(rule: rule, html: html, baseURL: baseURL) + } + } +} diff --git a/vreader/Services/BookSource/SourceSharingService.swift b/vreader/Services/BookSource/SourceSharingService.swift new file mode 100644 index 0000000..08b646e --- /dev/null +++ b/vreader/Services/BookSource/SourceSharingService.swift @@ -0,0 +1,144 @@ +// Purpose: Export/import single BookSource as Legado-compatible JSON, +// generate QR codes via CoreImage, and parse vreader:// URL schemes. +// +// Key decisions: +// - Reuses LegadoImporter for JSON serialization. +// - QR code via CIQRCodeGenerator (CoreImage, no external deps). +// - URL scheme: vreader://import-source?data=. +// +// @coordinates-with: LegadoImporter.swift, BookSource.swift, +// LegadoBookSourceDTO.swift + +import Foundation +import UIKit +import CoreImage + +/// Errors from source sharing operations. +enum SourceSharingError: Error, Sendable, Equatable { + /// The URL scheme is not vreader://. + case invalidScheme + /// The URL host is not import-source. + case invalidHost + /// The data query parameter is missing. + case missingData + /// The base64 data could not be decoded. + case invalidBase64 + /// The decoded data is not valid JSON. + case invalidJSON +} + +/// Exports and imports single BookSource objects for sharing. +/// +/// Uses LegadoImporter for JSON serialization, CoreImage for QR generation, +/// and vreader:// URL scheme for one-tap import. +enum SourceSharingService { + + // MARK: - Constants + + /// URL scheme for VReader source import. + private static let urlScheme = "vreader" + + /// URL host for source import action. + private static let importHost = "import-source" + + /// Query parameter name for base64-encoded source data. + private static let dataParam = "data" + + // MARK: - Export + + /// Exports a single BookSource as Legado-compatible JSON data. + /// + /// - Parameter source: The BookSource to export. + /// - Returns: JSON data (always an array with one element). + static func exportSource(_ source: BookSource) throws -> Data { + try LegadoImporter.exportSources([source]) + } + + // MARK: - Import + + /// Imports BookSource objects from shared JSON data. + /// + /// - Parameter data: JSON data in Legado format. + /// - Returns: Array of imported BookSource objects. + static func importSource(from data: Data) throws -> [BookSource] { + try LegadoImporter.importSources(from: data) + } + + // MARK: - URL Scheme + + /// Generates a vreader:// URL string for sharing a source. + /// + /// Format: `vreader://import-source?data=` + /// + /// - Parameter source: The BookSource to share. + /// - Returns: A well-formed vreader:// URL string. + static func generateSharingURL(for source: BookSource) throws -> String { + let jsonData = try exportSource(source) + let base64 = jsonData.base64EncodedString() + return "\(urlScheme)://\(importHost)?\(dataParam)=\(base64)" + } + + /// Parses a vreader://import-source URL and extracts BookSource objects. + /// + /// - Parameter url: The URL to parse. + /// - Returns: Array of imported BookSource objects. + /// - Throws: `SourceSharingError` on invalid URL format. + static func parseImportURL(_ url: URL) throws -> [BookSource] { + // Validate scheme + guard url.scheme == urlScheme else { + throw SourceSharingError.invalidScheme + } + + // Validate host + guard url.host == importHost else { + throw SourceSharingError.invalidHost + } + + // Extract data parameter + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let dataValue = components.queryItems? + .first(where: { $0.name == dataParam })?.value, + !dataValue.isEmpty else { + throw SourceSharingError.missingData + } + + // Decode base64 + guard let jsonData = Data(base64Encoded: dataValue) else { + throw SourceSharingError.invalidBase64 + } + + // Parse JSON via LegadoImporter + return try importSource(from: jsonData) + } + + // MARK: - QR Code + + /// Generates a QR code image (PNG data) for the given string. + /// + /// Uses CoreImage's CIQRCodeGenerator. Returns nil if the string + /// is empty or QR generation fails. + /// + /// - Parameter string: The content to encode in the QR code. + /// - Returns: PNG image data, or nil on failure. + static func generateQRCode(for string: String) -> Data? { + guard !string.isEmpty else { return nil } + guard let data = string.data(using: .utf8) else { return nil } + + let filter = CIFilter(name: "CIQRCodeGenerator") + filter?.setValue(data, forKey: "inputMessage") + filter?.setValue("M", forKey: "inputCorrectionLevel") + + guard let ciImage = filter?.outputImage else { return nil } + + // Scale up for readability (QR output is tiny by default) + let scale = CGAffineTransform(scaleX: 10, y: 10) + let scaledImage = ciImage.transformed(by: scale) + + let context = CIContext() + guard let cgImage = context.createCGImage( + scaledImage, from: scaledImage.extent + ) else { return nil } + + return UIImage(cgImage: cgImage).pngData() + } +} diff --git a/vreader/Services/BookSource/UpdateChecker.swift b/vreader/Services/BookSource/UpdateChecker.swift new file mode 100644 index 0000000..0d07b55 --- /dev/null +++ b/vreader/Services/BookSource/UpdateChecker.swift @@ -0,0 +1,104 @@ +// Purpose: Detects new chapters by comparing remote TOC chapter count +// against a stored/last-known count. +// +// Key decisions: +// - Actor-isolated for thread safety. +// - Uses BookSourcePipeline.chapters() to fetch remote TOC. +// - Rate limiting per source URL to prevent excessive checks. +// - Graceful degradation on network errors (returns nil, not throws). +// +// @coordinates-with: BookSourcePipeline.swift, PipelineTypes.swift, +// BookSourceSnapshot, HTMLFetchProvider + +import Foundation + +/// Result of an update check for a single book. +struct UpdateResult: Sendable, Equatable { + /// The source URL that was checked. + let sourceURL: String + /// The book URL being tracked. + let bookURL: String + /// The number of new chapters detected. + let newChapterCount: Int +} + +/// Checks for new chapters by comparing remote TOC against stored chapter count. +actor UpdateChecker { + + /// Minimum seconds between checks for the same source URL. + private let minimumCheckInterval: TimeInterval + + /// Tracks when each source was last checked. + private var lastCheckTimes: [String: Date] = [:] + + /// Creates an UpdateChecker. + /// + /// - Parameter minimumCheckInterval: Min seconds between checks per source (default 0). + init(minimumCheckInterval: TimeInterval = 0) { + self.minimumCheckInterval = minimumCheckInterval + } + + /// Checks for new chapters on a book. + /// + /// Fetches the remote TOC and compares chapter count against last known. + /// Returns nil (not throws) on network errors for graceful degradation. + /// + /// - Parameters: + /// - source: The BookSource snapshot with TOC rules. + /// - bookURL: The book's URL (for result identification). + /// - tocURL: The TOC page URL to fetch. + /// - lastKnownChapterCount: The previously stored chapter count. + /// - fetcher: The HTML fetch provider. + /// - sourceEnabled: Whether the source is enabled (default true). + /// - Returns: UpdateResult if new chapters found, nil otherwise. + func checkForUpdates( + source: BookSourceSnapshot, + bookURL: String, + tocURL: String, + lastKnownChapterCount: Int, + fetcher: @escaping HTMLFetchProvider, + sourceEnabled: Bool = true + ) async throws -> UpdateResult? { + // Skip disabled sources + guard sourceEnabled else { return nil } + + // Skip empty TOC URL + guard !tocURL.isEmpty else { return nil } + + // Rate limiting: skip if checked too recently + let sourceKey = "\(source.sourceURL)|\(bookURL)" + if let lastCheck = lastCheckTimes[sourceKey], + minimumCheckInterval > 0 { + let elapsed = Date().timeIntervalSince(lastCheck) + if elapsed < minimumCheckInterval { + return nil + } + } + + // Fetch TOC via pipeline + let pipeline = BookSourcePipeline(fetchHTML: fetcher) + let chapters: [ChapterInfo] + do { + chapters = try await pipeline.chapters( + source: source, tocUrl: tocURL + ) + } catch { + // Graceful degradation: network/parse errors return nil + return nil + } + + // Record check time after successful fetch + lastCheckTimes[sourceKey] = Date() + + let remoteCount = chapters.count + + // No new chapters if count is unchanged or decreased + guard remoteCount > lastKnownChapterCount else { return nil } + + return UpdateResult( + sourceURL: source.sourceURL, + bookURL: bookURL, + newChapterCount: remoteCount - lastKnownChapterCount + ) + } +} diff --git a/vreader/Services/BookSource/WebPageEncodingDetector.swift b/vreader/Services/BookSource/WebPageEncodingDetector.swift new file mode 100644 index 0000000..b3752c6 --- /dev/null +++ b/vreader/Services/BookSource/WebPageEncodingDetector.swift @@ -0,0 +1,264 @@ +// Purpose: Detects text encoding from HTTP responses and HTML content. +// Used by BookSourceHTTPClient to correctly decode web pages. +// +// Pipeline: HTTP Content-Type header → HTML meta charset → BOM → default UTF-8 +// +// Key decisions: +// - HTTP Content-Type charset takes highest priority (server-authoritative). +// - HTML meta charset is parsed from raw bytes (ASCII-safe scan) before full decode. +// - BOM detection as fallback for unusual encodings. +// - Supports GB2312, GBK, Big5, Shift_JIS, EUC-KR, UTF-8 (common for Chinese novels). +// - Enum (not actor) because detection is a pure function with no mutable state. +// +// @coordinates-with: BookSourceHTTPClient.swift + +import Foundation + +/// Detects and resolves text encoding for web page content. +enum WebPageEncodingDetector { + + // MARK: - Public Encoding Constants + + /// GBK / GB18030 encoding (covers GB2312 as a subset). + static let gbkEncoding = String.Encoding( + rawValue: CFStringConvertEncodingToNSStringEncoding( + CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue) + ) + ) + + /// GB2312 encoding — maps to GBK/GB18030 on Apple platforms. + static let gb2312Encoding = gbkEncoding + + /// Big5 encoding (Traditional Chinese). + static let big5Encoding = String.Encoding( + rawValue: CFStringConvertEncodingToNSStringEncoding( + CFStringEncoding(CFStringEncodings.big5.rawValue) + ) + ) + + /// EUC-KR encoding (Korean). + static let eucKREncoding = String.Encoding( + rawValue: CFStringConvertEncodingToNSStringEncoding( + CFStringEncoding(CFStringEncodings.EUC_KR.rawValue) + ) + ) + + // MARK: - Detect Encoding + + /// Detects the encoding of web page data using multiple signals. + /// + /// Priority order: + /// 1. HTTP Content-Type charset (if present and recognized) + /// 2. HTML `` or `` (parsed from raw bytes) + /// 3. BOM (Byte Order Mark) + /// 4. Default: UTF-8 + /// + /// - Parameters: + /// - data: Raw response body bytes. + /// - contentTypeHeader: Value of the HTTP `Content-Type` header, if available. + /// - Returns: The detected `String.Encoding`. + static func detect(data: Data, contentTypeHeader: String?) -> String.Encoding { + // 1. Check HTTP Content-Type header + if let header = contentTypeHeader, + let charset = parseCharset(from: header), + let encoding = encodingFromName(charset) { + return encoding + } + + // 2. Check HTML meta charset (scan raw bytes for ASCII-safe patterns) + if let charset = parseMetaCharset(from: data), + let encoding = encodingFromName(charset) { + return encoding + } + + // 3. Check BOM + if let encoding = detectBOM(data: data) { + return encoding + } + + // 4. Default + return .utf8 + } + + // MARK: - Decode Data + + /// Decodes raw bytes using the specified encoding, with UTF-8 fallback. + /// + /// - Parameters: + /// - data: Raw bytes to decode. + /// - encoding: The encoding to use. + /// - Returns: Decoded string, or nil if decoding fails entirely. + static func decode(data: Data, encoding: String.Encoding) -> String? { + if data.isEmpty { return "" } + + // Try requested encoding first + if let text = String(data: data, encoding: encoding) { + return text + } + + // Fallback to UTF-8 + if encoding != .utf8, let text = String(data: data, encoding: .utf8) { + return text + } + + // Last resort: lossy UTF-8 + return String(decoding: data, as: UTF8.self) + } + + // MARK: - Private: Parse Charset from Content-Type + + /// Extracts charset value from a Content-Type header string. + /// Example: `"text/html; charset=utf-8"` → `"utf-8"` + private static func parseCharset(from contentType: String) -> String? { + let lower = contentType.lowercased() + guard let range = lower.range(of: "charset=") else { return nil } + + var value = String(lower[range.upperBound...]) + .trimmingCharacters(in: .whitespaces) + + // Remove quotes if present + if value.hasPrefix("\"") || value.hasPrefix("'") { + value = String(value.dropFirst()) + } + if value.hasSuffix("\"") || value.hasSuffix("'") { + value = String(value.dropLast()) + } + + // Remove trailing semicolons or whitespace + if let semi = value.firstIndex(of: ";") { + value = String(value[..` + /// - `` + /// - `` + private static func parseMetaCharset(from data: Data) -> String? { + // Only scan the head section (first 1024 bytes is sufficient) + let scanSize = min(data.count, 1024) + guard scanSize > 0 else { return nil } + + // Convert to ASCII string for pattern matching (safe because charset + // names and HTML tags are ASCII) + let bytes = [UInt8](data.prefix(scanSize)) + guard let ascii = String(bytes: bytes, encoding: .ascii) else { return nil } + let lower = ascii.lowercased() + + // Pattern 1: or + if let match = matchPattern( + in: lower, + pattern: #" + if let match = matchPattern( + in: lower, + pattern: #" String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []), + let match = regex.firstMatch( + in: text, + range: NSRange(text.startIndex..., in: text) + ), + match.numberOfRanges > 1, + let range = Range(match.range(at: 1), in: text) else { + return nil + } + return String(text[range]) + } + + // MARK: - Private: BOM Detection + + /// Detects encoding from Byte Order Mark. + private static func detectBOM(data: Data) -> String.Encoding? { + guard data.count >= 2 else { return nil } + + let bytes = [UInt8](data.prefix(4)) + + // UTF-8 BOM: EF BB BF + if bytes.count >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF { + return .utf8 + } + + // UTF-16 LE BOM: FF FE (check before UTF-32 LE which starts the same) + if bytes.count >= 4 && bytes[0] == 0xFF && bytes[1] == 0xFE + && bytes[2] == 0x00 && bytes[3] == 0x00 { + return .utf32LittleEndian + } + + // UTF-32 BE BOM: 00 00 FE FF + if bytes.count >= 4 && bytes[0] == 0x00 && bytes[1] == 0x00 + && bytes[2] == 0xFE && bytes[3] == 0xFF { + return .utf32BigEndian + } + + // UTF-16 LE BOM: FF FE + if bytes[0] == 0xFF && bytes[1] == 0xFE { + return .utf16LittleEndian + } + + // UTF-16 BE BOM: FE FF + if bytes[0] == 0xFE && bytes[1] == 0xFF { + return .utf16BigEndian + } + + return nil + } + + // MARK: - Private: Encoding Name Mapping + + /// Maps a charset name string to a Swift `String.Encoding`. + /// Handles common aliases used in web content. + private static func encodingFromName(_ name: String) -> String.Encoding? { + switch name.lowercased() { + case "utf-8", "utf8": + return .utf8 + case "gb2312", "gbk", "gb18030", "x-gbk": + return gbkEncoding + case "big5", "big5-hkscs": + return big5Encoding + case "shift_jis", "shift-jis", "sjis", "x-sjis": + return .shiftJIS + case "euc-kr", "euckr", "ks_c_5601-1987": + return eucKREncoding + case "euc-jp", "eucjp": + return .japaneseEUC + case "iso-8859-1", "latin1": + return .isoLatin1 + case "windows-1252", "cp1252": + return .windowsCP1252 + case "utf-16", "utf16": + return .utf16 + case "utf-16le": + return .utf16LittleEndian + case "utf-16be": + return .utf16BigEndian + default: + // Try CoreFoundation IANA charset name mapping + let cfEncoding = CFStringConvertIANACharSetNameToEncoding(name as CFString) + if cfEncoding != kCFStringEncodingInvalidId { + let nsEncoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding) + return String.Encoding(rawValue: nsEncoding) + } + return nil + } + } +} diff --git a/vreader/Services/CustomCoverStore.swift b/vreader/Services/CustomCoverStore.swift new file mode 100644 index 0000000..de8fcfd --- /dev/null +++ b/vreader/Services/CustomCoverStore.swift @@ -0,0 +1,139 @@ +// Purpose: Manages saving, loading, and deleting custom cover images for books. +// Covers are stored as JPEG files in /CustomCovers/.jpg. +// +// Key decisions: +// - JPEG compression at quality 0.8 to balance size and quality. +// - Images are resized to max 512x512 before saving (no upscaling). +// - Fingerprint keys are sanitized (colons, slashes replaced) for filesystem safety. +// - All methods accept an optional baseDirectory for testability (defaults to App Support). +// - Enum with static methods — no instance state needed. +// +// @coordinates-with: LibraryView.swift, BookCardView.swift, BookRowView.swift, LibraryBookItem.swift + +import UIKit + +enum CustomCoverStore { + + // MARK: - Constants + + private static let subdirectory = "CustomCovers" + private static let jpegQuality: CGFloat = 0.8 + private static let maxDimension: CGFloat = 512 + + // MARK: - Public API + + /// Returns the file URL where a custom cover would be stored for the given key. + static func coverPath( + for fingerprintKey: String, + baseDirectory: URL? = nil + ) -> URL { + let base = baseDirectory ?? defaultBaseDirectory + let dir = base.appendingPathComponent(subdirectory, isDirectory: true) + let safeName = sanitize(fingerprintKey) + return dir.appendingPathComponent(safeName).appendingPathExtension("jpg") + } + + /// Saves a cover image for the given book, resizing if necessary. + /// Replaces any existing custom cover. + static func saveCover( + _ image: UIImage, + for fingerprintKey: String, + baseDirectory: URL? = nil + ) throws { + let path = coverPath(for: fingerprintKey, baseDirectory: baseDirectory) + let dir = path.deletingLastPathComponent() + + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let resized = resizeIfNeeded(image, maxDimension: maxDimension) + guard let data = resized.jpegData(compressionQuality: jpegQuality) else { + throw CustomCoverError.encodingFailed + } + + try data.write(to: path, options: .atomic) + } + + /// Loads the custom cover image for the given book, or nil if none exists. + static func loadCover( + for fingerprintKey: String, + baseDirectory: URL? = nil + ) -> UIImage? { + let path = coverPath(for: fingerprintKey, baseDirectory: baseDirectory) + guard FileManager.default.fileExists(atPath: path.path) else { return nil } + return UIImage(contentsOfFile: path.path) + } + + /// Removes the custom cover for the given book. No-op if none exists. + static func removeCover( + for fingerprintKey: String, + baseDirectory: URL? = nil + ) throws { + let path = coverPath(for: fingerprintKey, baseDirectory: baseDirectory) + guard FileManager.default.fileExists(atPath: path.path) else { return } + try FileManager.default.removeItem(at: path) + } + + /// Returns true if a custom cover exists for the given book. + static func hasCover( + for fingerprintKey: String, + baseDirectory: URL? = nil + ) -> Bool { + let path = coverPath(for: fingerprintKey, baseDirectory: baseDirectory) + return FileManager.default.fileExists(atPath: path.path) + } + + // MARK: - Private + + private static var defaultBaseDirectory: URL { + FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + } + + /// Sanitizes a fingerprint key for use as a filename. + /// Replaces colons, slashes, and other unsafe characters with underscores. + private static func sanitize(_ key: String) -> String { + let unsafe = CharacterSet.alphanumerics.inverted + return key.unicodeScalars + .map { unsafe.contains($0) ? "_" : String($0) } + .joined() + } + + /// Resizes the image so that neither pixel dimension exceeds maxDimension. + /// Does not upscale — returns the original if it is already small enough. + /// Uses scale 1.0 so JPEG output dimensions match exactly. + private static func resizeIfNeeded(_ image: UIImage, maxDimension: CGFloat) -> UIImage { + // Use pixel dimensions to avoid scale mismatch when saving as JPEG. + let pixelWidth = CGFloat(image.cgImage?.width ?? Int(image.size.width * image.scale)) + let pixelHeight = CGFloat(image.cgImage?.height ?? Int(image.size.height * image.scale)) + guard pixelWidth > maxDimension || pixelHeight > maxDimension else { + return image + } + + let ratio = min(maxDimension / pixelWidth, maxDimension / pixelHeight) + let newSize = CGSize( + width: (pixelWidth * ratio).rounded(.down), + height: (pixelHeight * ratio).rounded(.down) + ) + + // Render at scale 1.0 so the output image's point size equals its pixel size. + let format = UIGraphicsImageRendererFormat() + format.scale = 1.0 + let renderer = UIGraphicsImageRenderer(size: newSize, format: format) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + } +} + +// MARK: - Errors + +enum CustomCoverError: Error, LocalizedError { + case encodingFailed + + var errorDescription: String? { + switch self { + case .encodingFailed: + return "Failed to encode image as JPEG." + } + } +} diff --git a/vreader/Services/DictionaryLookup.swift b/vreader/Services/DictionaryLookup.swift new file mode 100644 index 0000000..7c84874 --- /dev/null +++ b/vreader/Services/DictionaryLookup.swift @@ -0,0 +1,57 @@ +// Purpose: Dictionary lookup and word extraction for Define/Translate-on-Select. +// Provides system dictionary integration via UIReferenceLibraryViewController +// and word extraction from text selections. +// +// Key decisions: +// - enum namespace (no instances needed — all static). +// - extractWord splits on whitespace and returns the first non-empty token. +// - canLookUp wraps UIReferenceLibraryViewController.dictionaryHasDefinition. +// - Menu title constants for consistency across bridges. +// +// @coordinates-with TXTBridgeShared.swift, ReaderNotifications.swift + +#if canImport(UIKit) +import UIKit + +/// Dictionary lookup utilities for Define/Translate on text selection. +enum DictionaryLookup { + + /// Menu title for the "Define" action in the edit menu. + static let defineMenuTitle = "Define" + + /// Menu title for the "Translate" action in the edit menu. + static let translateMenuTitle = "Translate" + + /// Check if the system dictionary can define a word. + /// + /// - Parameter word: The word to look up. + /// - Returns: `true` if a definition is available, `false` otherwise. + static func canLookUp(_ word: String) -> Bool { + guard !word.isEmpty else { return false } + return UIReferenceLibraryViewController.dictionaryHasDefinition(forTerm: word) + } + + /// Create a system dictionary view controller for a word. + /// + /// - Parameter word: The word to define. + /// - Returns: A `UIReferenceLibraryViewController` displaying the definition. + static func viewController(for word: String) -> UIReferenceLibraryViewController { + UIReferenceLibraryViewController(term: word) + } + + /// Extract a single word from selected text for dictionary lookup. + /// + /// Trims whitespace/newlines, then returns the first whitespace-delimited token. + /// Returns `nil` if the selection is empty or whitespace-only. + /// + /// - Parameter selection: The selected text string. + /// - Returns: The first word, or `nil` if no word can be extracted. + static func extractWord(from selection: String) -> String? { + let trimmed = selection.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + // Split on all whitespace characters (spaces, tabs, newlines) + return trimmed.components(separatedBy: .whitespacesAndNewlines) + .first { !$0.isEmpty } + } +} +#endif diff --git a/vreader/Services/EPUB/EPUBComplexityClassifier.swift b/vreader/Services/EPUB/EPUBComplexityClassifier.swift new file mode 100644 index 0000000..ac28cab --- /dev/null +++ b/vreader/Services/EPUB/EPUBComplexityClassifier.swift @@ -0,0 +1,93 @@ +// Purpose: Classifies EPUB chapter HTML as simple or complex to route +// chapters to the appropriate rendering engine. Simple content goes to +// the Unified reflow engine; complex content falls back to WKWebView. +// +// Key decisions: +// - Conservative: if uncertain, classify as complex (safer fallback). +// - String-based detection for speed — no full HTML/DOM parsing. +// - Per-chapter classification with book-level rollup. +// +// @coordinates-with: FormatCapabilities.swift — isComplexEPUB parameter + +import Foundation + +/// Whether an EPUB chapter (or entire book) is simple enough for +/// the Unified reflow engine or requires the Native WKWebView renderer. +enum EPUBComplexity: Sendable, Equatable { + case simple + case complex +} + +/// Classifies EPUB HTML content by scanning for complex layout indicators. +/// +/// Detection strategy (conservative — anything uncertain is complex): +/// - **Complex tags**: ``, `
`, ``, ``, +/// ``, ``, ``, `