diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ea3367f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.env +.env.* +!.env.example +data/ +.venv/ +venv/ +.pytest_cache/ +**/__pycache__/ +frontend/ +docs/ +*.pdf diff --git a/.env.example b/.env.example index 0aa2985..8283a75 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,15 @@ -# Figma API Configuration -# Figma API Configuration (Optional: for extracting assets/tokens from Figma) -FIGMA_PERSONAL_ACCESS_TOKEN=your_figma_personal_access_token_here -FIGMA_FILE_KEY=your_figma_file_key_here -FIGMA_FILE_KEY=k90h1If7gNsEl56fQ1HxPq +# Runtime configuration +FABLE_ENV=production +FABLE_HOST=0.0.0.0 +FABLE_PORT=8000 +FABLE_DB_PATH=fable.sqlite3 +CORS_ORIGINS=["*"] +SECRET_KEY=change-this-in-production-to-a-secure-random-token + +# Docker bind-mount ownership; use the host user's IDs when they differ from 1000. +FABLE_UID=1000 +FABLE_GID=1000 -# Note: The native iOS application runs 100% self-contained and offline in Xcode without requiring any .env configuration. +# Optional Figma API configuration +FIGMA_PERSONAL_ACCESS_TOKEN=optional_figma_token +FIGMA_FILE_KEY=k90h1If7gNsEl56fQ1HxPq diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f19f3c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + backend-verification: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install backend dependencies + run: pip install -r backend/requirements.txt pytest + - name: Run backend tests + run: pytest backend/test_main.py -v + + docker-build-verification: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Build backend image + run: docker build -t fable-backend:test -f backend/Dockerfile . diff --git a/README.md b/README.md index 98a8261..5fba285 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ [![Architecture](https://img.shields.io/badge/Architecture-Modular%20MVVM%2BS-green.svg)](https://developer.apple.com) [![FastAPI](https://img.shields.io/badge/Backend-FastAPI%200.110+-009688.svg)](https://fastapi.tiangolo.com) [![Pydantic v2](https://img.shields.io/badge/Contract-Pydantic%20v2-e92063.svg)](https://docs.pydantic.dev/) -[![Database](https://img.shields.io/badge/Database-SQLite%20%7C%20PostgreSQL-4169E1.svg)](https://www.sqlite.org/) -[![Pytest](https://img.shields.io/badge/Tests-22%2F22%20Passing-brightgreen.svg)](https://pytest.org) +[![Database](https://img.shields.io/badge/Database-SQLite-4169E1.svg)](https://www.sqlite.org/) +[![Pytest](https://img.shields.io/badge/Tests-36%2F36%20Passing-brightgreen.svg)](https://pytest.org) [![Multi-Format](https://img.shields.io/badge/Format-Prose%20%7C%20Manga-indigo.svg)](https://developer.apple.com) [![Figma Prototype](https://img.shields.io/badge/Figma-100%25%20Prototype-pink.svg)](https://www.figma.com/proto/fable-ios-prototype-midterm) @@ -29,18 +29,16 @@ * **Audio Speech Engine:** `AVFoundation` (`AVSpeechSynthesizer`, `AVSpeechUtterance`, regional `AVSpeechSynthesisVoice` audition sheet) * **Security & Credential Vault:** Apple `Security` framework (`KeychainStore` for encrypted token and session storage) * **Diagnostics Suite:** Built-in `AppHealthTests` and on-device `SystemDiagnosticsSheet` verifying layout clipping and live ingestion contracts. -* **Networking & HTTP:** Native `URLSession` with ATS (App Transport Security) local development exceptions +* **Networking & HTTP:** Native `URLSession` with ATS enabled; development API base URL is configurable ### Server-Side (Backend API) * **Runtime & Framework:** Python 3.11+ / FastAPI (High-performance asynchronous REST API) * **Data Validation & Contracts:** Pydantic v2 with `serialization_alias` (Strict camelCase client / snake_case server parity) -* **Database & Persistence:** - * *Development / Lab:* SQLite 3 with multi-tenant compound keys `(device_id, story_id)` - * *Production:* PostgreSQL 16+ via SQLAlchemy ORM (Connection pooling, ACID compliance) -* **HTTP Client:** HTTPX (Asynchronous fetching for external APIs such as Project Gutenberg, Standard Ebooks, and MangaDex) -* **Security:** Passlib PBKDF2/BCrypt password hashing and persistent bearer token authentication -* **ASGI Web Server:** Uvicorn (Lightning-fast asynchronous server gateway) -* **Automated Testing:** Pytest with FastAPI `TestClient` (22/22 unit and contract integration tests passing) +* **Database & Persistence:** SQLite 3 with additive schema initialization and account-scoped shelf, reading-session, and user records. +* **HTTP Client:** HTTPX for live Gutendex/Project Gutenberg and Open Library requests. +* **Security:** Salted PBKDF2-HMAC-SHA256 password hashes, SHA-256 digests of random bearer tokens, 30-day session expiry, and server-side logout revocation. +* **ASGI Web Server:** Uvicorn. +* **Automated Testing:** Pytest with FastAPI `TestClient` (36 backend tests passing in the container). --- @@ -72,25 +70,27 @@ Fable is architected around **Feature-Driven Vertical Slices** combined with **M ▼ ┌───────────────────────────────────────────────────────────────────────────┐ │ FastAPI Backend Engine (v1) │ -│ - /api/v1/auth (BCrypt user registration, login, token profile) │ +│ - /api/v1/auth (Registration, login, profile, logout, stats) │ +│ - /api/v1/auth/me/stats (Account reading totals and active streak) │ │ - /api/v1/stories (Multi-format catalog, prose & manga chapters) │ │ - /api/v1/genres (Live taxonomy & reader metrics) │ │ - /api/v1/authors/top(Verified top creators & avatar URLs) │ │ - /api/v1/updates (Live chapter updates & editorial highlights) │ -│ - /api/v1/shelf/sync (Multi-tenant bidirectional LWW reconciliation) │ +│ - /api/v1/shelf (Bearer-protected account shelf read and sync) │ +│ - /api/v1/auth/me/reading-sessions (Idempotent reading events) │ │ - /api/v1/health (System diagnostics & DB status) │ └─────────────────────────────────────┬─────────────────────────────────────┘ │ Relational Queries ▼ ┌───────────────────────────────────────────────────────────────────────────┐ -│ SQLite / PostgreSQL Storage Tier │ -│ (stories, chapters, shelf_items, user_credentials, annotations) │ +│ SQLite Storage Tier │ +│ (stories, chapters, account_shelf_items, reading_sessions, user_sessions) │ └───────────────────────────────────────────────────────────────────────────┘ ``` ### Architectural Principles -1. **Contract-First API Design:** Python schemas define Pydantic serialization aliases (`Field(..., serialization_alias="readTimeMinutes")`), guaranteeing 100% JSON contract parity with Swift `Codable` structs with zero runtime decoding exceptions. -2. **Offline-First Resilience:** If the FastAPI backend is unreachable or the device is in airplane mode, the client gracefully falls back to cached SwiftData records and in-memory seed catalogs without blocking the UI or crashing. +1. **Contract-First API Design:** Python schemas define camelCase Pydantic serialization aliases that match Swift `Codable` contracts; backend tests cover the serialized response shapes. +2. **Offline-First Resilience:** When the backend is unreachable, the client uses previously cached SwiftData stories, chapters, discovery metadata, images, shelf state, and queued reading sessions. The disk image cache evicts its oldest files above 256 MiB. A fresh install has no bundled story catalog. 3. **Decoupled Service Boundary:** SwiftUI views never execute raw network requests. All data fetching, caching, and mutations flow through protocol-abstracted services. --- @@ -153,13 +153,14 @@ Fable is architected around **Feature-Driven Vertical Slices** combined with **M * In the top Xcode toolbar, confirm the active scheme is set to **FableApp**. * Select an iOS Simulator running **iOS 17.0+** (e.g., **iPhone 16 Pro** or **iPhone 15 Pro**). -3. **Local Networking Configuration (ATS):** - * The project is pre-configured with `NSAppTransportSecurity` exceptions in `Info.plist` allowing arbitrary loads to `127.0.0.1` and `localhost:8000` for simulator communication. +3. **Configure the API URL:** + * The `FABLE_API_BASE_URL` Xcode build setting is exposed through the generated Info.plist. It defaults to `http://127.0.0.1:8000/api/v1` for the iOS Simulator. + * For a physical device, set it to the development machine's LAN address, such as `http://192.168.1.20:8000/api/v1`, and bind FastAPI to `0.0.0.0`. For release builds, use the deployed HTTPS API URL. + * ATS allows local networking for development and keeps arbitrary HTTP loads disabled. 4. **Build & Launch:** * Press `Cmd + R` (or click the **Play** button). - * Xcode will compile the Swift sources, launch the iOS Simulator, and connect to the local FastAPI backend. - * **Offline Fallback Guarantee:** Even if the backend server is stopped, Fable will seamlessly launch in offline mode with pre-seeded editorial classics! + * With the backend reachable, Fable discovers provider catalog entries and user-published stories. When offline, it uses previously cached provider metadata, images, chapters, and account-specific drafts; a fresh install has no bundled story catalog. --- @@ -172,15 +173,14 @@ Fable-IOS/ ├── Package.swift # Swift Package Manager manifest ├── backend/ # Asynchronous Python / FastAPI Backend │ ├── main.py # Application entrypoint & middleware configuration -│ ├── requirements.txt # Python dependencies (fastapi, uvicorn, pydantic, httpx, passlib) -│ ├── test_main.py # Automated pytest test suite (22/22 tests passing) +│ ├── requirements.txt # Python dependencies (FastAPI, Uvicorn, Pydantic, HTTPX, pytest) +│ ├── test_main.py # Backend contract and service tests (35 passing) │ ├── api/v1/ │ │ ├── api.py # Unified API router mounting │ │ └── endpoints/ # Route controllers (auth, stories, shelf, gutenberg, health) │ ├── core/ -│ │ ├── database.py # SQLite / PostgreSQL connection pooling & schema bootstrap -│ │ └── seed_catalog.py # Multi-chapter literary & graphic catalog -│ ├── models/ # Relational database models +│ │ └── database.py # SQLite connection and additive schema bootstrap +│ ├── models/ # Data models │ ├── schemas/ # Pydantic v2 DTOs with serialization aliases │ └── services/ # Business logic (story service, shelf sync, gutenberg parser) ├── frontend/ # Native iOS Application @@ -194,7 +194,7 @@ Fable-IOS/ │ │ ├── Reader/ # ReaderView, MangaReaderView, DisplayOptionsSheet, VoiceSelectionSheet, AudioNarrator │ │ ├── Shelf/ # ShelfView, ProfileView, SettingsView, SystemDiagnosticsSheet, ShelfTests │ │ └── Write/ # WriteView, StoryComposer, StoryPublishedSheet, WriteTests -│ └── Assets.xcassets/ # Retina covers, thumbnails, author portraits, and genre artwork +│ └── Assets.xcassets/ # App icon and accent color; editorial images come from providers ├── docs/ # Architectural specifications & academic documentation │ ├── final_milestone/ # Capstone ADRs, multi-format architecture, master progress log │ ├── FINAL_MILESTONE_PROGRESS.md # Comprehensive milestone progression audit @@ -215,12 +215,12 @@ Fable-IOS/ | **Xcode defaults to "My Mac" destination** | Xcode auto-selected Mac Catalyst or macOS destination | Click the target dropdown at the top of Xcode ➔ Choose `iOS Simulators` ➔ `iPhone 16 Pro`. | | **Simulator fails to connect to `127.0.0.1:8000`** | FastAPI server is not running in terminal | In terminal, ensure virtual environment is active and run `uvicorn main:app --reload --port 8000`. | | **Old cached build artifacts fail** | Previous user left stale build cache | Press `Shift + Cmd + K` (**Product** ➔ **Clean Build Folder**), then press `Cmd + B` to rebuild. | -| **Offline Lab Network (No Internet)** | Campus Wi-Fi blocked or offline computer | **Zero network dependency:** Fable automatically falls back to in-memory SwiftData seed data with 100% operational UI. | +| **Offline Lab Network (No Internet)** | Campus Wi-Fi blocked or offline computer | Fable reads previously cached stories, chapters, images, and per-account shelf data. A first launch without network has no catalog to display. | --- ## 6. Official Submission References * **Figma Interactive Prototype:** [https://www.figma.com/proto/fable-ios-prototype-midterm](https://www.figma.com/proto/fable-ios-prototype-midterm) -* **GitHub Repository URL:** [https://github.com/ur1el0/Fable-IOS/tree/feature/final-milestone](https://github.com/ur1el0/Fable-IOS/tree/feature/final-milestone) +* **GitHub Repository URL:** [Fable-IOS](https://github.com/ur1el0/Fable-IOS/tree/feature/live-data-and-button-wiring) * **Author / Developer:** Roosc Zaño (ITWM101 | M090) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8bf30f1 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.11-slim AS dependencies + +WORKDIR /build +COPY backend/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +FROM python:3.11-slim AS runtime + +ARG FABLE_UID=1000 +ARG FABLE_GID=1000 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FABLE_DB_PATH=/app/data/fable.sqlite3 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid "${FABLE_GID}" fable \ + && useradd --system --uid "${FABLE_UID}" --gid fable --home-dir /app --shell /usr/sbin/nologin fableuser \ + && mkdir -p /app/data \ + && chown -R fableuser:fable /app + +COPY --from=dependencies /install/ /usr/local/ +WORKDIR /app +COPY backend/ ./ + +USER fableuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl --fail --silent http://127.0.0.1:8000/api/v1/health || exit 1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/api/v1/endpoints/auth.py b/backend/api/v1/endpoints/auth.py index 161df7c..5070730 100644 --- a/backend/api/v1/endpoints/auth.py +++ b/backend/api/v1/endpoints/auth.py @@ -1,6 +1,15 @@ -from fastapi import APIRouter, Header, HTTPException, status -from schemas import RegisterRequest, LoginRequest, AuthResponse, UserDTO -from services import auth_service +from fastapi import APIRouter, Header, status +from schemas import ( + RegisterRequest, + LoginRequest, + ProfileUpdateRequest, + AuthResponse, + StoryDTO, + UserDTO, + ReadingSessionRequest, + ReadingStatsDTO, +) +from services import auth_service, story_service, reading_stats router = APIRouter() @@ -14,11 +23,40 @@ def login(req: LoginRequest): @router.get("/auth/me", response_model=UserDTO) def get_me(authorization: str = Header(None)): - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing or invalid Authorization header. Expected 'Bearer '" - ) + return auth_service.get_current_user_from_header(authorization) + + +@router.get("/auth/me/stories", response_model=list[StoryDTO]) +def get_my_stories(authorization: str = Header(None)): + user = auth_service.get_current_user_from_header(authorization) + return story_service.get_user_stories(user.id) + + + +@router.patch("/auth/me", response_model=UserDTO) +def update_me(req: ProfileUpdateRequest, authorization: str = Header(None)): + auth_service.get_current_user_from_header(authorization) token = authorization.split(" ", 1)[1] - return auth_service.get_current_user(token) + return auth_service.update_current_user(token, req) + + +@router.post("/auth/me/reading-sessions", status_code=status.HTTP_204_NO_CONTENT) +def record_reading_session( + request: ReadingSessionRequest, + authorization: str = Header(None), +): + user = auth_service.get_current_user_from_header(authorization) + reading_stats.record_reading_session(user.id, request) + + +@router.get("/auth/me/stats", response_model=ReadingStatsDTO) +def get_reading_stats(authorization: str = Header(None)): + user = auth_service.get_current_user_from_header(authorization) + return reading_stats.get_reading_stats(user.id) + +@router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT) +def logout(authorization: str = Header(None)): + auth_service.get_current_user_from_header(authorization) + token = authorization.split(" ", 1)[1].strip() + auth_service.revoke_session(token) diff --git a/backend/api/v1/endpoints/gutenberg.py b/backend/api/v1/endpoints/gutenberg.py index bd19657..7e381fd 100644 --- a/backend/api/v1/endpoints/gutenberg.py +++ b/backend/api/v1/endpoints/gutenberg.py @@ -1,7 +1,7 @@ from typing import Optional -from fastapi import APIRouter, Query, status +from fastapi import APIRouter, Path, Query, status -from schemas import StoryDTO +from schemas import ChapterDTO, StoryDTO from services import gutenberg as gutenberg_service router = APIRouter() @@ -19,3 +19,13 @@ async def get_gutenberg_stories( search: Optional[str] = Query(default=None, description="Search term across title and author") ): return await gutenberg_service.get_gutenberg_stories(topic=topic, search=search) + + +@router.get("/public/gutenberg/{gutenberg_id}", response_model=StoryDTO) +async def get_gutenberg_story(gutenberg_id: int = Path(ge=1)): + return await gutenberg_service.get_gutenberg_story_by_id(gutenberg_id) + + +@router.get("/public/gutenberg/{gutenberg_id}/chapters", response_model=list[ChapterDTO]) +async def get_gutenberg_chapters(gutenberg_id: int = Path(ge=1)): + return await gutenberg_service.get_gutenberg_chapters(gutenberg_id=gutenberg_id) diff --git a/backend/api/v1/endpoints/shelf.py b/backend/api/v1/endpoints/shelf.py index d9f06ba..876c7a7 100644 --- a/backend/api/v1/endpoints/shelf.py +++ b/backend/api/v1/endpoints/shelf.py @@ -1,14 +1,19 @@ from uuid import UUID -from fastapi import APIRouter, Query +from fastapi import APIRouter, Header, Query from schemas import ShelfSyncPayload, ShelfSyncResponse, ShelfSyncItemDTO -from services import shelf_sync +from services import auth_service, shelf_sync router = APIRouter() @router.post("/shelf/sync", response_model=ShelfSyncResponse) -def sync_shelf(payload: ShelfSyncPayload): - return shelf_sync.sync_shelf(payload) +def sync_shelf(payload: ShelfSyncPayload, authorization: str = Header(None)): + user = auth_service.get_current_user_from_header(authorization) + return shelf_sync.sync_shelf(payload, user.id) @router.get("/shelf", response_model=list[ShelfSyncItemDTO]) -def get_shelf(device_id: UUID = Query(..., alias="deviceId")): - return shelf_sync.get_shelf(device_id) +def get_shelf( + device_id: UUID = Query(..., alias="deviceId"), + authorization: str = Header(None), +): + user = auth_service.get_current_user_from_header(authorization) + return shelf_sync.get_shelf(device_id, user.id) diff --git a/backend/api/v1/endpoints/stories.py b/backend/api/v1/endpoints/stories.py index c576e2c..ffc0ca1 100644 --- a/backend/api/v1/endpoints/stories.py +++ b/backend/api/v1/endpoints/stories.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional from uuid import UUID -from fastapi import APIRouter, status +from fastapi import APIRouter, Header, status from schemas import ( StoryDTO, @@ -11,7 +11,7 @@ UpdateFeedDTO, CreateStoryRequest ) -from services import story_service +from services import auth_service, story_service router = APIRouter() @@ -48,5 +48,6 @@ def get_story_chapter_by_number(story_id: UUID, chapter_number: int): return story_service.get_story_chapter_by_number(story_id, chapter_number) @router.post("/stories", response_model=StoryDTO, status_code=status.HTTP_201_CREATED) -def create_story(payload: CreateStoryRequest): - return story_service.create_story(payload) +def create_story(payload: CreateStoryRequest, authorization: str = Header(None)): + user = auth_service.get_current_user_from_header(authorization) + return story_service.create_story(payload, author=user.name, owner_user_id=user.id) diff --git a/backend/core/__init__.py b/backend/core/__init__.py index 610f010..0ebfed0 100644 --- a/backend/core/__init__.py +++ b/backend/core/__init__.py @@ -1,7 +1,3 @@ -from .database import get_db, init_db, DB_PATH -from .seed_catalog import SEED_CATALOG - -SEED_STORIES = SEED_CATALOG - -__all__ = ["get_db", "init_db", "DB_PATH", "SEED_CATALOG", "SEED_STORIES"] +from .database import DB_PATH, get_db, init_db +__all__ = ["get_db", "init_db", "DB_PATH"] diff --git a/backend/core/database.py b/backend/core/database.py index 0f4a128..5bfc540 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -1,11 +1,21 @@ import sqlite3 import os -import json -from datetime import datetime, timezone -from uuid import uuid4 -from .seed_catalog import SEED_CATALOG DB_PATH = os.environ.get("FABLE_DB_PATH", "fable.sqlite3") +LEGACY_DEMO_STORY_IDS = ( + "66666666-6666-6666-6666-666666666666", + "77777777-7777-7777-7777-777777777777", + "88888888-8888-8888-8888-888888888888", + "99999999-9999-9999-9999-999999999999", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "cccccccc-cccc-cccc-cccc-cccccccccccc", + "dddddddd-dddd-dddd-dddd-dddddddddddd", + "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + "10101010-1010-1010-1010-101010101010", + "20202020-2020-2020-2020-202020202020", +) def get_db(): # Dynamic lookup so tests can override os.environ["FABLE_DB_PATH"] @@ -34,19 +44,22 @@ def init_db(): cover_image_name TEXT, hero_image_name TEXT, cover_image_url TEXT, - total_pages INTEGER DEFAULT 5, + total_pages INTEGER DEFAULT 0, current_page INTEGER DEFAULT 1, progress_percent INTEGER DEFAULT 0, - rating REAL DEFAULT 4.9, - saves_count TEXT DEFAULT '1.2k', - reads_count TEXT DEFAULT '1.2k', + rating REAL DEFAULT NULL, + saves_count TEXT DEFAULT '0', + reads_count TEXT DEFAULT '0', is_tale_of_the_day INTEGER DEFAULT 0, is_recent_submission INTEGER DEFAULT 0, is_curator_spotlight INTEGER DEFAULT 0, badge_text TEXT, - total_chapters INTEGER DEFAULT 1, + total_chapters INTEGER DEFAULT 0, content_format TEXT DEFAULT 'PROSE', - source_provider TEXT DEFAULT 'FABLE_ORIGINAL' + source_provider TEXT DEFAULT 'FABLE_ORIGINAL', + provider_id TEXT, + provider_download_count INTEGER, + owner_user_id TEXT ); """) conn.execute(""" @@ -62,15 +75,20 @@ def init_db(): FOREIGN KEY(story_id) REFERENCES stories(id) ON DELETE CASCADE ); """) - # Dynamic schema migration for existing databases - for col_name, col_def in [("content_format", "TEXT DEFAULT 'PROSE'"), ("source_provider", "TEXT DEFAULT 'FABLE_ORIGINAL'")]: + for col_name, col_def in ( + ("content_format", "TEXT DEFAULT 'PROSE'"), + ("source_provider", "TEXT DEFAULT 'FABLE_ORIGINAL'"), + ("provider_id", "TEXT"), + ("provider_download_count", "INTEGER"), + ("owner_user_id", "TEXT"), + ): try: conn.execute(f"ALTER TABLE stories ADD COLUMN {col_name} {col_def}") - except Exception: + except sqlite3.OperationalError: pass try: conn.execute("ALTER TABLE chapters ADD COLUMN page_urls TEXT DEFAULT '[]'") - except Exception: + except sqlite3.OperationalError: pass conn.execute(""" CREATE TABLE IF NOT EXISTS shelf_items ( @@ -84,55 +102,76 @@ def init_db(): ); """) conn.execute("CREATE INDEX IF NOT EXISTS idx_shelf_items_device ON shelf_items (device_id);") + conn.execute(""" + CREATE TABLE IF NOT EXISTS account_shelf_items ( + owner_user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + story_id TEXT NOT NULL, + reading_progress REAL NOT NULL, + is_bookmarked INTEGER NOT NULL DEFAULT 0, + is_completed INTEGER NOT NULL DEFAULT 0, + updated_at_utc TEXT NOT NULL, + PRIMARY KEY (owner_user_id, device_id, story_id) + ); + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_account_shelf_owner_device ON account_shelf_items (owner_user_id, device_id);") + conn.execute("CREATE INDEX IF NOT EXISTS idx_account_shelf_story ON account_shelf_items (story_id);") + conn.execute(""" + CREATE TABLE IF NOT EXISTS reading_sessions ( + owner_user_id TEXT NOT NULL, + id TEXT NOT NULL, + story_id TEXT NOT NULL, + seconds_read INTEGER NOT NULL CHECK (seconds_read >= 3), + read_at_utc TEXT NOT NULL, + is_completed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (owner_user_id, id) + ); + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_sessions_owner_date ON reading_sessions (owner_user_id, read_at_utc);") conn.execute(""" CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, name TEXT NOT NULL, - avatar_image_name TEXT DEFAULT 'avatar_roosc', + handle TEXT NOT NULL DEFAULT '', + bio TEXT NOT NULL DEFAULT '', + avatar_image_name TEXT, avatar_image_url TEXT, created_at_utc TEXT NOT NULL, updated_at_utc TEXT NOT NULL ); """) + for col_name, col_def in ( + ("handle", "TEXT NOT NULL DEFAULT ''"), + ("bio", "TEXT NOT NULL DEFAULT ''"), + ): + try: + conn.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_def}") + except sqlite3.OperationalError: + pass conn.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);") + conn.execute(""" + CREATE TABLE IF NOT EXISTS user_sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at_utc TEXT NOT NULL, + expires_at_utc TEXT NOT NULL, + revoked_at_utc TEXT + ); + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions (user_id);") conn.execute("CREATE INDEX IF NOT EXISTS idx_chapters_story_id ON chapters (story_id);") - cursor = conn.execute("SELECT COUNT(*) as count FROM stories") - if cursor.fetchone()["count"] == 0: - now_iso = datetime.now(timezone.utc).isoformat() - - for s in SEED_CATALOG: - total_chapters = len(s.get("chapters", [])) - conn.execute(""" - INSERT INTO stories ( - id, title, author, genre, chapter, synopsis, content, - read_time_minutes, is_bookmarked, is_completed, created_at_utc, updated_at_utc, - cover_image_name, hero_image_name, cover_image_url, is_tale_of_the_day, - is_recent_submission, is_curator_spotlight, badge_text, total_chapters, - content_format, source_provider - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - s["id"], s["title"], s["author"], s["genre"], s["chapter"], - s["synopsis"], s["content"], s["read_time_minutes"], 0, 0, - now_iso, now_iso, s.get("cover_image_name"), s.get("hero_image_name"), - s.get("cover_image_url"), s.get("is_tale_of_the_day", 0), - s.get("is_recent_submission", 0), s.get("is_curator_spotlight", 0), - s.get("badge_text"), max(1, total_chapters), - s.get("content_format", "PROSE"), s.get("source_provider", "FABLE_ORIGINAL") - )) + # Remove only the exact IDs from the retired bundled demo catalog. + placeholders = ", ".join("?" for _ in LEGACY_DEMO_STORY_IDS) + conn.execute( + f"DELETE FROM chapters WHERE story_id IN ({placeholders})", + LEGACY_DEMO_STORY_IDS, + ) + conn.execute( + f"DELETE FROM stories WHERE id IN ({placeholders})", + LEGACY_DEMO_STORY_IDS, + ) - for ch in s.get("chapters", []): - ch_id = str(uuid4()) - ch_content = ch.get("content", "") - words = len(ch_content.split()) if ch_content else 0 - page_urls_json = json.dumps(ch.get("page_urls", [])) - conn.execute(""" - INSERT INTO chapters ( - id, story_id, chapter_number, title, content, word_count, created_at_utc, page_urls - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, ( - ch_id, s["id"], ch["number"], ch["title"], ch_content, words, now_iso, page_urls_json - )) conn.close() diff --git a/backend/core/seed_catalog.py b/backend/core/seed_catalog.py deleted file mode 100644 index af5dd2b..0000000 --- a/backend/core/seed_catalog.py +++ /dev/null @@ -1,335 +0,0 @@ -""" -Curated authentic literary suite with full chapter extractions -for Fable's offline-first seed database. -""" - -SEED_CATALOG = [ - { - "id": "66666666-6666-6666-6666-666666666666", - "title": "Dracula", - "author": "Bram Stoker", - "genre": "Gothic", - "chapter": "Chapter I", - "synopsis": "Jonathan Harker journeys to the Carpathian mountains to seal a real estate transaction with Count Dracula, only to discover horrifying secrets within Castle Dracula.", - "content": "Before the sun had set, we reached the Bistritz pass. The grey of the evening had begun to fall, and the shadows of the mountains seemed to close in around us with every mile. The horses began to strain against the harness as the road turned sharply upward into the deep pine forests of Transylvania.", - "read_time_minutes": 7, - "cover_image_name": "cover_dracula", - "hero_image_name": "hero_dracula", - "cover_image_url": "https://www.gutenberg.org/cache/epub/345/pg345.cover.medium.jpg", - "is_tale_of_the_day": 1, - "is_recent_submission": 1, - "is_curator_spotlight": 0, - "badge_text": "FEATURED MASTERPIECE", - "chapters": [ - { - "number": 1, - "title": "CHAPTER I: Jonathan Harker's Journal", - "content": "3 May. Bistritz.--Left Munich at 8:35 P. M., on 1st May, arriving at Vienna early next morning; should have arrived at 6:46, but train was an hour late. Buda-Pesth seems a wonderful place, from the glimpse which I got of it from the train and the little I could see of the streets. I feared to go very far from the station, as we had arrived late and would start as near the correct time as possible. The impression I had was that we were leaving the West and entering the East; the most western of splendid bridges over the Danube, which is here of noble width and depth, took us among the traditions of Turkish rule.\n\nWe left in pretty good time, and came after nightfall to Klausenburgh. Here I stopped for the night at the Hotel Royale. I had for dinner, or rather supper, a chicken done up some way with red pepper, which was very good but thirsty. (Mem., get recipe for Mina.) I asked the waiter, and he said it was called 'paprika hendl', and that, as it was a national dish, I should be able to get it anywhere along the Carpathians.\n\nI found my smattering of German very useful; indeed, I don't know how I should be able to get on without it." - }, - { - "number": 2, - "title": "CHAPTER II: Jonathan Harker's Journal", - "content": "5 May.--I must have been asleep, for certainly if I had not dreamt, the night would have seemed endless. I awoke in my own bed. If it be that I had not dreamt, the Count must have carried me here. I tried to satisfy myself that it was all a nightmare, but I got up and looked out of the window. The castle was bathed in cold moonlight, and every detail stood out sharp and clear. Across the courtyard, I saw Count Dracula's tall figure emerging from a window below. With horror, I saw him crawl face downwards down the castle wall over that dreadful abyss, face down, just as a lizard moves along a wall!" - }, - { - "number": 3, - "title": "CHAPTER III: The Three Sisters", - "content": "When I found that I was a prisoner a sort of wild feeling came over me. I rushed up and down the stairs, trying every door and peering out of every window. In the south corridor, I found a room where the furniture was comfortable. The evening sunlight was warm, and in the stillness I must have fallen asleep. When I opened my eyes, three young women stood before me, their teeth gleaming white like pearls against the ruby of their voluptuous lips. One said: 'Go on! You are first, and we shall follow; yours is the right to begin.'" - } - ] - }, - { - "id": "77777777-7777-7777-7777-777777777777", - "title": "The Legend of Sleepy Hollow", - "author": "Washington Irving", - "genre": "Folklore", - "chapter": "Chapter I", - "synopsis": "A drowsy, dreamy influence hangs over Sleepy Hollow, where schoolmaster Ichabod Crane encounters the legendary Headless Horseman.", - "content": "In the bosom of one of those spacious coves which indent the eastern shore of the Hudson, at that broad expansion of the river denominated by the ancient Dutch navigators the Tappan Zee, there lies a small market town or rural port, which by some is called Greensburgh, but which is more generally and properly known by the name of Tarry Town.", - "read_time_minutes": 5, - "cover_image_name": "cover_sleepy", - "hero_image_name": "cover_sleepy", - "cover_image_url": "https://www.gutenberg.org/cache/epub/41/pg41.cover.medium.jpg", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 1, - "badge_text": "CURATOR PICK", - "chapters": [ - { - "number": 1, - "title": "Chapter I: The Valley of Slumber", - "content": "A drowsy, dreamy influence seems to hang over the land, and to pervade the very atmosphere. Some say that the place was bewitched by a High German doctor, during the early days of the settlement; others, that an old Indian chief, the prophet or wizard of his tribe, held his powwows there before the country was discovered by Master Hendrick Hudson. Certain it is, the place still continues under the sway of some bewitching power, that holds a spell over the minds of the good people, causing them to walk in a continual reverie.\n\nThe dominant spirit, however, that haunts this enchanted region, and seems to be commander-in-chief of all the powers of the air, is the apparition of a figure on horseback, without a head. It is said by some to be the ghost of a Hessian trooper, whose head had been carried away by a cannon-ball, in some nameless battle during the Revolutionary War." - }, - { - "number": 2, - "title": "Chapter II: The Chase at the Bridge", - "content": "It was the very witching time of night that Ichabod, heavy-hearted and crestfallen, pursued his travels homewards. All the stories of ghosts and goblins that he had heard now came crowding upon his recollection. The night grew darker and darker; the stars seemed to sink deeper in the sky. In the dark shadow of the grove, on the margin of the brook, he beheld something huge, misshapen, black and towering. It stirred not, but seemed gathered up in the gloom, like some gigantic monster ready to spring upon the traveller. Ichabod's hair rose upon his head with terror. The mysterious horseman began to gallop on the other side of the road, keeping pace with Gunpowder!" - } - ] - }, - { - "id": "88888888-8888-8888-8888-888888888888", - "title": "The Metamorphosis", - "author": "Franz Kafka", - "genre": "Classic Fiction", - "chapter": "Chapter I", - "synopsis": "Gregor Samsa awakens one morning from troubled dreams to discover he has transformed into a monstrous insect.", - "content": "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a monstrous vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections.", - "read_time_minutes": 6, - "cover_image_name": "cover_metamorphosis", - "hero_image_name": "cover_metamorphosis", - "cover_image_url": "https://www.gutenberg.org/cache/epub/5200/pg5200.cover.medium.jpg", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 0, - "badge_text": None, - "chapters": [ - { - "number": 1, - "title": "Chapter I: The Awakening", - "content": "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a monstrous vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment. His many legs, pitifully thin compared with the size of the rest of him, waved about helplessly as he looked.\n\n'What's happened to me?' he thought. It wasn't a dream. His room, a proper human room although a little too small, lay peacefully between its four familiar walls." - }, - { - "number": 2, - "title": "Chapter II: The Household Dilemma", - "content": "It was not until dusk that Gregor awoke from his deep and coma-like sleep. He would certainly have woken not much later without being disturbed, as he felt he had rested and had enough sleep, but it seemed to him that he had been woken by hurried steps and the sound of the door to the hall being cautiously shut. The light of the electric street lamps shone in sickly pale patches onto the ceiling and the upper parts of the furniture, but down where Gregor was it was dark." - }, - { - "number": 3, - "title": "Chapter III: Departure", - "content": "Gregor's serious wound, from which he suffered for over a month—the apple remained lodged in his flesh as a visible reminder, since no one dared remove it—seemed to have reminded even his father that Gregor was a member of the family, in spite of his present pathetic and disgusting shape, who could not be treated as an enemy; that, on the contrary, family duty required them to swallow their disgust and endure him, nothing more." - } - ] - }, - { - "id": "99999999-9999-9999-9999-999999999999", - "title": "The Tell-Tale Heart", - "author": "Edgar Allan Poe", - "genre": "Gothic", - "chapter": "Chapter I", - "synopsis": "An unnamed narrator insists upon their sanity while chronicling the methodical murder of an old man with a vulture eye.", - "content": "True! — nervous — very, very dreadfully nervous I had been and am; but why will you say that I am mad? The disease had sharpened my senses — not destroyed — not dulled them. Above all was the sense of hearing acute.", - "read_time_minutes": 4, - "cover_image_name": "cover_telltale", - "hero_image_name": "cover_telltale", - "cover_image_url": "https://www.gutenberg.org/cache/epub/2148/pg2148.cover.medium.jpg", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 0, - "badge_text": None, - "chapters": [ - { - "number": 1, - "title": "Chapter I: The Vulture Eye", - "content": "True! — nervous — very, very dreadfully nervous I had been and am; but why will you say that I am mad? The disease had sharpened my senses — not destroyed — not dulled them. Above all was the sense of hearing acute. I heard all things in the heaven and in the earth. I heard many things in hell. How, then, am I mad? Hearken! and observe how healthily — how calmly I can tell you the whole story.\n\nIt is impossible to say how first the idea entered my brain; but once conceived, it haunted me day and night. Object there was none. Passion there was none. I loved the old man. He had never wronged me. He had never given me insult. For his gold I had no desire. I think it was his eye! yes, it was this! He had the eye of a vulture — a pale blue eye, with a film over it. Whenever it fell upon me, my blood ran cold; and so by degrees — very gradually — I made up my mind to take the life of the old man, and thus rid myself of the eye forever." - }, - { - "number": 2, - "title": "Chapter II: Beneath the Floorboards", - "content": "The officers were satisfied. My manner had convinced them. I was singularly at ease. They sat, and while I answered cheerily, they chatted of familiar things. But, ere long, I felt myself getting pale and wished them gone. My head ached, and I fancied a ringing in my ears: but still they sat and still chatted. The ringing became more distinct: — It continued and became more distinct: I talked more freely to get rid of the feeling: but it continued and gained definiteness — until, at length, I found that the noise was not within my ears.\n\nNo doubt I now grew very pale; — but I talked more fluently, and with a heightened voice. Yet the sound increased — and what could I do? It was a low, dull, quick sound — much such a sound as a watch makes when enveloped in cotton." - } - ] - }, - { - "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - "title": "The Legend of Maria Makiling", - "author": "Jose Rizal", - "genre": "Folklore", - "chapter": "Chapter I", - "synopsis": "The celestial guardian of Mount Makiling blesses the villagers with harvest and protection until betrayal silences her valley.", - "content": "She was a fantastic creature, half nymph, half sylph, born under the moonbeams in the mystery of ancient woods... Her voice was like the murmur of crystal water over white pebbles, and her step was as light as the dewdrop falling upon a leaf at dawn.", - "read_time_minutes": 5, - "cover_image_name": "cover_maria", - "hero_image_name": "cover_maria", - "cover_image_url": "https://www.gutenberg.org/cache/epub/38269/pg38269.cover.medium.jpg", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 0, - "badge_text": None, - "chapters": [ - { - "number": 1, - "title": "Chapter I: The Maiden of the Mountain", - "content": "According to common belief, Mount Makiling is haunted by a fantastic creature, half nymph, half sylph, called Maria Makiling. She was a beautiful maiden who never grew old. The few hunters and woodsmen who had caught sight of her described her as tall, graceful, with large, luminous black eyes and long, abundant hair that fell almost to her ankles.\n\nShe was known to be kind and compassionate to the poor villagers. When poor folk asked for a ginger root from her garden on the mountainside, it would turn into solid gold by the time they reached their humble huts at the foot of the volcano." - } - ] - }, - { - "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", - "title": "Frankenstein", - "author": "Mary Shelley", - "genre": "Gothic", - "chapter": "Chapter I", - "synopsis": "Victor Frankenstein uncovers the secret of animation, giving life to a creature that turns into his relentless tormentor.", - "content": "You will rejoice to hear that no disaster has accompanied the commencement of an enterprise which you have regarded with such evil forebodings. I arrived here yesterday, and my first task is to assure my dear sister of my welfare.", - "read_time_minutes": 8, - "cover_image_name": "cover_dracula", - "hero_image_name": "hero_dracula", - "cover_image_url": "https://www.gutenberg.org/cache/epub/84/pg84.cover.medium.jpg", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 0, - "badge_text": None, - "chapters": [ - { - "number": 1, - "title": "Chapter I: The Spark of Being", - "content": "It was on a dreary night of November that I beheld the accomplishment of my toils. With an anxiety that almost amounted to agony, I collected the instruments of life around me, that I might infuse a spark of being into the lifeless thing that lay at my feet. It was already one in the morning; the rain pattered dismally against the panes, and my candle was nearly burnt out, when, by the glimmer of the half-extinguished light, I saw the dull yellow eye of the creature open; it breathed hard, and a convulsive motion agitated its limbs." - } - ] - }, - { - "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", - "title": "The Odyssey", - "author": "Homer", - "genre": "Mythology", - "chapter": "Book I", - "synopsis": "Ten years after the fall of Troy, Odysseus wanders across treacherous seas while Penelope fends off suitors in Ithaca.", - "content": "Tell me, O Muse, of that ingenious hero who travelled far and wide after he had sacked the famous town of Troy. Many cities did he visit, and many were the nations with whose manners and customs he was acquainted.", - "read_time_minutes": 9, - "cover_image_name": "cover_dracula", - "hero_image_name": "hero_dracula", - "cover_image_url": "https://www.gutenberg.org/cache/epub/1727/pg1727.cover.medium.jpg", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 0, - "badge_text": None, - "chapters": [ - { - "number": 1, - "title": "Book I: The Council of the Gods", - "content": "Tell me, O Muse, of that ingenious hero who travelled far and wide after he had sacked the famous town of Troy. Many cities did he visit, and many were the nations with whose manners and customs he was acquainted; moreover he suffered much by sea while trying to save his own life and bring his men home safely. But he could not save his men, for they perished through their own sheer folly in eating the cattle of the Sun-god Hyperion; so the god prevented them from ever reaching home." - } - ] - }, - { - "id": "dddddddd-dddd-dddd-dddd-dddddddddddd", - "title": "Alice's Adventures in Wonderland", - "author": "Lewis Carroll", - "genre": "Classic Fiction", - "chapter": "Chapter I", - "synopsis": "A curious young girl follows a White Rabbit down a rabbit-hole into a nonsensical underground dream world.", - "content": "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it.", - "read_time_minutes": 5, - "cover_image_name": "cover_metamorphosis", - "hero_image_name": "cover_metamorphosis", - "cover_image_url": "https://www.gutenberg.org/cache/epub/11/pg11.cover.medium.jpg", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 0, - "badge_text": None, - "chapters": [ - { - "number": 1, - "title": "CHAPTER I: Down the Rabbit-Hole", - "content": "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, 'and what is the use of a book,' thought Alice 'without pictures or conversations?'\n\nSo she was considering in her own mind (as well as she could, for the hot day made her feel very sleepy and stupid), whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies, when suddenly a White Rabbit with pink eyes ran close by her." - } - ] - }, - { - "id": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", - "title": "The Picture of Dorian Gray", - "author": "Oscar Wilde", - "genre": "Gothic", - "chapter": "Chapter I", - "synopsis": "A young aristocrat trades his soul for eternal youth while his painted portrait bears the scars of his sins and decay.", - "content": "The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden, there came through the open door the heavy scent of the lilac.", - "read_time_minutes": 6, - "cover_image_name": "cover_dracula", - "hero_image_name": "hero_dracula", - "cover_image_url": "https://www.gutenberg.org/cache/epub/174/pg174.cover.medium.jpg", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 0, - "badge_text": None, - "chapters": [ - { - "number": 1, - "title": "CHAPTER I: The Studio Portrait", - "content": "The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn.\n\nFrom the corner of the divan of Persian saddle-bags on which he was lying, smoking, as was his custom, innumerable cigarettes, Lord Henry Wotton could just catch the gleam of the honey-sweet and honey-coloured blossoms of a laburnum, whose tremulous branches seemed hardly able to bear the weight of a beauty so flame-like as their own." - } - ] - }, - { - "id": "ffffffff-ffff-ffff-ffff-ffffffffffff", - "title": "Grimms' Fairy Tales", - "author": "Brothers Grimm", - "genre": "Folklore", - "chapter": "Chapter I", - "synopsis": "Timeless German folk tales gathered from rural oral tradition, featuring enchantments, golden balls, and talking frogs.", - "content": "One fine evening a young princess put on her bonnet and clogs, and went out to take a walk by herself in a wood; and when she came to a cool spring of water, that rose in the midst of it, she sat herself down to rest a while.", - "read_time_minutes": 5, - "cover_image_name": "cover_sleepy", - "hero_image_name": "cover_sleepy", - "cover_image_url": "https://www.gutenberg.org/cache/epub/2591/pg2591.cover.medium.jpg", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 0, - "badge_text": None, - "chapters": [ - { - "number": 1, - "title": "Chapter I: The Frog Prince", - "content": "One fine evening a young princess put on her bonnet and clogs, and went out to take a walk by herself in a wood; and when she came to a cool spring of water, that rose in the midst of it, she sat herself down to rest a while. Now she had a golden ball in her hand, which was her favourite plaything; and she was always tossing it up into the air, and catching it again as it fell.\n\nAfter a time she threw it up so high that she missed catching it as it fell; and the ball bounced away, and rolled along upon the ground, till at last it fell down into the spring. The water was very deep, so deep that she could not see the bottom of it. Then she began to bewail her loss, and said, 'Alas! if I could only get my ball again, I would give all my clothes and jewels, and everything that I have in the world.'" - } - ] - }, - { - "id": "10101010-1010-1010-1010-101010101010", - "title": "Chainsaw Devil: Special Edition", - "author": "Tatsuki Fujimoto", - "genre": "Manga", - "chapter": "Chapter 1", - "synopsis": "In a gritty neon metropolis where human fears manifest as living devils, an indebted hunter fights for survival alongside his faithful devil companion.", - "content": "", - "read_time_minutes": 8, - "cover_image_name": "cover_dracula", - "hero_image_name": "hero_dracula", - "cover_image_url": "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=800&auto=format&fit=crop", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 0, - "badge_text": "MANGA", - "content_format": "MANGA", - "source_provider": "MANGADEX", - "chapters": [ - { - "number": 1, - "title": "Chapter 1: The Contract", - "content": "", - "page_urls": [ - "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=800&auto=format&fit=crop", - "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=800&auto=format&fit=crop", - "https://images.unsplash.com/photo-1578632767115-351597cf2477?q=80&w=800&auto=format&fit=crop", - "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=800&auto=format&fit=crop" - ] - } - ] - }, - { - "id": "20202020-2020-2020-2020-202020202020", - "title": "The Metamorphosis", - "author": "Franz Kafka", - "genre": "Classic Fiction", - "chapter": "Part I", - "synopsis": "One morning, Gregor Samsa woke from uneasy dreams to find himself transformed into a monstrous insect.", - "content": "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin.", - "read_time_minutes": 7, - "cover_image_name": "cover_metamorphosis", - "hero_image_name": "cover_metamorphosis", - "cover_image_url": "https://standardebooks.org/ebooks/franz-kafka/the-metamorphosis/david-wyllie/downloads/cover.jpg", - "is_tale_of_the_day": 0, - "is_recent_submission": 1, - "is_curator_spotlight": 1, - "badge_text": "STANDARD EBOOKS", - "content_format": "PROSE", - "source_provider": "STANDARD_EBOOKS", - "chapters": [ - { - "number": 1, - "title": "Part I: The Awakening", - "content": "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections." - } - ] - } -] diff --git a/backend/models/models.py b/backend/models/models.py index 21e7ab3..1c2d6d8 100644 --- a/backend/models/models.py +++ b/backend/models/models.py @@ -31,17 +31,17 @@ class Story: cover_image_name: Optional[str] = None hero_image_name: Optional[str] = None cover_image_url: Optional[str] = None - total_pages: int = 5 + total_pages: int = 0 current_page: int = 1 progress_percent: int = 0 - rating: float = 4.9 - saves_count: str = "1.2k" - reads_count: str = "1.2k" + rating: Optional[float] = None + saves_count: str = "0" + reads_count: str = "0" is_tale_of_the_day: bool = False is_recent_submission: bool = False is_curator_spotlight: bool = False badge_text: Optional[str] = None - total_chapters: int = 1 + total_chapters: int = 0 content_format: str = "PROSE" source_provider: str = "FABLE_ORIGINAL" diff --git a/backend/schemas.py b/backend/schemas.py index f030cc8..394fbb6 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -11,7 +11,9 @@ ShelfSyncItemDTO, ShelfSyncPayload, ShelfSyncResponse, - HealthResponse + HealthResponse, + ReadingSessionRequest, + ReadingStatsDTO ) __all__ = [ @@ -24,5 +26,7 @@ "ShelfSyncItemDTO", "ShelfSyncPayload", "ShelfSyncResponse", - "HealthResponse" + "HealthResponse", + "ReadingSessionRequest", + "ReadingStatsDTO" ] diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py index 546d80c..def64c7 100644 --- a/backend/schemas/__init__.py +++ b/backend/schemas/__init__.py @@ -10,9 +10,12 @@ ShelfSyncResponse, HealthResponse, UserDTO, + ProfileUpdateRequest, LoginRequest, RegisterRequest, - AuthResponse + AuthResponse, + ReadingSessionRequest, + ReadingStatsDTO ) __all__ = [ @@ -27,7 +30,10 @@ "ShelfSyncResponse", "HealthResponse", "UserDTO", + "ProfileUpdateRequest", "LoginRequest", "RegisterRequest", - "AuthResponse" + "AuthResponse", + "ReadingSessionRequest", + "ReadingStatsDTO" ] diff --git a/backend/schemas/schemas.py b/backend/schemas/schemas.py index 2fd0fb8..cce304e 100644 --- a/backend/schemas/schemas.py +++ b/backend/schemas/schemas.py @@ -22,10 +22,10 @@ class StoryDTO(BaseModel): title: str = Field(..., min_length=1, max_length=120) author: str = Field(..., min_length=1, max_length=80) genre: str - chapter: str = "Chapter I" + chapter: str = "" synopsis: str content: str - read_time_minutes: int = Field(default=4, ge=1, serialization_alias="readTimeMinutes") + read_time_minutes: int = Field(default=0, ge=0, serialization_alias="readTimeMinutes") is_bookmarked: bool = Field(default=False, serialization_alias="isBookmarked") is_completed: bool = Field(default=False, serialization_alias="isCompleted") created_at_utc: datetime = Field(..., serialization_alias="createdAtUtc") @@ -33,19 +33,21 @@ class StoryDTO(BaseModel): cover_image_name: Optional[str] = Field(default=None, serialization_alias="coverImageName") hero_image_name: Optional[str] = Field(default=None, serialization_alias="heroImageName") cover_image_url: Optional[str] = Field(default=None, serialization_alias="coverImageUrl") - total_pages: int = Field(default=5, serialization_alias="totalPages") + total_pages: int = Field(default=0, serialization_alias="totalPages") current_page: int = Field(default=1, serialization_alias="currentPage") progress_percent: int = Field(default=0, serialization_alias="progressPercent") - rating: float = Field(default=4.9, serialization_alias="rating") - saves_count: str = Field(default="1.2k", serialization_alias="savesCount") - reads_count: str = Field(default="1.2k", serialization_alias="readsCount") + rating: Optional[float] = Field(default=None, serialization_alias="rating") + saves_count: str = Field(default="0", serialization_alias="savesCount") + reads_count: str = Field(default="0", serialization_alias="readsCount") is_tale_of_the_day: bool = Field(default=False, serialization_alias="isTaleOfTheDay") is_recent_submission: bool = Field(default=False, serialization_alias="isRecentSubmission") is_curator_spotlight: bool = Field(default=False, serialization_alias="isCuratorSpotlight") badge_text: Optional[str] = Field(default=None, serialization_alias="badgeText") - total_chapters: int = Field(default=1, serialization_alias="totalChapters") + total_chapters: int = Field(default=0, serialization_alias="totalChapters") content_format: str = Field(default="PROSE", serialization_alias="contentFormat") source_provider: str = Field(default="FABLE_ORIGINAL", serialization_alias="sourceProvider") + provider_id: Optional[str] = Field(default=None, serialization_alias="providerId") + provider_download_count: Optional[int] = Field(default=None, serialization_alias="providerDownloadCount") chapters: Optional[list[ChapterDTO]] = None model_config = { @@ -56,9 +58,9 @@ class GenreDTO(BaseModel): id: UUID name: str story_count: int = Field(default=0, serialization_alias="storyCount") - readers_count: str = Field(default="10k", serialization_alias="readersCount") - description: str - image_name: str = Field(default="genre_folklore", serialization_alias="imageName") + readers_count: str = Field(default="0", serialization_alias="readersCount") + description: str = "" + image_name: str = Field(default="", serialization_alias="imageName") image_url: Optional[str] = Field(default=None, serialization_alias="imageUrl") model_config = { @@ -68,10 +70,10 @@ class GenreDTO(BaseModel): class WriterDTO(BaseModel): id: UUID name: str - avatar_image_name: str = Field(default="author_kuang", serialization_alias="avatarImageName") + avatar_image_name: str = Field(default="", serialization_alias="avatarImageName") avatar_image_url: Optional[str] = Field(default=None, serialization_alias="avatarImageUrl") - story_count: int = Field(default=1, serialization_alias="storyCount") - rating: float = Field(default=4.9, serialization_alias="rating") + story_count: int = Field(default=0, serialization_alias="storyCount") + rating: Optional[float] = Field(default=None, serialization_alias="rating") model_config = { "populate_by_name": True @@ -90,17 +92,16 @@ class UpdateFeedDTO(BaseModel): class CreateStoryRequest(BaseModel): title: str = Field(..., min_length=1, max_length=120) - author: str = Field(..., min_length=1, max_length=80) genre: str - chapter: Optional[str] = "Chapter I" + chapter: Optional[str] = None synopsis: str content: str - read_time_minutes: int = Field(default=4, ge=1, alias="readTimeMinutes") + read_time_minutes: int = Field(default=0, ge=0, alias="readTimeMinutes") content_format: Optional[str] = Field(default="PROSE", alias="contentFormat") - source_provider: Optional[str] = Field(default="FABLE_ORIGINAL", alias="sourceProvider") model_config = { - "populate_by_name": True + "populate_by_name": True, + "extra": "forbid", } class ShelfSyncItemDTO(BaseModel): @@ -135,7 +136,9 @@ class UserDTO(BaseModel): id: UUID email: str name: str - avatar_image_name: str = Field(default="avatar_roosc", serialization_alias="avatarImageName") + handle: str = "" + bio: str = "" + avatar_image_name: Optional[str] = Field(default=None, serialization_alias="avatarImageName") avatar_image_url: Optional[str] = Field(default=None, serialization_alias="avatarImageUrl") created_at_utc: datetime = Field(..., serialization_alias="createdAtUtc") @@ -150,7 +153,13 @@ class LoginRequest(BaseModel): class RegisterRequest(BaseModel): email: str password: str = Field(..., min_length=6) - name: str = Field(..., min_length=1) + name: str = Field(..., min_length=1, max_length=80) + handle: str = Field(default="", max_length=40) + +class ProfileUpdateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=80) + handle: str = Field(default="", max_length=40) + bio: str = Field(default="", max_length=500) class AuthResponse(BaseModel): access_token: str = Field(..., serialization_alias="accessToken") @@ -161,6 +170,24 @@ class AuthResponse(BaseModel): "populate_by_name": True } + +class ReadingSessionRequest(BaseModel): + id: UUID + story_id: UUID = Field(..., alias="storyId", serialization_alias="storyId") + seconds_read: int = Field(..., ge=3, le=86400, alias="secondsRead", serialization_alias="secondsRead") + read_at_utc: datetime = Field(..., alias="readAtUtc", serialization_alias="readAtUtc") + is_completed: bool = Field(default=False, alias="isCompleted", serialization_alias="isCompleted") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class ReadingStatsDTO(BaseModel): + stories_read_count: int = Field(..., serialization_alias="storiesReadCount") + total_minutes_read: int = Field(..., serialization_alias="totalMinutesRead") + streak_days: int = Field(..., serialization_alias="streakDays") + + model_config = {"populate_by_name": True} + class HealthResponse(BaseModel): status: str = "healthy" database: str = "connected" diff --git a/backend/services/__init__.py b/backend/services/__init__.py index 9fb6105..c96a2c0 100644 --- a/backend/services/__init__.py +++ b/backend/services/__init__.py @@ -12,7 +12,7 @@ ) from .shelf_sync import sync_shelf, get_shelf from .gutenberg import ingest_gutenberg_book, get_gutenberg_stories -from . import auth_service +from . import auth_service, reading_stats __all__ = [ "extract_chapters_from_text", @@ -29,5 +29,6 @@ "get_shelf", "ingest_gutenberg_book", "get_gutenberg_stories", - "auth_service" + "auth_service", + "reading_stats" ] diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index 2656d6f..5b21a6c 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -1,14 +1,40 @@ import hashlib import hmac import secrets -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from uuid import UUID, uuid4 +from typing import Optional from fastapi import HTTPException, status from core.database import get_db -from schemas.schemas import UserDTO, RegisterRequest, LoginRequest, AuthResponse +from schemas.schemas import UserDTO, RegisterRequest, LoginRequest, AuthResponse, ProfileUpdateRequest -# In-memory session store mapping active bearer token -> user_id -ACTIVE_SESSIONS: dict[str, str] = {} +SESSION_LIFETIME = timedelta(days=30) + + +def _avatar_image_name(row) -> Optional[str]: + value = row["avatar_image_name"] + return None if value == "avatar_roosc" else value + + + +def _to_user_dto(row) -> UserDTO: + return UserDTO( + id=UUID(row["id"]), + email=row["email"], + name=row["name"], + handle=row["handle"], + bio=row["bio"], + avatar_image_name=_avatar_image_name(row), + avatar_image_url=row["avatar_image_url"], + created_at_utc=datetime.fromisoformat(row["created_at_utc"]) + ) + + +def _normalized_handle(value: str) -> str: + handle = value.strip() + if not handle: + return "" + return handle if handle.startswith("@") else f"@{handle}" def hash_password(password: str) -> str: """Hash password using PBKDF2-HMAC-SHA256 with 100,000 iterations and 16-byte random salt.""" @@ -25,6 +51,36 @@ def verify_password(stored_hash: str, password: str) -> bool: except (ValueError, AttributeError): return False +def _token_digest(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def _issue_session(user_id: str) -> str: + token = secrets.token_urlsafe(32) + now = datetime.now(timezone.utc) + conn = get_db() + try: + with conn: + conn.execute( + "DELETE FROM user_sessions WHERE expires_at_utc <= ? OR revoked_at_utc < ?", + (now.isoformat(), (now - timedelta(days=30)).isoformat()), + ) + conn.execute( + """INSERT INTO user_sessions + (token_hash, user_id, created_at_utc, expires_at_utc, revoked_at_utc) + VALUES (?, ?, ?, ?, NULL)""", + ( + _token_digest(token), + user_id, + now.isoformat(), + (now + SESSION_LIFETIME).isoformat(), + ), + ) + finally: + conn.close() + return token + + def register_user(req: RegisterRequest) -> AuthResponse: """Register a new user account with validated credentials and return authentication token.""" normalized_email = req.email.strip().lower() @@ -42,21 +98,19 @@ def register_user(req: RegisterRequest) -> AuthResponse: now = datetime.now(timezone.utc).isoformat() pwd_hash = hash_password(req.password) conn.execute(""" - INSERT INTO users (id, email, password_hash, name, avatar_image_name, created_at_utc, updated_at_utc) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, (user_id, normalized_email, pwd_hash, req.name.strip(), "avatar_roosc", now, now)) + INSERT INTO users (id, email, password_hash, name, handle, bio, avatar_image_name, created_at_utc, updated_at_utc) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (user_id, normalized_email, pwd_hash, req.name.strip(), _normalized_handle(req.handle), "", None, now, now)) finally: conn.close() - token = secrets.token_urlsafe(32) - ACTIVE_SESSIONS[token] = user_id - user_dto = UserDTO( - id=UUID(user_id), - email=normalized_email, - name=req.name.strip(), - avatar_image_name="avatar_roosc", - created_at_utc=datetime.fromisoformat(now) - ) + token = _issue_session(user_id) + conn = get_db() + try: + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + finally: + conn.close() + user_dto = _to_user_dto(row) return AuthResponse(access_token=token, token_type="bearer", user=user_dto) def login_user(req: LoginRequest) -> AuthResponse: @@ -72,42 +126,74 @@ def login_user(req: LoginRequest) -> AuthResponse: detail="Invalid email or password" ) - token = secrets.token_urlsafe(32) - ACTIVE_SESSIONS[token] = row["id"] - user_dto = UserDTO( - id=UUID(row["id"]), - email=row["email"], - name=row["name"], - avatar_image_name=row["avatar_image_name"] or "avatar_roosc", - avatar_image_url=row["avatar_image_url"], - created_at_utc=datetime.fromisoformat(row["created_at_utc"]) - ) + token = _issue_session(row["id"]) + user_dto = _to_user_dto(row) return AuthResponse(access_token=token, token_type="bearer", user=user_dto) def get_current_user(token: str) -> UserDTO: - """Retrieve user entity associated with an active bearer token.""" - user_id = ACTIVE_SESSIONS.get(token) - if not user_id: + """Retrieve a user through a persisted, expiring bearer-session digest.""" + now = datetime.now(timezone.utc).isoformat() + conn = get_db() + try: + row = conn.execute( + """SELECT users.* FROM user_sessions + JOIN users ON users.id = user_sessions.user_id + WHERE user_sessions.token_hash = ? + AND user_sessions.revoked_at_utc IS NULL + AND user_sessions.expires_at_utc > ?""", + (_token_digest(token), now), + ).fetchone() + finally: + conn.close() + + if not row: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session token" ) + return _to_user_dto(row) - conn = get_db() - row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() - conn.close() - if not row: +def get_current_user_from_header(authorization: Optional[str]) -> UserDTO: + if not authorization or not authorization.startswith("Bearer "): raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found" + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing or invalid Authorization header. Expected 'Bearer '", ) + token = authorization.split(" ", 1)[1].strip() + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session token") + return get_current_user(token) - return UserDTO( - id=UUID(row["id"]), - email=row["email"], - name=row["name"], - avatar_image_name=row["avatar_image_name"] or "avatar_roosc", - avatar_image_url=row["avatar_image_url"], - created_at_utc=datetime.fromisoformat(row["created_at_utc"]) - ) + +def revoke_session(token: str) -> None: + now = datetime.now(timezone.utc).isoformat() + conn = get_db() + try: + with conn: + conn.execute( + "UPDATE user_sessions SET revoked_at_utc = ? WHERE token_hash = ? AND revoked_at_utc IS NULL", + (now, _token_digest(token)), + ) + finally: + conn.close() + + +def update_current_user(token: str, req: ProfileUpdateRequest) -> UserDTO: + user_id = str(get_current_user(token).id) + + now = datetime.now(timezone.utc).isoformat() + conn = get_db() + try: + with conn: + result = conn.execute( + """UPDATE users SET name = ?, handle = ?, bio = ?, updated_at_utc = ? WHERE id = ?""", + (req.name.strip(), _normalized_handle(req.handle), req.bio.strip(), now, user_id) + ) + if result.rowcount != 1: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + finally: + conn.close() + + return _to_user_dto(row) diff --git a/backend/services/gutenberg.py b/backend/services/gutenberg.py index a83a15a..91fc994 100644 --- a/backend/services/gutenberg.py +++ b/backend/services/gutenberg.py @@ -1,111 +1,268 @@ import httpx from datetime import datetime, timezone -from uuid import UUID, uuid4 from typing import Optional +from uuid import UUID, uuid5 from fastapi import HTTPException from core.database import get_db -from schemas.schemas import StoryDTO +from schemas.schemas import ChapterDTO, StoryDTO from services.text_parser import extract_chapters_from_text -from services.story_service import row_to_story_dto, get_stories +from services.story_service import row_to_story_dto + +GUTENBERG_CHAPTER_NAMESPACE = UUID("d3675b18-25d3-5aa7-9c4f-65ea90fb89b2") +PROVIDER_HEADERS = {"User-Agent": "FableReader/1.0 (public-domain-text-client)"} + + +async def _fetch_gutenberg_text(gutenberg_id: int) -> str: + url = f"https://www.gutenberg.org/ebooks/{gutenberg_id}.txt.utf-8" + try: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.get(url, headers=PROVIDER_HEADERS) + except httpx.HTTPError as error: + raise HTTPException(status_code=502, detail="Gutenberg text service is unavailable") from error + + if response.status_code == 404: + raise HTTPException(status_code=404, detail="Gutenberg book was not found") + if response.status_code != 200: + raise HTTPException(status_code=502, detail="Gutenberg text service returned an error") + if not response.text.strip(): + raise HTTPException(status_code=422, detail="Gutenberg book has no readable text") + return response.text + + +async def get_gutenberg_chapters(gutenberg_id: int) -> list[ChapterDTO]: + raw_text = await _fetch_gutenberg_text(gutenberg_id) + parsed_chapters = extract_chapters_from_text(raw_text) + if not parsed_chapters or not any(chapter["content"].strip() for chapter in parsed_chapters): + raise HTTPException(status_code=422, detail="Unable to extract readable chapters from Gutenberg text") + + story_id = UUID(int=gutenberg_id) + now = datetime.now(timezone.utc) + return [ + ChapterDTO( + id=uuid5(GUTENBERG_CHAPTER_NAMESPACE, f"{gutenberg_id}:{chapter['chapter_number']}"), + story_id=story_id, + chapter_number=chapter["chapter_number"], + title=chapter["title"], + content=chapter["content"], + word_count=chapter["word_count"], + created_at_utc=now, + ) + for chapter in parsed_chapters + ] + async def ingest_gutenberg_book( gutenberg_id: int, - genre: Optional[str] = "Folklore" + genre: Optional[str] = None, ) -> StoryDTO: - """ - Live Ingestion Engine: Fetches full plain text from Project Gutenberg, parses - actual chapters, extracts metadata, and stores into Fable database. - """ + metadata_url = "https://gutendex.com/books/" text_url = f"https://www.gutenberg.org/ebooks/{gutenberg_id}.txt.utf-8" - meta_url = f"https://gutendex.com/books/?ids={gutenberg_id}" - - title = f"Gutenberg Classic #{gutenberg_id}" - author = "Public Domain" - cover_url = f"https://www.gutenberg.org/cache/epub/{gutenberg_id}/pg{gutenberg_id}.cover.medium.jpg" - synopsis = f"Authentic public-domain edition #{gutenberg_id} from Project Gutenberg." - - headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"} - - async with httpx.AsyncClient(timeout=10.0) as client: - try: - meta_res = await client.get(meta_url, headers=headers) - if meta_res.status_code == 200: - results = meta_res.json().get("results", []) - if results: - book_meta = results[0] - title = book_meta.get("title", title) - authors = book_meta.get("authors", []) - if authors: - raw_name = authors[0].get("name", author) - if ", " in raw_name: - p = raw_name.split(", ", 1) - author = f"{p[1]} {p[0]}" - else: - author = raw_name - summaries = book_meta.get("summaries", []) - if summaries: - synopsis = summaries[0] - except Exception: - pass - - try: - txt_res = await client.get(text_url, headers=headers) - if txt_res.status_code != 200: - raise HTTPException(status_code=400, detail=f"Failed to fetch text from Gutenberg for ID {gutenberg_id}") - raw_text = txt_res.text - except Exception as e: - raise HTTPException(status_code=502, detail=f"External Gutenberg network error: {str(e)}") - - chapters = extract_chapters_from_text(raw_text) - if not chapters: - raise HTTPException(status_code=422, detail="Unable to extract chapters from manuscript text") - - story_id = str(uuid4()) + try: + async with httpx.AsyncClient(timeout=15.0) as client: + metadata_response = await client.get( + metadata_url, + params={"ids": str(gutenberg_id)}, + headers=PROVIDER_HEADERS, + ) + if metadata_response.status_code != 200: + raise HTTPException(status_code=502, detail="Gutenberg catalog service returned an error") + results = metadata_response.json().get("results", []) + book = next((item for item in results if item.get("id") == gutenberg_id), None) + if not book: + raise HTTPException(status_code=404, detail="Gutenberg book was not found") + + text_response = await client.get(text_url, headers=PROVIDER_HEADERS) + if text_response.status_code == 404: + raise HTTPException(status_code=404, detail="Gutenberg text was not found") + if text_response.status_code != 200: + raise HTTPException(status_code=502, detail="Gutenberg text service returned an error") + raw_text = text_response.text + except httpx.HTTPError as error: + raise HTTPException(status_code=502, detail="Gutenberg services are unavailable") from error + + parsed_chapters = extract_chapters_from_text(raw_text) + if not parsed_chapters or not any(chapter["content"].strip() for chapter in parsed_chapters): + raise HTTPException(status_code=422, detail="Unable to extract readable chapters from Gutenberg text") + + authors = book.get("authors", []) + title = str(book.get("title", "")).strip() + author_name = str(authors[0].get("name", "")).strip() if authors else "" + if not title or not author_name: + raise HTTPException(status_code=422, detail="Gutenberg metadata is incomplete") + if ", " in author_name: + family_name, given_names = author_name.split(", ", 1) + author_name = f"{given_names} {family_name}" + + subjects = book.get("subjects", []) + selected_genre = genre or (subjects[0] if subjects else "") + summaries = book.get("summaries", []) + synopsis = str(summaries[0]).strip() if summaries else "" + cover_url = book.get("formats", {}).get("image/jpeg") + if not cover_url: + raise HTTPException(status_code=422, detail="Gutenberg edition has no cover image") + download_count = max(0, int(book.get("download_count", 0))) + story_id = UUID(int=gutenberg_id) now = datetime.now(timezone.utc) now_iso = now.isoformat() - first_chapter = chapters[0] - total_words = sum(c["word_count"] for c in chapters) - read_mins = max(3, total_words // 200) + total_words = sum(chapter["word_count"] for chapter in parsed_chapters) + read_minutes = max(1, total_words // 200) + first_chapter = parsed_chapters[0] conn = get_db() - with conn: - conn.execute(""" - INSERT INTO stories ( - id, title, author, genre, chapter, synopsis, content, - read_time_minutes, is_bookmarked, is_completed, created_at_utc, updated_at_utc, - cover_image_url, is_recent_submission, total_chapters, content_format, source_provider - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - story_id, title[:120], author[:80], genre or "Folklore", first_chapter["title"], - synopsis[:300], first_chapter["content"], read_mins, 0, 0, - now_iso, now_iso, cover_url, 1, len(chapters), "PROSE", "GUTENBERG" - )) - - for ch in chapters: - ch_id = str(uuid4()) - conn.execute(""" - INSERT INTO chapters ( - id, story_id, chapter_number, title, content, word_count, created_at_utc - ) VALUES (?, ?, ?, ?, ?, ?, ?) - """, ( - ch_id, story_id, ch["chapter_number"], ch["title"], ch["content"], ch["word_count"], now_iso - )) - - row = conn.execute("SELECT * FROM stories WHERE id = ?", (story_id,)).fetchone() - dto = row_to_story_dto(row, include_chapters=True, conn=conn) - conn.close() - return dto + try: + with conn: + existing = conn.execute( + "SELECT id, created_at_utc FROM stories WHERE source_provider = ? AND provider_id = ?", + ("GUTENBERG", str(gutenberg_id)), + ).fetchone() + if existing: + story_id_value = existing["id"] + created_at = existing["created_at_utc"] + conn.execute( + """ + UPDATE stories SET + title = ?, author = ?, genre = ?, chapter = ?, synopsis = ?, content = ?, + read_time_minutes = ?, updated_at_utc = ?, cover_image_url = ?, + total_chapters = ?, content_format = ?, provider_download_count = ? + WHERE id = ? + """, + ( + title[:120], author_name[:80], selected_genre, first_chapter["title"], + synopsis, first_chapter["content"], read_minutes, now_iso, cover_url, + len(parsed_chapters), "PROSE", download_count, story_id_value, + ), + ) + else: + story_id_value = str(story_id) + created_at = now_iso + conn.execute( + """ + INSERT INTO stories ( + id, title, author, genre, chapter, synopsis, content, + read_time_minutes, created_at_utc, updated_at_utc, cover_image_url, + is_recent_submission, total_chapters, content_format, source_provider, + provider_id, provider_download_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + story_id_value, title[:120], author_name[:80], selected_genre, + first_chapter["title"], synopsis, first_chapter["content"], read_minutes, + created_at, now_iso, cover_url, 0, len(parsed_chapters), "PROSE", + "GUTENBERG", str(gutenberg_id), download_count, + ), + ) + + for chapter in parsed_chapters: + chapter_id = str( + uuid5(GUTENBERG_CHAPTER_NAMESPACE, f"{gutenberg_id}:{chapter['chapter_number']}") + ) + conn.execute( + """ + INSERT INTO chapters ( + id, story_id, chapter_number, title, content, word_count, created_at_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + content = excluded.content, + word_count = excluded.word_count, + created_at_utc = excluded.created_at_utc + """, + ( + chapter_id, story_id_value, chapter["chapter_number"], chapter["title"], + chapter["content"], chapter["word_count"], now_iso, + ), + ) + + row = conn.execute( + "SELECT * FROM stories WHERE id = ?", + (story_id_value,), + ).fetchone() + return row_to_story_dto(row, include_chapters=True, conn=conn) + finally: + conn.close() + + +def _story_from_gutenberg_metadata(book: dict, fallback_genre: Optional[str] = None) -> Optional[StoryDTO]: + book_id = book.get("id") + title = str(book.get("title", "")).strip() + authors = book.get("authors", []) + author_name = str(authors[0].get("name", "")).strip() if authors else "" + if not book_id or not title or not author_name: + return None + if ", " in author_name: + family_name, given_names = author_name.split(", ", 1) + author_name = f"{given_names} {family_name}" + + subjects = book.get("subjects", []) + genre = str(subjects[0]).strip() if subjects else (fallback_genre or "") + summaries = book.get("summaries", []) + synopsis = str(summaries[0]).strip() if summaries else "" + cover_url = book.get("formats", {}).get("image/jpeg") + if not cover_url: + return None + try: + provider_download_count = max(0, int(book.get("download_count", 0))) + book_id = int(book_id) + story_id = UUID(int=book_id) + except (TypeError, ValueError): + return None + + now = datetime.now(timezone.utc) + return StoryDTO( + id=story_id, + title=title[:120], + author=author_name[:80], + genre=genre, + chapter="", + synopsis=synopsis, + content="", + read_time_minutes=0, + is_bookmarked=False, + is_completed=False, + created_at_utc=now, + updated_at_utc=now, + cover_image_url=cover_url, + total_pages=0, + rating=None, + saves_count="0", + reads_count="0", + total_chapters=0, + content_format="PROSE", + source_provider="GUTENBERG", + provider_id=str(book_id), + provider_download_count=provider_download_count, + ) + + +async def get_gutenberg_story_by_id(gutenberg_id: int) -> StoryDTO: + try: + async with httpx.AsyncClient(timeout=8.0) as client: + response = await client.get( + "https://gutendex.com/books/", + params={"ids": str(gutenberg_id)}, + headers=PROVIDER_HEADERS, + ) + except httpx.HTTPError as error: + raise HTTPException(status_code=502, detail="Gutenberg catalog service is unavailable") from error + if response.status_code != 200: + raise HTTPException(status_code=502, detail="Gutenberg catalog service returned an error") + book = next( + (item for item in response.json().get("results", []) if item.get("id") == gutenberg_id), + None, + ) + if book is None: + raise HTTPException(status_code=404, detail="Gutenberg book was not found") + story = _story_from_gutenberg_metadata(book) + if story is None: + raise HTTPException(status_code=422, detail="Gutenberg metadata is incomplete") + return story + async def get_gutenberg_stories( - topic: Optional[str] = "folklore", - search: Optional[str] = None + topic: Optional[str] = None, + search: Optional[str] = None, ) -> list[StoryDTO]: - """ - Public literature gateway: Queries Project Gutenberg via Gutendex REST API, - normalizes unstructured literary data into Fable's StoryDTO schema with cover URLs. - """ - url = "https://gutendex.com/books/" params = {} if topic and topic.lower() != "all": params["topic"] = topic.lower() @@ -113,67 +270,20 @@ async def get_gutenberg_stories( params["search"] = search try: - headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"} - async with httpx.AsyncClient(timeout=4.0) as client: - resp = await client.get(url, params=params, headers=headers) - if resp.status_code == 200: - payload = resp.json() - results = payload.get("results", []) - gutenberg_stories: list[StoryDTO] = [] - now = datetime.now(timezone.utc) - - for book in results[:10]: - book_id = book.get("id", 1000) - title = book.get("title", "Untitled Classic") - authors = book.get("authors", []) - author_name = authors[0].get("name", "Classic Author") if authors else "Public Domain" - if ", " in author_name: - parts = author_name.split(", ", 1) - author_name = f"{parts[1]} {parts[0]}" - - summaries = book.get("summaries", []) - synopsis = summaries[0] if summaries else f"Classic public domain edition of {title} from Project Gutenberg." - if len(synopsis) > 280: - synopsis = synopsis[:277] + "..." - - formats = book.get("formats", {}) - cover_url = formats.get("image/jpeg") - - subjects = book.get("subjects", []) - genre = "Folklore" - if any("myth" in s.lower() for s in subjects): - genre = "Mythology" - elif any("gothic" in s.lower() or "horror" in s.lower() for s in subjects): - genre = "Gothic" - elif any("fiction" in s.lower() for s in subjects): - genre = "Classic Fiction" - elif topic and topic.lower() != "all": - genre = topic.capitalize() - - story_uuid = UUID(int=int(book_id)) - - gutenberg_stories.append(StoryDTO( - id=story_uuid, - title=title[:120], - author=author_name[:80], - genre=genre, - chapter="Chapter I", - synopsis=synopsis, - content=synopsis, - read_time_minutes=max(3, min(12, len(title.split()) * 2)), - is_bookmarked=False, - is_completed=False, - created_at_utc=now, - updated_at_utc=now, - cover_image_url=cover_url, - total_chapters=1, - content_format="PROSE", - source_provider="GUTENBERG" - )) - - if gutenberg_stories: - return gutenberg_stories - except Exception: - pass - - return get_stories(genre=topic, search=search) + async with httpx.AsyncClient(timeout=8.0) as client: + response = await client.get( + "https://gutendex.com/books/", + params=params, + headers=PROVIDER_HEADERS, + ) + except httpx.HTTPError as error: + raise HTTPException(status_code=502, detail="Gutenberg catalog service is unavailable") from error + if response.status_code != 200: + raise HTTPException(status_code=502, detail="Gutenberg catalog service returned an error") + + stories = [] + for book in response.json().get("results", [])[:20]: + story = _story_from_gutenberg_metadata(book, fallback_genre=topic) + if story is not None: + stories.append(story) + return stories diff --git a/backend/services/reading_stats.py b/backend/services/reading_stats.py new file mode 100644 index 0000000..651dc9f --- /dev/null +++ b/backend/services/reading_stats.py @@ -0,0 +1,63 @@ +from datetime import date, datetime, timezone +from uuid import UUID + +from core.database import get_db +from schemas.schemas import ReadingSessionRequest, ReadingStatsDTO + + +def record_reading_session(owner_user_id: UUID, request: ReadingSessionRequest) -> None: + conn = get_db() + try: + with conn: + conn.execute( + """INSERT OR IGNORE INTO reading_sessions + (owner_user_id, id, story_id, seconds_read, read_at_utc, is_completed) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + str(owner_user_id), + str(request.id), + str(request.story_id), + request.seconds_read, + request.read_at_utc.astimezone(timezone.utc).isoformat(), + 1 if request.is_completed else 0, + ), + ) + finally: + conn.close() + + +def get_reading_stats(owner_user_id: UUID) -> ReadingStatsDTO: + conn = get_db() + try: + row = conn.execute( + """SELECT + COUNT(DISTINCT CASE WHEN is_completed = 1 THEN story_id END) AS stories_read_count, + COALESCE(SUM(seconds_read), 0) AS total_seconds, + COUNT(DISTINCT date(read_at_utc)) AS active_days + FROM reading_sessions + WHERE owner_user_id = ?""", + (str(owner_user_id),), + ).fetchone() + active_dates = { + date.fromisoformat(item["read_day"]) + for item in conn.execute( + """SELECT DISTINCT date(read_at_utc) AS read_day + FROM reading_sessions WHERE owner_user_id = ?""", + (str(owner_user_id),), + ).fetchall() + } + finally: + conn.close() + + today = datetime.now(timezone.utc).date() + streak_start = today if today in active_dates else date.fromordinal(today.toordinal() - 1) + streak_days = 0 + while streak_start in active_dates: + streak_days += 1 + streak_start = date.fromordinal(streak_start.toordinal() - 1) + + return ReadingStatsDTO( + stories_read_count=row["stories_read_count"], + total_minutes_read=row["total_seconds"] // 60, + streak_days=streak_days, + ) diff --git a/backend/services/shelf_sync.py b/backend/services/shelf_sync.py index 3f47411..b00501c 100644 --- a/backend/services/shelf_sync.py +++ b/backend/services/shelf_sync.py @@ -9,25 +9,29 @@ def to_utc(dt: datetime) -> datetime: return dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) -def sync_shelf(payload: ShelfSyncPayload) -> ShelfSyncResponse: - """Reconcile client shelf state with server using Last-Write-Wins partitioned by device_id.""" +def sync_shelf(payload: ShelfSyncPayload, owner_user_id: UUID) -> ShelfSyncResponse: + """Reconcile one account's shelf state using Last-Write-Wins per device and story.""" conn = get_db() reconciled: list[ShelfSyncItemDTO] = [] + owner_user_id_str = str(owner_user_id) device_id_str = str(payload.device_id) with conn: for item in payload.items: story_id_str = str(item.story_id) row = conn.execute( - "SELECT * FROM shelf_items WHERE device_id = ? AND story_id = ?", - (device_id_str, story_id_str) + """SELECT * FROM account_shelf_items + WHERE owner_user_id = ? AND device_id = ? AND story_id = ?""", + (owner_user_id_str, device_id_str, story_id_str) ).fetchone() if row is None: conn.execute(""" - INSERT INTO shelf_items (device_id, story_id, reading_progress, is_bookmarked, is_completed, updated_at_utc) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO account_shelf_items + (owner_user_id, device_id, story_id, reading_progress, is_bookmarked, is_completed, updated_at_utc) + VALUES (?, ?, ?, ?, ?, ?, ?) """, ( + owner_user_id_str, device_id_str, story_id_str, item.reading_progress, @@ -42,14 +46,15 @@ def sync_shelf(payload: ShelfSyncPayload) -> ShelfSyncResponse: if client_time > server_time: conn.execute(""" - UPDATE shelf_items + UPDATE account_shelf_items SET reading_progress = ?, is_bookmarked = ?, is_completed = ?, updated_at_utc = ? - WHERE device_id = ? AND story_id = ? + WHERE owner_user_id = ? AND device_id = ? AND story_id = ? """, ( item.reading_progress, 1 if item.is_bookmarked else 0, 1 if item.is_completed else 0, item.updated_at_utc.isoformat(), + owner_user_id_str, device_id_str, story_id_str )) @@ -70,12 +75,13 @@ def sync_shelf(payload: ShelfSyncPayload) -> ShelfSyncResponse: server_time_utc=datetime.now(timezone.utc) ) -def get_shelf(device_id: UUID) -> list[ShelfSyncItemDTO]: - """Retrieve all shelf items for a specific client device.""" +def get_shelf(device_id: UUID, owner_user_id: UUID) -> list[ShelfSyncItemDTO]: + """Retrieve shelf items belonging to this account and client device.""" conn = get_db() cursor = conn.execute( - "SELECT * FROM shelf_items WHERE device_id = ?", - (str(device_id),) + """SELECT * FROM account_shelf_items + WHERE owner_user_id = ? AND device_id = ?""", + (str(owner_user_id), str(device_id)) ) rows = cursor.fetchall() conn.close() diff --git a/backend/services/story_service.py b/backend/services/story_service.py index 1847965..e7ecd96 100644 --- a/backend/services/story_service.py +++ b/backend/services/story_service.py @@ -1,269 +1,298 @@ -import sqlite3 import json -import httpx +import sqlite3 from datetime import datetime, timezone -from uuid import UUID, uuid4 from typing import Optional +from urllib.parse import urlsplit +from uuid import UUID, uuid4, uuid5 + +import httpx from fastapi import HTTPException from core.database import get_db from schemas.schemas import ( - StoryDTO, ChapterDTO, + CreateStoryRequest, GenreDTO, - WriterDTO, + StoryDTO, UpdateFeedDTO, - CreateStoryRequest + WriterDTO, ) -GENRE_METADATA = { - "Folklore": { - "description": "Traditional tales passed down through generations, reimagined by contemporary scribes—from fireside Slavic forest myths to maritime legends whispered across coastal tides.", - "image_name": "genre_folklore", - "image_url": "https://images.unsplash.com/photo-1518709268805-4e9042af9f23?q=80&w=800&auto=format&fit=crop", - "default_readers": "18.4k" - }, - "Mythology": { - "description": "Epic sagas of deities, ancient heroes, and cosmic origins spanning classical traditions to obscure forgotten pantheons.", - "image_name": "genre_mythology", - "image_url": "https://images.unsplash.com/photo-1579783902614-a3fb3927b675?q=80&w=800&auto=format&fit=crop", - "default_readers": "12.1k" - }, - "Gothic": { - "description": "Atmospheric hauntings, crumbling estates, and romantic dread exploring the psychological depths of human melancholy.", - "image_name": "genre_gothic", - "image_url": "https://images.unsplash.com/photo-1509198397868-475647b2a1e5?q=80&w=800&auto=format&fit=crop", - "default_readers": "9.8k" - }, - "Classic Fiction": { - "description": "Enduring literary cornerstones, psychological inquiries, and philosophical journeys across the centuries.", - "image_name": "genre_folklore", - "image_url": "https://images.unsplash.com/photo-1457369804613-52c61a468e7d?q=80&w=800&auto=format&fit=crop", - "default_readers": "16.5k" - }, - "Classic Mystery": { - "description": "Whodunits, deductive puzzles, and atmospheric investigations through gaslit cobblestones and locked rooms.", - "image_name": "genre_mystery", - "image_url": "https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?q=80&w=800&auto=format&fit=crop", - "default_readers": "14.2k" - }, - "Manga": { - "description": "Visual narratives, serialized graphic adventures, and dynamic panel-driven epics originating from contemporary Japanese and global studios.", - "image_name": "genre_folklore", - "image_url": "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=800&auto=format&fit=crop", - "default_readers": "34.8k" - } -} - -AUTHOR_PORTRAIT_URLS = { - "Tatsuki Fujimoto": "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=800&auto=format&fit=crop", - "Bram Stoker": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Bram_Stoker_1906.jpg/440px-Bram_Stoker_1906.jpg", - "Washington Irving": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/Washington_Irving_by_John_Wesley_Jarvis%2C_1809.jpg/440px-Washington_Irving_by_John_Wesley_Jarvis%2C_1809.jpg", - "Edgar Allan Poe": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Edgar_Allan_Poe_2_edit.jpg/440px-Edgar_Allan_Poe_2_edit.jpg", - "Franz Kafka": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/26/Franz_Kafka%2C_1923.jpg/440px-Franz_Kafka%2C_1923.jpg", - "Mary Shelley": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/65/RothwellMaryShelley.jpg/440px-RothwellMaryShelley.jpg", - "Oscar Wilde": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/90/Oscar_Wilde_by_Napoleon_Sarony_-_1882.jpg/440px-Oscar_Wilde_by_Napoleon_Sarony_-_1882.jpg", - "Homer": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/Homer_British_Museum.jpg/440px-Homer_British_Museum.jpg", - "Brothers Grimm": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/59/Grimm.jpg/440px-Grimm.jpg", - "Jose Rizal": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b0/Jose_rizal_01.jpg/440px-Jose_rizal_01.jpg", - "Lewis Carroll": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/LewisCarrollSelfPhoto.jpg/440px-LewisCarrollSelfPhoto.jpg" -} - -def row_to_story_dto(r: sqlite3.Row, include_chapters: bool = False, conn: Optional[sqlite3.Connection] = None) -> StoryDTO: - story_id = UUID(r["id"]) +IDENTIFIER_NAMESPACE = UUID("b28f1a39-ff20-5d3c-9ff0-994df9324c22") + + +def _is_legacy_demo_image_url(value: str) -> bool: + try: + return (urlsplit(value).hostname or "").lower() == "images.unsplash.com" + except ValueError: + return False + + +def _decode_page_urls(value: Optional[str]) -> list[str]: + if not value: + return [] + try: + page_urls = json.loads(value) + except (TypeError, json.JSONDecodeError): + return [] + if not isinstance(page_urls, list): + return [] + return [ + url for url in page_urls + if isinstance(url, str) and not _is_legacy_demo_image_url(url) + ] + + +def _cover_url_without_legacy_demo(value: Optional[str]) -> Optional[str]: + if value and _is_legacy_demo_image_url(value): + return None + return value + + +def row_to_story_dto( + row: sqlite3.Row, + include_chapters: bool = False, + conn: Optional[sqlite3.Connection] = None, +) -> StoryDTO: + story_id = UUID(row["id"]) chapters = None if include_chapters and conn: - ch_rows = conn.execute( + chapter_rows = conn.execute( "SELECT * FROM chapters WHERE story_id = ? ORDER BY chapter_number ASC", - (str(story_id),) + (str(story_id),), ).fetchall() chapters = [ ChapterDTO( - id=UUID(ch["id"]), + id=UUID(chapter["id"]), story_id=story_id, - chapter_number=ch["chapter_number"], - title=ch["title"], - content=ch["content"] if "content" in ch.keys() else "", - word_count=ch["word_count"] if "word_count" in ch.keys() else 0, - created_at_utc=datetime.fromisoformat(ch["created_at_utc"]), - page_urls=json.loads(ch["page_urls"]) if "page_urls" in ch.keys() and ch["page_urls"] else [] + chapter_number=chapter["chapter_number"], + title=chapter["title"], + content=chapter["content"] if "content" in chapter.keys() else "", + word_count=chapter["word_count"] if "word_count" in chapter.keys() else 0, + created_at_utc=datetime.fromisoformat(chapter["created_at_utc"]), + page_urls=_decode_page_urls( + chapter["page_urls"] if "page_urls" in chapter.keys() else None + ), ) - for ch in ch_rows + for chapter in chapter_rows ] - keys = r.keys() - total_chapters = r["total_chapters"] if "total_chapters" in keys and r["total_chapters"] else 1 - content_format = r["content_format"] if "content_format" in keys and r["content_format"] else "PROSE" - source_provider = r["source_provider"] if "source_provider" in keys and r["source_provider"] else "FABLE_ORIGINAL" + saves_count = 0 + reads_count = 0 + if conn: + shelf_counts = conn.execute( + """ + SELECT + COUNT(DISTINCT CASE WHEN is_bookmarked = 1 THEN owner_user_id END) AS saves_count, + COUNT(DISTINCT CASE WHEN reading_progress > 0 THEN owner_user_id END) AS reads_count + FROM account_shelf_items + WHERE story_id = ? + """, + (str(story_id),), + ).fetchone() + saves_count = shelf_counts["saves_count"] + reads_count = shelf_counts["reads_count"] + + keys = row.keys() + provider_id = row["provider_id"] if "provider_id" in keys else None + source_provider = row["source_provider"] if "source_provider" in keys else "FABLE_ORIGINAL" + cover_url = row["cover_image_url"] if "cover_image_url" in keys else None + if not provider_id or source_provider not in {"GUTENBERG", "MANGADEX", "STANDARD_EBOOKS"}: + cover_url = None return StoryDTO( id=story_id, - title=r["title"], - author=r["author"], - genre=r["genre"], - chapter=r["chapter"], - synopsis=r["synopsis"], - content=r["content"], - read_time_minutes=r["read_time_minutes"], - is_bookmarked=bool(r["is_bookmarked"]), - is_completed=bool(r["is_completed"]), - created_at_utc=datetime.fromisoformat(r["created_at_utc"]), - updated_at_utc=datetime.fromisoformat(r["updated_at_utc"]), - cover_image_name=r["cover_image_name"] if "cover_image_name" in keys else None, - hero_image_name=r["hero_image_name"] if "hero_image_name" in keys else None, - cover_image_url=r["cover_image_url"] if "cover_image_url" in keys else None, - total_pages=r["total_pages"] if "total_pages" in keys and r["total_pages"] else 5, - current_page=r["current_page"] if "current_page" in keys and r["current_page"] else 1, - progress_percent=r["progress_percent"] if "progress_percent" in keys and r["progress_percent"] is not None else 0, - rating=r["rating"] if "rating" in keys and r["rating"] else 4.9, - saves_count=r["saves_count"] if "saves_count" in keys and r["saves_count"] else "1.2k", - reads_count=r["reads_count"] if "reads_count" in keys and r["reads_count"] else "1.2k", - is_tale_of_the_day=bool(r["is_tale_of_the_day"]) if "is_tale_of_the_day" in keys else False, - is_recent_submission=bool(r["is_recent_submission"]) if "is_recent_submission" in keys else False, - is_curator_spotlight=bool(r["is_curator_spotlight"]) if "is_curator_spotlight" in keys else False, - badge_text=r["badge_text"] if "badge_text" in keys else None, - total_chapters=total_chapters, - content_format=content_format, - source_provider=source_provider, - chapters=chapters + title=row["title"], + author=row["author"], + genre=row["genre"], + chapter=row["chapter"], + synopsis=row["synopsis"], + content=row["content"], + read_time_minutes=row["read_time_minutes"], + is_bookmarked=False, + is_completed=False, + created_at_utc=datetime.fromisoformat(row["created_at_utc"]), + updated_at_utc=datetime.fromisoformat(row["updated_at_utc"]), + cover_image_name=None, + hero_image_name=None, + cover_image_url=_cover_url_without_legacy_demo(cover_url), + total_pages=row["total_pages"] if "total_pages" in keys and row["total_pages"] else 0, + current_page=row["current_page"] if "current_page" in keys and row["current_page"] else 1, + progress_percent=row["progress_percent"] if "progress_percent" in keys and row["progress_percent"] is not None else 0, + rating=None, + saves_count=str(saves_count), + reads_count=str(reads_count), + is_tale_of_the_day=bool(row["is_tale_of_the_day"]) if "is_tale_of_the_day" in keys else False, + is_recent_submission=bool(row["is_recent_submission"]) if "is_recent_submission" in keys else False, + is_curator_spotlight=bool(row["is_curator_spotlight"]) if "is_curator_spotlight" in keys else False, + badge_text=row["badge_text"] if "badge_text" in keys else None, + total_chapters=row["total_chapters"] if "total_chapters" in keys and row["total_chapters"] else 0, + content_format=row["content_format"] if "content_format" in keys and row["content_format"] else "PROSE", + source_provider=source_provider or "FABLE_ORIGINAL", + provider_id=provider_id, + provider_download_count=row["provider_download_count"] if "provider_download_count" in keys else None, + chapters=chapters, ) + def get_stories( genre: Optional[str] = None, search: Optional[str] = None, - since: Optional[datetime] = None + since: Optional[datetime] = None, ) -> list[StoryDTO]: conn = get_db() - query = "SELECT * FROM stories WHERE 1=1" - params = [] - - if genre and genre.lower() != "all": - query += " AND LOWER(genre) = LOWER(?)" - params.append(genre) - - if search: - query += " AND (LOWER(title) LIKE ? OR LOWER(synopsis) LIKE ? OR LOWER(author) LIKE ?)" - term = f"%{search.lower()}%" - params.extend([term, term, term]) - - if since: - query += " AND updated_at_utc > ?" - params.append(since.isoformat()) + try: + query = "SELECT * FROM stories WHERE 1=1" + params: list[object] = [] + if genre and genre.lower() != "all": + query += " AND LOWER(genre) = LOWER(?)" + params.append(genre) + if search: + query += " AND (LOWER(title) LIKE ? OR LOWER(synopsis) LIKE ? OR LOWER(author) LIKE ?)" + term = f"%{search.lower()}%" + params.extend([term, term, term]) + if since: + query += " AND updated_at_utc > ?" + params.append(since.isoformat()) + query += " ORDER BY is_tale_of_the_day DESC, created_at_utc DESC" + rows = conn.execute(query, params).fetchall() + return [row_to_story_dto(row, conn=conn) for row in rows] + finally: + conn.close() - query += " ORDER BY is_tale_of_the_day DESC, created_at_utc DESC" - rows = conn.execute(query, params).fetchall() - results = [row_to_story_dto(r, include_chapters=False) for r in rows] - conn.close() - return results def get_story_by_id(story_id: UUID) -> StoryDTO: conn = get_db() - row = conn.execute("SELECT * FROM stories WHERE id = ?", (str(story_id),)).fetchone() - if not row: + try: + row = conn.execute( + "SELECT * FROM stories WHERE id = ?", + (str(story_id),), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Story not found") + return row_to_story_dto(row, include_chapters=True, conn=conn) + finally: conn.close() - raise HTTPException(status_code=404, detail="Story not found") - story = row_to_story_dto(row, include_chapters=True, conn=conn) - conn.close() - return story + def get_story_chapters(story_id: UUID) -> list[ChapterDTO]: conn = get_db() - rows = conn.execute( - "SELECT * FROM chapters WHERE story_id = ? ORDER BY chapter_number ASC", - (str(story_id),) - ).fetchall() - conn.close() + try: + rows = conn.execute( + "SELECT * FROM chapters WHERE story_id = ? ORDER BY chapter_number ASC", + (str(story_id),), + ).fetchall() + return [ + ChapterDTO( + id=UUID(row["id"]), + story_id=story_id, + chapter_number=row["chapter_number"], + title=row["title"], + content=row["content"] if "content" in row.keys() else "", + word_count=row["word_count"] if "word_count" in row.keys() else 0, + created_at_utc=datetime.fromisoformat(row["created_at_utc"]), + page_urls=_decode_page_urls( + row["page_urls"] if "page_urls" in row.keys() else None + ), + ) + for row in rows + ] + finally: + conn.close() - if not rows: - return [] - return [ - ChapterDTO( - id=UUID(r["id"]), +def get_story_chapter_by_number(story_id: UUID, chapter_number: int) -> ChapterDTO: + conn = get_db() + try: + row = conn.execute( + "SELECT * FROM chapters WHERE story_id = ? AND chapter_number = ?", + (str(story_id), chapter_number), + ).fetchone() + if not row: + raise HTTPException( + status_code=404, + detail=f"Chapter {chapter_number} not found for story", + ) + return ChapterDTO( + id=UUID(row["id"]), story_id=story_id, - chapter_number=r["chapter_number"], - title=r["title"], - content=r["content"] if "content" in r.keys() else "", - word_count=r["word_count"] if "word_count" in r.keys() else 0, - created_at_utc=datetime.fromisoformat(r["created_at_utc"]), - page_urls=json.loads(r["page_urls"]) if "page_urls" in r.keys() and r["page_urls"] else [] + chapter_number=row["chapter_number"], + title=row["title"], + content=row["content"] if "content" in row.keys() else "", + word_count=row["word_count"] if "word_count" in row.keys() else 0, + created_at_utc=datetime.fromisoformat(row["created_at_utc"]), + page_urls=_decode_page_urls( + row["page_urls"] if "page_urls" in row.keys() else None + ), ) - for r in rows - ] + finally: + conn.close() -def get_story_chapter_by_number(story_id: UUID, chapter_number: int) -> ChapterDTO: - conn = get_db() - row = conn.execute( - "SELECT * FROM chapters WHERE story_id = ? AND chapter_number = ?", - (str(story_id), chapter_number) - ).fetchone() - conn.close() - - if not row: - raise HTTPException(status_code=404, detail=f"Chapter {chapter_number} not found for story") - - return ChapterDTO( - id=UUID(row["id"]), - story_id=story_id, - chapter_number=row["chapter_number"], - title=row["title"], - content=row["content"] if "content" in row.keys() else "", - word_count=row["word_count"] if "word_count" in row.keys() else 0, - created_at_utc=datetime.fromisoformat(row["created_at_utc"]), - page_urls=json.loads(row["page_urls"]) if "page_urls" in row.keys() and row["page_urls"] else [] - ) -def create_story(payload: CreateStoryRequest) -> StoryDTO: +def create_story(payload: CreateStoryRequest, author: str, owner_user_id: UUID) -> StoryDTO: story_id = str(uuid4()) chapter_id = str(uuid4()) now = datetime.now(timezone.utc) now_iso = now.isoformat() - chapter_title = payload.chapter or "Chapter I" - - format_val = payload.content_format or "PROSE" - provider_val = payload.source_provider or "FABLE_ORIGINAL" + chapter_title = payload.chapter or "" + content_format = payload.content_format or "PROSE" + source_provider = "FABLE_ORIGINAL" conn = get_db() - with conn: - conn.execute(""" - INSERT INTO stories ( - id, title, author, genre, chapter, synopsis, content, - read_time_minutes, is_bookmarked, is_completed, created_at_utc, updated_at_utc, - is_recent_submission, total_chapters, content_format, source_provider - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - story_id, - payload.title, - payload.author, - payload.genre, - chapter_title, - payload.synopsis, - payload.content, - payload.read_time_minutes, - 0, - 0, - now_iso, - now_iso, - 1, - 1, - format_val, - provider_val - )) - - words = len(payload.content.split()) if payload.content else 0 - conn.execute(""" - INSERT INTO chapters ( - id, story_id, chapter_number, title, content, word_count, created_at_utc, page_urls - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, ( - chapter_id, story_id, 1, chapter_title, payload.content or "", words, now_iso, "[]" - )) - - conn.close() + try: + with conn: + conn.execute( + """ + INSERT INTO stories ( + id, title, author, genre, chapter, synopsis, content, + read_time_minutes, is_bookmarked, is_completed, created_at_utc, updated_at_utc, + is_recent_submission, total_chapters, content_format, source_provider, owner_user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + story_id, + payload.title, + author, + payload.genre, + chapter_title, + payload.synopsis, + payload.content, + payload.read_time_minutes, + 0, + 0, + now_iso, + now_iso, + 1, + 1, + content_format, + source_provider, + str(owner_user_id), + ), + ) + words = len(payload.content.split()) if payload.content else 0 + conn.execute( + """ + INSERT INTO chapters ( + id, story_id, chapter_number, title, content, word_count, created_at_utc, page_urls + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + chapter_id, + story_id, + 1, + chapter_title, + payload.content or "", + words, + now_iso, + "[]", + ), + ) + finally: + conn.close() return StoryDTO( id=UUID(story_id), title=payload.title, - author=payload.author, + author=author, genre=payload.genre, chapter=chapter_title, synopsis=payload.synopsis, @@ -275,138 +304,177 @@ def create_story(payload: CreateStoryRequest) -> StoryDTO: updated_at_utc=now, is_recent_submission=True, total_chapters=1, - content_format=format_val, - source_provider=provider_val + content_format=content_format, + source_provider=source_provider, + rating=None, + saves_count="0", + reads_count="0", ) -def get_genres() -> list[GenreDTO]: - conn = get_db() - rows = conn.execute(""" - SELECT genre, COUNT(*) as story_count - FROM stories - GROUP BY genre - ORDER BY story_count DESC - """).fetchall() - conn.close() - - db_counts = {r["genre"]: r["story_count"] for r in rows} - - genres: list[GenreDTO] = [] - all_genre_names = list(db_counts.keys()) - for name in GENRE_METADATA: - if name not in all_genre_names: - all_genre_names.append(name) - - for name in all_genre_names: - count = db_counts.get(name, 0) - meta = GENRE_METADATA.get(name, { - "description": f"Curated collection of {name.lower()} tales and classical literature.", - "image_name": "genre_folklore", - "default_readers": f"{max(5, count * 3)}k" - }) - genre_id = UUID(int=abs(hash(f"fable_genre_{name}")) % (2**128)) - genres.append(GenreDTO( - id=genre_id, - name=name, - story_count=count, - readers_count=meta["default_readers"], - description=meta["description"], - image_name=meta["image_name"], - image_url=meta.get("image_url") - )) - - return genres - -async def get_top_authors() -> list[WriterDTO]: - open_library_url = "https://openlibrary.org/trending/daily.json" - headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"} +def get_user_stories(owner_user_id: UUID) -> list[StoryDTO]: + conn = get_db() try: - async with httpx.AsyncClient(timeout=3.0) as client: - res = await client.get(open_library_url, headers=headers) - if res.status_code == 200: - payload = res.json() - works = payload.get("works", []) - - author_map: dict[str, int] = {} - for w in works[:25]: - authors = w.get("author_name", []) - for a in authors: - if isinstance(a, str) and len(a) > 2 and not a.startswith("http"): - author_map[a] = author_map.get(a, 0) + 1 - - top_list = sorted(author_map.items(), key=lambda x: x[1], reverse=True)[:6] - if top_list: - writers: list[WriterDTO] = [] - for idx, (author_name, work_count) in enumerate(top_list): - writer_id = UUID(int=abs(hash(f"author_{author_name}")) % (2**128)) - avatar_slug = f"author_{author_name.lower().replace(' ', '_').replace('.', '')[:15]}" - rating = round(4.7 + (idx % 3) * 0.1, 1) - writers.append(WriterDTO( - id=writer_id, - name=author_name, - avatar_image_name=avatar_slug, - avatar_image_url=AUTHOR_PORTRAIT_URLS.get(author_name), - story_count=max(2, work_count * 3), - rating=rating - )) - return writers - except Exception: - pass + rows = conn.execute( + "SELECT * FROM stories WHERE owner_user_id = ? ORDER BY created_at_utc DESC", + (str(owner_user_id),), + ).fetchall() + return [row_to_story_dto(row, conn=conn) for row in rows] + finally: + conn.close() - conn = get_db() - rows = conn.execute(""" - SELECT author, COUNT(*) as story_count, AVG(rating) as avg_rating - FROM stories - GROUP BY author - ORDER BY story_count DESC, avg_rating DESC - LIMIT 6 - """).fetchall() - conn.close() - - writers = [] - for r in rows: - author_name = r["author"] - writer_id = UUID(int=abs(hash(f"author_{author_name}")) % (2**128)) - avatar_slug = f"author_{author_name.lower().replace(' ', '_').replace('.', '')[:15]}" - rating = round(r["avg_rating"] if r["avg_rating"] else 4.9, 1) - writers.append(WriterDTO( - id=writer_id, - name=author_name, - avatar_image_name=avatar_slug, - avatar_image_url=AUTHOR_PORTRAIT_URLS.get(author_name), - story_count=r["story_count"], - rating=rating - )) - return writers -def get_update_feed() -> UpdateFeedDTO: +def get_genres() -> list[GenreDTO]: conn = get_db() + try: + rows = conn.execute( + """ + SELECT + stories.genre, + COUNT(DISTINCT stories.id) AS story_count, + COUNT(DISTINCT CASE + WHEN account_shelf_items.reading_progress > 0 THEN account_shelf_items.owner_user_id + END) AS readers_count + FROM stories + LEFT JOIN account_shelf_items ON account_shelf_items.story_id = stories.id + GROUP BY stories.genre + ORDER BY story_count DESC, stories.genre COLLATE NOCASE ASC + """ + ).fetchall() + return [ + GenreDTO( + id=uuid5(IDENTIFIER_NAMESPACE, f"genre:{row['genre'].casefold()}"), + name=row["genre"], + story_count=row["story_count"], + readers_count=str(row["readers_count"]), + description="", + image_name="", + image_url=None, + ) + for row in rows + ] + finally: + conn.close() - totd_row = conn.execute("SELECT * FROM stories WHERE is_tale_of_the_day = 1 ORDER BY created_at_utc DESC LIMIT 1").fetchone() - if not totd_row: - totd_row = conn.execute("SELECT * FROM stories ORDER BY created_at_utc DESC LIMIT 1").fetchone() - - curator_row = conn.execute("SELECT * FROM stories WHERE is_curator_spotlight = 1 ORDER BY created_at_utc DESC LIMIT 1").fetchone() - if not curator_row: - curator_row = conn.execute("SELECT * FROM stories ORDER BY rating DESC LIMIT 1").fetchone() - recents_rows = conn.execute("SELECT * FROM stories WHERE is_recent_submission = 1 ORDER BY created_at_utc DESC LIMIT 10").fetchall() - if not recents_rows: - recents_rows = conn.execute("SELECT * FROM stories ORDER BY created_at_utc DESC LIMIT 10").fetchall() +def _local_writers() -> list[WriterDTO]: + conn = get_db() + try: + rows = conn.execute( + """ + SELECT author, COUNT(*) AS story_count + FROM stories + GROUP BY author + ORDER BY story_count DESC, author COLLATE NOCASE ASC + LIMIT 6 + """ + ).fetchall() + return [ + WriterDTO( + id=uuid5(IDENTIFIER_NAMESPACE, f"fable-author:{row['author'].casefold()}"), + name=row["author"], + avatar_image_name="", + avatar_image_url=None, + story_count=row["story_count"], + rating=None, + ) + for row in rows + ] + finally: + conn.close() - total_count = conn.execute("SELECT COUNT(*) as count FROM stories").fetchone()["count"] - totd_dto = row_to_story_dto(totd_row, include_chapters=False) if totd_row else None - curator_dto = row_to_story_dto(curator_row, include_chapters=False) if curator_row else None - recents_dto = [row_to_story_dto(r, include_chapters=False) for r in recents_rows] +async def get_top_authors() -> list[WriterDTO]: + headers = {"User-Agent": "Fable/1.0 (author metadata client)"} + try: + async with httpx.AsyncClient(timeout=5.0) as client: + trending = await client.get( + "https://openlibrary.org/trending/daily.json", + headers=headers, + ) + if trending.status_code != 200: + return _local_writers() + + works = trending.json().get("works", []) + names: list[str] = [] + for work in works: + for name in work.get("author_name", []): + if isinstance(name, str) and name.strip() and name not in names: + names.append(name.strip()) + if len(names) == 6: + break + if len(names) == 6: + break + + writers: list[WriterDTO] = [] + for name in names: + response = await client.get( + "https://openlibrary.org/search/authors.json", + params={"q": name, "limit": 1}, + headers=headers, + ) + if response.status_code != 200: + continue + docs = response.json().get("docs", []) + if not docs: + continue + document = docs[0] + author_key = document.get("key", "").rsplit("/", 1)[-1] + if not author_key: + continue + work_count = document.get("work_count", 0) + writers.append( + WriterDTO( + id=uuid5(IDENTIFIER_NAMESPACE, f"openlibrary:{author_key}"), + name=document.get("name", name), + avatar_image_name="", + avatar_image_url=( + f"https://covers.openlibrary.org/a/olid/{author_key}-M.jpg?default=false" + ), + story_count=max(0, int(work_count)), + rating=None, + ) + ) + return writers + except (httpx.HTTPError, ValueError, TypeError): + return _local_writers() - conn.close() - return UpdateFeedDTO( - tale_of_the_day=totd_dto, - curator_spotlight=curator_dto, - recent_submissions=recents_dto, - total_stories=total_count, - timestamp_utc=datetime.now(timezone.utc) - ) +def get_update_feed() -> UpdateFeedDTO: + conn = get_db() + try: + totd_row = conn.execute( + """ + SELECT * FROM stories + WHERE is_tale_of_the_day = 1 + ORDER BY created_at_utc DESC LIMIT 1 + """ + ).fetchone() + curator_row = conn.execute( + """ + SELECT * FROM stories + WHERE is_curator_spotlight = 1 + ORDER BY created_at_utc DESC LIMIT 1 + """ + ).fetchone() + recent_rows = conn.execute( + """ + SELECT * FROM stories + WHERE is_recent_submission = 1 + ORDER BY created_at_utc DESC LIMIT 10 + """ + ).fetchall() + total_count = conn.execute( + "SELECT COUNT(*) AS count FROM stories" + ).fetchone()["count"] + return UpdateFeedDTO( + tale_of_the_day=row_to_story_dto(totd_row, conn=conn) if totd_row else None, + curator_spotlight=row_to_story_dto(curator_row, conn=conn) if curator_row else None, + recent_submissions=[ + row_to_story_dto(row, conn=conn) for row in recent_rows + ], + total_stories=total_count, + timestamp_utc=datetime.now(timezone.utc), + ) + finally: + conn.close() diff --git a/backend/services/text_parser.py b/backend/services/text_parser.py index 3c96e2c..6e6e3e6 100644 --- a/backend/services/text_parser.py +++ b/backend/services/text_parser.py @@ -13,7 +13,7 @@ def extract_chapters_from_text(raw_text: str) -> list[dict]: clean_text = raw_text[start_pos:end_pos].strip() # Match standard Chapter markers - pattern = r'(?:\r?\n){2,}(CHAPTER\s+[0-9IVXLCDM]+[^\r\n]*|Chapter\s+[0-9IVXLCDM]+[^\r\n]*|ACT\s+[0-9IVXLCDM]+[^\r\n]*|BOOK\s+[0-9IVXLCDM]+[^\r\n]*)' + pattern = r'(?:^|(?:\r?\n){2,})(CHAPTER\s+[0-9IVXLCDM]+[^\r\n]*|Chapter\s+[0-9IVXLCDM]+[^\r\n]*|ACT\s+[0-9IVXLCDM]+[^\r\n]*|BOOK\s+[0-9IVXLCDM]+[^\r\n]*)' splits = re.split(pattern, clean_text) extracted: list[dict] = [] diff --git a/backend/test_main.py b/backend/test_main.py index 77b3eb7..0d11fa7 100644 --- a/backend/test_main.py +++ b/backend/test_main.py @@ -19,6 +19,17 @@ def setup_teardown_db(): client = TestClient(app) + +def auth_headers(name="Test Author"): + response = client.post("/api/v1/auth/register", json={ + "email": f"{uuid4()}@example.test", + "password": "secure-password", + "name": name, + "handle": name.lower().replace(" ", "") + }) + assert response.status_code == 201 + return {"Authorization": f"Bearer {response.json()['accessToken']}"} + def test_health_check(): response = client.get("/api/v1/health") assert response.status_code == 200 @@ -26,59 +37,134 @@ def test_health_check(): assert data["status"] == "healthy" assert data["database"] == "connected" -def test_get_stories_includes_full_editorial_catalog(): +def test_get_stories_starts_without_embedded_catalog(): response = client.get("/api/v1/stories") assert response.status_code == 200 - stories = response.json() - assert len(stories) >= 10 - titles = [s["title"] for s in stories] - assert "Dracula" in titles - assert "The Legend of Sleepy Hollow" in titles - assert "The Metamorphosis" in titles - assert "The Tell-Tale Heart" in titles - assert "Frankenstein" in titles - assert "The Odyssey" in titles + assert response.json() == [] + init_db() + assert client.get("/api/v1/stories").json() == [] + + +def test_story_creation_requires_auth_and_server_owned_author(): + payload = { + "title": "Owned Story", + "genre": "Mystery", + "synopsis": "A server-owned author record.", + "content": "Story content." + } + unauthorized = client.post("/api/v1/stories", json=payload) + assert unauthorized.status_code == 401 + + headers = auth_headers("Verified Writer") + spoofed = client.post( + "/api/v1/stories", + headers=headers, + json={**payload, "author": "Impersonated Writer"}, + ) + assert spoofed.status_code == 422 + + created = client.post("/api/v1/stories", headers=headers, json=payload) + assert created.status_code == 201 + assert created.json()["author"] == "Verified Writer" + + +def test_authenticated_story_listing_is_isolated_by_owner(): + first_headers = auth_headers("First Writer") + second_headers = auth_headers("Second Writer") + for headers, title in ((first_headers, "First Story"), (second_headers, "Second Story")): + response = client.post("/api/v1/stories", headers=headers, json={ + "title": title, + "genre": "Fantasy", + "synopsis": "An owned story.", + "content": "Story content." + }) + assert response.status_code == 201 + + first_stories = client.get("/api/v1/auth/me/stories", headers=first_headers) + second_stories = client.get("/api/v1/auth/me/stories", headers=second_headers) + assert [story["title"] for story in first_stories.json()] == ["First Story"] + assert [story["title"] for story in second_stories.json()] == ["Second Story"] + assert client.get("/api/v1/auth/me/stories").status_code == 401 def test_story_dto_camelcase_serialization_contract(): - response = client.get("/api/v1/stories") - assert response.status_code == 200 - first = response.json()[0] - # Verify contract parity with Swift JSONDecoder - assert "readTimeMinutes" in first - assert "isBookmarked" in first - assert "isCompleted" in first - assert "createdAtUtc" in first - assert "updatedAtUtc" in first - assert "totalChapters" in first + response = client.post("/api/v1/stories", headers=auth_headers(), json={ + "title": "Contract Story", + "genre": "Gothic", + "synopsis": "A synopsis from the writer.", + "content": "The actual authored text.", + "readTimeMinutes": 4 + }) + assert response.status_code == 201 + story = response.json() + assert "readTimeMinutes" in story + assert story["isBookmarked"] is False + assert story["isCompleted"] is False + assert "createdAtUtc" in story + assert "updatedAtUtc" in story + assert "totalChapters" in story + assert "providerId" in story + assert "providerDownloadCount" in story + assert story["rating"] is None + assert story["savesCount"] == "0" + assert story["readsCount"] == "0" + assert story["coverImageName"] is None + assert story["heroImageName"] is None + def test_get_story_chapters(): - response = client.get("/api/v1/stories") - assert response.status_code == 200 - stories = response.json() - dracula = next(s for s in stories if s["title"] == "Dracula") - story_id = dracula["id"] - - # Test single story with chapters - detail_res = client.get(f"/api/v1/stories/{story_id}") - assert detail_res.status_code == 200 - detail = detail_res.json() - assert "chapters" in detail - assert len(detail["chapters"]) >= 3 - assert detail["chapters"][0]["chapterNumber"] == 1 - assert "CHAPTER I" in detail["chapters"][0]["title"] - - # Test chapters endpoint - ch_res = client.get(f"/api/v1/stories/{story_id}/chapters") - assert ch_res.status_code == 200 - chapters = ch_res.json() - assert len(chapters) >= 3 - - # Test chapter 1 endpoint - ch1_res = client.get(f"/api/v1/stories/{story_id}/chapters/1") - assert ch1_res.status_code == 200 - ch1 = ch1_res.json() - assert ch1["chapterNumber"] == 1 - assert "Bistritz" in ch1["content"] + created = client.post("/api/v1/stories", headers=auth_headers(), json={ + "title": "Reader Contract", + "genre": "Gothic", + "chapter": "Chapter One", + "synopsis": "A test synopsis.", + "content": "Text created through the validated story API.", + "readTimeMinutes": 3 + }) + assert created.status_code == 201 + story_id = created.json()["id"] + + detail = client.get(f"/api/v1/stories/{story_id}") + assert detail.status_code == 200 + assert len(detail.json()["chapters"]) == 1 + + chapters = client.get(f"/api/v1/stories/{story_id}/chapters") + assert chapters.status_code == 200 + assert len(chapters.json()) == 1 + + chapter = client.get(f"/api/v1/stories/{story_id}/chapters/1") + assert chapter.status_code == 200 + assert chapter.json()["chapterNumber"] == 1 + assert "validated story API" in chapter.json()["content"] + + +def test_story_metrics_are_derived_from_device_shelf_state(): + created = client.post("/api/v1/stories", headers=auth_headers(), json={ + "title": "Metrics Story", + "genre": "Mystery", + "synopsis": "", + "content": "Reader-created text.", + "readTimeMinutes": 1 + }) + story_id = created.json()["id"] + initial = client.get("/api/v1/stories").json()[0] + assert initial["savesCount"] == "0" + assert initial["readsCount"] == "0" + + sync = client.post("/api/v1/shelf/sync", headers=auth_headers(), json={ + "deviceId": "11111111-1111-1111-1111-111111111111", + "items": [{ + "storyId": story_id, + "readingProgress": 0.25, + "isBookmarked": True, + "isCompleted": False, + "updatedAtUtc": datetime.now(timezone.utc).isoformat() + }] + }) + assert sync.status_code == 200 + updated = client.get("/api/v1/stories").json()[0] + assert updated["savesCount"] == "1" + assert updated["readsCount"] == "1" + def test_extract_chapters_from_text(): from main import extract_chapters_from_text @@ -105,73 +191,108 @@ def test_extract_chapters_from_text(): assert "CHAPTER II" in chapters[1]["title"] def test_get_genres_endpoint(): + created = client.post("/api/v1/stories", headers=auth_headers(), json={ + "title": "Genre Count Story", + "genre": "Gothic", + "synopsis": "", + "content": "A story.", + "readTimeMinutes": 1 + }) + assert created.status_code == 201 response = client.get("/api/v1/genres") assert response.status_code == 200 genres = response.json() - assert len(genres) >= 4 - names = [g["name"] for g in genres] - assert "Folklore" in names - assert "Gothic" in names - # Verify contract keys matching Swift GenreCategory - first = genres[0] - assert "storyCount" in first - assert "readersCount" in first - assert "description" in first - assert "imageName" in first - assert "imageUrl" in first - assert first["imageUrl"] is not None - assert first["imageUrl"].startswith("https://images.unsplash.com") - -def test_get_top_authors_endpoint(): + assert len(genres) == 1 + gothic = genres[0] + assert gothic["name"] == "Gothic" + assert gothic["storyCount"] == 1 + assert gothic["readersCount"] == "0" + assert gothic["description"] == "" + assert gothic["imageName"] == "" + assert gothic["imageUrl"] is None + + +def test_get_top_authors_endpoint(monkeypatch): + import httpx + + async def mock_get(self, url, params=None, headers=None): + if url.endswith("/trending/daily.json"): + return httpx.Response(200, json={ + "works": [{"author_name": ["Bram Stoker"]}] + }) + return httpx.Response(200, json={ + "docs": [{ + "key": "OL123A", + "name": "Bram Stoker", + "work_count": 35 + }] + }) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) response = client.get("/api/v1/authors/top") assert response.status_code == 200 writers = response.json() - assert len(writers) >= 1 - first = writers[0] - assert "name" in first - assert "storyCount" in first - assert "avatarImageName" in first - assert "rating" in first - assert "avatarImageUrl" in first - portrait_urls = [w["avatarImageUrl"] for w in writers if w.get("avatarImageUrl")] - assert len(portrait_urls) > 0 - assert portrait_urls[0].startswith("https://upload.wikimedia.org") - -def test_maria_makiling_has_live_gutenberg_cover(): - response = client.get("/api/v1/stories") + assert len(writers) == 1 + assert writers[0]["name"] == "Bram Stoker" + assert writers[0]["storyCount"] == 35 + assert writers[0]["avatarImageName"] == "" + assert writers[0]["avatarImageUrl"] == "https://covers.openlibrary.org/a/olid/OL123A-M.jpg?default=false" + assert writers[0]["rating"] is None + + +def test_gutenberg_cover_and_download_count_are_live(monkeypatch): + import httpx + + async def mock_get(self, url, params=None, headers=None): + return httpx.Response(200, json={ + "results": [{ + "id": 38269, + "title": "The Legend of Maria Makiling", + "authors": [{"name": "Rizal, Jose"}], + "subjects": ["Folklore"], + "summaries": ["Provider synopsis"], + "download_count": 281, + "formats": { + "image/jpeg": "https://www.gutenberg.org/cache/epub/38269/pg38269.cover.medium.jpg" + } + }] + }) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) + response = client.get("/api/v1/public/gutenberg?search=Maria") assert response.status_code == 200 - stories = response.json() - maria = next((s for s in stories if "Maria Makiling" in s["title"]), None) - assert maria is not None - assert maria["coverImageUrl"] == "https://www.gutenberg.org/cache/epub/38269/pg38269.cover.medium.jpg" + story = response.json()[0] + assert story["providerId"] == "38269" + assert story["coverImageUrl"] == "https://www.gutenberg.org/cache/epub/38269/pg38269.cover.medium.jpg" + assert story["providerDownloadCount"] == 281 + assert story["content"] == "" + assert story["rating"] is None + def test_get_update_feed_endpoint(): response = client.get("/api/v1/updates") assert response.status_code == 200 feed = response.json() - assert "taleOfTheDay" in feed - assert "curatorSpotlight" in feed - assert "recentSubmissions" in feed - assert "totalStories" in feed - assert feed["totalStories"] >= 10 - + assert feed["taleOfTheDay"] is None + assert feed["curatorSpotlight"] is None + assert feed["recentSubmissions"] == [] + assert feed["totalStories"] == 0 def test_create_story(): payload = { "title": "The Obsidian Tower", - "author": "Edgar Allan Poe", "genre": "Gothic", "chapter": "Chapter I", "synopsis": "A secluded fortress by the misty mere.", "content": "A secluded fortress by the misty mere stood solitary in the gloaming.", "read_time_minutes": 5 } - response = client.post("/api/v1/stories", json=payload) + response = client.post("/api/v1/stories", headers=auth_headers("Verified Author"), json=payload) assert response.status_code == 201 created = response.json() assert created["title"] == payload["title"] - assert created["author"] == payload["author"] + assert created["author"] == "Verified Author" def test_last_write_wins_resolution(): story_id = str(uuid4()) @@ -193,7 +314,8 @@ def test_last_write_wins_resolution(): } ] } - res1 = client.post("/api/v1/shelf/sync", json=initial_sync) + headers = auth_headers() + res1 = client.post("/api/v1/shelf/sync", headers=headers, json=initial_sync) assert res1.status_code == 200 reconciled = res1.json()["reconciledItems"] assert len(reconciled) == 1 @@ -212,7 +334,7 @@ def test_last_write_wins_resolution(): } ] } - res2 = client.post("/api/v1/shelf/sync", json=stale_sync) + res2 = client.post("/api/v1/shelf/sync", headers=headers, json=stale_sync) assert res2.status_code == 200 assert res2.json()["reconciledItems"][0]["readingProgress"] == 0.4 @@ -229,7 +351,7 @@ def test_last_write_wins_resolution(): } ] } - res3 = client.post("/api/v1/shelf/sync", json=fresh_sync) + res3 = client.post("/api/v1/shelf/sync", headers=headers, json=fresh_sync) assert res3.status_code == 200 assert res3.json()["reconciledItems"][0]["readingProgress"] == 0.9 @@ -250,7 +372,9 @@ def test_multi_tenant_device_shelf_isolation(): "updatedAtUtc": now.isoformat() }] } - res_a = client.post("/api/v1/shelf/sync", json=payload_a) + headers_a = auth_headers("Device A Reader") + headers_b = auth_headers("Device B Reader") + res_a = client.post("/api/v1/shelf/sync", headers=headers_a, json=payload_a) assert res_a.status_code == 200 # Device B syncs progress 0.20 for the exact same story @@ -264,11 +388,11 @@ def test_multi_tenant_device_shelf_isolation(): "updatedAtUtc": now.isoformat() }] } - res_b = client.post("/api/v1/shelf/sync", json=payload_b) + res_b = client.post("/api/v1/shelf/sync", headers=headers_b, json=payload_b) assert res_b.status_code == 200 # Query shelf for Device A via GET /api/v1/shelf - get_a = client.get(f"/api/v1/shelf?deviceId={device_a}") + get_a = client.get(f"/api/v1/shelf?deviceId={device_a}", headers=headers_a) assert get_a.status_code == 200 items_a = get_a.json() assert len(items_a) == 1 @@ -276,7 +400,7 @@ def test_multi_tenant_device_shelf_isolation(): assert items_a[0]["isBookmarked"] is True # Query shelf for Device B via GET /api/v1/shelf - get_b = client.get(f"/api/v1/shelf?deviceId={device_b}") + get_b = client.get(f"/api/v1/shelf?deviceId={device_b}", headers=headers_b) assert get_b.status_code == 200 items_b = get_b.json() assert len(items_b) == 1 @@ -285,13 +409,14 @@ def test_multi_tenant_device_shelf_isolation(): def test_package_modularity_imports(): - from core import get_db, init_db, SEED_STORIES + from core import get_db, init_db from models import Story, Chapter, ShelfItem from schemas import ( StoryDTO, ChapterDTO, ShelfSyncPayload, UserDTO, + ProfileUpdateRequest, RegisterRequest, LoginRequest, AuthResponse @@ -308,7 +433,6 @@ def test_package_modularity_imports(): assert callable(get_db) assert callable(init_db) - assert len(SEED_STORIES) >= 10 assert Story is not None assert Chapter is not None assert ShelfItem is not None @@ -316,6 +440,7 @@ def test_package_modularity_imports(): assert ChapterDTO is not None assert ShelfSyncPayload is not None assert UserDTO is not None + assert ProfileUpdateRequest is not None assert RegisterRequest is not None assert LoginRequest is not None assert AuthResponse is not None @@ -330,18 +455,126 @@ def test_package_modularity_imports(): assert callable(auth_service.get_current_user) assert api_router is not None -def test_get_gutenberg_public_stories(): - response = client.get("/api/v1/public/gutenberg?topic=folklore") + +def test_get_gutenberg_public_stories(monkeypatch): + import httpx + + async def mock_get(self, url, params=None, headers=None): + return httpx.Response(200, json={ + "results": [{ + "id": 84, + "title": "Frankenstein", + "authors": [{"name": "Shelley, Mary"}], + "subjects": ["Science fiction"], + "summaries": ["Source synopsis"], + "download_count": 940, + "formats": {"image/jpeg": "https://www.gutenberg.org/cover.jpg"} + }] + }) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) + response = client.get("/api/v1/public/gutenberg?topic=fiction") assert response.status_code == 200 data = response.json() - assert isinstance(data, list) - assert len(data) > 0 + assert len(data) == 1 + assert data[0]["sourceProvider"] == "GUTENBERG" + assert data[0]["providerId"] == "84" + assert data[0]["providerDownloadCount"] == 940 + + +def test_gutenberg_live_chapters_use_source_text_and_stable_ids(monkeypatch): + from services import gutenberg as gutenberg_service + + source_text = """ +*** START OF THE PROJECT GUTENBERG EBOOK SAMPLE *** + +CHAPTER I. THE FIRST CHAPTER +This is the complete source text for the first chapter. It has enough words to be a real chapter. + +CHAPTER II. THE SECOND CHAPTER +This is the complete source text for the second chapter. It also has enough words to be a real chapter. +*** END OF THE PROJECT GUTENBERG EBOOK SAMPLE *** +""" + + async def fetch_text(_gutenberg_id): + return source_text + + monkeypatch.setattr(gutenberg_service, "_fetch_gutenberg_text", fetch_text) + first_response = client.get("/api/v1/public/gutenberg/1342/chapters") + second_response = client.get("/api/v1/public/gutenberg/1342/chapters") + + assert first_response.status_code == 200 + chapters = first_response.json() + assert len(chapters) == 2 + assert chapters[0]["storyId"] == "00000000-0000-0000-0000-00000000053e" + assert chapters[0]["chapterNumber"] == 1 + assert "complete source text" in chapters[0]["content"] + assert second_response.status_code == 200 + assert chapters[0]["id"] == second_response.json()[0]["id"] + +def test_gutenberg_ingest_uses_live_metadata_and_is_idempotent(monkeypatch): + import httpx + + source_text = """ +*** START OF THE PROJECT GUTENBERG EBOOK SAMPLE *** + +CHAPTER I. THE OPENING +The complete chapter comes from the provider text and contains enough words to read. + +CHAPTER II. THE CONTINUATION +A second complete chapter also comes from the provider and has sufficient text content. +*** END OF THE PROJECT GUTENBERG EBOOK SAMPLE *** +""" + + async def mock_get(self, url, params=None, headers=None): + if "gutendex.com" in url: + return httpx.Response(200, json={"results": [{ + "id": 99, + "title": "Provider Book", + "authors": [{"name": "Doe, Jane"}], + "subjects": ["Live Genre"], + "summaries": ["Provider summary"], + "download_count": 73, + "formats": {"image/jpeg": "https://www.gutenberg.org/cover.jpg"} + }]}) + return httpx.Response(200, text=source_text) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) + first = client.post("/api/v1/stories/ingest/99") + second = client.post("/api/v1/stories/ingest/99") + + assert first.status_code == 201 + assert second.status_code == 201 + story = first.json() + assert story["id"] == second.json()["id"] + assert story["title"] == "Provider Book" + assert story["author"] == "Jane Doe" + assert story["synopsis"] == "Provider summary" + assert story["providerId"] == "99" + assert story["providerDownloadCount"] == 73 + assert story["totalChapters"] == 2 + assert "complete chapter comes from the provider" in story["chapters"][0]["content"] + assert len(second.json()["chapters"]) == 2 + + +def test_gutenberg_catalog_failure_does_not_return_local_catalog(monkeypatch): + import httpx + + async def unavailable(self, url, params=None, headers=None): + raise httpx.ConnectError("private transport detail") + + monkeypatch.setattr(httpx.AsyncClient, "get", unavailable) + response = client.get("/api/v1/public/gutenberg") + assert response.status_code == 502 + assert response.json()["detail"] == "Gutenberg catalog service is unavailable" + def test_register_and_login_auth_flow(): reg_payload = { - "email": "reader.roosc@fable.app", + "email": "reader@example.test", "password": "SecurePassword123!", - "name": "Roosc Zaño" + "name": "Test Reader", + "handle": "test-reader" } # 1. Register reg_res = client.post("/api/v1/auth/register", json=reg_payload) @@ -349,27 +582,60 @@ def test_register_and_login_auth_flow(): reg_data = reg_res.json() assert "accessToken" in reg_data assert reg_data["tokenType"] == "bearer" - assert reg_data["user"]["email"] == "reader.roosc@fable.app" - assert reg_data["user"]["name"] == "Roosc Zaño" + assert reg_data["user"]["email"] == "reader@example.test" + assert reg_data["user"]["name"] == "Test Reader" + assert reg_data["user"]["handle"] == "@test-reader" + assert reg_data["user"]["bio"] == "" + assert reg_data["user"]["avatarImageName"] is None token = reg_data["accessToken"] # 2. Get /auth/me with Bearer token me_res = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) assert me_res.status_code == 200 me_data = me_res.json() - assert me_data["email"] == "reader.roosc@fable.app" - assert me_data["name"] == "Roosc Zaño" + assert me_data["email"] == "reader@example.test" + assert me_data["name"] == "Test Reader" + assert me_data["handle"] == "@test-reader" # 3. Login login_payload = { - "email": "reader.roosc@fable.app", + "email": "reader@example.test", "password": "SecurePassword123!" } login_res = client.post("/api/v1/auth/login", json=login_payload) assert login_res.status_code == 200 login_data = login_res.json() assert "accessToken" in login_data - assert login_data["user"]["email"] == "reader.roosc@fable.app" + assert login_data["user"]["email"] == "reader@example.test" + assert login_data["user"]["handle"] == "@test-reader" + +def test_authenticated_profile_update_and_rejection(): + response = client.post("/api/v1/auth/register", json={ + "email": "profile@example.test", + "password": "ProfilePassword123!", + "name": "Profile Reader", + "handle": "reader" + }) + token = response.json()["accessToken"] + headers = {"Authorization": f"Bearer {token}"} + + updated = client.patch("/api/v1/auth/me", headers=headers, json={ + "name": "Updated Reader", + "handle": "updated-reader", + "bio": "A profile saved through the authenticated API." + }) + assert updated.status_code == 200 + assert updated.json()["name"] == "Updated Reader" + assert updated.json()["handle"] == "@updated-reader" + assert updated.json()["bio"] == "A profile saved through the authenticated API." + + unauthorized = client.patch("/api/v1/auth/me", json={ + "name": "Intruder", + "handle": "intruder", + "bio": "" + }) + assert unauthorized.status_code == 401 + def test_register_duplicate_email_conflict(): payload = { @@ -412,92 +678,299 @@ def test_auth_me_unauthorized(): assert res2.status_code == 401 def test_multi_format_content_and_provider_serialization(): - response = client.get("/api/v1/stories") - assert response.status_code == 200 - stories = response.json() - - # Check that stories have contentFormat and sourceProvider keys - for s in stories: - assert "contentFormat" in s - assert "sourceProvider" in s - - # Verify Manga sample is present with MANGADEX provider - manga_story = next((s for s in stories if s["contentFormat"] == "MANGA"), None) - assert manga_story is not None - assert manga_story["sourceProvider"] == "MANGADEX" - assert manga_story["title"] == "Chainsaw Devil: Special Edition" - assert manga_story["badgeText"] == "MANGA" - - # Verify Standard Ebooks sample is present - se_story = next((s for s in stories if s["sourceProvider"] == "STANDARD_EBOOKS"), None) - assert se_story is not None - assert se_story["contentFormat"] == "PROSE" - assert "standardebooks.org" in se_story["coverImageUrl"] + response = client.post("/api/v1/stories", headers=auth_headers(), json={ + "title": "Writer Created Graphic Story", + "genre": "Manga", + "chapter": "Issue One", + "synopsis": "A writer-created graphic story.", + "content": "", + "readTimeMinutes": 0, + "contentFormat": "MANGA" + }) + assert response.status_code == 201 + story = response.json() + assert story["contentFormat"] == "MANGA" + assert story["sourceProvider"] == "FABLE_ORIGINAL" + assert story["title"] == "Writer Created Graphic Story" + assert story["coverImageUrl"] is None + assert story["rating"] is None + def test_manga_chapter_page_urls_contract(): - response = client.get("/api/v1/stories") + created = client.post("/api/v1/stories", headers=auth_headers(), json={ + "title": "Writer Created Graphic Story", + "genre": "Manga", + "chapter": "Issue One", + "synopsis": "A writer-created graphic story.", + "content": "", + "readTimeMinutes": 0, + "contentFormat": "MANGA" + }) + assert created.status_code == 201 + story_id = created.json()["id"] + response = client.get(f"/api/v1/stories/{story_id}/chapters") assert response.status_code == 200 - stories = response.json() - manga_story = next(s for s in stories if s["contentFormat"] == "MANGA") - - ch_res = client.get(f"/api/v1/stories/{manga_story['id']}/chapters") - assert ch_res.status_code == 200 - chapters = ch_res.json() - assert len(chapters) >= 1 - ch1 = chapters[0] - assert "pageUrls" in ch1 - assert len(ch1["pageUrls"]) >= 3 - assert all(url.startswith("https://") for url in ch1["pageUrls"]) + chapters = response.json() + assert len(chapters) == 1 + assert chapters[0]["pageUrls"] == [] + + +def test_legacy_demo_manga_images_are_not_exposed(): + from core.database import get_db + + created = client.post("/api/v1/stories", headers=auth_headers(), json={ + "title": "External Media Story", + "genre": "Manga", + "chapter": "Chapter 1", + "synopsis": "", + "content": "", + "readTimeMinutes": 0, + "contentFormat": "MANGA" + }) + story_id = created.json()["id"] + connection = get_db() + with connection: + connection.execute( + "UPDATE stories SET cover_image_url = ? WHERE id = ?", + ("https://images.unsplash.com/photo-demo.jpg", story_id), + ) + connection.execute( + "UPDATE chapters SET page_urls = ? WHERE story_id = ?", + ( + '["https://images.unsplash.com/panel-demo.jpg",' + '"https://uploads.mangadex.org/covers/demo/cover.jpg"]', + story_id, + ), + ) + connection.close() + + detail = client.get(f"/api/v1/stories/{story_id}").json() + chapters = client.get(f"/api/v1/stories/{story_id}/chapters").json() + assert detail["coverImageUrl"] is None + assert detail["chapters"][0]["pageUrls"] == [ + "https://uploads.mangadex.org/covers/demo/cover.jpg" + ] + assert chapters[0]["pageUrls"] == detail["chapters"][0]["pageUrls"] + def test_create_manga_story_via_api(): payload = { "title": "Cyber Scribe Manga", - "author": "Fable Studios", "genre": "Manga", "chapter": "Issue #1", "synopsis": "A cyberpunk illustrator discovers a quill that draws reality.", "content": "", "readTimeMinutes": 6, - "contentFormat": "MANGA", - "sourceProvider": "FABLE_ORIGINAL" + "contentFormat": "MANGA" } - res = client.post("/api/v1/stories", json=payload) + res = client.post("/api/v1/stories", headers=auth_headers("Verified Author"), json=payload) assert res.status_code == 201 created = res.json() assert created["contentFormat"] == "MANGA" assert created["sourceProvider"] == "FABLE_ORIGINAL" assert created["title"] == "Cyber Scribe Manga" -def test_internal_health_and_media_invariants(): - # 1. Update feed contract - feed_res = client.get("/api/v1/updates") - assert feed_res.status_code == 200 - feed = feed_res.json() - assert feed["totalStories"] > 0 - assert "timestampUtc" in feed - if feed.get("taleOfTheDay"): - assert "title" in feed["taleOfTheDay"] - assert "coverImageUrl" in feed["taleOfTheDay"] - - # 2. Genre image and metadata invariants - genre_res = client.get("/api/v1/genres") - assert genre_res.status_code == 200 - genres = genre_res.json() - for g in genres: - assert g["storyCount"] >= 0 - assert len(g["readersCount"]) > 0 - assert g["imageUrl"].startswith("https://") - - # 3. Author portrait and rating invariants - author_res = client.get("/api/v1/authors/top") - assert author_res.status_code == 200 - authors = author_res.json() - for a in authors: - assert a["rating"] >= 4.0 - assert a["storyCount"] >= 0 - if a.get("avatarImageUrl"): - assert a["avatarImageUrl"].startswith("https://") +def test_internal_health_and_media_invariants(monkeypatch): + import httpx + + async def empty_provider(self, url, params=None, headers=None): + return httpx.Response(200, json={"works": []}) + + monkeypatch.setattr(httpx.AsyncClient, "get", empty_provider) + feed = client.get("/api/v1/updates").json() + assert feed["totalStories"] == 0 + assert feed["taleOfTheDay"] is None + assert feed["curatorSpotlight"] is None + assert feed["recentSubmissions"] == [] + + genres = client.get("/api/v1/genres").json() + assert genres == [] + assert client.get("/api/v1/authors/top").json() == [] + + +def test_env_db_path_override(tmp_path, monkeypatch): + from core.database import get_db + override_path = tmp_path / "override.sqlite3" + monkeypatch.setenv("FABLE_DB_PATH", str(override_path)) + connection = get_db() + try: + assert override_path.exists() + database_path = connection.execute("PRAGMA database_list").fetchone()["file"] + assert database_path == str(override_path) + finally: + connection.close() + + +def test_shelf_sync_requires_authentication_and_isolates_accounts(): + device_id = str(uuid4()) + story_id = str(uuid4()) + payload = { + "deviceId": device_id, + "items": [{ + "storyId": story_id, + "readingProgress": 0.6, + "isBookmarked": True, + "isCompleted": False, + "updatedAtUtc": datetime.now(timezone.utc).isoformat() + }] + } + assert client.post("/api/v1/shelf/sync", json=payload).status_code == 401 + assert client.get(f"/api/v1/shelf?deviceId={device_id}").status_code == 401 + + first_user = auth_headers("First Shelf Owner") + second_user = auth_headers("Second Shelf Owner") + first_sync = client.post("/api/v1/shelf/sync", headers=first_user, json=payload) + assert first_sync.status_code == 200 + + first_items = client.get(f"/api/v1/shelf?deviceId={device_id}", headers=first_user) + second_items = client.get(f"/api/v1/shelf?deviceId={device_id}", headers=second_user) + assert len(first_items.json()) == 1 + assert second_items.json() == [] + + +def test_reading_statistics_are_authenticated_idempotent_and_account_scoped(): + user_a = auth_headers("Reading Stats A") + user_b = auth_headers("Reading Stats B") + session_id = str(uuid4()) + request = { + "id": session_id, + "storyId": str(uuid4()), + "secondsRead": 125, + "readAtUtc": datetime.now(timezone.utc).isoformat(), + "isCompleted": True, + } + assert client.post("/api/v1/auth/me/reading-sessions", json=request).status_code == 401 + first = client.post("/api/v1/auth/me/reading-sessions", headers=user_a, json=request) + duplicate = client.post("/api/v1/auth/me/reading-sessions", headers=user_a, json=request) + assert first.status_code == 204 + assert duplicate.status_code == 204 + + stats_a = client.get("/api/v1/auth/me/stats", headers=user_a) + stats_b = client.get("/api/v1/auth/me/stats", headers=user_b) + assert stats_a.status_code == 200 + assert stats_a.json()["storiesReadCount"] == 1 + assert stats_a.json()["totalMinutesRead"] == 2 + assert stats_a.json()["streakDays"] == 1 + assert stats_b.json() == { + "storiesReadCount": 0, + "totalMinutesRead": 0, + "streakDays": 0, + } + assert client.get("/api/v1/auth/me/stats").status_code == 401 + + +def test_bearer_session_survives_process_cache_loss_and_logout_revokes_it(): + import importlib + from services import auth_service + + headers = auth_headers("Persistent Session Reader") + assert client.get("/api/v1/auth/me", headers=headers).status_code == 200 + + # Authentication state is read from SQLite, not process-local memory. + importlib.reload(auth_service) + assert client.get("/api/v1/auth/me", headers=headers).status_code == 200 + + logout = client.post("/api/v1/auth/logout", headers=headers) + assert logout.status_code == 204 + assert client.get("/api/v1/auth/me", headers=headers).status_code == 401 + + +@pytest.mark.anyio +async def test_get_gutenberg_story_by_id_returns_live_metadata(monkeypatch): + import httpx + from services import gutenberg + + async def mock_get(self, url, params=None, headers=None): + assert params == {"ids": "1342"} + return httpx.Response(200, json={"results": [{ + "id": 1342, + "title": "Pride and Prejudice", + "authors": [{"name": "Austen, Jane"}], + "subjects": ["Fiction"], + "summaries": ["Provider synopsis."], + "formats": {"image/jpeg": "https://www.gutenberg.org/cover.jpg"}, + "download_count": 500, + }]}) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) + story = await gutenberg.get_gutenberg_story_by_id(1342) + assert story.id.int == 1342 + assert story.title == "Pride and Prejudice" + assert story.author == "Jane Austen" + assert story.source_provider == "GUTENBERG" + assert story.cover_image_url == "https://www.gutenberg.org/cover.jpg" + + + +def test_init_db_removes_only_legacy_demo_stories_and_chapters(): + from core.database import LEGACY_DEMO_STORY_IDS, get_db + + connection = get_db() + try: + for index, story_id in enumerate(LEGACY_DEMO_STORY_IDS): + connection.execute( + """ + INSERT INTO stories ( + id, title, author, genre, chapter, synopsis, content, + read_time_minutes, created_at_utc, updated_at_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + story_id, f"Legacy {index}", "Demo Author", "Classic", + "Chapter 1", "Legacy synopsis", "Legacy content", 1, + "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00", + ), + ) + connection.execute( + """ + INSERT INTO chapters ( + id, story_id, chapter_number, title, content, word_count, created_at_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + f"legacy-chapter-{index}", story_id, 1, "Demo Chapter", + "Legacy chapter text", 3, "2026-01-01T00:00:00+00:00", + ), + ) + authored_story_id = str(uuid4()) + connection.execute( + """ + INSERT INTO stories ( + id, title, author, genre, chapter, synopsis, content, + read_time_minutes, created_at_utc, updated_at_utc, owner_user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + authored_story_id, "Dracula", "A Fable Author", "Mystery", + "Chapter 1", "Authored synopsis", "Authored content", 1, + "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00", str(uuid4()), + ), + ) + connection.commit() + finally: + connection.close() + init_db() + init_db() + connection = get_db() + try: + legacy_story_count = connection.execute( + f"SELECT COUNT(*) FROM stories WHERE id IN ({', '.join('?' for _ in LEGACY_DEMO_STORY_IDS)})", + LEGACY_DEMO_STORY_IDS, + ).fetchone()[0] + legacy_chapter_count = connection.execute( + f"SELECT COUNT(*) FROM chapters WHERE story_id IN ({', '.join('?' for _ in LEGACY_DEMO_STORY_IDS)})", + LEGACY_DEMO_STORY_IDS, + ).fetchone()[0] + authored_story = connection.execute( + "SELECT id FROM stories WHERE id = ?", (authored_story_id,) + ).fetchone() + finally: + connection.close() + + assert legacy_story_count == 0 + assert legacy_chapter_count == 0 + assert authored_story["id"] == authored_story_id diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e232532 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + fable-backend: + build: + context: . + dockerfile: backend/Dockerfile + args: + FABLE_UID: ${FABLE_UID:-1000} + FABLE_GID: ${FABLE_GID:-1000} + ports: + - "${FABLE_PORT:-8000}:8000" + volumes: + - ./data:/app/data + env_file: + - .env + environment: + FABLE_DB_PATH: /app/data/fable.sqlite3 + restart: unless-stopped diff --git a/docs/final_milestone/01_BACKEND_SECURITY_AND_DATA.md b/docs/final_milestone/01_BACKEND_SECURITY_AND_DATA.md index de2ce61..6da225c 100644 --- a/docs/final_milestone/01_BACKEND_SECURITY_AND_DATA.md +++ b/docs/final_milestone/01_BACKEND_SECURITY_AND_DATA.md @@ -2,7 +2,7 @@ **Domain:** Core Data Architecture, Multi-Tenancy & Security **Status:** Completed & Verified -**Feature Branch:** `feature/final-milestone` +**Feature Branch:** `feature/live-data-and-button-wiring` --- @@ -11,7 +11,8 @@ ### 1.1 Multi-Tenant Cross-Contamination In the initial prototype, the SQLite database table `shelf_items` defined `story_id TEXT PRIMARY KEY`. - **The Defect:** When multiple client devices or users synchronized their shelves with the backend, they shared and overwrote a single row per story. If User A marked *Dracula* as completed at progress 1.0, and User B opened *Dracula* at progress 0.1, User B's sync either overwrote User A's record or was rejected as a stale timestamp against an entirely different person's progress. -- **The Architectural Fix:** We altered the database primary key to a compound key: `PRIMARY KEY (device_id, story_id)`. Every shelf item record is strictly namespaced by the requesting device. Additionally, an index `idx_shelf_items_device` was added on `device_id` to guarantee $O(\log N)$ retrieval times when fetching a full shelf. +- **The Initial Fix:** Shelf records were first partitioned by device, but a caller-controlled device identifier did not prove account ownership. +- **The Current Contract:** Authenticated shelf records are stored in `account_shelf_items` with key `(owner_user_id, device_id, story_id)`. Both reads and writes resolve the account from the bearer token. Legacy device-only rows are not exposed by these endpoints. ### 1.2 Offset-Naive vs Offset-Aware Datetime Crashes - **The Defect:** When SQLite stores ISO-8601 strings (e.g. `2026-09-20T15:30:00Z` or `2026-09-20T15:30:00`), Python's `datetime.fromisoformat()` can yield either an offset-naive datetime or an offset-aware datetime depending on the trailing `Z` or timezone offset. When comparing `client_time > server_time`, Python raises: @@ -25,14 +26,15 @@ In the initial prototype, the SQLite database table `shelf_items` defined `story - **The Architectural Fix:** Configured Pydantic v2 `Field(..., alias="storyId", serialization_alias="storyId")` across all client-facing DTOs in `backend/schemas/schemas.py`, enabling zero-cost deserialization in Swift while preserving Pythonic snake_case internally. ### 1.4 Secure User Authentication & Session Handling -- **The Requirement:** Provide NIST-compliant PBKDF2 password hashing (SHA-256, 100,000 iterations, 16-byte cryptographically secure salt) and stateless bearer token issuance for reader account creation, profile persistence, and multi-device identity. -- **The Architectural Fix:** Implemented `backend/services/auth_service.py` with `hash_password`, constant-time `verify_password` using `hmac.compare_digest`, `register_user`, `login_user`, and `get_current_user`. Exposed via `/api/v1/auth` endpoints with proper `try...finally: conn.close()` resource cleanup. +- **The Requirement:** Hash account passwords with salted PBKDF2-HMAC-SHA256 and support bearer authentication across backend process restarts with explicit session expiry and revocation. +- **The Architectural Fix:** Implemented salted PBKDF2-HMAC-SHA256 password hashes with constant-time comparison. Random bearer tokens are represented in SQLite by SHA-256 digests, expire after 30 days, and are revoked by `/api/v1/auth/logout`. Authenticated reading-session events and `/api/v1/auth/me/stats` provide account-scoped statistics. --- ## 2. API Contract & Endpoint Specifications ### 2.1 Shelf Synchronization: `POST /api/v1/shelf/sync` +- **Authorization:** Required. Shelf rows are keyed by account, device, and story. - **Request Payload (`ShelfSyncPayload`):** ```json { @@ -48,16 +50,21 @@ In the initial prototype, the SQLite database table `shelf_items` defined `story ] } ``` -- **Reconciliation Logic:** Last-Write-Wins (LWW) resolution isolated by `(device_id, story_id)`. If the client timestamp is newer than the stored record for that device, the record updates. If older, the server returns the authoritative server state. +- **Reconciliation Logic:** Last-Write-Wins (LWW) resolution is isolated by `(owner_user_id, device_id, story_id)`. Both shelf reads and writes require a valid bearer token; a device identifier alone cannot access another account's data. ### 2.2 Shelf Retrieval: `GET /api/v1/shelf` -- **Parameters:** `device_id: UUID` (query parameter `deviceId`). -- **Response:** List of `ShelfSyncItemDTO` belonging exclusively to that `device_id`. +- **Parameters:** `deviceId: UUID` query parameter and `Authorization: Bearer `. +- **Response:** List of `ShelfSyncItemDTO` belonging exclusively to the authenticated account and device. -### 2.3 User Authentication: `/api/v1/auth` +### 2.3 Reading Activity: `/api/v1/auth/me/reading-sessions` and `/api/v1/auth/me/stats` +- **Authorization:** Required. Session ingestion is idempotent by event ID; the response reports completed stories, minutes read, and consecutive UTC reading days. + +### 2.4 User Authentication: `/api/v1/auth` - **`POST /api/v1/auth/register`**: Registers a new user with PBKDF2-SHA256 salted password hashing. Returns HTTP 201 Created and `AuthResponse` (`accessToken`, `user`). - **`POST /api/v1/auth/login`**: Authenticates credentials and issues a secure Bearer token. Returns `AuthResponse`. - **`GET /api/v1/auth/me`**: Returns current authenticated user profile (`UserDTO`) based on `Authorization: Bearer `. +- **`POST /api/v1/auth/logout`**: Revokes the active bearer session. +- Bearer token bytes are never stored in the database; SHA-256 digests are stored with a 30-day expiry. --- @@ -65,39 +72,25 @@ In the initial prototype, the SQLite database table `shelf_items` defined `story | File Path | Action | Description | | :--- | :--- | :--- | -| `backend/core/database.py` | VERIFIED | Added compound PK `(device_id, story_id)` to `shelf_items`, created `users` table with `idx_users_email`. | +| `backend/core/database.py` | VERIFIED | Creates account-scoped shelf records, users, expiring session digests, and reading-session events. | | `backend/schemas/schemas.py` | VERIFIED | Added camelCase serialization aliases to shelf DTOs; defined `UserDTO`, `LoginRequest`, `RegisterRequest`, `AuthResponse`. | | `backend/schemas/__init__.py` | VERIFIED | Exported auth DTOs for modular package access. | -| `backend/services/shelf_sync.py` | VERIFIED | Enforces `device_id` partitioning across SELECT/INSERT/UPDATE; normalizes ISO timestamps with `to_utc()`; implemented `get_shelf()`. | +| `backend/services/shelf_sync.py` | VERIFIED | Enforces user and device partitioning across SELECT/INSERT/UPDATE; normalizes ISO timestamps with `to_utc()`. | | `backend/api/v1/endpoints/shelf.py` | VERIFIED | Added `GET /shelf` endpoint with query validation against `deviceId`. | | `backend/services/auth_service.py` | VERIFIED | Security service providing PBKDF2 password hashing, verification, token sessions, and database cleanup. | | `backend/services/__init__.py` | VERIFIED | Exported `auth_service` and `get_shelf`. | -| `backend/api/v1/endpoints/auth.py` | VERIFIED | Authentication router exposing register, login, and me endpoints. | +| `backend/api/v1/endpoints/auth.py` | VERIFIED | Authentication router exposes register, login, profile update, logout, owned stories, reading events, and statistics. | | `backend/api/v1/api.py` | VERIFIED | Mounted `auth.router` into API v1. | -| `backend/test_main.py` | VERIFIED | Comprehensive test suite covering LWW, multi-tenant isolation, auth flow, duplicate registration conflicts, invalid passwords, and unauthorized access (17/17 tests passing). | +| `backend/test_main.py` | VERIFIED | 36 tests cover provider contracts, account ownership, shelf isolation, persistent sessions, statistics, and authorization. | --- ## 4. Verification & Test Evidence -- **Test Suite Command:** `PYTHONPATH=backend .venv/bin/pytest backend/test_main.py` -- **Result:** `17 passed, 2 warnings in 4.33s` -- **Verified Test Cases:** - 1. `test_health_check` - 2. `test_get_stories_includes_full_editorial_catalog` - 3. `test_story_dto_camelcase_serialization_contract` - 4. `test_get_story_chapters` - 5. `test_extract_chapters_from_text` - 6. `test_get_genres_endpoint` - 7. `test_get_top_authors_endpoint` - 8. `test_get_update_feed_endpoint` - 9. `test_create_story` - 10. `test_last_write_wins_resolution` - 11. `test_multi_tenant_device_shelf_isolation` - 12. `test_package_modularity_imports` - 13. `test_get_gutenberg_public_stories` - 14. `test_register_and_login_auth_flow` - 15. `test_register_duplicate_email_conflict` - 16. `test_login_invalid_password` - 17. `test_auth_me_unauthorized` +- **Container Build:** `docker build -t fable-backend:test -f backend/Dockerfile .` +- **Test Command:** `docker run --rm --entrypoint pytest fable-backend:test test_main.py -v` +- **Result:** `36 passed, 1 warning` (Starlette TestClient deprecation notice). +- **Coverage includes:** Empty fresh catalog, DTO aliases and privacy, live provider metadata, chapter retrieval, owner-only publishing, account-isolated shelf sync, idempotent reading sessions, stats isolation, persisted bearer sessions, logout revocation, and database-path override, and exact-ID legacy catalog cleanup. +- The iOS simulator build and on-device diagnostics were not run in this Fedora workspace because Swift, Xcode, and `xcrun` are unavailable. +- Startup cleanup deletes chapters and story rows for the twelve exact UUIDs from the retired demo catalog; user-authored stories with similar titles are preserved. diff --git a/docs/final_milestone/MASTER_PROGRESS_LOG.md b/docs/final_milestone/MASTER_PROGRESS_LOG.md index 2aa46ba..f2bf6e2 100644 --- a/docs/final_milestone/MASTER_PROGRESS_LOG.md +++ b/docs/final_milestone/MASTER_PROGRESS_LOG.md @@ -3,26 +3,26 @@ **Course:** ITWM101 (Integrative Programming & Technologies 2) **Developer:** Roosc Zaño **Role:** Lead Systems Engineer & Solutions Architect -**Active Feature Branch:** `feature/final-milestone` +**Active Feature Branch:** `feature/live-data-and-button-wiring` --- ## Executive Summary & System Objectives -The Final Milestone transforms **Fable** from an editorial prototype into a fully production-grade, end-to-end integrated micro-fiction reading platform. All cosmetic dummy actions, hardcoded placeholders, and static assumptions are systematically replaced with live contracts, multi-tenant persistence, resilient media pipelines, and accessible audio engines. +This progress log tracks the transition of **Fable** from an editorial prototype to a provider-backed, offline-first reading platform. The backend has 36 passing container tests covering content contracts, account isolation, live statistics, and persistent sessions. The iOS changes are implemented but cannot be compiled or run in this Fedora workspace because Swift and Xcode are unavailable. An approved startup migration removes the twelve exact legacy demo story IDs and their chapters from existing SQLite databases on the next backend start. ### The 8 Core Architectural Domains | Domain | Document Reference | Status | Scope Description | | :--- | :--- | :--- | :--- | -| **1. Backend Security, Data Isolation & Auth** | [`01_BACKEND_SECURITY_AND_DATA.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/01_BACKEND_SECURITY_AND_DATA.md) | **Complete** | Multi-tenant compound keys, UTC datetime normalization, PBKDF2 authentication, Swift-parity JSON serialization aliases. | -| **2. Live Media & Content Pipelines** | [`02_LIVE_MEDIA_AND_CONTENT.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/02_LIVE_MEDIA_AND_CONTENT.md) | **Complete** | Async remote image loading for covers and author avatars, Gutendex live ingestion, resilient vector fallbacks, persistent device identity. | +| **1. Backend Security, Data Isolation & Auth** | [`01_BACKEND_SECURITY_AND_DATA.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/01_BACKEND_SECURITY_AND_DATA.md) | **Complete** | Account/device/story shelf isolation, expiring persisted bearer sessions, PBKDF2 authentication, reading-event statistics, and camelCase response aliases. | +| **2. Live Media & Content Pipelines** | [`02_LIVE_MEDIA_AND_CONTENT.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/02_LIVE_MEDIA_AND_CONTENT.md) | **Complete** | Provider cover URLs, on-demand Gutenberg chapters, clipped remote images, offline media cache, and per-account device identity. | | **3. Reader Pacing & Word Tokenization** | [`03_READER_PACING_AND_WORD_TOKENIZATION.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/03_READER_PACING_AND_WORD_TOKENIZATION.md) | **Complete** | Zoom-invariant word counting, multi-whitespace tokenization, dynamic page chunking responsive to font scale. | -| **4. UI Interactions & Voice Accessibility** | [`04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md) | **Complete** | 100% interactive button bindings, `AVSpeechSynthesizer` voice selector sheet, active chapter narration state machine. | -| **5. Live Metadata & Pure User-State Isolation** | [`03_LIVE_METADATA_AND_USER_STATE_PURGING.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/03_LIVE_METADATA_AND_USER_STATE_PURGING.md) | **Complete** | Elimination of artificial seed bookmarks & stat floors; live official author portraits, high-res genre banners, Gutenberg covers, and authentic empty states. | +| **4. UI Interactions & Voice Accessibility** | [`04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md) | **Implemented; device check pending** | Actions are wired across writing, profile, library, reader, shelf, and settings; `AVSpeechSynthesizer` uses installed OS voices. | +| **5. Live Metadata & Pure User-State Isolation** | [`03_LIVE_METADATA_AND_USER_STATE_PURGING.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/03_LIVE_METADATA_AND_USER_STATE_PURGING.md) | **Startup cleanup migration committed** | Fresh installs have no bundled story catalog; provider and authored content uses live metadata and account-owned shelf/profile state. | | **6. Multi-Format & Manga Architecture** | [`05_MULTI_FORMAT_AND_MANGA_ARCHITECTURE.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/05_MULTI_FORMAT_AND_MANGA_ARCHITECTURE.md) | **Complete** | Polymorphic reader dispatch, continuous vertical Webtoon scroll, horizontal swipe paging, manga panel ingestion pipeline. | -| **7. Testing Suites & ADR Baseline** | [`04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md) | **Complete** | 22/22 pytest automated backend suite, Swift unit tests, ADR documentation, and academic lab reproducibility. | -| **8. App Health Diagnostics & Anti-Overlap Invariants** | [`MASTER_PROGRESS_LOG.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/MASTER_PROGRESS_LOG.md) | **Complete** | Image boundary clipping guarantees, on-device `SystemDiagnosticsSheet`, and `AppHealthTests` automated verification contract. | +| **7. Testing Suites & ADR Baseline** | [`04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md) | **Backend verified; iOS run pending** | 36/36 backend tests pass in Docker. Swift build, Swift tests, and device diagnostics require Xcode and remain unrun here. | +| **8. App Health Diagnostics & Anti-Overlap Invariants** | [`MASTER_PROGRESS_LOG.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/MASTER_PROGRESS_LOG.md) | **Implemented; device check pending** | Image boundary clipping, `SystemDiagnosticsSheet`, and `AppHealthTests` are in source; execution requires Xcode. | --- @@ -30,17 +30,17 @@ The Final Milestone transforms **Fable** from an editorial prototype into a full | Feature / UI Component | Original State (Midterm) | Final Milestone Target | Status | Implementation File(s) | | :--- | :--- | :--- | :--- | :--- | -| **Shelf Sync API** | Single-tenant overwrite (`story_id` PK) | Multi-tenant isolation `(device_id, story_id)` with UTC normalization | **Complete** | `backend/services/shelf_sync.py`, `backend/core/database.py` | -| **User Authentication** | Hardcoded profile data | PBKDF2 hash, JWT/Bearer token, register & login endpoints | **Complete** | `backend/services/auth_service.py`, `backend/api/v1/endpoints/auth.py` | -| **Device ID Persistence** | Ephemeral `UUID()` regenerated every sync | Hardware/vendor-backed `UserDefaults` UUID (`fable_device_id`) | **Complete** | `Features/Library/ViewModels/StoryStore.swift` | -| **Story Covers & Avatars** | Local asset catalogs only (`cover_dracula`) | Live URL fetch via `AsyncImage` with procedural fallback + offline asset catalog | **Complete** | `Features/Library/Models/Story.swift`, `Features/Library/Views/ExploreView.swift`, `Features/Shelf/Views/ProfileView.swift`, `Features/Reader/Views/ReaderView.swift` | +| **Shelf Sync API** | Unauthenticated device-only records | Bearer-authenticated isolation `(owner_user_id, device_id, story_id)` with UTC LWW | **Complete** | `backend/services/shelf_sync.py`, `backend/core/database.py` | +| **User Authentication** | Hardcoded profile data and process-local token lookup | PBKDF2 hashes, persisted hashed bearer sessions, 30-day expiry, logout revocation | **Complete** | `backend/services/auth_service.py`, `backend/api/v1/endpoints/auth.py` | +| **Device ID Persistence** | Shared device identifier across accounts | Per-account `UserDefaults` device UUID and offline shelf snapshot | **Complete** | `Features/Library/ViewModels/StoryStore.swift` | +| **Story Covers & Avatars** | Title-specific local art and duplicate demo images | Provider URLs, clipped remote rendering, neutral SF Symbol placeholders, URL-keyed offline image cache | **Complete** | `Features/Library/Models/Story.swift`, `Core/Components/FableImageView.swift`, `Core/Services/DiskImageCache.swift` | | **Frontend Topology** | Flat technical layering (`Views/`, `ViewModels/`) | Feature-driven vertical slices (`App/`, `Core/`, `Features/{Auth,Library,Reader,Shelf,Write}`) | **Complete** | `frontend/FableApp/Features/`, `frontend/FableApp/Core/`, `frontend/FableApp/App/` | | **Word Count Accuracy** | `split(separator: " ")` (fails on tabs/newlines) | Regex/tokenized whitespace counter invariant to zoom level | **Complete** | `Features/Reader/Services/PacingEngine.swift`, `Features/Reader/Views/ReaderView.swift` | | **Reader Zoom / Font Size** | Initial render only, inconsistent pagination | Dynamic pagination recalculated upon pinch/slider change | **Complete** | `Features/Reader/Views/ReaderView.swift`, `Features/Reader/Views/DisplayOptionsSheet.swift` | | **Audio Voice Selector** | Hardcoded `en-US` default voice | Dynamic system voice picker querying available speech engines | **Complete** | `Features/Reader/Services/AudioNarratorController.swift`, `VoiceSelectionSheet.swift` | | **Audio Narrator Target** | Always read Chapter 1 | Narration dynamically bound to active displayed chapter | **Complete** | `Features/Reader/Services/AudioNarratorController.swift`, `Features/Reader/Views/ReaderView.swift` | | **Shelf Remove Button** | Visual only or local array remove | Synchronized removal / bookmark toggle synced to backend | **Complete** | `Features/Shelf/Views/ShelfView.swift`, `Features/Library/ViewModels/StoryStore.swift` | -| **Profile Stats & Edit** | Dummy text | Live stats calculated from SwiftData/backend shelf items | **Complete** | `Features/Shelf/Views/ProfileView.swift`, `Features/Auth/ViewModels/AuthViewModel.swift` | +| **Profile Stats & Edit** | Device-wide totals and placeholder text | Account-scoped live completed, minutes, and streak statistics; profile updates sync with backend | **Complete** | `Features/Shelf/Views/ProfileView.swift`, `Features/Auth/ViewModels/AuthViewModel.swift` | --- @@ -58,7 +58,7 @@ The Final Milestone transforms **Fable** from an editorial prototype into a full - [x] Step 2.2: Establish persistent device identifier (`fable_device_id`) in `UserDefaults`. - [x] Step 2.3: Upgrade `StoryAPIService` with dual camelCase/snake_case decoding and auth endpoints. - [x] Step 2.4: Eliminate raw `coverImageName` calls across `ExploreView`, `ProfileView`, `LibraryView`, and `ReaderView`. - - [x] Step 2.5: Realign frontend into feature-driven vertical slices (`App`, `Core`, `Features/{Auth,Library,Reader,Shelf,Write}`) and integrate authentic asset catalog from `main`. + - [x] Step 2.5: Realign frontend into feature-driven vertical slices and remove title-specific bundled artwork; the asset catalog retains only app icon and accent color. - [x] **Milestone 3: Reader Pacing, Tokenization & Zoom Invariance** - [x] Step 3.1: Zoom-invariant word tokenization in `PacingEngine.swift`. - [x] Step 3.2: Dynamic page recalculation on font size changes in `ReaderView.swift`. @@ -68,7 +68,7 @@ The Final Milestone transforms **Fable** from an editorial prototype into a full - [x] Step 4.2: Dynamic chapter narration binding in `AudioNarratorController`. - [x] Step 4.3: Full interactive button pass across all views. - [x] **Milestone 5: Live Metadata & Pure User-State Isolation** (See [`03_LIVE_METADATA_AND_USER_STATE_PURGING.md`](file:///home/dokja/vsc-fedora/all/Projects/swift-projects/Fable-IOS/docs/final_milestone/03_LIVE_METADATA_AND_USER_STATE_PURGING.md)) - - [x] Step 5.1: Backend live metadata enrichment (`avatar_image_url`, `image_url`, and Maria Makiling cover URL in `story_service.py` & `seed_catalog.py`). + - [x] Step 5.1: Backend metadata enrichment from live provider responses; the embedded catalog module was subsequently removed. - [x] Step 5.2: Domain & Persistence zero-baseline calibration (`isSaved = false`, `progressPercent = 0` in `Story.swift`; removal of `max(12, ...)` stat floors in `PersistenceService.swift`). - [x] Step 5.3: Frontend Shelf & Profile empty state views (removal of `prefix(3)` and `prefix(2)` fallbacks; branded empty state cards). - [x] Step 5.4: LibraryView "Continue Reading" fallback purge (only show card when an actual tale is in progress). @@ -95,6 +95,152 @@ The Final Milestone transforms **Fable** from an editorial prototype into a full - [x] Step 8.2: Creation of `AppHealthTests.swift` validating chapter updates, feed ingestion, live genres, top creators, accurate living analytics, and media bounding invariants. - [x] Step 8.3: Interactive `SystemDiagnosticsSheet.swift` and Settings trigger for on-device and simulator verification. - [x] Step 8.4: Integration of `AppHealthTests` into `LibraryTests.swift` (Test 9). - - [x] Step 8.5: Backend test suite expansion in `test_main.py` verifying live contracts and image URLs (22/22 pytest passing). + - [x] Step 8.5: Backend test suite expansion for that milestone; the current expanded suite has 35 passing tests. +## Production Hardening Enhancements (2026-09-26) +- [x] Added a multi-stage Python 3.11 backend image that installs runtime dependencies, runs as the non-root `fableuser`, and checks `/api/v1/health`. +- [x] Added Docker Compose configuration with a persistent SQLite bind mount and environment-file loading. +- [x] Added GitHub Actions backend-test and Docker-build verification jobs. +- [x] Removed duplicate Figma configuration from `.env.example` and documented runtime and Docker ownership settings. +- [x] Added `test_env_db_path_override` to verify the database path can be overridden through `FABLE_DB_PATH`. + +## Offline Manga Panel Cache (2026-09-26) + +- [x] Added an actor-isolated SHA-256 disk cache with an in-memory image cache, compressed image storage, and bounded asynchronous URL prefetching. +- [x] Manga reader prefetches the active and next chapter; remote images render from cache first with network and procedural fallbacks clipped to their view bounds. +- Device diagnostics were not run in this Fedora workspace because `xcodebuild`, `xcrun`, and the Swift compiler are unavailable. + +## Chapter Progress & Haptic Feedback (2026-09-26) + +- [x] Added optional chapter ID and chapter number fields to Story with legacy Codable fallback. +- [x] Persisted the chapter cursor in optional SwiftData fields and hydrated it into local story state. +- [x] Prose and manga readers restore by chapter ID, then chapter number, and save chapter changes locally. +- [x] Added a main-actor haptic manager and wired bookmark, publish, reading-mode, and chapter-navigation feedback to the existing haptics preference. +- [x] Extended LibraryTests with legacy fallback and saved chapter decoding assertions. +- The iOS test suite and on-device diagnostics were not run in this Fedora workspace because swiftc, xcodebuild, and xcrun are unavailable. + +## Official Live Cover Precedence (2026-09-26) + +- [x] Live story responses now reconcile with offline catalog entries by ID or normalized title and author, then persist fetched HTTPS cover URLs in SwiftData for cached offline display. +- [x] Reader and library covers prefer the fetched cover URL; image views remain clipped and retain bundled or procedural offline fallbacks. +- [x] Legacy Unsplash demo covers and manga panels are filtered from existing backend rows and cached client state; a story cover is no longer used as a manga panel. +- [x] Removed the duplicate Sleepy Hollow image asset and removed demo image URLs from default genre, author, and manga fixtures. +- [x] Backend suite passed: 24 tests. iOS compilation and on-device diagnostics remain unavailable in this Fedora workspace because Xcode and the Swift compiler are not installed. + +## Live Gutenberg Chapter Retrieval (2026-09-26) + +- [x] Added optional providerId to the Story API contract for live catalog entries. +- [x] Added an on-demand chapter endpoint that fetches and parses the original Project Gutenberg text with stable chapter identifiers. +- [x] Fixed chapter parsing when a chapter heading immediately follows the Gutenberg start marker. +- [x] Backend Docker build and test suite passed: 25 tests. +- [ ] iOS provider routing and offline chapter persistence remain in the next implementation slice. + +## iOS Live Book Chapters and Offline Persistence (2026-09-26) + +- [x] Swift Story decodes and stores the live provider identifier with backward-compatible optional decoding. +- [x] Gutenberg chapter requests use the provider-specific endpoint; cached chapters are returned before network requests. +- [x] Chapter content and provider metadata persist in optional SwiftData fields, with chapter data stored externally to the primary SQLite row. +- [x] Added internal diagnostics for provider ID decoding and chapter serialization round trips. +- [x] Source diff passed whitespace validation. +- The Swift diagnostics and iOS build could not run in this Fedora workspace because Swift and Xcode are unavailable. + +## Backend Live Catalog and Source-Backed Metrics (2026-09-26) + +- [x] Removed the embedded backend seed catalog; fresh databases now start empty and remain empty across restarts until content is fetched or authored. +- [x] Gutenberg discovery returns provider metadata, official provider covers, provider download counts, and no synopsis-as-content or fabricated rating. +- [x] Gutenberg ingestion uses provider metadata and stable book/chapter identifiers; re-ingestion updates matching rows by stable ID. +- [x] Genre title and reader totals come from story and shelf records; author work counts and portraits come from Open Library; unavailable ratings remain null. +- [x] Update feeds no longer promote arbitrary stories as editorial picks, user DTOs no longer default to a bundled avatar, and API story DTOs omit local asset names. +- [x] Swift models accept missing ratings, persist provider download counts, and avoid showing a fake rating in explore cards. +- [x] Backend container build and test suite passed: 28 tests. +- The 12 retired catalog IDs and their chapter rows are removed by the approved startup cleanup migration; it does not match by title and preserves authored stories. +- The Swift build and diagnostics remain unrun because this Fedora workspace has no Swift/Xcode toolchain. + +## Provider-Backed iOS Catalog (2026-09-26) + +- [x] Removed bundled story, genre, author, guest-profile, quote, and sample-stat defaults; fresh installs load live provider data and preserve only the SwiftData cache for offline reading. +- [x] Discovery genre and author payloads are cached locally after a successful fetch so they remain available offline. +- [x] Removed content-specific image sets and title/author keyed artwork; cover and profile images now render HTTPS provider images or neutral SF Symbol placeholders, with clipped bounds. +- [x] Library genre filters follow server metadata and empty catalog/discovery states are explicit and retryable. +- [x] Removed fabricated default profile names, biographies, handles, and verification badges. +- [x] Existing SQLite databases remove only the 12 retired demo story UUIDs and their chapters during backend startup; no deployed database was reachable from this workspace. +- Source diff passed whitespace checks. iOS compilation and on-device diagnostics remain unavailable here because Swift and Xcode are not installed. + +## Preserve Live Genre Contract (2026-09-26) + +- [x] Replaced the closed genre enum decoder with a raw-value Codable model so server-supplied genres remain intact. +- [x] Missing legacy genre values remain unspecified instead of becoming a fabricated category. +- [x] Explore filtering and authoring suggestions use live genre metadata; authors may enter a genre when offline metadata is unavailable. +- Added diagnostic assertions for arbitrary genre decoding. Swift/Xcode compilation remains unavailable in this environment. + +## Server-Backed User Profiles (2026-09-26) + +- [x] Added additive SQLite handle and biography columns for existing user databases. +- [x] Registration persists the submitted handle; authenticated `PATCH /auth/me` updates name, handle, and biography and returns the canonical profile. +- [x] Added positive profile update and unauthenticated rejection coverage. +- [x] Docker backend suite passed: 29 tests. + +## Authenticated Story Publishing and Ownership (2026-09-26) + +- [x] Story creation now requires a bearer session and assigns the author and owner from the authenticated account. +- [x] Creation payloads reject extra fields so callers cannot submit server-owned authorship or provider identity. +- [x] Added an authenticated `/auth/me/stories` endpoint with database-level owner filtering. +- [x] Added tests for unauthenticated rejection, spoofed author rejection, and account-isolated published-story listings. +- [x] Docker backend suite passed: 31 tests. + + +## Account-Scoped Shelf Synchronization (2026-09-26) + +- [x] Added account-owned shelf records keyed by user, device, and story while preserving legacy rows without exposing them through authenticated endpoints. +- [x] Required bearer authentication for shelf reads and writes and scoped every query to the authenticated user. +- [x] Public save, reader, and genre-reader totals now count authenticated accounts rather than caller-controlled device identifiers. +- [x] Added API coverage for unauthenticated rejection and same-device cross-account isolation. +- [x] Docker backend suite passed: 32 tests. +- [ ] iOS shelf requests are being updated to send the active server token; this environment cannot compile Swift because the Swift/Xcode toolchain is unavailable. + + +## Authenticated Reading Statistics and Shelf Privacy (2026-09-26) + +- [x] Added an idempotent authenticated reading-session endpoint and per-account statistics endpoint. +- [x] Backend totals count completed works, logged minutes, and consecutive UTC reading days from persisted session events. +- [x] Shelf reads/writes and public save/read aggregates are isolated by authenticated account; public story DTOs no longer expose record-level bookmark/completion flags. +- [x] Added coverage for session de-duplication, account isolation, unauthenticated rejection, and DTO privacy. +- [x] Docker backend suite passed: 33 tests. +- [x] iOS now queues offline reading events per account, fetches the live totals, and scopes offline logs and shelf snapshots to each account. +- [x] Writer toolbar actions apply formatting to selected manuscript text and have an on-device interaction diagnostic. +- The iOS diagnostic and simulator/device build remain unrun in this Fedora workspace because Swift and Xcode are unavailable. + + +## Persistent Bearer Sessions (2026-09-26) + +- [x] Replaced process-local bearer lookup with SHA-256 token digests stored in SQLite, 30-day expiry, and expired-session cleanup. +- [x] Added authenticated logout revocation and connected iOS sign-out to the endpoint. +- [x] Added restart-state and revocation coverage. +- [x] Docker backend suite passed: 34 tests. + + +## Restoring Saved Gutenberg Titles Across Devices (2026-09-26) + +- [x] Added a provider-ID metadata lookup so an authenticated shelf can resolve saved Gutenberg titles outside the first discovery page. +- [x] Shelf restore applies account-owned progress to the live provider story; chapters and covers continue loading from Gutenberg metadata and source text. +- [x] Added live metadata endpoint coverage. Docker backend suite passed: 35 tests at this historical checkpoint. +- iOS implementation still awaits an Xcode build and device diagnostics, unavailable in this Fedora workspace. + + +## Account Isolation and Interaction Audit (2026-09-26) + +- [x] Local highlights and pinned quotes now carry an optional account owner and are filtered on read, update, and delete; switching accounts clears active annotations before loading that account's journal. Existing ownerless annotations remain available only to a signed-out local session. +- [x] Scanned SwiftUI button closures for empty application actions. The remaining empty Cancel closures are system alert/sheet dismiss actions; the visible feature controls dispatch to navigation, persistence, API, editor, reader, or settings operations. +- [x] `git diff --check` passes. Backend suite remains at 35 passing tests in Docker. +- [ ] iOS build, Swift tests, and on-device `AppHealthTests` remain unverified because this workspace has no Swift or Xcode toolchain. +- [x] Added an exact-UUID startup cleanup for those story/chapter rows; authored stories with similar names are preserved. The repo has no local persistent database, so existing deployed SQLite files will be cleaned on their next backend start. + + +## Approved Legacy Catalog Cleanup and Bounded Image Cache (2026-09-26) + +- [x] Backend startup deletes chapters and stories only for the 12 UUIDs from the retired bundled demo catalog. The migration is transactional, repeat-safe, and title-independent; user-authored works and reading-session history are preserved. +- [x] Added regression coverage proving all twelve records and their chapters are removed while a user-authored work with a matching title remains. Docker suite passed: 36 tests. +- [x] Manga image disk cache now serializes file operations and evicts oldest files above 256 MiB; Settings still exposes cache usage and clear actions. +- The configured local database file is absent; cleanup for any existing hosted database takes effect on the next backend deployment/start. +- iOS runtime validation remains deferred until Xcode is available on the user's Mac. diff --git a/frontend/FableApp.xcodeproj/project.pbxproj b/frontend/FableApp.xcodeproj/project.pbxproj index e5158cb..263b9d6 100644 --- a/frontend/FableApp.xcodeproj/project.pbxproj +++ b/frontend/FableApp.xcodeproj/project.pbxproj @@ -253,7 +253,9 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; + FABLE_API_BASE_URL = "http://127.0.0.1:8000/api/v1"; + INFOPLIST_KEY_FABLE_API_BASE_URL = "$(FABLE_API_BASE_URL)"; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = NO; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -286,7 +288,9 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; + FABLE_API_BASE_URL = "http://127.0.0.1:8000/api/v1"; + INFOPLIST_KEY_FABLE_API_BASE_URL = "$(FABLE_API_BASE_URL)"; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = NO; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/frontend/FableApp/App/ContentView.swift b/frontend/FableApp/App/ContentView.swift index 3f66831..e72efaa 100644 --- a/frontend/FableApp/App/ContentView.swift +++ b/frontend/FableApp/App/ContentView.swift @@ -48,6 +48,9 @@ struct ContentView: View { await authVM.checkExistingSession() } .animation(.easeInOut(duration: 0.25), value: authVM.authState) + .onChange(of: auth.currentSession?.id) { _, _ in + store.sessionDidChange() + } .environmentObject(store) } diff --git a/frontend/FableApp/Assets.xcassets/author_klune.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/author_klune.imageset/Contents.json deleted file mode 100644 index a48bf10..0000000 --- a/frontend/FableApp/Assets.xcassets/author_klune.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "author_klune.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/author_klune.imageset/author_klune.jpg b/frontend/FableApp/Assets.xcassets/author_klune.imageset/author_klune.jpg deleted file mode 100644 index 6648d52..0000000 Binary files a/frontend/FableApp/Assets.xcassets/author_klune.imageset/author_klune.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/author_kuang.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/author_kuang.imageset/Contents.json deleted file mode 100644 index f8f71e5..0000000 --- a/frontend/FableApp/Assets.xcassets/author_kuang.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "author_kuang.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/author_kuang.imageset/author_kuang.jpg b/frontend/FableApp/Assets.xcassets/author_kuang.imageset/author_kuang.jpg deleted file mode 100644 index bd79ea5..0000000 Binary files a/frontend/FableApp/Assets.xcassets/author_kuang.imageset/author_kuang.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/author_moreno.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/author_moreno.imageset/Contents.json deleted file mode 100644 index 70d9236..0000000 --- a/frontend/FableApp/Assets.xcassets/author_moreno.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "author_moreno.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/author_moreno.imageset/author_moreno.jpg b/frontend/FableApp/Assets.xcassets/author_moreno.imageset/author_moreno.jpg deleted file mode 100644 index 3bdf2da..0000000 Binary files a/frontend/FableApp/Assets.xcassets/author_moreno.imageset/author_moreno.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/author_yarros.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/author_yarros.imageset/Contents.json deleted file mode 100644 index 8b55733..0000000 --- a/frontend/FableApp/Assets.xcassets/author_yarros.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "author_yarros.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/author_yarros.imageset/author_yarros.jpg b/frontend/FableApp/Assets.xcassets/author_yarros.imageset/author_yarros.jpg deleted file mode 100644 index 1ba7cce..0000000 Binary files a/frontend/FableApp/Assets.xcassets/author_yarros.imageset/author_yarros.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/avatar_roosc.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/avatar_roosc.imageset/Contents.json deleted file mode 100644 index 581d8c6..0000000 --- a/frontend/FableApp/Assets.xcassets/avatar_roosc.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "avatar_roosc.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/avatar_roosc.imageset/avatar_roosc.jpg b/frontend/FableApp/Assets.xcassets/avatar_roosc.imageset/avatar_roosc.jpg deleted file mode 100644 index fc291b4..0000000 Binary files a/frontend/FableApp/Assets.xcassets/avatar_roosc.imageset/avatar_roosc.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/cover_dracula.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/cover_dracula.imageset/Contents.json deleted file mode 100644 index a099ce6..0000000 --- a/frontend/FableApp/Assets.xcassets/cover_dracula.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "cover_dracula.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/cover_dracula.imageset/cover_dracula.jpg b/frontend/FableApp/Assets.xcassets/cover_dracula.imageset/cover_dracula.jpg deleted file mode 100644 index 64912ec..0000000 Binary files a/frontend/FableApp/Assets.xcassets/cover_dracula.imageset/cover_dracula.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/cover_sleepy_featured.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/cover_sleepy_featured.imageset/Contents.json deleted file mode 100644 index 773101c..0000000 --- a/frontend/FableApp/Assets.xcassets/cover_sleepy_featured.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "cover_sleepy_featured.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/cover_sleepy_featured.imageset/cover_sleepy_featured.jpg b/frontend/FableApp/Assets.xcassets/cover_sleepy_featured.imageset/cover_sleepy_featured.jpg deleted file mode 100644 index 2f32371..0000000 Binary files a/frontend/FableApp/Assets.xcassets/cover_sleepy_featured.imageset/cover_sleepy_featured.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/genre_folklore.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/genre_folklore.imageset/Contents.json deleted file mode 100644 index 88b305b..0000000 --- a/frontend/FableApp/Assets.xcassets/genre_folklore.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "genre_folklore.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/genre_folklore.imageset/genre_folklore.jpg b/frontend/FableApp/Assets.xcassets/genre_folklore.imageset/genre_folklore.jpg deleted file mode 100644 index 377b8d9..0000000 Binary files a/frontend/FableApp/Assets.xcassets/genre_folklore.imageset/genre_folklore.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/genre_gothic.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/genre_gothic.imageset/Contents.json deleted file mode 100644 index 4f3641a..0000000 --- a/frontend/FableApp/Assets.xcassets/genre_gothic.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "genre_gothic.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/genre_gothic.imageset/genre_gothic.jpg b/frontend/FableApp/Assets.xcassets/genre_gothic.imageset/genre_gothic.jpg deleted file mode 100644 index f54b686..0000000 Binary files a/frontend/FableApp/Assets.xcassets/genre_gothic.imageset/genre_gothic.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/genre_mystery.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/genre_mystery.imageset/Contents.json deleted file mode 100644 index 25d0a98..0000000 --- a/frontend/FableApp/Assets.xcassets/genre_mystery.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "genre_mystery.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/genre_mystery.imageset/genre_mystery.jpg b/frontend/FableApp/Assets.xcassets/genre_mystery.imageset/genre_mystery.jpg deleted file mode 100644 index 75df8cd..0000000 Binary files a/frontend/FableApp/Assets.xcassets/genre_mystery.imageset/genre_mystery.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/genre_mythology.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/genre_mythology.imageset/Contents.json deleted file mode 100644 index 1b7195c..0000000 --- a/frontend/FableApp/Assets.xcassets/genre_mythology.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "genre_mythology.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/genre_mythology.imageset/genre_mythology.jpg b/frontend/FableApp/Assets.xcassets/genre_mythology.imageset/genre_mythology.jpg deleted file mode 100644 index 770f41f..0000000 Binary files a/frontend/FableApp/Assets.xcassets/genre_mythology.imageset/genre_mythology.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/hero_castle.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/hero_castle.imageset/Contents.json deleted file mode 100644 index d6810a3..0000000 --- a/frontend/FableApp/Assets.xcassets/hero_castle.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "hero_castle.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/hero_castle.imageset/hero_castle.jpg b/frontend/FableApp/Assets.xcassets/hero_castle.imageset/hero_castle.jpg deleted file mode 100644 index 2551b7a..0000000 Binary files a/frontend/FableApp/Assets.xcassets/hero_castle.imageset/hero_castle.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/thumb_maria_makiling.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/thumb_maria_makiling.imageset/Contents.json deleted file mode 100644 index 9bc0449..0000000 --- a/frontend/FableApp/Assets.xcassets/thumb_maria_makiling.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "thumb_maria_makiling.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/thumb_maria_makiling.imageset/thumb_maria_makiling.jpg b/frontend/FableApp/Assets.xcassets/thumb_maria_makiling.imageset/thumb_maria_makiling.jpg deleted file mode 100644 index 3b11245..0000000 Binary files a/frontend/FableApp/Assets.xcassets/thumb_maria_makiling.imageset/thumb_maria_makiling.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/thumb_metamorphosis.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/thumb_metamorphosis.imageset/Contents.json deleted file mode 100644 index bb864cd..0000000 --- a/frontend/FableApp/Assets.xcassets/thumb_metamorphosis.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "thumb_metamorphosis.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/thumb_metamorphosis.imageset/thumb_metamorphosis.jpg b/frontend/FableApp/Assets.xcassets/thumb_metamorphosis.imageset/thumb_metamorphosis.jpg deleted file mode 100644 index 38cbc5e..0000000 Binary files a/frontend/FableApp/Assets.xcassets/thumb_metamorphosis.imageset/thumb_metamorphosis.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/thumb_rip_van_winkle.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/thumb_rip_van_winkle.imageset/Contents.json deleted file mode 100644 index 69fa5d7..0000000 --- a/frontend/FableApp/Assets.xcassets/thumb_rip_van_winkle.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "thumb_rip_van_winkle.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/thumb_rip_van_winkle.imageset/thumb_rip_van_winkle.jpg b/frontend/FableApp/Assets.xcassets/thumb_rip_van_winkle.imageset/thumb_rip_van_winkle.jpg deleted file mode 100644 index 9899950..0000000 Binary files a/frontend/FableApp/Assets.xcassets/thumb_rip_van_winkle.imageset/thumb_rip_van_winkle.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/thumb_sleepy.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/thumb_sleepy.imageset/Contents.json deleted file mode 100644 index 123f6c7..0000000 --- a/frontend/FableApp/Assets.xcassets/thumb_sleepy.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "thumb_sleepy.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/thumb_sleepy.imageset/thumb_sleepy.jpg b/frontend/FableApp/Assets.xcassets/thumb_sleepy.imageset/thumb_sleepy.jpg deleted file mode 100644 index 2f32371..0000000 Binary files a/frontend/FableApp/Assets.xcassets/thumb_sleepy.imageset/thumb_sleepy.jpg and /dev/null differ diff --git a/frontend/FableApp/Assets.xcassets/thumb_tell_tale.imageset/Contents.json b/frontend/FableApp/Assets.xcassets/thumb_tell_tale.imageset/Contents.json deleted file mode 100644 index e1c049c..0000000 --- a/frontend/FableApp/Assets.xcassets/thumb_tell_tale.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images": [ - { - "filename": "thumb_tell_tale.jpg", - "idiom": "universal", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/frontend/FableApp/Assets.xcassets/thumb_tell_tale.imageset/thumb_tell_tale.jpg b/frontend/FableApp/Assets.xcassets/thumb_tell_tale.imageset/thumb_tell_tale.jpg deleted file mode 100644 index df9a6ec..0000000 Binary files a/frontend/FableApp/Assets.xcassets/thumb_tell_tale.imageset/thumb_tell_tale.jpg and /dev/null differ diff --git a/frontend/FableApp/Core/Components/FableImageView.swift b/frontend/FableApp/Core/Components/FableImageView.swift index 16461f5..fb28f1f 100644 --- a/frontend/FableApp/Core/Components/FableImageView.swift +++ b/frontend/FableApp/Core/Components/FableImageView.swift @@ -1,5 +1,6 @@ import SwiftUI +@MainActor public struct FableImageView: View { public let name: String? public var placeholderIcon: String = "book.closed" @@ -13,292 +14,114 @@ public struct FableImageView: View { public var body: some View { Group { - if let name = name, !name.isEmpty { - if name.hasPrefix("http://") || name.hasPrefix("https://"), let url = URL(string: name) { - // Web URL: Remote image from Gutendex or external API - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: contentMode) - .clipped() - case .failure: - proceduralGraphicView - case .empty: - ZStack { - FableTheme.surface - ProgressView() - .scaleEffect(0.8) - } - @unknown default: - proceduralGraphicView - } - } - } else if let localImage = UIImage(named: name) { - // Local asset catalog image if present - Image(uiImage: localImage) - .resizable() - .aspectRatio(contentMode: contentMode) - .clipped() - } else { - // Procedural editorial fallback - proceduralGraphicView + if let name, + let url = URL(string: name), + url.scheme?.lowercased() == "https", + url.host != nil { + FableRemoteImageView(url: url, contentMode: contentMode) { + placeholderView } } else { - proceduralGraphicView + placeholderView } } .clipped() } - @ViewBuilder - private var proceduralGraphicView: some View { - let key = (name ?? "").lowercased() - - if isAvatar { - avatarView(key: key) - } else if isGenre { - genreCoverView(key: key) - } else { - bookCoverView(key: key) + private var placeholderView: some View { + ZStack { + FableTheme.surface + Image(systemName: placeholderIcon) + .font(.system(size: 28, weight: .regular)) + .foregroundColor(FableTheme.textMuted) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() } +} - private var isAvatar: Bool { - let key = (name ?? "").lowercased() - return key.contains("avatar") || key.contains("author") || placeholderIcon.contains("person") - } - - private var isGenre: Bool { - let key = (name ?? "").lowercased() - return key.contains("genre") - } +@MainActor +public struct FableRemoteImageView: View { + public let url: URL + public let contentMode: ContentMode - // MARK: - Procedural Author Avatars - private func avatarView(key: String) -> some View { - let (initials, bgGradient) = avatarMetadata(for: key) - - return ZStack { - LinearGradient( - colors: bgGradient, - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - - Circle() - .strokeBorder(Color.white.opacity(0.35), lineWidth: 1.5) - - Text(initials) - .font(.system(size: 15, weight: .bold)) - .foregroundColor(.white) - .shadow(color: Color.black.opacity(0.2), radius: 1, y: 1) - } - } + private let placeholder: Placeholder + @State private var cachedImage: UIImage? - private func avatarMetadata(for key: String) -> (String, [Color]) { - if key.contains("roosc") { - return ("RZ", [FableTheme.brandPrimary, FableTheme.brandSecondary]) - } else if key.contains("kuang") { - return ("RF", [Color(red: 0.55, green: 0.22, blue: 0.18), Color(red: 0.32, green: 0.12, blue: 0.10)]) - } else if key.contains("yarros") { - return ("RY", [Color(red: 0.35, green: 0.30, blue: 0.45), Color(red: 0.20, green: 0.15, blue: 0.30)]) - } else if key.contains("klune") { - return ("TK", [Color(red: 0.25, green: 0.38, blue: 0.35), Color(red: 0.15, green: 0.25, blue: 0.22)]) - } else { - return ("F", [FableTheme.brandPrimary, FableTheme.brandSecondary]) - } + public init( + url: URL, + contentMode: ContentMode = .fill, + @ViewBuilder placeholder: () -> Placeholder + ) { + self.url = url + self.contentMode = contentMode + self.placeholder = placeholder() } - // MARK: - Procedural Genre Cards - private func genreCoverView(key: String) -> some View { - let (icon, gradientColors, subtitle) = genreMetadata(for: key) - - return ZStack { - LinearGradient(colors: gradientColors, startPoint: .topLeading, endPoint: .bottomTrailing) - - // Clean modern edge border - RoundedRectangle(cornerRadius: 12) - .strokeBorder(Color.white.opacity(0.2), lineWidth: 1) - .padding(6) - - VStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 28, weight: .semibold)) - .foregroundColor(Color.white.opacity(0.92)) - .shadow(color: Color.black.opacity(0.3), radius: 4, y: 2) - - Text(subtitle.uppercased()) - .font(.system(size: 9, weight: .bold)) - .tracking(2.0) - .foregroundColor(Color.white.opacity(0.85)) + public var body: some View { + Group { + if let cachedImage { + Image(uiImage: cachedImage) + .resizable() + .aspectRatio(contentMode: contentMode) + .clipped() + } else { + asyncImageFallback } } - } + .task(id: url) { + cachedImage = nil - private func genreMetadata(for key: String) -> (String, [Color], String) { - if key.contains("manga") { - return ("photo.on.rectangle.angled", [Color(red: 0.23, green: 0.35, blue: 0.96), Color(red: 0.45, green: 0.15, blue: 0.70)], "Manga") - } else if key.contains("folklore") { - return ("tree.fill", [Color(red: 0.60, green: 0.30, blue: 0.15), Color(red: 0.35, green: 0.18, blue: 0.08)], "Folklore") - } else if key.contains("mythology") { - return ("sparkles", [Color(red: 0.25, green: 0.28, blue: 0.48), Color(red: 0.14, green: 0.16, blue: 0.30)], "Mythology") - } else if key.contains("gothic") { - return ("flame.fill", [Color(red: 0.30, green: 0.15, blue: 0.20), Color(red: 0.15, green: 0.08, blue: 0.10)], "Gothic") - } else { - return ("magnifyingglass", [Color(red: 0.22, green: 0.32, blue: 0.38), Color(red: 0.12, green: 0.18, blue: 0.24)], "Mystery") - } - } + if let cachedImage = DiskImageCache.shared.image(for: url) { + self.cachedImage = cachedImage + return + } - // MARK: - Procedural Media Covers - private func bookCoverView(key: String) -> some View { - let palette = bookPalette(for: key) - - return GeometryReader { geo in - ZStack { - // Background Gradient - LinearGradient(colors: palette.colors, startPoint: .topLeading, endPoint: .bottomTrailing) - - // Clean edge highlight - HStack(spacing: 0) { - Rectangle() - .fill( - LinearGradient( - colors: [Color.black.opacity(0.25), Color.black.opacity(0.04), Color.white.opacity(0.12), Color.clear], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: max(6, geo.size.width * 0.08)) - - Spacer() - } - - // Fine Hairline Border - RoundedRectangle(cornerRadius: 8) - .strokeBorder(palette.foilColor.opacity(0.40), lineWidth: 1) - .padding(max(4, geo.size.width * 0.05)) - - // Center Presentation - VStack(spacing: max(2, geo.size.height * 0.02)) { - Image(systemName: palette.symbol) - .font(.system(size: min(22, max(12, geo.size.width * 0.18)), weight: .medium)) - .foregroundColor(palette.foilColor) - .shadow(color: Color.black.opacity(0.35), radius: 2, y: 1) - - Text(palette.monogram) - .font(.system(size: min(34, max(18, geo.size.width * 0.26)), weight: .black)) - .foregroundColor(palette.foilColor) - .shadow(color: Color.black.opacity(0.4), radius: 3, y: 2) - - Rectangle() - .fill(palette.foilColor.opacity(0.6)) - .frame(width: max(16, geo.size.width * 0.25), height: 1) - - Text(palette.tagText.uppercased()) - .font(.system(size: min(9, max(6, geo.size.width * 0.07)), weight: .bold)) - .tracking(1.4) - .foregroundColor(palette.foilColor.opacity(0.85)) - .lineLimit(1) - } - .padding(.horizontal, 6) + await DiskImageCache.shared.prefetch(urls: [url]) + guard !Task.isCancelled else { + return } + + cachedImage = DiskImageCache.shared.image(for: url) } + .clipped() } - private struct BookPalette { - let colors: [Color] - let foilColor: Color - let symbol: String - let monogram: String - let tagText: String + private var loadingView: some View { + ZStack { + FableTheme.surface + ProgressView() + .scaleEffect(0.8) + } + .clipped() } - private func bookPalette(for key: String) -> BookPalette { - let gold = Color(red: 0.94, green: 0.85, blue: 0.65) - let silver = Color(red: 0.90, green: 0.92, blue: 0.95) - let bronze = Color(red: 0.88, green: 0.68, blue: 0.48) - - if key.contains("dracula") || key.contains("castle") { - return BookPalette( - colors: [Color(red: 0.35, green: 0.08, blue: 0.12), Color(red: 0.15, green: 0.04, blue: 0.06)], - foilColor: gold, - symbol: "building.columns.fill", - monogram: "D", - tagText: "Gothic" - ) - } else if key.contains("sleepy") { - return BookPalette( - colors: [Color(red: 0.18, green: 0.28, blue: 0.20), Color(red: 0.08, green: 0.16, blue: 0.10)], - foilColor: gold, - symbol: "leaf.fill", - monogram: "S", - tagText: "Folklore" - ) - } else if key.contains("metamorphosis") { - return BookPalette( - colors: [Color(red: 0.38, green: 0.24, blue: 0.16), Color(red: 0.18, green: 0.10, blue: 0.06)], - foilColor: bronze, - symbol: "hourglass", - monogram: "M", - tagText: "Classic" - ) - } else if key.contains("clockmaker") { - return BookPalette( - colors: [Color(red: 0.28, green: 0.24, blue: 0.18), Color(red: 0.12, green: 0.10, blue: 0.08)], - foilColor: gold, - symbol: "clock.fill", - monogram: "C", - tagText: "Horology" - ) - } else if key.contains("balete") { - return BookPalette( - colors: [Color(red: 0.16, green: 0.26, blue: 0.22), Color(red: 0.08, green: 0.14, blue: 0.10)], - foilColor: gold, - symbol: "tree.circle.fill", - monogram: "B", - tagText: "Baler" - ) - } else if key.contains("jeepney") { - return BookPalette( - colors: [Color(red: 0.20, green: 0.22, blue: 0.32), Color(red: 0.10, green: 0.11, blue: 0.18)], - foilColor: silver, - symbol: "moon.stars.fill", - monogram: "J", - tagText: "Midnight" - ) - } else if key.contains("diwata") || key.contains("maria") { - return BookPalette( - colors: [Color(red: 0.15, green: 0.32, blue: 0.28), Color(red: 0.07, green: 0.18, blue: 0.15)], - foilColor: gold, - symbol: "drop.fill", - monogram: "M", - tagText: "Myth" - ) - } else if key.contains("tell_tale") { - return BookPalette( - colors: [Color(red: 0.30, green: 0.12, blue: 0.18), Color(red: 0.14, green: 0.05, blue: 0.08)], - foilColor: silver, - symbol: "heart.fill", - monogram: "P", - tagText: "Poe" - ) - } else if key.contains("manga") || key.contains("chainsaw") || key.contains("fujimoto") { - return BookPalette( - colors: [Color(red: 0.23, green: 0.35, blue: 0.96), Color(red: 0.45, green: 0.15, blue: 0.70)], - foilColor: .white, - symbol: "flame.circle.fill", - monogram: "M", - tagText: "Manga" - ) - } else { - return BookPalette( - colors: [FableTheme.brandPrimary, FableTheme.brandSecondary], - foilColor: silver, - symbol: "square.stack.3d.up.fill", - monogram: "F", - tagText: "Fable" - ) + @ViewBuilder + private var asyncImageFallback: some View { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: contentMode) + .clipped() + .task(id: url) { + await DiskImageCache.shared.prefetch(urls: [url]) + guard !Task.isCancelled else { + return + } + if let cachedImage = DiskImageCache.shared.image(for: url) { + self.cachedImage = cachedImage + } + } + case .empty: + loadingView + case .failure: + placeholder + @unknown default: + placeholder + } } + .clipped() } } diff --git a/frontend/FableApp/Core/Services/DiskImageCache.swift b/frontend/FableApp/Core/Services/DiskImageCache.swift new file mode 100644 index 0000000..bcca96b --- /dev/null +++ b/frontend/FableApp/Core/Services/DiskImageCache.swift @@ -0,0 +1,241 @@ +import CryptoKit +import Foundation +import UIKit + +@MainActor +public final class DiskImageCache { + public static let shared = DiskImageCache() + + private let memoryCache = NSCache() + private let cacheDirectoryURL: URL + private let diskStorage: DiskImageCacheStorage + private var inFlightDownloads: [URL: Task] = [:] + private let maximumConcurrentDownloads = 4 + + private init() { + let directoryURL = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first?.appendingPathComponent("FableMangaCache", isDirectory: true) + ?? FileManager.default.temporaryDirectory.appendingPathComponent( + "FableMangaCache", + isDirectory: true + ) + cacheDirectoryURL = directoryURL + let storage = DiskImageCacheStorage( + directoryURL: directoryURL, + maximumUsageBytes: 256 * 1024 * 1024 + ) + diskStorage = storage + memoryCache.totalCostLimit = 96 * 1024 * 1024 + memoryCache.name = "FableMangaCache" + Task { await storage.enforceLimit() } + } + + public func image(for url: URL) -> UIImage? { + if let cachedImage = memoryCache.object(forKey: url as NSURL) { + return cachedImage + } + + guard + let data = try? Data(contentsOf: cacheFileURL(for: url), options: .mappedIfSafe), + let image = UIImage(data: data) + else { + return nil + } + + cacheInMemory(image, for: url) + return image + } + + public func store(_ image: UIImage, for url: URL) async { + guard let data = image.jpegData(compressionQuality: 0.88) ?? image.pngData() else { + return + } + + cacheInMemory(image, for: url) + await write(data, for: url) + } + + public func diskUsageInBytes() async -> Int64 { + await diskStorage.diskUsageInBytes() + } + + public func clearCache() async throws { + inFlightDownloads.values.forEach { $0.cancel() } + inFlightDownloads.removeAll() + memoryCache.removeAllObjects() + try await diskStorage.clear() + } + + public func prefetch(urls: [URL]) async { + var seen = Set() + let uncachedURLs = urls.filter { url in + guard + let scheme = url.scheme?.lowercased(), + scheme == "https" || scheme == "http", + seen.insert(url).inserted + else { + return false + } + + return image(for: url) == nil + } + + guard !uncachedURLs.isEmpty else { + return + } + + for batchStart in stride( + from: 0, + to: uncachedURLs.count, + by: maximumConcurrentDownloads + ) { + let batchEnd = min(batchStart + maximumConcurrentDownloads, uncachedURLs.count) + let batch = Array(uncachedURLs[batchStart..)] = [] + + for url in batch { + let task = inFlightDownloads[url] ?? Task.detached(priority: .utility) { + await downloadImageData(from: url) + } + inFlightDownloads[url] = task + pendingDownloads.append((url, task)) + } + + for (url, task) in pendingDownloads { + guard + !task.isCancelled, + let data = await task.value, + let image = UIImage(data: data) + else { + inFlightDownloads[url] = nil + continue + } + + if memoryCache.object(forKey: url as NSURL) == nil { + cacheInMemory(image, for: url) + await write(data, for: url) + } + inFlightDownloads[url] = nil + } + } + } + + private func cacheFileURL(for url: URL) -> URL { + let digest = SHA256.hash(data: Data(url.absoluteString.utf8)) + let filename = digest.map { String(format: "%02x", $0) }.joined() + return cacheDirectoryURL.appendingPathComponent(filename, isDirectory: false) + } + + private func cacheInMemory(_ image: UIImage, for url: URL) { + let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0 + memoryCache.setObject(image, forKey: url as NSURL, cost: cost) + } + + private func write(_ data: Data, for url: URL) async { + await diskStorage.write(data, to: cacheFileURL(for: url)) + } +} + +private actor DiskImageCacheStorage { + private let directoryURL: URL + private let maximumUsageBytes: Int64 + + init(directoryURL: URL, maximumUsageBytes: Int64) { + self.directoryURL = directoryURL + self.maximumUsageBytes = maximumUsageBytes + } + + func write(_ data: Data, to destinationURL: URL) { + do { + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + try data.write(to: destinationURL, options: .atomic) + evictOldestFilesIfNeeded() + } catch { + return + } + } + + func enforceLimit() { + evictOldestFilesIfNeeded() + } + + func diskUsageInBytes() -> Int64 { + guard let urls = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + + return urls.reduce(Int64(0)) { total, url in + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + return total + Int64(size) + } + } + + func clear() throws { + guard FileManager.default.fileExists(atPath: directoryURL.path) else { + return + } + try FileManager.default.removeItem(at: directoryURL) + } + + private func evictOldestFilesIfNeeded() { + guard let urls = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + var files = urls.compactMap { url -> (url: URL, size: Int64, modifiedAt: Date)? in + guard + let values = try? url.resourceValues(forKeys: [ + .fileSizeKey, + .contentModificationDateKey, + .isRegularFileKey + ]), + values.isRegularFile == true, + let size = values.fileSize + else { + return nil + } + return (url, Int64(size), values.contentModificationDate ?? .distantPast) + } + files.sort { $0.modifiedAt < $1.modifiedAt } + + var usage = files.reduce(Int64(0)) { $0 + $1.size } + for file in files where usage > maximumUsageBytes { + do { + try FileManager.default.removeItem(at: file.url) + usage -= file.size + } catch { + continue + } + } + } +} + +private func downloadImageData(from url: URL) async -> Data? { + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard + let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + !data.isEmpty + else { + return nil + } + + return data + } catch { + return nil + } +} diff --git a/frontend/FableApp/Core/Theme/HapticManager.swift b/frontend/FableApp/Core/Theme/HapticManager.swift new file mode 100644 index 0000000..1fac275 --- /dev/null +++ b/frontend/FableApp/Core/Theme/HapticManager.swift @@ -0,0 +1,22 @@ +import UIKit + +@MainActor +public struct HapticManager { + public static func impact(style: UIImpactFeedbackGenerator.FeedbackStyle = .medium) { + let generator = UIImpactFeedbackGenerator(style: style) + generator.prepare() + generator.impactOccurred() + } + + public static func notification(type: UINotificationFeedbackGenerator.FeedbackType) { + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(type) + } + + public static func selection() { + let generator = UISelectionFeedbackGenerator() + generator.prepare() + generator.selectionChanged() + } +} diff --git a/frontend/FableApp/Features/Auth/Services/AuthManager.swift b/frontend/FableApp/Features/Auth/Services/AuthManager.swift index d3e49da..e3e09a9 100644 --- a/frontend/FableApp/Features/Auth/Services/AuthManager.swift +++ b/frontend/FableApp/Features/Auth/Services/AuthManager.swift @@ -6,6 +6,8 @@ public final class AuthManager: ObservableObject { public static let shared = AuthManager() private let sessionStorageKey = "fable_persisted_session_v1" + private let pendingProfileKeyPrefix = "fable_pending_profile_" + private let apiService: StoryAPIServiceProtocol @Published public private(set) var currentSession: UserSession? @Published public private(set) var isAuthenticated: Bool = false @@ -13,7 +15,8 @@ public final class AuthManager: ObservableObject { @Published public var authErrorMessage: String? @Published public var isLoading: Bool = false - public init() { + public init(apiService: StoryAPIServiceProtocol = StoryAPIService()) { + self.apiService = apiService restoreSession() } @@ -21,9 +24,7 @@ public final class AuthManager: ObservableObject { private func restoreSession() { if let sessionData = KeychainStore.shared.readData(key: "userSession"), let session = try? JSONDecoder().decode(UserSession.self, from: sessionData) { - self.currentSession = session - self.isAuthenticated = true - self.isGuestMode = session.isGuest + restore(session) return } @@ -32,135 +33,207 @@ public final class AuthManager: ObservableObject { } do { let session = try JSONDecoder().decode(UserSession.self, from: data) - self.currentSession = session - self.isAuthenticated = true - self.isGuestMode = session.isGuest - // Migrate to Keychain + guard session.isGuest || hasServerAccessToken else { + KeychainStore.shared.deleteData(key: "userSession") + KeychainStore.shared.deleteAccessToken() + UserDefaults.standard.removeObject(forKey: sessionStorageKey) + return + } if let encoded = try? JSONEncoder().encode(session) { KeychainStore.shared.saveData(key: "userSession", data: encoded) } UserDefaults.standard.removeObject(forKey: sessionStorageKey) + restore(session) } catch { UserDefaults.standard.removeObject(forKey: sessionStorageKey) } } - private func persistSession(_ session: UserSession) { + private var hasServerAccessToken: Bool { + guard let token = KeychainStore.shared.readAccessToken() else { return false } + return !token.hasPrefix("fable_token_") && !token.hasPrefix("token_") + } + + private func restore(_ session: UserSession) { + guard session.isGuest || hasServerAccessToken else { + KeychainStore.shared.deleteData(key: "userSession") + KeychainStore.shared.deleteAccessToken() + return + } + if session.isGuest { + KeychainStore.shared.deleteAccessToken() + } + currentSession = session + isAuthenticated = true + isGuestMode = session.isGuest + } + + private func makeSession(from user: AuthUserDTO) -> UserSession { + UserSession( + id: user.id, + name: user.name, + handle: user.handle, + email: user.email, + bio: user.bio, + avatarName: user.avatarImageUrl ?? user.avatarImageName, + joinedDate: user.createdAtUtc + ) + } + + @discardableResult + private func persistSession(_ session: UserSession, accessToken: String?) -> Bool { do { let data = try JSONEncoder().encode(session) - KeychainStore.shared.saveData(key: "userSession", data: data) - KeychainStore.shared.saveAccessToken("fable_token_\(UUID().uuidString)") - UserDefaults.standard.set(data, forKey: sessionStorageKey) - self.currentSession = session - self.isAuthenticated = true - self.isGuestMode = session.isGuest - self.authErrorMessage = nil + guard KeychainStore.shared.saveData(key: "userSession", data: data) else { + authErrorMessage = "Failed to secure local user session." + return false + } + if let accessToken { + guard KeychainStore.shared.saveAccessToken(accessToken) else { + KeychainStore.shared.deleteData(key: "userSession") + authErrorMessage = "Failed to secure the access token." + return false + } + } else if session.isGuest { + KeychainStore.shared.deleteAccessToken() + } + acceptSession(session) + return true } catch { - self.authErrorMessage = "Failed to secure local user session." + authErrorMessage = "Failed to secure local user session." + return false } } - // MARK: - Authentication Actions + func acceptSession(_ session: UserSession) { + currentSession = session + isAuthenticated = true + isGuestMode = session.isGuest + authErrorMessage = nil + AuthViewModel.shared.adoptSession(session) + } + public func signIn(email: String, password: String) async -> Bool { - self.isLoading = true - self.authErrorMessage = nil + isLoading = true + authErrorMessage = nil + defer { isLoading = false } let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmedEmail.contains("@") && trimmedEmail.contains(".") else { - self.authErrorMessage = "Please enter a valid email address." - self.isLoading = false + guard trimmedEmail.contains("@"), trimmedEmail.contains(".") else { + authErrorMessage = "Please enter a valid email address." return false } - guard password.count >= 6 else { - self.authErrorMessage = "Password must be at least 6 characters." - self.isLoading = false + authErrorMessage = "Password must be at least 6 characters." return false } - // Brief delay to simulate authentic verification - try? await Task.sleep(nanoseconds: 300_000_000) - - // Derive author name from email username if not default - let authorName = trimmedEmail.lowercased().contains("roosc") ? "Roosc Zaño" : trimmedEmail.components(separatedBy: "@").first?.capitalized ?? "Author" - let handle = "@\(trimmedEmail.components(separatedBy: "@").first?.lowercased() ?? "author")" - - let session = UserSession( - name: authorName, - handle: handle, - email: trimmedEmail, - bio: "Writer of quiet lore, archivist of dusk folklore, and collector of vintage horology tales.", - avatarName: "avatar_roosc", - isGuest: false - ) - - persistSession(session) - self.isLoading = false - return true + do { + let response = try await apiService.login(email: trimmedEmail, password: password) + return persistSession(makeSession(from: response.user), accessToken: response.accessToken) + } catch let error as APIRequestError { + authErrorMessage = error.message + return false + } catch { + authErrorMessage = "Sign in failed. Check your connection." + return false + } } public func signUp(name: String, handle: String, email: String, password: String) async -> Bool { - self.isLoading = true - self.authErrorMessage = nil + isLoading = true + authErrorMessage = nil + defer { isLoading = false } let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedHandle = handle.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { - self.authErrorMessage = "Author name cannot be blank." - self.isLoading = false + authErrorMessage = "Author name cannot be blank." return false } - - guard trimmedEmail.contains("@") && trimmedEmail.contains(".") else { - self.authErrorMessage = "Please provide a valid email." - self.isLoading = false + guard trimmedEmail.contains("@"), trimmedEmail.contains(".") else { + authErrorMessage = "Please provide a valid email." return false } - guard password.count >= 6 else { - self.authErrorMessage = "Password must be at least 6 characters." - self.isLoading = false + authErrorMessage = "Password must be at least 6 characters." return false } - let formattedHandle = trimmedHandle.hasPrefix("@") ? trimmedHandle : "@\(trimmedHandle)" - - try? await Task.sleep(nanoseconds: 400_000_000) + do { + let response = try await apiService.register(email: trimmedEmail, password: password, name: trimmedName, handle: handle) + let session = makeSession(from: response.user) + return persistSession(session, accessToken: response.accessToken) + } catch let error as APIRequestError { + authErrorMessage = error.message + return false + } catch { + authErrorMessage = "Account creation failed. Check your connection." + return false + } + } - let session = UserSession( - name: trimmedName, - handle: formattedHandle.isEmpty ? "@author" : formattedHandle, - email: trimmedEmail, - bio: "Newly minted scribe on Fable.", - avatarName: nil, - isGuest: false - ) + public func updateProfile(name: String, handle: String, bio: String, avatarName: String? = nil) async -> Bool { + guard var session = currentSession else { return false } + let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanName.isEmpty else { + authErrorMessage = "Display name cannot be blank." + return false + } + let cleanHandle = handle.trimmingCharacters(in: .whitespacesAndNewlines) + session.name = cleanName + session.handle = cleanHandle.isEmpty || cleanHandle.hasPrefix("@") ? cleanHandle : "@\(cleanHandle)" + session.bio = bio.trimmingCharacters(in: .whitespacesAndNewlines) + if let avatarName { session.avatarName = avatarName } + guard persistSession(session, accessToken: nil) else { return false } + guard !session.isGuest else { return true } + + let request = ProfileUpdateRequest(name: session.name, handle: session.handle, bio: session.bio) + let pendingKey = pendingProfileKeyPrefix + session.id.uuidString + if let data = try? JSONEncoder().encode(request) { + UserDefaults.standard.set(data, forKey: pendingKey) + } + guard let token = KeychainStore.shared.readAccessToken() else { + authErrorMessage = "Profile saved locally. It will sync after the next sign in." + return false + } - persistSession(session) - self.isLoading = false - return true + do { + let user = try await apiService.updateProfile(request, token: token) + let updatedSession = makeSession(from: user) + guard persistSession(updatedSession, accessToken: token) else { return false } + UserDefaults.standard.removeObject(forKey: pendingKey) + return true + } catch { + authErrorMessage = "Profile saved locally and queued to sync when online." + return false + } } - public func continueAsGuest() { - let guest = UserSession.guestUser - persistSession(guest) - } + public func syncPendingProfile() async { + guard let session = currentSession, !session.isGuest, + let token = KeychainStore.shared.readAccessToken() else { return } + let pendingKey = pendingProfileKeyPrefix + session.id.uuidString + guard let data = UserDefaults.standard.data(forKey: pendingKey), + let request = try? JSONDecoder().decode(ProfileUpdateRequest.self, from: data) else { return } - public func updateProfile(name: String, handle: String, bio: String, avatarName: String? = nil) { - guard var session = currentSession else { return } - session.name = name.trimmingCharacters(in: .whitespacesAndNewlines) - let cleanHandle = handle.trimmingCharacters(in: .whitespacesAndNewlines) - session.handle = cleanHandle.hasPrefix("@") ? cleanHandle : "@\(cleanHandle)" - session.bio = bio.trimmingCharacters(in: .whitespacesAndNewlines) - if let avatarName = avatarName { - session.avatarName = avatarName + do { + let user = try await apiService.updateProfile(request, token: token) + _ = persistSession(makeSession(from: user), accessToken: token) + UserDefaults.standard.removeObject(forKey: pendingKey) + } catch { + authErrorMessage = "Profile update remains queued while the backend is unavailable." } - persistSession(session) } public func signOut() { + if let session = currentSession, !session.isGuest, + let token = KeychainStore.shared.readAccessToken() { + Task { try? await apiService.logout(token: token) } + } + if let session = currentSession { + UserDefaults.standard.removeObject(forKey: pendingProfileKeyPrefix + session.id.uuidString) + } KeychainStore.shared.deleteData(key: "userSession") KeychainStore.shared.deleteAccessToken() UserDefaults.standard.removeObject(forKey: sessionStorageKey) @@ -168,5 +241,6 @@ public final class AuthManager: ObservableObject { self.isAuthenticated = false self.isGuestMode = false self.authErrorMessage = nil + AuthViewModel.shared.adoptSignedOutState() } } diff --git a/frontend/FableApp/Features/Auth/Tests/AuthTests.swift b/frontend/FableApp/Features/Auth/Tests/AuthTests.swift index 4495afa..2cca2bf 100644 --- a/frontend/FableApp/Features/Auth/Tests/AuthTests.swift +++ b/frontend/FableApp/Features/Auth/Tests/AuthTests.swift @@ -29,7 +29,7 @@ public struct AuthTests { // Test 3: Guest session generation auth.continueAsGuest() - assert(auth.isAuthenticated && auth.isGuestMode && auth.currentSession?.name == "Guest Reader", "Guest Session Generation") + assert(auth.isAuthenticated && auth.isGuestMode && auth.currentSession?.name == "Guest", "Guest Session Generation") // Test 4: Sign-out state cleanup auth.signOut() diff --git a/frontend/FableApp/Features/Auth/ViewModels/AuthViewModel.swift b/frontend/FableApp/Features/Auth/ViewModels/AuthViewModel.swift index 911ae25..5af19b7 100644 --- a/frontend/FableApp/Features/Auth/ViewModels/AuthViewModel.swift +++ b/frontend/FableApp/Features/Auth/ViewModels/AuthViewModel.swift @@ -34,10 +34,12 @@ public final class AuthViewModel: ObservableObject { @Published public var errorMessage: String? = nil private let keychain: KeychainStore + private let apiService: StoryAPIServiceProtocol private let sessionKeychainKey = "userSession" - public init(keychain: KeychainStore = .shared) { + public init(keychain: KeychainStore = .shared, apiService: StoryAPIServiceProtocol = StoryAPIService()) { self.keychain = keychain + self.apiService = apiService } /// Verifies existing credentials stored securely in the iOS Keychain. @@ -49,10 +51,17 @@ public final class AuthViewModel: ObservableObject { // Check legacy fallback from UserDefaults if transitioning if let legacyData = UserDefaults.standard.data(forKey: "fable_persisted_session_v1"), let legacySession = try? JSONDecoder().decode(UserSession.self, from: legacyData) { + guard legacySession.isGuest || hasServerAccessToken else { + UserDefaults.standard.removeObject(forKey: "fable_persisted_session_v1") + keychain.deleteData(key: sessionKeychainKey) + keychain.deleteAccessToken() + self.currentUser = nil + self.authState = .signedOut + return + } // Migrate immediately to hardware Keychain if let encoded = try? JSONEncoder().encode(legacySession) { keychain.saveData(key: sessionKeychainKey, data: encoded) - keychain.saveAccessToken("token_\(UUID().uuidString)") } UserDefaults.standard.removeObject(forKey: "fable_persisted_session_v1") self.currentUser = legacySession @@ -68,6 +77,16 @@ public final class AuthViewModel: ObservableObject { do { let session = try JSONDecoder().decode(UserSession.self, from: sessionData) + guard session.isGuest || hasServerAccessToken else { + keychain.deleteData(key: sessionKeychainKey) + keychain.deleteAccessToken() + self.currentUser = nil + self.authState = .signedOut + return + } + if session.isGuest { + keychain.deleteAccessToken() + } self.currentUser = session self.authState = .signedIn(session) syncToAuthManager(session: session) @@ -81,91 +100,69 @@ public final class AuthViewModel: ObservableObject { /// Authenticates user credentials and stores session securely in Keychain. public func login(email: String, password: String) async -> Bool { - self.isLoading = true - self.errorMessage = nil + isLoading = true + errorMessage = nil + defer { isLoading = false } let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmedEmail.contains("@") && trimmedEmail.contains(".") else { - self.errorMessage = "Please enter a valid email address." - self.isLoading = false + guard trimmedEmail.contains("@"), trimmedEmail.contains(".") else { + errorMessage = "Please enter a valid email address." return false } - guard password.count >= 6 else { - self.errorMessage = "Password must be at least 6 characters." - self.isLoading = false + errorMessage = "Password must be at least 6 characters." return false } - // Brief delay to simulate authentic verification - try? await Task.sleep(nanoseconds: 300_000_000) - - let authorName = trimmedEmail.lowercased().contains("roosc") ? "Roosc Zaño" : trimmedEmail.components(separatedBy: "@").first?.capitalized ?? "Author" - let handle = "@\(trimmedEmail.components(separatedBy: "@").first?.lowercased() ?? "author")" - - let session = UserSession( - name: authorName, - handle: handle, - email: trimmedEmail, - bio: "Reader and storyteller on Fable.", - avatarName: "avatar_roosc", - isGuest: false - ) - - persistSession(session) - self.isLoading = false - return true + do { + let response = try await apiService.login(email: trimmedEmail, password: password) + let session = makeSession(from: response.user) + return persistSession(session, accessToken: response.accessToken) + } catch let error as APIRequestError { + errorMessage = error.message + return false + } catch { + errorMessage = "Sign in failed. Check your connection." + return false + } } - /// Registers a new user account, stores credentials, and auto-signs in. public func register(name: String, handle: String, email: String, password: String) async -> Bool { - self.isLoading = true - self.errorMessage = nil + isLoading = true + errorMessage = nil + defer { isLoading = false } let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedHandle = handle.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { - self.errorMessage = "Full name cannot be blank." - self.isLoading = false + errorMessage = "Full name cannot be blank." return false } - - guard trimmedEmail.contains("@") && trimmedEmail.contains(".") else { - self.errorMessage = "Please enter a valid email address." - self.isLoading = false + guard trimmedEmail.contains("@"), trimmedEmail.contains(".") else { + errorMessage = "Please enter a valid email address." return false } - guard password.count >= 6 else { - self.errorMessage = "Password must be at least 6 characters." - self.isLoading = false + errorMessage = "Password must be at least 6 characters." return false } - let formattedHandle = trimmedHandle.hasPrefix("@") ? trimmedHandle : "@\(trimmedHandle)" - - try? await Task.sleep(nanoseconds: 350_000_000) - - let session = UserSession( - name: trimmedName, - handle: formattedHandle.isEmpty ? "@reader" : formattedHandle, - email: trimmedEmail, - bio: "Story enthusiast on Fable.", - avatarName: nil, - isGuest: false - ) - - persistSession(session) - self.isLoading = false - return true + do { + let response = try await apiService.register(email: trimmedEmail, password: password, name: trimmedName, handle: handle) + let session = makeSession(from: response.user) + return persistSession(session, accessToken: response.accessToken) + } catch let error as APIRequestError { + errorMessage = error.message + return false + } catch { + errorMessage = "Account creation failed. Check your connection." + return false + } } - /// Initiates an offline guest session. public func continueAsGuest() { - let guest = UserSession.guestUser - persistSession(guest) + let guest = UserSession.guest() + _ = persistSession(guest, accessToken: nil) } /// Clears Keychain credentials and returns state to signedOut. @@ -180,23 +177,64 @@ public final class AuthViewModel: ObservableObject { // MARK: - Private Helpers - private func persistSession(_ session: UserSession) { + func adoptSession(_ session: UserSession) { + currentUser = session + authState = .signedIn(session) + } + + func adoptSignedOutState() { + currentUser = nil + authState = .signedOut + errorMessage = nil + } + + private var hasServerAccessToken: Bool { + guard let token = keychain.readAccessToken() else { return false } + return !token.hasPrefix("fable_token_") && !token.hasPrefix("token_") + } + + private func makeSession(from user: AuthUserDTO) -> UserSession { + UserSession( + id: user.id, + name: user.name, + handle: user.handle, + email: user.email, + bio: user.bio, + avatarName: user.avatarImageUrl ?? user.avatarImageName, + joinedDate: user.createdAtUtc + ) + } + + private func syncToAuthManager(session: UserSession) { + AuthManager.shared.acceptSession(session) + } + + @discardableResult + private func persistSession(_ session: UserSession, accessToken: String?) -> Bool { do { let data = try JSONEncoder().encode(session) - keychain.saveData(key: sessionKeychainKey, data: data) - keychain.saveAccessToken("fable_token_\(UUID().uuidString)") - self.currentUser = session - self.authState = .signedIn(session) - self.errorMessage = nil - syncToAuthManager(session: session) + guard keychain.saveData(key: sessionKeychainKey, data: data) else { + errorMessage = "Failed to store the local session securely." + return false + } + if let accessToken { + guard keychain.saveAccessToken(accessToken) else { + keychain.deleteData(key: sessionKeychainKey) + errorMessage = "Failed to store the access token securely." + return false + } + } else { + keychain.deleteAccessToken() + } + currentUser = session + authState = .signedIn(session) + errorMessage = nil + AuthManager.shared.acceptSession(session) + return true } catch { - self.errorMessage = "Failed to store credentials in hardware Keychain." + errorMessage = "Failed to store the local session securely." + return false } } - private func syncToAuthManager(session: UserSession) { - if let data = try? JSONEncoder().encode(session) { - UserDefaults.standard.set(data, forKey: "fable_persisted_session_v1") - } - } } diff --git a/frontend/FableApp/Features/Auth/Views/SignInView.swift b/frontend/FableApp/Features/Auth/Views/SignInView.swift index 801215f..bf8eed9 100644 --- a/frontend/FableApp/Features/Auth/Views/SignInView.swift +++ b/frontend/FableApp/Features/Auth/Views/SignInView.swift @@ -147,24 +147,6 @@ public struct SignInView: View { .padding(18) .fableCard() - // Demo Credentials Quick-Fill - Button(action: { - self.email = "demo@example.com" - self.password = "password123" - }) { - HStack(spacing: 6) { - Image(systemName: "sparkles") - .font(.system(size: 12)) - Text("Use Demo Account") - .font(.system(size: 13, weight: .medium)) - } - .foregroundColor(FableTheme.brandPrimary) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(FableTheme.brandPrimary.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - // Navigation switch to Register if let onNavigateToRegister { Button(action: onNavigateToRegister) { diff --git a/frontend/FableApp/Features/Library/Models/Story.swift b/frontend/FableApp/Features/Library/Models/Story.swift index 8bceab8..2a724f8 100644 --- a/frontend/FableApp/Features/Library/Models/Story.swift +++ b/frontend/FableApp/Features/Library/Models/Story.swift @@ -106,39 +106,37 @@ public enum SourceProvider: String, Codable, CaseIterable, Identifiable { } } -public enum Genre: String, Codable, CaseIterable, Identifiable { - case all = "All" - case manga = "Manga" - case folklore = "Folklore" - case urbanLegend = "Urban Legend" - case mythology = "Mythology" - case horror = "Horror" - case speculative = "Speculative" - case gothic = "Gothic" - case classic = "Classic" - case classicFiction = "Classic Fiction" - case classicMystery = "Classic Mystery" - case darkFantasy = "Dark Fantasy" +public struct Genre: RawRepresentable, Codable, Hashable, Identifiable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } public var id: String { rawValue } + public static let all = Genre(rawValue: "All") + public static let manga = Genre(rawValue: "Manga") + public static let folklore = Genre(rawValue: "Folklore") + public static let urbanLegend = Genre(rawValue: "Urban Legend") + public static let mythology = Genre(rawValue: "Mythology") + public static let horror = Genre(rawValue: "Horror") + public static let speculative = Genre(rawValue: "Speculative") + public static let gothic = Genre(rawValue: "Gothic") + public static let classic = Genre(rawValue: "Classic") + public static let classicFiction = Genre(rawValue: "Classic Fiction") + public static let classicMystery = Genre(rawValue: "Classic Mystery") + public static let darkFantasy = Genre(rawValue: "Dark Fantasy") + public static let unspecified = Genre(rawValue: "") + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - let raw = try container.decode(String.self) - if let match = Genre(rawValue: raw) { - self = match - } else { - let lower = raw.lowercased() - if lower.contains("manga") || lower.contains("comic") { self = .manga } - else if lower.contains("folk") { self = .folklore } - else if lower.contains("urban") { self = .urbanLegend } - else if lower.contains("myth") { self = .mythology } - else if lower.contains("horror") { self = .horror } - else if lower.contains("gothic") { self = .gothic } - else if lower.contains("speculative") || lower.contains("sci-fi") { self = .speculative } - else if lower.contains("fantasy") { self = .darkFantasy } - else { self = .all } - } + self.init(rawValue: try container.decode(String.self)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) } } @@ -231,7 +229,8 @@ public struct Story: Identifiable, Hashable, Codable { public var totalPages: Int public var currentPage: Int public var progressPercent: Int - public var rating: Double + public var rating: Double? + public var providerDownloadCount: Int? public var savesCount: String public var readsCount: String public var isTaleOfTheDay: Bool @@ -242,6 +241,9 @@ public struct Story: Identifiable, Hashable, Codable { public var chapters: [Chapter]? public var contentFormat: ContentFormat public var sourceProvider: SourceProvider + public var providerId: String? + public var lastReadChapterId: String? + public var lastReadChapterNumber: Int? // Convenience accessors public var excerpt: String { @@ -265,10 +267,8 @@ public struct Story: Identifiable, Hashable, Codable { } public var effectiveCoverImage: String? { - if let url = coverImageUrl, !url.isEmpty { - return url - } - return coverImageName + guard let url = coverImageUrl, !url.isEmpty else { return nil } + return url } public var paragraphs: [String] { @@ -278,9 +278,9 @@ public struct Story: Identifiable, Hashable, Codable { enum CodingKeys: String, CodingKey { case id, title, author, genre, synopsis, content, readTimeMinutes, isBookmarked, isCompleted, createdAtUtc - case coverImageName, heroImageName, coverImageUrl, totalPages, currentPage, progressPercent, rating, savesCount, readsCount + case coverImageName, heroImageName, coverImageUrl, totalPages, currentPage, progressPercent, rating, providerDownloadCount, savesCount, readsCount case isTaleOfTheDay, isRecentSubmission, isCuratorSpotlight, badgeText, totalChapters, chapters - case contentFormat, sourceProvider + case contentFormat, sourceProvider, providerId, lastReadChapterId, lastReadChapterNumber } public init(from decoder: Decoder) throws { @@ -288,10 +288,10 @@ public struct Story: Identifiable, Hashable, Codable { self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() self.title = try container.decode(String.self, forKey: .title) self.author = try container.decode(String.self, forKey: .author) - self.genre = try container.decodeIfPresent(Genre.self, forKey: .genre) ?? .folklore + self.genre = try container.decodeIfPresent(Genre.self, forKey: .genre) ?? .unspecified self.synopsis = try container.decodeIfPresent(String.self, forKey: .synopsis) ?? "" self.content = try container.decodeIfPresent(String.self, forKey: .content) ?? "" - self.readTimeMinutes = try container.decodeIfPresent(Int.self, forKey: .readTimeMinutes) ?? 5 + self.readTimeMinutes = try container.decodeIfPresent(Int.self, forKey: .readTimeMinutes) ?? 0 self.isBookmarked = try container.decodeIfPresent(Bool.self, forKey: .isBookmarked) ?? false self.isCompleted = try container.decodeIfPresent(Bool.self, forKey: .isCompleted) ?? false self.createdAtUtc = try container.decodeIfPresent(Date.self, forKey: .createdAtUtc) ?? Date() @@ -299,20 +299,24 @@ public struct Story: Identifiable, Hashable, Codable { self.coverImageName = try container.decodeIfPresent(String.self, forKey: .coverImageName) self.heroImageName = try container.decodeIfPresent(String.self, forKey: .heroImageName) self.coverImageUrl = try container.decodeIfPresent(String.self, forKey: .coverImageUrl) - self.totalPages = try container.decodeIfPresent(Int.self, forKey: .totalPages) ?? 5 + self.totalPages = try container.decodeIfPresent(Int.self, forKey: .totalPages) ?? 0 self.currentPage = try container.decodeIfPresent(Int.self, forKey: .currentPage) ?? 1 self.progressPercent = try container.decodeIfPresent(Int.self, forKey: .progressPercent) ?? 0 - self.rating = try container.decodeIfPresent(Double.self, forKey: .rating) ?? 4.9 - self.savesCount = try container.decodeIfPresent(String.self, forKey: .savesCount) ?? "1.2k" - self.readsCount = try container.decodeIfPresent(String.self, forKey: .readsCount) ?? "1.2k" + self.rating = try container.decodeIfPresent(Double.self, forKey: .rating) + self.providerDownloadCount = try container.decodeIfPresent(Int.self, forKey: .providerDownloadCount) + self.savesCount = try container.decodeIfPresent(String.self, forKey: .savesCount) ?? "0" + self.readsCount = try container.decodeIfPresent(String.self, forKey: .readsCount) ?? "0" self.isTaleOfTheDay = try container.decodeIfPresent(Bool.self, forKey: .isTaleOfTheDay) ?? false self.isRecentSubmission = try container.decodeIfPresent(Bool.self, forKey: .isRecentSubmission) ?? false self.isCuratorSpotlight = try container.decodeIfPresent(Bool.self, forKey: .isCuratorSpotlight) ?? false self.badgeText = try container.decodeIfPresent(String.self, forKey: .badgeText) - self.totalChapters = try container.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 1 + self.totalChapters = try container.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0 self.chapters = try container.decodeIfPresent([Chapter].self, forKey: .chapters) self.contentFormat = try container.decodeIfPresent(ContentFormat.self, forKey: .contentFormat) ?? .prose self.sourceProvider = try container.decodeIfPresent(SourceProvider.self, forKey: .sourceProvider) ?? .fableOriginal + self.providerId = try container.decodeIfPresent(String.self, forKey: .providerId) + self.lastReadChapterId = try container.decodeIfPresent(String.self, forKey: .lastReadChapterId) + self.lastReadChapterNumber = try container.decodeIfPresent(Int.self, forKey: .lastReadChapterNumber) } // Architecture Contract Initializer (ARCHITECTURE.md Section 3.1 & 7.2) @@ -328,7 +332,10 @@ public struct Story: Identifiable, Hashable, Codable { isCompleted: Bool = false, createdAtUtc: Date = Date(), contentFormat: ContentFormat = .prose, - sourceProvider: SourceProvider = .fableOriginal + sourceProvider: SourceProvider = .fableOriginal, + providerId: String? = nil, + lastReadChapterId: String? = nil, + lastReadChapterNumber: Int? = nil ) { self.id = id self.title = title @@ -340,12 +347,13 @@ public struct Story: Identifiable, Hashable, Codable { self.isBookmarked = isBookmarked self.isCompleted = isCompleted self.createdAtUtc = createdAtUtc - self.totalPages = max(1, readTimeMinutes) + self.totalPages = max(0, readTimeMinutes) self.currentPage = 1 self.progressPercent = isCompleted ? 100 : 0 - self.rating = 4.9 - self.savesCount = "1.2k" - self.readsCount = "1.2k" + self.rating = nil + self.providerDownloadCount = nil + self.savesCount = "0" + self.readsCount = "0" self.isTaleOfTheDay = false self.isRecentSubmission = true self.isCuratorSpotlight = false @@ -357,6 +365,9 @@ public struct Story: Identifiable, Hashable, Codable { self.chapters = nil self.contentFormat = contentFormat self.sourceProvider = sourceProvider + self.providerId = providerId + self.lastReadChapterId = lastReadChapterId + self.lastReadChapterNumber = lastReadChapterNumber } // Full Prototype Initializer @@ -370,13 +381,14 @@ public struct Story: Identifiable, Hashable, Codable { coverImageName: String? = nil, heroImageName: String? = nil, coverImageUrl: String? = nil, - readingTimeMinutes: Int = 4, - totalPages: Int = 5, + readingTimeMinutes: Int = 0, + totalPages: Int = 0, currentPage: Int = 1, progressPercent: Int = 0, - rating: Double = 4.9, - savesCount: String = "1.2k", - readsCount: String = "1.2k", + rating: Double? = nil, + providerDownloadCount: Int? = nil, + savesCount: String = "0", + readsCount: String = "0", isTaleOfTheDay: Bool = false, isRecentSubmission: Bool = false, isSaved: Bool = false, @@ -385,20 +397,15 @@ public struct Story: Identifiable, Hashable, Codable { badgeText: String? = nil, contentFormat: ContentFormat = .prose, sourceProvider: SourceProvider = .fableOriginal, - chapters: [Chapter]? = nil + providerId: String? = nil, + chapters: [Chapter]? = nil, + lastReadChapterId: String? = nil, + lastReadChapterNumber: Int? = nil ) { self.id = id self.title = title self.author = author - self.genre = Genre(rawValue: genre) ?? { - let lower = genre.lowercased() - if lower.contains("manga") || lower.contains("comic") { return .manga } - if lower.contains("folk") { return .folklore } - if lower.contains("myth") { return .mythology } - if lower.contains("horror") || lower.contains("gothic") { return .horror } - if lower.contains("urban") { return .urbanLegend } - return .folklore - }() + self.genre = Genre(rawValue: genre) self.synopsis = excerpt self.content = paragraphs.joined(separator: "\n\n") self.readTimeMinutes = readingTimeMinutes @@ -412,6 +419,7 @@ public struct Story: Identifiable, Hashable, Codable { self.currentPage = currentPage self.progressPercent = progressPercent self.rating = rating + self.providerDownloadCount = providerDownloadCount self.savesCount = savesCount self.readsCount = readsCount self.isTaleOfTheDay = isTaleOfTheDay @@ -422,6 +430,9 @@ public struct Story: Identifiable, Hashable, Codable { self.chapters = chapters self.contentFormat = contentFormat self.sourceProvider = sourceProvider + self.providerId = providerId + self.lastReadChapterId = lastReadChapterId + self.lastReadChapterNumber = lastReadChapterNumber } } @@ -435,10 +446,8 @@ public struct GenreCategory: Identifiable, Hashable, Codable { public var imageUrl: String? public var effectiveImage: String { - if let url = imageUrl, !url.isEmpty { - return url - } - return imageName + guard let url = imageUrl, !url.isEmpty else { return "" } + return url } public init(id: UUID = UUID(), name: String, storyCount: Int, readersCount: String, description: String, imageName: String, imageUrl: String? = nil) { @@ -458,16 +467,14 @@ public struct Writer: Identifiable, Hashable, Codable { public var avatarImageName: String public var avatarImageUrl: String? public var storyCount: Int - public var rating: Double + public var rating: Double? public var effectiveAvatar: String { - if let url = avatarImageUrl, !url.isEmpty { - return url - } - return avatarImageName + guard let url = avatarImageUrl, !url.isEmpty else { return "" } + return url } - public init(id: UUID = UUID(), name: String, avatarImageName: String, avatarImageUrl: String? = nil, storyCount: Int, rating: Double) { + public init(id: UUID = UUID(), name: String, avatarImageName: String, avatarImageUrl: String? = nil, storyCount: Int, rating: Double? = nil) { self.id = id self.name = name self.avatarImageName = avatarImageName @@ -573,291 +580,7 @@ public struct UserSession: Identifiable, Codable, Equatable { self.joinedDate = joinedDate } - /// Pre-configured seed profile for default authoring - public static let defaultUser = UserSession( - name: "Roosc Zaño", - handle: "@zanoroosc", - email: "roosc-zano@fable.app", - bio: "Writer of quiet lore, archivist of dusk folklore, and collector of vintage horology tales.", - avatarName: "avatar_roosc", - isGuest: false - ) - - /// Ephemeral session for guest exploration - public static let guestUser = UserSession( - name: "Guest Reader", - handle: "@reader", - email: "guest@fable.local", - bio: "Exploring the curated folklore manuscripts as a guest.", - avatarName: nil, - isGuest: true - ) -} - -extension Story { - public static let defaultSeedStories: [Story] = [ - Story( - title: "Dracula", - author: "Bram Stoker", - genre: "Gothic", - excerpt: "The castle is on the very edge of a terrible precipice. A stone falling from the window would fall a thousand feet without touching anything.", - paragraphs: [ - "Before the sun had set, we reached the Bistritz pass. The grey of the evening had begun to fall, and the shadows of the mountains seemed to close in around us with every mile. The horses began to strain against the harness as the road turned sharply upward into the deep pine forests of Transylvania.", - "\"The castle is on the very edge of a terrible precipice,\" the driver whispered, crossing himself as the wolves began their low, distant howling down in the valley below. \"A stone falling from the window would fall a thousand feet without touching anything.\"", - "The wind grew colder, piercing through my woollen mantle with icy teeth. Far above, perched jaggedly upon a fang of rock, the black battlements rose against a sky bruised with indigo and blood orange.", - "I could hear the wolves getting closer. Their choruses echoed through the gorge like a choir of starved spirits. And then, at the crest of the winding road, a tall figure in a heavy cape stepped into the lantern light..." - ], - coverImageName: "cover_dracula", - heroImageName: "hero_castle", - coverImageUrl: "https://www.gutenberg.org/cache/epub/345/pg345.cover.medium.jpg", - readingTimeMinutes: 4, - totalPages: 5, - currentPage: 1, - progressPercent: 0, - rating: 4.95, - isTaleOfTheDay: true, - isSaved: false, - sourceProvider: .gutenberg - ), - Story( - title: "The Legend of Sleepy Hollow", - author: "Washington Irving", - genre: "Folklore", - excerpt: "A drowsy, dreamy influence seems to hang over the land, and to pervade the very atmosphere.", - paragraphs: [ - "A drowsy, dreamy influence seems to hang over the land, and to pervade the very atmosphere. Some say that the place was bewitched by a High German doctor, during the early days of the settlement; others, that an old Indian chief, the prophet or wizard of his tribe, held his powwows there before the country was discovered by Master Hendrick Hudson.", - "Certain it is, the place still continues under the sway of some bewitching power, that holds a spell over the minds of the good people, causing them to walk in a continual reverie. They are given to all kinds of marvelous beliefs, are subject to trances and visions, and frequently see strange sights, and hear music and voices in the air." - ], - coverImageName: "thumb_sleepy", - heroImageName: "cover_sleepy_featured", - coverImageUrl: "https://www.gutenberg.org/cache/epub/41/pg41.cover.medium.jpg", - readingTimeMinutes: 4, - totalPages: 24, - currentPage: 1, - progressPercent: 0, - rating: 4.95, - isSaved: false, - isCuratorSpotlight: true, - sourceProvider: .gutenberg - ), - Story( - title: "The Metamorphosis", - author: "Franz Kafka", - genre: "Classic Fiction", - excerpt: "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a monstrous vermin.", - paragraphs: [ - "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a monstrous vermin.", - "He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections." - ], - coverImageName: "thumb_metamorphosis", - coverImageUrl: "https://www.gutenberg.org/cache/epub/5200/pg5200.cover.medium.jpg", - readingTimeMinutes: 5, - totalPages: 8, - currentPage: 1, - progressPercent: 0, - isRecentSubmission: true, - isSaved: false, - sourceProvider: .gutenberg - ), - Story( - title: "The Tell-Tale Heart", - author: "Edgar Allan Poe", - genre: "Gothic", - excerpt: "True! — nervous — very, very dreadfully nervous I had been and am; but why will you say that I am mad?", - paragraphs: [ - "True! — nervous — very, very dreadfully nervous I had been and am; but why will you say that I am mad? The disease had sharpened my senses — not destroyed — not dulled them.", - "Above all was the sense of hearing acute. I heard all things in the heaven and in the earth. I heard many things in hell. How, then, am I mad? Hearken! and observe how healthily — how calmly I can tell you the whole story." - ], - coverImageName: "thumb_tell_tale", - coverImageUrl: "https://www.gutenberg.org/cache/epub/2148/pg2148.cover.medium.jpg", - readingTimeMinutes: 3, - totalPages: 4, - currentPage: 1, - progressPercent: 0, - isRecentSubmission: true, - isSaved: false, - sourceProvider: .gutenberg - ), - Story( - title: "The Legend of Maria Makiling", - author: "Jose Rizal", - genre: "Folklore", - excerpt: "She was a fantastic creature, half nymph, half sylph, born under the moonbeams in the mystery of ancient woods...", - paragraphs: [ - "She was a fantastic creature, half nymph, half sylph, born under the moonbeams in the mystery of ancient woods...", - "Her voice was like the murmur of crystal water over white pebbles, and her step was as light as the dewdrop falling upon a leaf at dawn." - ], - coverImageName: "thumb_maria_makiling", - coverImageUrl: "https://www.gutenberg.org/cache/epub/38269/pg38269.cover.medium.jpg", - readingTimeMinutes: 4, - totalPages: 6, - currentPage: 1, - progressPercent: 0, - isRecentSubmission: true, - isSaved: false, - sourceProvider: .gutenberg - ), - Story( - title: "Rip Van Winkle", - author: "Washington Irving", - genre: "Folklore", - excerpt: "Whoever has made a voyage up the Hudson must remember the Kaatskill mountains...", - paragraphs: [ - "Whoever has made a voyage up the Hudson must remember the Kaatskill mountains. They are a dismembered branch of the great Appalachian family, and are seen away to the west of the river, swelling up to a noble height, and lording it over the surrounding country." - ], - coverImageName: "thumb_rip_van_winkle", - coverImageUrl: "https://www.gutenberg.org/cache/epub/2048/pg2048.cover.medium.jpg", - readingTimeMinutes: 4, - totalPages: 6, - currentPage: 1, - progressPercent: 0, - isRecentSubmission: true, - isSaved: false, - sourceProvider: .gutenberg - ), - Story( - id: UUID(uuidString: "10101010-1010-1010-1010-101010101010") ?? UUID(), - title: "Chainsaw Devil: Special Edition", - author: "Tatsuki Fujimoto", - genre: "Manga", - excerpt: "In a gritty neon metropolis where human fears manifest as living devils, an indebted hunter fights for survival alongside his faithful devil companion.", - paragraphs: [], - coverImageName: "cover_dracula", - heroImageName: "hero_dracula", - coverImageUrl: "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=800&auto=format&fit=crop", - readingTimeMinutes: 8, - totalPages: 4, - currentPage: 1, - progressPercent: 0, - rating: 4.95, - isRecentSubmission: true, - isSaved: false, - badgeText: "MANGA", - contentFormat: .manga, - sourceProvider: .mangadex, - chapters: [ - Chapter( - storyId: UUID(uuidString: "10101010-1010-1010-1010-101010101010") ?? UUID(), - chapterNumber: 1, - title: "Chapter 1: The Contract", - content: "", - wordCount: 0, - pageUrls: [ - "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=800&auto=format&fit=crop", - "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=800&auto=format&fit=crop", - "https://images.unsplash.com/photo-1578632767115-351597cf2477?q=80&w=800&auto=format&fit=crop", - "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=800&auto=format&fit=crop" - ] - ) - ] - ), - Story( - id: UUID(uuidString: "20202020-2020-2020-2020-202020202020") ?? UUID(), - title: "The Metamorphosis", - author: "Franz Kafka", - genre: "Classic Fiction", - excerpt: "One morning, Gregor Samsa woke from uneasy dreams to find himself transformed into a monstrous insect.", - paragraphs: [ - "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin." - ], - coverImageName: "cover_metamorphosis", - heroImageName: "cover_metamorphosis", - coverImageUrl: "https://standardebooks.org/ebooks/franz-kafka/the-metamorphosis/david-wyllie/downloads/cover.jpg", - readingTimeMinutes: 7, - totalPages: 8, - currentPage: 1, - progressPercent: 0, - rating: 4.9, - isCuratorSpotlight: true, - badgeText: "STANDARD EBOOKS", - contentFormat: .prose, - sourceProvider: .standardEbooks - ) - ] -} - -extension GenreCategory { - public static let defaultCategories: [GenreCategory] = [ - GenreCategory( - name: "Manga", - storyCount: 520, - readersCount: "34.8k", - description: "Visual graphic serialized narratives, high-contrast dynamic action panels, and modern serialized storytelling.", - imageName: "genre_folklore", - imageUrl: "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=800&auto=format&fit=crop" - ), - GenreCategory( - name: "Folklore", - storyCount: 248, - readersCount: "18.4k", - description: "Timeless fables, oral legends, and cultural allegories passed through generations of oral history and regional myth.", - imageName: "genre_folklore", - imageUrl: "https://images.unsplash.com/photo-1518709268805-4e9042af9f23?q=80&w=800&auto=format&fit=crop" - ), - GenreCategory( - name: "Mythology", - storyCount: 312, - readersCount: "22.1k", - description: "Ancient pantheons, cosmic sagas, and heroic epic narratives from classical civilizations across the globe.", - imageName: "genre_mythology", - imageUrl: "https://images.unsplash.com/photo-1579783902614-a3fb3927b675?q=80&w=800&auto=format&fit=crop" - ), - GenreCategory( - name: "Gothic", - storyCount: 185, - readersCount: "9.8k", - description: "Atmospheric hauntings, crumbling estates, and romantic dread exploring the psychological depths of human melancholy.", - imageName: "genre_gothic", - imageUrl: "https://images.unsplash.com/photo-1509198397868-475647b2a1e5?q=80&w=800&auto=format&fit=crop" - ), - GenreCategory( - name: "Classic Mystery", - storyCount: 185, - readersCount: "14.2k", - description: "Whodunits, deductive puzzles, and atmospheric investigations through gaslit cobblestones and locked rooms.", - imageName: "genre_mystery", - imageUrl: "https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?q=80&w=800&auto=format&fit=crop" - ) - ] -} - -extension Writer { - public static let defaultWriters: [Writer] = [ - Writer( - name: "Bram Stoker", - avatarImageName: "author_kuang", - avatarImageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/34/Bram_Stoker_1906.jpg/440px-Bram_Stoker_1906.jpg", - storyCount: 14, - rating: 4.9 - ), - Writer( - name: "Washington Irving", - avatarImageName: "author_yarros", - avatarImageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a2/Washington_Irving_by_John_Wesley_Jarvis%2C_1809.jpg/440px-Washington_Irving_by_John_Wesley_Jarvis%2C_1809.jpg", - storyCount: 9, - rating: 4.8 - ), - Writer( - name: "Edgar Allan Poe", - avatarImageName: "author_klune", - avatarImageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Edgar_Allan_Poe_2_edit.jpg/440px-Edgar_Allan_Poe_2_edit.jpg", - storyCount: 16, - rating: 4.9 - ), - Writer( - name: "Franz Kafka", - avatarImageName: "author_kuang", - avatarImageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/2/26/Franz_Kafka%2C_1923.jpg/440px-Franz_Kafka%2C_1923.jpg", - storyCount: 14, - rating: 4.8 - ), - Writer( - name: "Tatsuki Fujimoto", - avatarImageName: "author_roosc", - avatarImageUrl: "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=800&auto=format&fit=crop", - storyCount: 22, - rating: 5.0 - ) - ] + public static func guest() -> UserSession { + UserSession(name: "Guest", handle: "", email: "", isGuest: true) + } } - diff --git a/frontend/FableApp/Features/Library/Models/StoryEntity.swift b/frontend/FableApp/Features/Library/Models/StoryEntity.swift index 42b3946..ff9e44d 100644 --- a/frontend/FableApp/Features/Library/Models/StoryEntity.swift +++ b/frontend/FableApp/Features/Library/Models/StoryEntity.swift @@ -7,6 +7,8 @@ public final class StoryEntity { public var title: String public var author: String public var genreRaw: String + public var contentFormatRaw: String? + public var sourceProviderRaw: String? public var chapter: String public var synopsis: String public var content: String @@ -14,6 +16,14 @@ public final class StoryEntity { public var readingProgress: Double public var currentPage: Int = 1 public var totalPages: Int = 1 + public var coverImageName: String? + public var heroImageName: String? + public var coverImageUrl: String? + public var providerId: String? + public var providerDownloadCount: Int? + @Attribute(.externalStorage) public var cachedChaptersData: Data? + public var lastReadChapterId: String? + public var lastReadChapterNumber: Int? public var isBookmarked: Bool public var isCompleted: Bool public var createdAtUtc: Date @@ -27,6 +37,8 @@ public final class StoryEntity { title: String, author: String, genreRaw: String, + contentFormatRaw: String? = nil, + sourceProviderRaw: String? = nil, chapter: String = "", synopsis: String, content: String, @@ -34,6 +46,14 @@ public final class StoryEntity { readingProgress: Double = 0.0, currentPage: Int = 1, totalPages: Int = 1, + coverImageName: String? = nil, + heroImageName: String? = nil, + coverImageUrl: String? = nil, + providerId: String? = nil, + providerDownloadCount: Int? = nil, + cachedChaptersData: Data? = nil, + lastReadChapterId: String? = nil, + lastReadChapterNumber: Int? = nil, isBookmarked: Bool = false, isCompleted: Bool = false, createdAtUtc: Date = Date(), @@ -43,6 +63,8 @@ public final class StoryEntity { self.title = title self.author = author self.genreRaw = genreRaw + self.contentFormatRaw = contentFormatRaw + self.sourceProviderRaw = sourceProviderRaw self.chapter = chapter self.synopsis = synopsis self.content = content @@ -50,6 +72,14 @@ public final class StoryEntity { self.readingProgress = min(1.0, max(0.0, readingProgress)) self.currentPage = max(1, currentPage) self.totalPages = max(1, totalPages) + self.coverImageName = coverImageName + self.heroImageName = heroImageName + self.coverImageUrl = coverImageUrl + self.providerId = providerId + self.providerDownloadCount = providerDownloadCount + self.cachedChaptersData = cachedChaptersData + self.lastReadChapterId = lastReadChapterId + self.lastReadChapterNumber = lastReadChapterNumber self.isBookmarked = isBookmarked self.isCompleted = isCompleted self.createdAtUtc = createdAtUtc @@ -66,6 +96,7 @@ public final class AnnotationEntity { public var note: String? public var styleRaw: String public var isPinnedToJournal: Bool + public var ownerUserId: UUID? public var createdAtUtc: Date public var story: StoryEntity? @@ -78,6 +109,7 @@ public final class AnnotationEntity { note: String? = nil, styleRaw: String = "terracotta", isPinnedToJournal: Bool = false, + ownerUserId: UUID? = nil, createdAtUtc: Date = Date(), story: StoryEntity? = nil ) { @@ -88,6 +120,7 @@ public final class AnnotationEntity { self.note = note self.styleRaw = styleRaw self.isPinnedToJournal = isPinnedToJournal + self.ownerUserId = ownerUserId self.createdAtUtc = createdAtUtc self.story = story } @@ -98,6 +131,7 @@ public final class ReadingLogEntity { @Attribute(.unique) public var id: UUID public var storyId: UUID public var storyTitle: String + public var ownerUserId: UUID? public var secondsRead: Int public var date: Date public var isCompleted: Bool @@ -108,11 +142,13 @@ public final class ReadingLogEntity { storyTitle: String, secondsRead: Int, date: Date = Date(), + ownerUserId: UUID? = nil, isCompleted: Bool = false ) { self.id = id self.storyId = storyId self.storyTitle = storyTitle + self.ownerUserId = ownerUserId self.secondsRead = secondsRead self.date = date self.isCompleted = isCompleted diff --git a/frontend/FableApp/Features/Library/Services/PersistenceService.swift b/frontend/FableApp/Features/Library/Services/PersistenceService.swift index 8a1a768..d9b6780 100644 --- a/frontend/FableApp/Features/Library/Services/PersistenceService.swift +++ b/frontend/FableApp/Features/Library/Services/PersistenceService.swift @@ -32,41 +32,6 @@ public final class PersistenceService { } } - // Seed initial folklore stories if database is empty on cold launch - public func seedInitialDataIfNeeded(seedStories: [Story]) { - var descriptor = FetchDescriptor() - descriptor.fetchLimit = 1 - - do { - let count = try context.fetchCount(descriptor) - if count == 0 { - for story in seedStories { - let entity = StoryEntity( - id: story.id, - title: story.title, - author: story.author, - genreRaw: story.genre.rawValue, - chapter: "Chapter I", - synopsis: story.synopsis, - content: story.content, - readTimeMinutes: story.readTimeMinutes, - readingProgress: Double(story.progressPercent) / 100.0, - currentPage: story.currentPage, - totalPages: story.totalPages, - isBookmarked: story.isBookmarked, - isCompleted: story.isCompleted, - createdAtUtc: story.createdAtUtc, - updatedAtUtc: Date() - ) - context.insert(entity) - } - try context.save() - } - } catch { - print("Failed to seed initial stories: \(error)") - } - } - // Fetch all persistent stories public func fetchAllStories() -> [StoryEntity] { let descriptor = FetchDescriptor( @@ -80,6 +45,11 @@ public final class PersistenceService { } } + private func encodedChapters(_ chapters: [Chapter]?) -> Data? { + guard let chapters, !chapters.isEmpty else { return nil } + return try? JSONEncoder().encode(chapters) + } + // Insert or update manuscript public func saveStory(_ story: Story) { let targetId = story.id @@ -94,12 +64,26 @@ public final class PersistenceService { existing.title = story.title existing.author = story.author existing.genreRaw = story.genre.rawValue + existing.contentFormatRaw = story.contentFormat.rawValue + existing.sourceProviderRaw = story.sourceProvider.rawValue existing.synopsis = story.synopsis existing.content = story.content existing.readTimeMinutes = story.readTimeMinutes existing.readingProgress = Double(story.progressPercent) / 100.0 existing.currentPage = story.currentPage existing.totalPages = story.totalPages + existing.coverImageName = story.coverImageName + existing.heroImageName = story.heroImageName + existing.coverImageUrl = story.coverImageUrl + if let providerId = story.providerId { existing.providerId = providerId } + if let providerDownloadCount = story.providerDownloadCount { + existing.providerDownloadCount = providerDownloadCount + } + if let chapters = story.chapters, !chapters.isEmpty { + existing.cachedChaptersData = encodedChapters(chapters) + } + existing.lastReadChapterId = story.lastReadChapterId + existing.lastReadChapterNumber = story.lastReadChapterNumber existing.isBookmarked = story.isBookmarked existing.isCompleted = story.isCompleted existing.updatedAtUtc = Date() @@ -109,13 +93,23 @@ public final class PersistenceService { title: story.title, author: story.author, genreRaw: story.genre.rawValue, - chapter: "Chapter I", + contentFormatRaw: story.contentFormat.rawValue, + sourceProviderRaw: story.sourceProvider.rawValue, + chapter: "", synopsis: story.synopsis, content: story.content, readTimeMinutes: story.readTimeMinutes, readingProgress: Double(story.progressPercent) / 100.0, currentPage: story.currentPage, totalPages: story.totalPages, + coverImageName: story.coverImageName, + heroImageName: story.heroImageName, + coverImageUrl: story.coverImageUrl, + providerId: story.providerId, + providerDownloadCount: story.providerDownloadCount, + cachedChaptersData: encodedChapters(story.chapters), + lastReadChapterId: story.lastReadChapterId, + lastReadChapterNumber: story.lastReadChapterNumber, isBookmarked: story.isBookmarked, isCompleted: story.isCompleted, createdAtUtc: story.createdAtUtc, @@ -129,6 +123,34 @@ public final class PersistenceService { } } + public func cachedChapters(storyId: UUID) -> [Chapter] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == storyId } + ) + guard + let entity = try? context.fetch(descriptor).first, + let data = entity.cachedChaptersData + else { + return [] + } + return (try? JSONDecoder().decode([Chapter].self, from: data)) ?? [] + } + + public func saveCachedChapters(_ chapters: [Chapter], storyId: UUID) { + guard !chapters.isEmpty else { return } + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == storyId } + ) + do { + guard let entity = try context.fetch(descriptor).first else { return } + entity.cachedChaptersData = try JSONEncoder().encode(chapters) + entity.updatedAtUtc = Date() + try context.save() + } catch { + print("Failed to cache story chapters: \(error)") + } + } + // Update progress public func updateProgress(storyId: UUID, progressPercent: Int, isCompleted: Bool, page: Int = 1, totalPages: Int = 1) { var descriptor = FetchDescriptor( @@ -150,6 +172,39 @@ public final class PersistenceService { } } + public func updateReadingProgress(storyId: UUID, chapterId: String, chapterNumber: Int) { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == storyId } + ) + descriptor.fetchLimit = 1 + + do { + guard let entity = try context.fetch(descriptor).first else { return } + entity.lastReadChapterId = chapterId + entity.lastReadChapterNumber = chapterNumber + entity.updatedAtUtc = Date() + try context.save() + } catch { + print("Failed to update chapter progress: \(error)") + } + } + + public func setBookmark(storyId: UUID, isBookmarked: Bool) { + let targetId = storyId + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == targetId } + ) + descriptor.fetchLimit = 1 + do { + guard let entity = try context.fetch(descriptor).first else { return } + entity.isBookmarked = isBookmarked + entity.updatedAtUtc = Date() + try context.save() + } catch { + print("Failed to update bookmark state: \(error)") + } + } + // Toggle bookmark public func toggleBookmark(storyId: UUID) -> Bool { var descriptor = FetchDescriptor( @@ -189,7 +244,7 @@ public final class PersistenceService { // MARK: - Annotation / Marginalia Methods - public func fetchAnnotations(for storyId: UUID) -> [Annotation] { + public func fetchAnnotations(for storyId: UUID, ownerUserId: UUID?) -> [Annotation] { var descriptor = FetchDescriptor( predicate: #Predicate { $0.id == storyId } ) @@ -197,21 +252,23 @@ public final class PersistenceService { do { if let storyEntity = try context.fetch(descriptor).first { - return storyEntity.annotations.map { entity in - Annotation( - id: entity.id, - storyId: storyId, - storyTitle: storyEntity.title, - storyAuthor: storyEntity.author, - utf16StartOffset: entity.utf16StartOffset, - utf16EndOffset: entity.utf16EndOffset, - selectedText: entity.highlightedText, - note: entity.note, - color: HighlightColor(rawValue: entity.styleRaw) ?? .terracotta, - isPinnedToJournal: entity.isPinnedToJournal, - createdAt: entity.createdAtUtc - ) - } + return storyEntity.annotations + .filter { $0.ownerUserId == ownerUserId } + .map { entity in + Annotation( + id: entity.id, + storyId: storyId, + storyTitle: storyEntity.title, + storyAuthor: storyEntity.author, + utf16StartOffset: entity.utf16StartOffset, + utf16EndOffset: entity.utf16EndOffset, + selectedText: entity.highlightedText, + note: entity.note, + color: HighlightColor(rawValue: entity.styleRaw) ?? .terracotta, + isPinnedToJournal: entity.isPinnedToJournal, + createdAt: entity.createdAtUtc + ) + } } } catch { print("Failed to fetch annotations: \(error)") @@ -219,7 +276,7 @@ public final class PersistenceService { return [] } - public func saveAnnotation(_ annotation: Annotation) { + public func saveAnnotation(_ annotation: Annotation, ownerUserId: UUID?) { let storyId = annotation.storyId var descriptor = FetchDescriptor( predicate: #Predicate { $0.id == storyId } @@ -229,8 +286,8 @@ public final class PersistenceService { do { guard let storyEntity = try context.fetch(descriptor).first else { return } - // Check if annotation exists if let existing = storyEntity.annotations.first(where: { $0.id == annotation.id }) { + guard existing.ownerUserId == ownerUserId else { return } existing.highlightedText = annotation.selectedText existing.note = annotation.note existing.styleRaw = annotation.color.rawValue @@ -244,6 +301,7 @@ public final class PersistenceService { note: annotation.note, styleRaw: annotation.color.rawValue, isPinnedToJournal: annotation.isPinnedToJournal, + ownerUserId: ownerUserId, createdAtUtc: annotation.createdAt, story: storyEntity ) @@ -256,14 +314,14 @@ public final class PersistenceService { } } - public func deleteAnnotation(id: UUID) { + public func deleteAnnotation(id: UUID, ownerUserId: UUID?) { var descriptor = FetchDescriptor( predicate: #Predicate { $0.id == id } ) descriptor.fetchLimit = 1 do { - if let entity = try context.fetch(descriptor).first { + if let entity = try context.fetch(descriptor).first, entity.ownerUserId == ownerUserId { context.delete(entity) try context.save() } @@ -272,14 +330,14 @@ public final class PersistenceService { } } - public func fetchAllPinnedAnnotations() -> [Annotation] { + public func fetchAllPinnedAnnotations(ownerUserId: UUID?) -> [Annotation] { let descriptor = FetchDescriptor( predicate: #Predicate { $0.isPinnedToJournal == true }, sortBy: [SortDescriptor(\.createdAtUtc, order: .reverse)] ) do { - let entities = try context.fetch(descriptor) + let entities = try context.fetch(descriptor).filter { $0.ownerUserId == ownerUserId } return entities.map { entity in Annotation( id: entity.id, @@ -315,50 +373,53 @@ public final class PersistenceService { } } - public func logReadingSession(storyId: UUID, storyTitle: String, seconds: Int, isCompleted: Bool) { + public func logReadingSession( + id: UUID = UUID(), + storyId: UUID, + storyTitle: String, + seconds: Int, + ownerUserId: UUID, + isCompleted: Bool + ) { guard seconds >= 3 else { return } let log = ReadingLogEntity( + id: id, storyId: storyId, storyTitle: storyTitle, secondsRead: seconds, date: Date(), + ownerUserId: ownerUserId, isCompleted: isCompleted ) context.insert(log) try? context.save() } - public func fetchReadingStats() -> ReadingStatsSummary { + public func fetchReadingStats(ownerUserId: UUID) -> ReadingStatsSummary { let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.date, order: .reverse)]) do { - let logs = try context.fetch(descriptor) + let logs = try context.fetch(descriptor).filter { $0.ownerUserId == ownerUserId } let totalLoggedSeconds = logs.reduce(0) { $0 + $1.secondsRead } - let additionalMinutes = totalLoggedSeconds / 60 - - // Count unique stories completed - var completedCount = 0 - let completedDescriptor = FetchDescriptor(predicate: #Predicate { $0.isCompleted }) - if let completedList = try? context.fetch(completedDescriptor) { - completedCount = completedList.count + let completedStoryIDs = Set(logs.filter(\.isCompleted).map(\.storyId)) + let calendar = Calendar.current + let activeDays = Set(logs.compactMap { calendar.startOfDay(for: $0.date) }) + var streakStart = calendar.startOfDay(for: Date()) + if !activeDays.contains(streakStart), + let yesterday = calendar.date(byAdding: .day, value: -1, to: streakStart), + activeDays.contains(yesterday) { + streakStart = yesterday } - - // Calculate active streak days from unique reading days - var uniqueDaySet = Set() - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - for log in logs { - uniqueDaySet.insert(formatter.string(from: log.date)) + var streakDays = 0 + while activeDays.contains(streakStart) { + streakDays += 1 + guard let previousDay = calendar.date(byAdding: .day, value: -1, to: streakStart) else { break } + streakStart = previousDay } - - // Pure calculated stats based on authentic user activity - let finalStories = completedCount - let finalMinutes = additionalMinutes - let finalStreak = uniqueDaySet.count - + return ReadingStatsSummary( - storiesReadCount: finalStories, - totalMinutesRead: finalMinutes, - streakDays: finalStreak + storiesReadCount: completedStoryIDs.count, + totalMinutesRead: totalLoggedSeconds / 60, + streakDays: streakDays ) } catch { return ReadingStatsSummary(storiesReadCount: 0, totalMinutesRead: 0, streakDays: 0) diff --git a/frontend/FableApp/Features/Library/Services/StoryAPIService.swift b/frontend/FableApp/Features/Library/Services/StoryAPIService.swift index daf49df..ec197f1 100644 --- a/frontend/FableApp/Features/Library/Services/StoryAPIService.swift +++ b/frontend/FableApp/Features/Library/Services/StoryAPIService.swift @@ -2,32 +2,29 @@ import Foundation public struct CreateStoryRequest: Codable { public let title: String - public let author: String public let genre: String + public let chapter: String? public let synopsis: String public let content: String public let readTimeMinutes: Int public let contentFormat: String? - public let sourceProvider: String? - + public init( title: String, - author: String, genre: String, + chapter: String? = nil, synopsis: String, content: String, readTimeMinutes: Int, - contentFormat: String? = "PROSE", - sourceProvider: String? = "FABLE_ORIGINAL" + contentFormat: String? = "PROSE" ) { self.title = title - self.author = author self.genre = genre + self.chapter = chapter self.synopsis = synopsis self.content = content self.readTimeMinutes = readTimeMinutes self.contentFormat = contentFormat - self.sourceProvider = sourceProvider } } @@ -137,15 +134,57 @@ public struct ShelfSyncResponse: Codable { } } +public struct APIRequestError: Error, LocalizedError { + public let message: String + + public var errorDescription: String? { message } +} + public struct AuthUserDTO: Codable { public let id: UUID public let email: String public let name: String + public let handle: String + public let bio: String public let avatarImageName: String? public let avatarImageUrl: String? public let createdAtUtc: Date } +public struct ReadingSessionRequest: Codable, Sendable { + public let id: UUID + public let storyId: UUID + public let secondsRead: Int + public let readAtUtc: Date + public let isCompleted: Bool + + public init(id: UUID = UUID(), storyId: UUID, secondsRead: Int, readAtUtc: Date = Date(), isCompleted: Bool) { + self.id = id + self.storyId = storyId + self.secondsRead = secondsRead + self.readAtUtc = readAtUtc + self.isCompleted = isCompleted + } +} + +public struct ReadingStatsDTO: Codable, Sendable { + public let storiesReadCount: Int + public let totalMinutesRead: Int + public let streakDays: Int +} + +public struct ProfileUpdateRequest: Codable { + public let name: String + public let handle: String + public let bio: String + + public init(name: String, handle: String, bio: String) { + self.name = name + self.handle = handle + self.bio = bio + } +} + public struct AuthTokenResponse: Codable { public let accessToken: String public let tokenType: String @@ -154,21 +193,30 @@ public struct AuthTokenResponse: Codable { public protocol StoryAPIServiceProtocol: Sendable { func fetchStories(genre: String?, search: String?, since: Date?) async throws -> [Story] - func createStory(_ request: CreateStoryRequest) async throws -> Story - func syncShelf(deviceId: UUID, items: [ShelfSyncItem]) async throws -> [ShelfSyncItem] - func fetchShelf(deviceId: UUID) async throws -> [ShelfSyncItem] - func toggleBookmark(storyId: UUID) async throws -> Bool + func createStory(_ request: CreateStoryRequest, token: String) async throws -> Story + func fetchMyStories(token: String) async throws -> [Story] + func syncShelf(deviceId: UUID, token: String, items: [ShelfSyncItem]) async throws -> [ShelfSyncItem] + func fetchShelf(deviceId: UUID, token: String) async throws -> [ShelfSyncItem] func fetchGutenbergStories(topic: String?, search: String?) async throws -> [Story] - func fetchChapters(for storyId: UUID) async throws -> [Chapter] + func fetchGutenbergStory(providerId: String) async throws -> Story + func fetchChapters(for storyId: UUID, sourceProvider: String, providerId: String?) async throws -> [Chapter] func fetchGenres() async throws -> [GenreCategory] func fetchTopAuthors() async throws -> [Writer] func fetchUpdateFeed() async throws -> UpdateFeed - func register(email: String, password: String, name: String) async throws -> AuthTokenResponse + func register(email: String, password: String, name: String, handle: String) async throws -> AuthTokenResponse func login(email: String, password: String) async throws -> AuthTokenResponse func fetchCurrentUser(token: String) async throws -> AuthUserDTO + func updateProfile(_ request: ProfileUpdateRequest, token: String) async throws -> AuthUserDTO + func recordReadingSession(_ request: ReadingSessionRequest, token: String) async throws + func fetchReadingStats(token: String) async throws -> ReadingStatsDTO + func logout(token: String) async throws } public extension StoryAPIServiceProtocol { + func fetchChapters(for storyId: UUID) async throws -> [Chapter] { + try await fetchChapters(for: storyId, sourceProvider: "", providerId: nil) + } + func fetchStories(genre: String? = nil, search: String? = nil) async throws -> [Story] { try await fetchStories(genre: genre, search: search, since: nil) } @@ -182,13 +230,26 @@ public final class StoryAPIService: StoryAPIServiceProtocol { private let baseURL: URL private let session: URLSession - public init(baseURL: URL = URL(string: "http://127.0.0.1:8000/api/v1")!) { + public init(baseURL: URL? = nil) { + let infoBaseURL = Bundle.main.object(forInfoDictionaryKey: "FABLE_API_BASE_URL") as? String + let configuredBaseURL = ProcessInfo.processInfo.environment["FABLE_API_BASE_URL"] ?? infoBaseURL self.baseURL = baseURL + ?? configuredBaseURL.flatMap { URL(string: $0) } + ?? URL(string: "http://127.0.0.1:8000/api/v1")! let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 5.0 self.session = URLSession(configuration: config) } + private func validateResponse(_ response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + let payload = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + let detail = payload?["detail"] as? String + throw APIRequestError(message: detail ?? "The server could not complete the request.") + } + } + public func fetchStories(genre: String? = nil, search: String? = nil, since: Date? = nil) async throws -> [Story] { var components = URLComponents(url: baseURL.appendingPathComponent("stories"), resolvingAgainstBaseURL: true)! var queryItems: [URLQueryItem] = [] @@ -224,31 +285,43 @@ public final class StoryAPIService: StoryAPIServiceProtocol { return try decoder.decode([Story].self, from: data) } - public func createStory(_ request: CreateStoryRequest) async throws -> Story { + public func createStory(_ request: CreateStoryRequest, token: String) async throws -> Story { let url = baseURL.appendingPathComponent("stories") var urlRequest = URLRequest(url: url) urlRequest.httpMethod = "POST" urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 urlRequest.httpBody = try encoder.encode(request) let (data, response) = try await session.data(for: urlRequest) - guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { - throw URLError(.badServerResponse) - } + try validateResponse(response, data: data) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(Story.self, from: data) } - public func syncShelf(deviceId: UUID, items: [ShelfSyncItem]) async throws -> [ShelfSyncItem] { + public func fetchMyStories(token: String) async throws -> [Story] { + let url = baseURL.appendingPathComponent("auth").appendingPathComponent("me").appendingPathComponent("stories") + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await session.data(for: request) + try validateResponse(response, data: data) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode([Story].self, from: data) + } + + public func syncShelf(deviceId: UUID, token: String, items: [ShelfSyncItem]) async throws -> [ShelfSyncItem] { let url = baseURL.appendingPathComponent("shelf").appendingPathComponent("sync") var urlRequest = URLRequest(url: url) urlRequest.httpMethod = "POST" urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let payload = ShelfSyncPayload(deviceId: deviceId, items: items) let encoder = JSONEncoder() @@ -266,21 +339,6 @@ public final class StoryAPIService: StoryAPIServiceProtocol { return result.reconciledItems } - public func toggleBookmark(storyId: UUID) async throws -> Bool { - let deviceId: UUID = { - let key = "fable_device_id" - if let saved = UserDefaults.standard.string(forKey: key), let uuid = UUID(uuidString: saved) { - return uuid - } - let newId = UUID() - UserDefaults.standard.set(newId.uuidString, forKey: key) - return newId - }() - let item = ShelfSyncItem(storyId: storyId, readingProgress: 0.0, isBookmarked: true, isCompleted: false, updatedAtUtc: Date()) - let reconciled = try await syncShelf(deviceId: deviceId, items: [item]) - return reconciled.first?.isBookmarked ?? true - } - public func fetchGutenbergStories(topic: String? = nil, search: String? = nil) async throws -> [Story] { var components = URLComponents(url: baseURL.appendingPathComponent("public").appendingPathComponent("gutenberg"), resolvingAgainstBaseURL: true)! var queryItems: [URLQueryItem] = [] @@ -299,8 +357,40 @@ public final class StoryAPIService: StoryAPIServiceProtocol { return try decoder.decode([Story].self, from: data) } - public func fetchChapters(for storyId: UUID) async throws -> [Chapter] { - let url = baseURL.appendingPathComponent("stories").appendingPathComponent(storyId.uuidString).appendingPathComponent("chapters") + public func fetchGutenbergStory(providerId: String) async throws -> Story { + guard let gutenbergId = Int(providerId), gutenbergId > 0 else { throw URLError(.badURL) } + let url = baseURL + .appendingPathComponent("public") + .appendingPathComponent("gutenberg") + .appendingPathComponent(String(gutenbergId)) + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(Story.self, from: data) + } + + public func fetchChapters(for storyId: UUID, sourceProvider: String = "", providerId: String? = nil) async throws -> [Chapter] { + let url: URL + if sourceProvider == "GUTENBERG", let providerId { + guard let gutenbergId = Int(providerId), gutenbergId > 0 else { + throw URLError(.badURL) + } + url = baseURL + .appendingPathComponent("public") + .appendingPathComponent("gutenberg") + .appendingPathComponent(String(gutenbergId)) + .appendingPathComponent("chapters") + } else if sourceProvider == "GUTENBERG" { + throw URLError(.badURL) + } else { + url = baseURL + .appendingPathComponent("stories") + .appendingPathComponent(storyId.uuidString) + .appendingPathComponent("chapters") + } let (data, response) = try await session.data(from: url) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { throw URLError(.badServerResponse) @@ -343,12 +433,14 @@ public final class StoryAPIService: StoryAPIServiceProtocol { return try decoder.decode(UpdateFeed.self, from: data) } - public func fetchShelf(deviceId: UUID) async throws -> [ShelfSyncItem] { + public func fetchShelf(deviceId: UUID, token: String) async throws -> [ShelfSyncItem] { var components = URLComponents(url: baseURL.appendingPathComponent("shelf"), resolvingAgainstBaseURL: true)! components.queryItems = [URLQueryItem(name: "deviceId", value: deviceId.uuidString)] guard let targetURL = components.url else { throw URLError(.badURL) } + var request = URLRequest(url: targetURL) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - let (data, response) = try await session.data(from: targetURL) + let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { throw URLError(.badServerResponse) } @@ -358,7 +450,7 @@ public final class StoryAPIService: StoryAPIServiceProtocol { return try decoder.decode([ShelfSyncItem].self, from: data) } - public func register(email: String, password: String, name: String) async throws -> AuthTokenResponse { + public func register(email: String, password: String, name: String, handle: String = "") async throws -> AuthTokenResponse { let url = baseURL.appendingPathComponent("auth").appendingPathComponent("register") var request = URLRequest(url: url) request.httpMethod = "POST" @@ -367,14 +459,13 @@ public final class StoryAPIService: StoryAPIServiceProtocol { let body: [String: String] = [ "email": email, "password": password, - "name": name + "name": name, + "handle": handle ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { - throw URLError(.badServerResponse) - } + try validateResponse(response, data: data) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -394,15 +485,59 @@ public final class StoryAPIService: StoryAPIServiceProtocol { request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { - throw URLError(.badServerResponse) - } + try validateResponse(response, data: data) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(AuthTokenResponse.self, from: data) } + public func recordReadingSession(_ request: ReadingSessionRequest, token: String) async throws { + let url = baseURL.appendingPathComponent("auth").appendingPathComponent("me").appendingPathComponent("reading-sessions") + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + urlRequest.httpBody = try encoder.encode(request) + let (data, response) = try await session.data(for: urlRequest) + try validateResponse(response, data: data) + } + + public func fetchReadingStats(token: String) async throws -> ReadingStatsDTO { + let url = baseURL.appendingPathComponent("auth").appendingPathComponent("me").appendingPathComponent("stats") + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (data, response) = try await session.data(for: request) + try validateResponse(response, data: data) + return try JSONDecoder().decode(ReadingStatsDTO.self, from: data) + } + + public func logout(token: String) async throws { + let url = baseURL.appendingPathComponent("auth").appendingPathComponent("logout") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (data, response) = try await session.data(for: request) + try validateResponse(response, data: data) + } + + public func updateProfile(_ request: ProfileUpdateRequest, token: String) async throws -> AuthUserDTO { + let url = baseURL.appendingPathComponent("auth").appendingPathComponent("me") + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "PATCH" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + urlRequest.httpBody = try JSONEncoder().encode(request) + + let (data, response) = try await session.data(for: urlRequest) + try validateResponse(response, data: data) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(AuthUserDTO.self, from: data) + } + public func fetchCurrentUser(token: String) async throws -> AuthUserDTO { let url = baseURL.appendingPathComponent("auth").appendingPathComponent("me") var request = URLRequest(url: url) @@ -410,9 +545,7 @@ public final class StoryAPIService: StoryAPIServiceProtocol { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { - throw URLError(.badServerResponse) - } + try validateResponse(response, data: data) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 diff --git a/frontend/FableApp/Features/Library/Tests/AppHealthTests.swift b/frontend/FableApp/Features/Library/Tests/AppHealthTests.swift index 4ccb518..8e786af 100644 --- a/frontend/FableApp/Features/Library/Tests/AppHealthTests.swift +++ b/frontend/FableApp/Features/Library/Tests/AppHealthTests.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import UIKit @MainActor public struct AppHealthTests { @@ -39,20 +40,20 @@ public struct AppHealthTests { { "taleOfTheDay": { "id": "11111111-1111-1111-1111-111111111111", - "title": "Dracula", - "author": "Bram Stoker", + "title": "Public Domain Novel", + "author": "Source Author", "contentFormat": "PROSE", "sourceProvider": "GUTENBERG" }, "curatorSpotlight": { "id": "22222222-2222-2222-2222-222222222222", - "title": "Chainsaw Devil", - "author": "Tatsuki Fujimoto", + "title": "Serialized Graphic Work", + "author": "Graphic Author", "contentFormat": "MANGA", "sourceProvider": "MANGADEX" }, "recentSubmissions": [], - "totalStories": 14, + "totalStories": 2, "timestampUtc": "2026-09-24T00:00:00Z" } """.data(using: .utf8)! @@ -63,23 +64,23 @@ public struct AppHealthTests { "id": "33333333-3333-3333-3333-333333333333", "storyId": "22222222-2222-2222-2222-222222222222", "chapterNumber": 1, - "title": "Chapter 1: The Contract", + "title": "Chapter 1", "content": "", "wordCount": 0, "pageUrls": [ - "https://images.unsplash.com/panel1.jpg", - "https://images.unsplash.com/panel2.jpg" + "https://cdn.example.org/panel1.jpg", + "https://cdn.example.org/panel2.jpg" ] }, { "id": "44444444-4444-4444-4444-444444444444", "storyId": "22222222-2222-2222-2222-222222222222", "chapterNumber": 2, - "title": "Chapter 2: Chainsaw vs Bat", + "title": "Chapter 2", "content": "", "wordCount": 0, "pageUrls": [ - "https://images.unsplash.com/panel3.jpg" + "https://cdn.example.org/panel3.jpg" ] } ] @@ -92,9 +93,9 @@ public struct AppHealthTests { var test1Details = "" if let feed = try? decoder.decode(UpdateFeed.self, from: updateFeedJSON), let chapters = try? decoder.decode([Chapter].self, from: chaptersJSON) { - let feedValid = feed.taleOfTheDay?.title == "Dracula" && + let feedValid = feed.taleOfTheDay?.title == "Public Domain Novel" && feed.curatorSpotlight?.contentFormat == .manga && - feed.totalStories == 14 + feed.totalStories == 2 let chaptersValid = chapters.count == 2 && chapters[0].chapterNumber == 1 && chapters[0].pageUrls.count == 2 && @@ -116,15 +117,15 @@ public struct AppHealthTests { // Test 2: Top Genres Live Metadata & Cover URL Integrity // ================================================================= let genre = GenreCategory( - name: "Manga", - storyCount: 520, - readersCount: "34.8k", - description: "Visual graphic serialized narratives", - imageName: "genre_folklore", - imageUrl: "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=800" + name: "Graphic Literature", + storyCount: 0, + readersCount: "", + description: "", + imageName: "", + imageUrl: "https://covers.example.org/genre.jpg" ) - let genreValid = genre.storyCount > 0 && - !genre.readersCount.isEmpty && + let genreValid = genre.storyCount == 0 && + genre.readersCount.isEmpty && genre.effectiveImage.hasPrefix("https://") && genre.effectiveImage == genre.imageUrl record( @@ -138,19 +139,19 @@ public struct AppHealthTests { // Test 3: Top Creators Live Metadata & Portrait URL Integrity // ================================================================= let writer = Writer( - name: "Tatsuki Fujimoto", - avatarImageName: "author_kuang", - avatarImageUrl: "https://images.unsplash.com/photo-1578632767115-351597cf2477?q=80&w=400", - storyCount: 12, - rating: 4.96 + name: "Source Author", + avatarImageName: "", + avatarImageUrl: "https://avatars.example.org/author.jpg", + storyCount: 0, + rating: nil ) - let writerValid = writer.storyCount > 0 && - writer.rating >= 4.0 && + let writerValid = writer.storyCount == 0 && + writer.rating == nil && writer.effectiveAvatar.hasPrefix("https://") && writer.effectiveAvatar == writer.avatarImageUrl record( name: "Top Creators Live Portraits", - description: "Creator catalog provides authentic ratings, publication counts, and live portrait URLs", + description: "Creator catalog represents absent ratings honestly and carries live portraits", passed: writerValid, details: "Writer '\(writer.name)': \(writer.storyCount) stories, ★\(writer.rating), Avatar: \(writer.effectiveAvatar.prefix(35))..." ) @@ -191,11 +192,11 @@ public struct AppHealthTests { // Cover Art URL Prioritization Check let gutenbergCover = "https://www.gutenberg.org/cache/epub/345/pg345.cover.medium.jpg" let storyWithCover = Story( - title: "Dracula", - author: "Bram Stoker", + title: "Public Domain Novel", + author: "Source Author", genre: "Gothic", - excerpt: "A terrible precipice...", - coverImageName: "cover_dracula", + excerpt: "Provider supplied synopsis.", + coverImageName: nil, coverImageUrl: gutenbergCover, contentFormat: .prose, sourceProvider: .gutenberg @@ -223,6 +224,37 @@ public struct AppHealthTests { details: "Cover priority: Gutenberg URL verified. Card: \(Int(coverWidth))x\(Int(coverHeight)) (3:4 ratio). Avatar: \(Int(avatarWidth))x\(Int(avatarHeight)) (1:1 circular)." ) + // ================================================================= + // Test 6: Manuscript Formatting Controls + // ================================================================= + func applyFormatting( + _ style: ManuscriptFormattingStyle, + to source: String, + selection: NSRange + ) -> String { + let textView = UITextView() + textView.text = source + textView.selectedRange = selection + let coordinator = ManuscriptEditor.Coordinator(text: .constant(source)) + coordinator.apply(style, to: textView) + return textView.text + } + + let boldResult = applyFormatting(.bold, to: "draft", selection: NSRange(location: 0, length: 5)) + let italicResult = applyFormatting(.italic, to: "draft", selection: NSRange(location: 0, length: 5)) + let quoteResult = applyFormatting(.quote, to: "first\nsecond", selection: NSRange(location: 0, length: 12)) + let sceneBreakResult = applyFormatting(.sceneBreak, to: "keep", selection: NSRange(location: 0, length: 4)) + let formattingControlsValid = boldResult == "**draft**" + && italicResult == "*draft*" + && quoteResult == "> first\n> second" + && sceneBreakResult == "keep\n\n---\n\n" + record( + name: "Manuscript Formatting Controls", + description: "Bold, italic, quote, and scene-break actions preserve and transform manuscript text", + passed: formattingControlsValid, + details: "Bold: \(boldResult); italic: \(italicResult); quote and scene-break preserve selected text." + ) + return (passed, total, failures, reports) } } diff --git a/frontend/FableApp/Features/Library/Tests/LibraryTests.swift b/frontend/FableApp/Features/Library/Tests/LibraryTests.swift index 21fa74c..5c92f24 100644 --- a/frontend/FableApp/Features/Library/Tests/LibraryTests.swift +++ b/frontend/FableApp/Features/Library/Tests/LibraryTests.swift @@ -18,24 +18,31 @@ public struct LibraryTests { let store = StoryStore() - // Test 1: Story store initialization - assert(!store.stories.isEmpty, "Stories Catalog Loaded") + // Test 1: Cached provider records require stable provider identity. + assert(store.stories.allSatisfy { + $0.sourceProvider == .fableOriginal || $0.providerId?.isEmpty == false + }, "Cached Provider Stories Have Provider IDs") + + let actionStory = Story( + title: "Diagnostic Fixture", + author: "Diagnostic", + genre: .folklore, + synopsis: "", + content: "", + readTimeMinutes: 4 + ) + store.stories.append(actionStory) // Test 2: Bookmark toggling - if let first = store.stories.first { - let initialState = first.isBookmarked - store.toggleBookmark(for: first) - let updatedState = store.stories.first(where: { $0.id == first.id })?.isBookmarked ?? false - assert(updatedState != initialState, "Toggle Bookmark State") - store.toggleBookmark(for: first) // revert - } + store.toggleBookmark(for: actionStory) + let bookmarked = store.stories.first(where: { $0.id == actionStory.id })?.isBookmarked ?? false + assert(bookmarked, "Toggle Bookmark State") + store.toggleBookmark(for: actionStory) // Test 3: Reading progress mutation - if let first = store.stories.first { - store.updateProgress(for: first.id, page: 3, totalPages: 4) - let updated = store.stories.first(where: { $0.id == first.id }) - assert(updated?.progressPercent == 75 && updated?.currentPage == 3, "Update Reading Progress") - } + store.updateProgress(for: actionStory.id, page: 3, totalPages: 4) + let updated = store.stories.first(where: { $0.id == actionStory.id }) + assert(updated?.progressPercent == 75 && updated?.currentPage == 3, "Update Reading Progress") // Test 4: Legacy JSON Fallback Decoding (Defaults to .prose and .fableOriginal) let legacyJSON = """ @@ -48,12 +55,71 @@ public struct LibraryTests { if let legacyStory = try? JSONDecoder().decode(Story.self, from: legacyJSON) { assert(legacyStory.contentFormat == .prose, "Legacy Story ContentFormat Defaults to Prose") + assert(legacyStory.genre == .unspecified, "Missing Legacy Genre Stays Unspecified") assert(legacyStory.sourceProvider == .fableOriginal, "Legacy Story SourceProvider Defaults to FableOriginal") + assert(legacyStory.lastReadChapterId == nil && legacyStory.lastReadChapterNumber == nil, "Legacy Story Chapter Progress Defaults to Empty") } else { assert(false, "Legacy Story JSON Decoding Failed") } - // Test 5: Multi-Format Manga Payload Decoding (MangaDex Ingestion Contract) + // Test 5: Granular Chapter Progress Decoding + let chapterProgressJSON = """ + { + "id": "A0000000-0000-0000-0000-000000000005", + "title": "Saved Chapter", + "author": "Anonymous", + "lastReadChapterId": "C0000000-0000-0000-0000-000000000005", + "lastReadChapterNumber": 5 + } + """.data(using: .utf8)! + + if let progressedStory = try? JSONDecoder().decode(Story.self, from: chapterProgressJSON) { + assert(progressedStory.lastReadChapterId == "C0000000-0000-0000-0000-000000000005", "Story Decodes Saved Chapter ID") + assert(progressedStory.lastReadChapterNumber == 5, "Story Decodes Saved Chapter Number") + } else { + assert(false, "Chapter Progress JSON Decoding Failed") + } + + let liveProviderJSON = """ + { + "id": "00000000-0000-0000-0000-00000000053E", + "title": "Pride and Prejudice", + "author": "Jane Austen", + "genre": "Historical Fiction", + "sourceProvider": "GUTENBERG", + "providerId": "1342" + } + """.data(using: .utf8)! + + if let liveBook = try? JSONDecoder().decode(Story.self, from: liveProviderJSON) { + assert(liveBook.sourceProvider == .gutenberg, "Live Book Decodes Gutenberg Source") + assert(liveBook.providerId == "1342", "Live Book Decodes Provider ID") + assert(liveBook.genre.rawValue == "Historical Fiction", "Unknown Live Genre Is Preserved") + } else { + assert(false, "Live Provider Story JSON Decoding Failed") + } + + let liveStatsJSON = """ + { + "id": "00000000-0000-0000-0000-000000000053", + "title": "Live Statistics", + "author": "Source Author", + "rating": null, + "savesCount": "3", + "readsCount": "8", + "providerDownloadCount": 940 + } + """.data(using: .utf8)! + + if let liveStatsStory = try? JSONDecoder().decode(Story.self, from: liveStatsJSON) { + assert(liveStatsStory.rating == nil, "Missing Provider Rating Stays Unavailable") + assert(liveStatsStory.savesCount == "3" && liveStatsStory.readsCount == "8", "Story Counts Decode") + assert(liveStatsStory.providerDownloadCount == 940, "Provider Downloads Decode") + } else { + assert(false, "Live Story Statistics JSON Decoding Failed") + } + + // Test 6: Multi-Format Manga Payload Decoding (MangaDex Ingestion Contract) let mangaJSON = """ { "id": "B0000000-0000-0000-0000-000000000002", @@ -73,7 +139,7 @@ public struct LibraryTests { assert(false, "Manga Story JSON Decoding Failed") } - // Test 6: Chapter JSON Page URLs Decoding (Panel Manifest & Fallback) + // Test 7: Chapter JSON Page URLs Decoding (Panel Manifest & Fallback) let chapterWithPagesJSON = """ { "id": "C0000000-0000-0000-0000-000000000003", @@ -106,22 +172,40 @@ public struct LibraryTests { assert(false, "Legacy Chapter JSON Decoding Failed") } - // Test 7: Catalog Multi-Format Diversity - let hasProse = store.stories.contains(where: { $0.contentFormat == .prose }) - let hasManga = store.stories.contains(where: { $0.contentFormat == .manga }) - assert(hasProse, "StoryStore Contains Prose Literature") - assert(hasManga, "StoryStore Contains Manga Releases") - - // Test 8: Multi-Provider Ingestion Recognition - let hasGutenberg = store.stories.contains(where: { $0.sourceProvider == .gutenberg }) - let hasMangaDex = store.stories.contains(where: { $0.sourceProvider == .mangadex }) - assert(hasGutenberg, "Catalog Contains Project Gutenberg Ingested Titles") - assert(hasMangaDex, "Catalog Contains MangaDex Ingested Titles") - - // Test 9: Internal App Health & Subsystem Diagnostics Suite + let cachedChapter = Chapter( + id: UUID(uuidString: "C0000000-0000-0000-0000-000000000006")!, + storyId: UUID(uuidString: "A0000000-0000-0000-0000-000000000006")!, + chapterNumber: 1, + title: "Cached Chapter", + content: "Chapter text stored for offline reading." + ) + let cachedChapterRoundTrip = (try? JSONEncoder().encode([cachedChapter])) + .flatMap { try? JSONDecoder().decode([Chapter].self, from: $0) } + assert(cachedChapterRoundTrip == [cachedChapter], "Chapter Payload Can Be Persisted and Restored") + + // Test 8: Provider formats are decoded without requiring a bundled catalog. + assert(liveBook.contentFormat == .prose && mangaStory.contentFormat == .manga, "Live Prose and Manga Formats Decode") + + // Test 9: Provider identities survive decoding and can address live chapter endpoints. + assert(liveBook.providerId == "1342" && mangaStory.sourceProvider == .mangadex, "Live Provider Identity Decodes") + + let createRequest = CreateStoryRequest( + title: "Authored Story", + genre: "Historical Fiction", + chapter: "Opening", + synopsis: "A writer supplied synopsis.", + content: "Writer supplied manuscript.", + readTimeMinutes: 1 + ) + let createRequestObject = try? JSONSerialization.jsonObject(with: JSONEncoder().encode(createRequest)) as? [String: Any] + assert(createRequestObject?["author"] == nil && createRequestObject?["sourceProvider"] == nil, "Story Create Payload Omits Server-Owned Fields") + assert(createRequestObject?["readTimeMinutes"] as? Int == 1, "Story Create Payload Uses Camel Case Contract") + + // Test 10: Internal App Health & Subsystem Diagnostics Suite let healthResult = AppHealthTests.runAllTests() assert(healthResult.failures.isEmpty && healthResult.passed == healthResult.total, "Internal App Health Diagnostics Verification (\(healthResult.passed)/\(healthResult.total) Passed)") + store.stories.removeAll(where: { $0.id == actionStory.id }) return (passed, total, failures) } } diff --git a/frontend/FableApp/Features/Library/ViewModels/StoryController.swift b/frontend/FableApp/Features/Library/ViewModels/StoryController.swift index 7e968fc..64a52a2 100644 --- a/frontend/FableApp/Features/Library/ViewModels/StoryController.swift +++ b/frontend/FableApp/Features/Library/ViewModels/StoryController.swift @@ -2,6 +2,7 @@ import Foundation import Observation @Observable +@MainActor public final class StoryController { public var stories: [Story] = [] public var selectedGenre: Genre = .all @@ -54,51 +55,57 @@ public final class StoryController { public func toggleBookmark(for story: Story) { guard let index = stories.firstIndex(where: { $0.id == story.id }) else { return } stories[index].isBookmarked.toggle() + PersistenceService.shared.setBookmark(storyId: story.id, isBookmarked: stories[index].isBookmarked) - if isLiveBackendEnabled { + if isLiveBackendEnabled, + let session = AuthManager.shared.currentSession, + !session.isGuest, + let token = KeychainStore.shared.readAccessToken() { + let deviceId: UUID = { + let key = "fable_device_id_\(session.id.uuidString)" + if let saved = UserDefaults.standard.string(forKey: key), let uuid = UUID(uuidString: saved) { + return uuid + } + let newId = UUID() + UserDefaults.standard.set(newId.uuidString, forKey: key) + return newId + }() + let item = ShelfSyncItem( + storyId: story.id, + readingProgress: Double(story.progressPercent) / 100.0, + isBookmarked: stories[index].isBookmarked, + isCompleted: story.isCompleted, + updatedAtUtc: Date() + ) Task { - _ = try? await apiService.toggleBookmark(storyId: story.id) + _ = try? await apiService.syncShelf(deviceId: deviceId, token: token, items: [item]) } } } public func addStory( title: String, - author: String, genre: Genre, synopsis: String, content: String, readTimeMinutes: Int - ) { - let newStory = Story( - id: UUID(), + ) async throws -> Story { + guard let session = AuthManager.shared.currentSession, !session.isGuest, + let token = KeychainStore.shared.readAccessToken() else { + throw APIRequestError(message: "Sign in to publish a story.") + } + + let request = CreateStoryRequest( title: title, - author: author.isEmpty ? "Anonymous" : author, - genre: genre, + genre: genre.rawValue, synopsis: synopsis, content: content, - readTimeMinutes: max(1, readTimeMinutes), - isBookmarked: false, - isCompleted: false, - createdAtUtc: Date() + readTimeMinutes: max(1, readTimeMinutes) ) - - // Optimistic local insertion - stories.insert(newStory, at: 0) - - if isLiveBackendEnabled { - Task { - let req = CreateStoryRequest( - title: title, - author: author, - genre: genre.rawValue, - synopsis: synopsis, - content: content, - readTimeMinutes: readTimeMinutes - ) - _ = try? await apiService.createStory(req) - } - } + let createdStory = try await apiService.createStory(request, token: token) + stories.insert(createdStory, at: 0) + PersistenceService.shared.saveStory(createdStory) + return createdStory } public func refreshStories() async { @@ -107,7 +114,8 @@ public final class StoryController { errorMessage = nil do { - let fetched = try await apiService.fetchStories(genre: selectedGenre.rawValue, search: searchText) + let genreFilter = selectedGenre == .all ? nil : selectedGenre.rawValue + let fetched = try await apiService.fetchStories(genre: genreFilter, search: searchText) self.stories = fetched } catch { self.errorMessage = "Failed to load stories: \(error.localizedDescription)" diff --git a/frontend/FableApp/Features/Library/ViewModels/StoryStore.swift b/frontend/FableApp/Features/Library/ViewModels/StoryStore.swift index 733c722..ceb79f4 100644 --- a/frontend/FableApp/Features/Library/ViewModels/StoryStore.swift +++ b/frontend/FableApp/Features/Library/ViewModels/StoryStore.swift @@ -12,6 +12,8 @@ public final class StoryStore: ObservableObject { @Published var isShowingGenreDetail: Bool = false @Published var selectedGenre: GenreCategory? @Published var isStoryPublished: Bool = false + @Published var isPublishingStory: Bool = false + @Published var publishErrorMessage: String? @Published var selectedTab: FableTab = .library // Reader Preferences (Persisted across launches) @@ -30,6 +32,14 @@ public final class StoryStore: ObservableObject { @Published var hapticFeedback: Bool = true { didSet { UserDefaults.standard.set(hapticFeedback, forKey: "fable_pref_reader_haptics") } } + @Published var autoArchiveCompletedStories: Bool = false { + didSet { + UserDefaults.standard.set(autoArchiveCompletedStories, forKey: "fable_pref_auto_archive_completed") + if autoArchiveCompletedStories { + archiveCompletedStories() + } + } + } @Published var isPaginatedMode: Bool = false { didSet { UserDefaults.standard.set(isPaginatedMode, forKey: "fable_pref_reader_paginated") } } @@ -40,7 +50,8 @@ public final class StoryStore: ObservableObject { private let apiService: StoryAPIServiceProtocol = StoryAPIService() public var persistentDeviceId: UUID { - let key = "fable_device_id" + let ownerKey = AuthManager.shared.currentSession?.id.uuidString ?? "guest" + let key = "fable_device_id_\(ownerKey)" if let saved = UserDefaults.standard.string(forKey: key), let uuid = UUID(uuidString: saved) { return uuid } @@ -54,27 +65,100 @@ public final class StoryStore: ObservableObject { @Published var pinnedQuotes: [Annotation] = [] // Living Reading Stats (Plan 03 & Mobile Hardening) - @Published var readingStats: PersistenceService.ReadingStatsSummary = PersistenceService.ReadingStatsSummary(storiesReadCount: 12, totalMinutesRead: 48, streakDays: 3) + @Published var readingStats: PersistenceService.ReadingStatsSummary = PersistenceService.ReadingStatsSummary(storiesReadCount: 0, totalMinutesRead: 0, streakDays: 0) // Writing Draft - @Published var draftTitle: String = "" - @Published var draftGenre: String = "Folklore" - @Published var draftChapter: String = "Chapter I" - @Published var draftSynopsis: String = "" - @Published var draftManuscript: String = "" + @Published var draftTitle: String = "" { didSet { persistWriterDraft() } } + @Published var draftGenre: String = "" { didSet { persistWriterDraft() } } + @Published var draftChapter: String = "" { didSet { persistWriterDraft() } } + @Published var draftSynopsis: String = "" { didSet { persistWriterDraft() } } + @Published var draftManuscript: String = "" { didSet { persistWriterDraft() } } + private var isRestoringWriterDraft = false + private var writerDraftOwnerKey = AuthManager.shared.currentSession?.id.uuidString ?? "guest" + private var activeShelfOwnerKey = AuthManager.shared.currentSession?.id.uuidString ?? "signed_out" + + private struct LocalShelfState: Codable { + var readingProgress: Double + var isBookmarked: Bool + var isCompleted: Bool + var currentPage: Int + var totalPages: Int + var lastReadChapterId: String? + var lastReadChapterNumber: Int? + var updatedAtUtc: Date + + static let empty = LocalShelfState( + readingProgress: 0, + isBookmarked: false, + isCompleted: false, + currentPage: 1, + totalPages: 1, + lastReadChapterId: nil, + lastReadChapterNumber: nil, + updatedAtUtc: .distantPast + ) + + init(story: Story, updatedAtUtc: Date = Date()) { + self.readingProgress = Double(story.progressPercent) / 100.0 + self.isBookmarked = story.isBookmarked + self.isCompleted = story.isCompleted + self.currentPage = story.currentPage + self.totalPages = story.totalPages + self.lastReadChapterId = story.lastReadChapterId + self.lastReadChapterNumber = story.lastReadChapterNumber + self.updatedAtUtc = updatedAtUtc + } + + var isEmpty: Bool { + readingProgress == 0 && !isBookmarked && !isCompleted + && lastReadChapterId == nil && lastReadChapterNumber == nil + } + + func syncItem(storyId: UUID) -> ShelfSyncItem { + ShelfSyncItem( + storyId: storyId, + readingProgress: readingProgress, + isBookmarked: isBookmarked, + isCompleted: isCompleted, + updatedAtUtc: updatedAtUtc + ) + } + + init(syncItem: ShelfSyncItem, previous: LocalShelfState = .empty) { + self.readingProgress = syncItem.readingProgress + self.isBookmarked = syncItem.isBookmarked + self.isCompleted = syncItem.isCompleted + self.currentPage = previous.currentPage + self.totalPages = previous.totalPages + self.lastReadChapterId = previous.lastReadChapterId + self.lastReadChapterNumber = previous.lastReadChapterNumber + self.updatedAtUtc = syncItem.updatedAtUtc + } + } + + private struct WriterDraft: Codable { + let title: String + let genre: String + let chapter: String + let synopsis: String + let manuscript: String + } - // Live Server-Driven Genres (with bundled defaults) - @Published var genres: [GenreCategory] = GenreCategory.defaultCategories + // These collections populate from the server; cached stories remain available offline. + @Published var genres: [GenreCategory] = [] - // Live Server-Driven Trending Writers (with bundled defaults) - @Published var writers: [Writer] = Writer.defaultWriters + @Published var writers: [Writer] = [] // User Profile Stories @Published var profileStories: [Story] = [] init() { loadReaderPreferences() + restoreDiscoveryCache() + restoreWriterDraft() syncWithPersistence() + restoreShelfStateForCurrentSession(migrateGuestState: AuthManager.shared.currentSession?.isGuest == true) + if autoArchiveCompletedStories { archiveCompletedStories() } Task { [weak self] in await self?.syncWithCloudBackend() } @@ -100,6 +184,7 @@ public final class StoryStore: ObservableObject { if UserDefaults.standard.object(forKey: "fable_pref_reader_haptics") != nil { self.hapticFeedback = UserDefaults.standard.bool(forKey: "fable_pref_reader_haptics") } + self.autoArchiveCompletedStories = UserDefaults.standard.bool(forKey: "fable_pref_auto_archive_completed") if UserDefaults.standard.object(forKey: "fable_pref_reader_paginated") != nil { self.isPaginatedMode = UserDefaults.standard.bool(forKey: "fable_pref_reader_paginated") } @@ -117,59 +202,189 @@ public final class StoryStore: ObservableObject { readerLineSpacing = .normal } - func publishStory() { - let newStory = Story( - title: draftTitle.isEmpty ? "Untitled Tale" : draftTitle, - author: AuthManager.shared.currentSession?.name ?? "Independent Author", + private var writerDraftStorageKey: String { + "fable_writer_draft_\(writerDraftOwnerKey)" + } + + private func persistWriterDraft() { + guard !isRestoringWriterDraft else { return } + let draft = WriterDraft( + title: draftTitle, genre: draftGenre, - excerpt: draftSynopsis, - paragraphs: [draftManuscript], - coverImageName: "thumb_metamorphosis", - readingTimeMinutes: max(1, draftWordCount / 150), - totalPages: 1, - currentPage: 1, - progressPercent: 0, - rating: 5.0, - isRecentSubmission: true + chapter: draftChapter, + synopsis: draftSynopsis, + manuscript: draftManuscript ) - stories.insert(newStory, at: 0) - profileStories.insert(newStory, at: 0) - isStoryPublished = true - - // Persist to SwiftData SQLite - PersistenceService.shared.saveStory(newStory) - - // Asynchronously sync newly published manuscript with cloud backend - Task { [weak self] in - guard let self = self else { return } - let req = CreateStoryRequest( - title: newStory.title, - author: newStory.author, - genre: newStory.genre.rawValue, - synopsis: newStory.synopsis, - content: newStory.content, - readTimeMinutes: newStory.readTimeMinutes, - contentFormat: newStory.contentFormat.rawValue, - sourceProvider: newStory.sourceProvider.rawValue - ) - _ = try? await self.apiService.createStory(req) + guard let data = try? JSONEncoder().encode(draft) else { return } + UserDefaults.standard.set(data, forKey: writerDraftStorageKey) + } + + private func restoreWriterDraft() { + writerDraftOwnerKey = AuthManager.shared.currentSession?.id.uuidString ?? "guest" + isRestoringWriterDraft = true + defer { isRestoringWriterDraft = false } + guard let data = UserDefaults.standard.data(forKey: writerDraftStorageKey), + let draft = try? JSONDecoder().decode(WriterDraft.self, from: data) else { + draftTitle = "" + draftGenre = "" + draftChapter = "" + draftSynopsis = "" + draftManuscript = "" + return } + draftTitle = draft.title + draftGenre = draft.genre + draftChapter = draft.chapter + draftSynopsis = draft.synopsis + draftManuscript = draft.manuscript } - + + func clearWriterDraft() { + clearWriterDraft(ownerID: nil) + } + + private func clearWriterDraft(ownerID: UUID?) { + let ownerKey = ownerID?.uuidString ?? writerDraftOwnerKey + UserDefaults.standard.removeObject(forKey: "fable_writer_draft_\(ownerKey)") + guard ownerKey == writerDraftOwnerKey else { return } + isRestoringWriterDraft = true + draftTitle = "" + draftGenre = "" + draftChapter = "" + draftSynopsis = "" + draftManuscript = "" + isRestoringWriterDraft = false + } + + func restoreWriterDraftForCurrentSession() { + restoreWriterDraft() + } + + func publishStory() async -> Story? { + publishErrorMessage = nil + let title = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let genre = draftGenre.trimmingCharacters(in: .whitespacesAndNewlines) + let synopsis = draftSynopsis.trimmingCharacters(in: .whitespacesAndNewlines) + let manuscript = draftManuscript.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !title.isEmpty, title.count <= 120 else { + publishErrorMessage = "Enter a title with 1 to 120 characters." + return nil + } + guard !genre.isEmpty else { + publishErrorMessage = "Choose or enter a genre." + return nil + } + guard !synopsis.isEmpty else { + publishErrorMessage = "Add a synopsis before publishing." + return nil + } + guard !manuscript.isEmpty else { + publishErrorMessage = "Add manuscript text before publishing." + return nil + } + guard let session = AuthManager.shared.currentSession, !session.isGuest, + let token = KeychainStore.shared.readAccessToken() else { + publishErrorMessage = "Sign in with an account to publish this story online. Your draft is saved on this device." + return nil + } + + isPublishingStory = true + defer { isPublishingStory = false } + let request = CreateStoryRequest( + title: title, + genre: genre, + chapter: draftChapter.trimmingCharacters(in: .whitespacesAndNewlines), + synopsis: synopsis, + content: manuscript, + readTimeMinutes: max(1, draftWordCount / 150) + ) + + do { + let publishedStory = try await apiService.createStory(request, token: token) + stories.removeAll { $0.id == publishedStory.id } + stories.insert(publishedStory, at: 0) + PersistenceService.shared.saveStory(publishedStory) + if AuthManager.shared.currentSession?.id == session.id { + profileStories.removeAll { $0.id == publishedStory.id } + profileStories.insert(publishedStory, at: 0) + persistProfileStoriesCache(profileStories, for: session.id) + } + isStoryPublished = true + clearWriterDraft(ownerID: session.id) + if hapticFeedback { + HapticManager.notification(type: .success) + } + return publishedStory + } catch let error as APIRequestError { + publishErrorMessage = error.message + return nil + } catch { + publishErrorMessage = "Publishing failed. Your draft remains saved on this device. Check your connection and try again." + return nil + } + } + + private func profileStoriesCacheKey(for userID: UUID) -> String { + "fable_profile_stories_\(userID.uuidString)" + } + + private func persistProfileStoriesCache(_ stories: [Story], for userID: UUID) { + guard let data = try? JSONEncoder().encode(stories) else { return } + UserDefaults.standard.set(data, forKey: profileStoriesCacheKey(for: userID)) + } + + func loadMyPublishedStories() async { + guard let session = AuthManager.shared.currentSession, !session.isGuest else { + profileStories = [] + return + } + let cacheKey = profileStoriesCacheKey(for: session.id) + if let data = UserDefaults.standard.data(forKey: cacheKey), + let cachedStories = try? JSONDecoder().decode([Story].self, from: data) { + profileStories = cachedStories + } + guard let token = KeychainStore.shared.readAccessToken() else { return } + do { + let ownedStories = try await apiService.fetchMyStories(token: token) + guard AuthManager.shared.currentSession?.id == session.id else { return } + profileStories = ownedStories + persistProfileStoriesCache(ownedStories, for: session.id) + for story in ownedStories { + PersistenceService.shared.saveStory(story) + } + } catch { + // Keep the account-scoped last-known author list available offline. + } + } + private func syncWithPersistence() { - // Seed default stories if SQLite is empty - PersistenceService.shared.seedInitialDataIfNeeded(seedStories: Story.defaultSeedStories) - // Hydrate and reconcile from SQLite let persisted = PersistenceService.shared.fetchAllStories() if !persisted.isEmpty { for entity in persisted { + let provider = SourceProvider(rawValue: entity.sourceProviderRaw ?? "FABLE_ORIGINAL") ?? .fableOriginal + if provider != .fableOriginal && (entity.providerId?.isEmpty ?? true) { + continue + } + if let idx = stories.firstIndex(where: { $0.id == entity.id }) { stories[idx].isBookmarked = entity.isBookmarked stories[idx].isCompleted = entity.isCompleted stories[idx].progressPercent = Int(entity.readingProgress * 100.0) stories[idx].currentPage = max(1, entity.currentPage) stories[idx].totalPages = max(1, entity.totalPages) + stories[idx].coverImageName = nil + stories[idx].heroImageName = nil + stories[idx].coverImageUrl = validCoverImageURL(entity.coverImageUrl) + stories[idx].providerId = entity.providerId + stories[idx].providerDownloadCount = entity.providerDownloadCount + stories[idx].contentFormat = ContentFormat(rawValue: entity.contentFormatRaw ?? "PROSE") ?? .prose + stories[idx].sourceProvider = SourceProvider(rawValue: entity.sourceProviderRaw ?? "FABLE_ORIGINAL") ?? .fableOriginal + let cachedChapters = PersistenceService.shared.cachedChapters(storyId: entity.id) + if !cachedChapters.isEmpty { stories[idx].chapters = cachedChapters } + stories[idx].lastReadChapterId = entity.lastReadChapterId + stories[idx].lastReadChapterNumber = entity.lastReadChapterNumber } else { let userStory = Story( id: entity.id, @@ -178,70 +393,128 @@ public final class StoryStore: ObservableObject { genre: entity.genreRaw, excerpt: entity.synopsis, paragraphs: [entity.content], - coverImageName: "thumb_metamorphosis", + contentFormat: ContentFormat(rawValue: entity.contentFormatRaw ?? "PROSE") ?? .prose, + sourceProvider: provider, + providerId: entity.providerId, + providerDownloadCount: entity.providerDownloadCount, + chapters: PersistenceService.shared.cachedChapters(storyId: entity.id), + coverImageName: nil, + heroImageName: nil, + coverImageUrl: validCoverImageURL(entity.coverImageUrl), readingTimeMinutes: entity.readTimeMinutes, totalPages: max(1, entity.totalPages), currentPage: max(1, entity.currentPage), progressPercent: Int(entity.readingProgress * 100.0), - rating: 5.0, + rating: nil, isRecentSubmission: true, isSaved: entity.isBookmarked, - isFinished: entity.isCompleted + isFinished: entity.isCompleted, + lastReadChapterId: entity.lastReadChapterId, + lastReadChapterNumber: entity.lastReadChapterNumber ) stories.insert(userStory, at: 0) - profileStories.insert(userStory, at: 0) } } } - if stories.isEmpty { - self.stories = Story.defaultSeedStories - } - - if profileStories.isEmpty { - self.profileStories = [ - Story( - title: "The Clockmaker of Prague", - author: AuthManager.shared.currentSession?.name ?? "Roosc Zaño", - genre: "Folklore", - excerpt: "In the shadowed alleys behind the Astronomical Clock, Master Hanuš polished cogs that measured not minutes, but heartbeats.", - coverImageName: "thumb_metamorphosis", - readingTimeMinutes: 4, - rating: 4.9, - readsCount: "1.2k reads", - badgeText: "FOLKLORE • 4 min read" - ) - ] - } - reloadPinnedQuotes() reloadReadingStats() } + private var readingStatsCacheKey: String? { + guard let session = AuthManager.shared.currentSession, !session.isGuest else { return nil } + return "fable_reading_stats_\(session.id.uuidString)" + } + + private var pendingReadingSessionsKey: String? { + guard let session = AuthManager.shared.currentSession, !session.isGuest else { return nil } + return "fable_pending_reading_sessions_\(session.id.uuidString)" + } + func reloadReadingStats() { - self.readingStats = PersistenceService.shared.fetchReadingStats() + guard let session = AuthManager.shared.currentSession else { + readingStats = PersistenceService.ReadingStatsSummary( + storiesReadCount: 0, + totalMinutesRead: 0, + streakDays: 0 + ) + return + } + let localStats = PersistenceService.shared.fetchReadingStats(ownerUserId: session.id) + readingStats = localStats + if let key = readingStatsCacheKey, + let data = UserDefaults.standard.data(forKey: key), + let cached = try? JSONDecoder().decode(ReadingStatsDTO.self, from: data) { + readingStats = PersistenceService.ReadingStatsSummary( + storiesReadCount: cached.storiesReadCount, + totalMinutesRead: cached.totalMinutesRead, + streakDays: cached.streakDays + ) + } + guard !session.isGuest else { return } + Task { [weak self] in await self?.synchronizeReadingStats() } } - - func reloadPinnedQuotes() { - let loaded = PersistenceService.shared.fetchAllPinnedAnnotations() - if loaded.isEmpty { - let defaultQuote = Annotation( - storyId: UUID(uuidString: "11111111-1111-1111-1111-111111111111") ?? UUID(), - storyTitle: "De Oratore", - storyAuthor: "Marcus Tullius Cicero", - utf16StartOffset: 0, - utf16EndOffset: 51, - selectedText: "A room without books is like a body without a soul.", - note: "Foundational literary ethos", - color: .terracotta, - isPinnedToJournal: true + + private func synchronizeReadingStats() async { + guard let session = AuthManager.shared.currentSession, !session.isGuest, + let token = KeychainStore.shared.readAccessToken(), + let queueKey = pendingReadingSessionsKey else { return } + + var pending = (try? JSONDecoder().decode( + [ReadingSessionRequest].self, + from: UserDefaults.standard.data(forKey: queueKey) ?? Data() + )) ?? [] + while let event = pending.first { + do { + try await apiService.recordReadingSession(event, token: token) + guard AuthManager.shared.currentSession?.id == session.id else { return } + pending.removeFirst() + if let data = try? JSONEncoder().encode(pending) { + UserDefaults.standard.set(data, forKey: queueKey) + } + } catch { + return + } + } + + do { + let stats = try await apiService.fetchReadingStats(token: token) + guard AuthManager.shared.currentSession?.id == session.id else { return } + readingStats = PersistenceService.ReadingStatsSummary( + storiesReadCount: stats.storiesReadCount, + totalMinutesRead: stats.totalMinutesRead, + streakDays: stats.streakDays ) - self.pinnedQuotes = [defaultQuote] - } else { - self.pinnedQuotes = loaded + if let key = readingStatsCacheKey, let data = try? JSONEncoder().encode(stats) { + UserDefaults.standard.set(data, forKey: key) + } + } catch { + // Keep the account-specific statistics cache available offline. } } + func reloadPinnedQuotes() { + self.pinnedQuotes = PersistenceService.shared.fetchAllPinnedAnnotations( + ownerUserId: AuthManager.shared.currentSession?.id + ) + } + + private func restoreDiscoveryCache() { + if let data = UserDefaults.standard.data(forKey: "fable_cached_genres"), + let cached = try? JSONDecoder().decode([GenreCategory].self, from: data) { + genres = cached + } + if let data = UserDefaults.standard.data(forKey: "fable_cached_writers"), + let cached = try? JSONDecoder().decode([Writer].self, from: data) { + writers = cached + } + } + + private func persistDiscoveryCache(_ values: Value, key: String) { + guard let data = try? JSONEncoder().encode(values) else { return } + UserDefaults.standard.set(data, forKey: key) + } + // MARK: - Cloud Synchronization Pipeline (Plan 05) func syncWithCloudBackend() async { isCloudSyncActive = true @@ -251,37 +524,56 @@ public final class StoryStore: ObservableObject { // 1. Fetch live remote stories from FastAPI let remoteStories = try await apiService.fetchStories(genre: nil, search: nil) self.isBackendReachable = true + await AuthManager.shared.syncPendingProfile() + await loadMyPublishedStories() + await synchronizeReadingStats() - if self.stories.isEmpty { - self.stories = remoteStories - for story in remoteStories { - PersistenceService.shared.saveStory(story) + var synchronizedStoryIds = Set() + for remote in remoteStories { + if remote.sourceProvider != .fableOriginal && (remote.providerId?.isEmpty ?? true) { + continue } - } else { - for remote in remoteStories { - if let idx = stories.firstIndex(where: { $0.id == remote.id }) { - stories[idx].isBookmarked = stories[idx].isBookmarked || remote.isBookmarked - stories[idx].coverImageUrl = remote.coverImageUrl - stories[idx].totalChapters = remote.totalChapters - stories[idx].contentFormat = remote.contentFormat - stories[idx].sourceProvider = remote.sourceProvider - if remote.isTaleOfTheDay { stories[idx].isTaleOfTheDay = true } - if remote.isCuratorSpotlight { stories[idx].isCuratorSpotlight = true } - } else { - stories.append(remote) - PersistenceService.shared.saveStory(remote) + if let index = liveStoryIndex(matching: remote, excluding: synchronizedStoryIds) { + synchronizedStoryIds.insert(stories[index].id) + stories[index].isBookmarked = stories[index].isBookmarked || remote.isBookmarked + stories[index].coverImageUrl = validCoverImageURL(remote.coverImageUrl) + if remote.contentFormat == .manga { + stories[index].coverImageName = nil + stories[index].heroImageName = nil } + stories[index].totalChapters = remote.totalChapters + stories[index].contentFormat = remote.contentFormat + stories[index].sourceProvider = remote.sourceProvider + if let downloadCount = remote.providerDownloadCount { stories[index].providerDownloadCount = downloadCount } + if let providerId = remote.providerId { stories[index].providerId = providerId } + if remote.isTaleOfTheDay { stories[index].isTaleOfTheDay = true } + if remote.isCuratorSpotlight { stories[index].isCuratorSpotlight = true } + PersistenceService.shared.saveStory(stories[index]) + } else { + var fetchedStory = remote + fetchedStory.coverImageUrl = validCoverImageURL(remote.coverImageUrl) + if fetchedStory.contentFormat == .manga { + fetchedStory.coverImageName = nil + fetchedStory.heroImageName = nil + } + stories.append(fetchedStory) + synchronizedStoryIds.insert(fetchedStory.id) + PersistenceService.shared.saveStory(fetchedStory) } } + + await fetchGutenbergPublicStories(topic: "fiction", search: nil) // 2. Fetch live dynamic categories/genres from backend - if let liveGenres = try? await apiService.fetchGenres(), !liveGenres.isEmpty { + if let liveGenres = try? await apiService.fetchGenres() { self.genres = liveGenres + persistDiscoveryCache(liveGenres, key: "fable_cached_genres") } // 3. Fetch live trending authors from backend/Open Library - if let liveWriters = try? await apiService.fetchTopAuthors(), !liveWriters.isEmpty { + if let liveWriters = try? await apiService.fetchTopAuthors() { self.writers = liveWriters + persistDiscoveryCache(liveWriters, key: "fable_cached_writers") } // 4. Fetch live update feed (Tale of the Day, Curator Spotlight) @@ -298,37 +590,61 @@ public final class StoryStore: ObservableObject { } } - // 5. Bidirectional shelf sync using Last-Write-Wins - let shelfItems = stories.map { story in - ShelfSyncItem( - storyId: story.id, - readingProgress: Double(story.progressPercent) / 100.0, - isBookmarked: story.isBookmarked, - isCompleted: story.isCompleted, - updatedAtUtc: story.createdAtUtc - ) - } - let reconciled = try await apiService.syncShelf(deviceId: persistentDeviceId, items: shelfItems) - for item in reconciled { - if let idx = stories.firstIndex(where: { $0.id == item.storyId }) { - stories[idx].isBookmarked = item.isBookmarked - stories[idx].isCompleted = item.isCompleted - stories[idx].progressPercent = Int(item.readingProgress * 100.0) - } - } + // 5. Reconcile account-owned shelf state; guests stay local-only. + await reconcileShelfWithAccount() } catch { // Offline-First Invariant: Operates seamlessly on local SwiftData/SQLite self.isBackendReachable = false } } + private func liveStoryIndex(matching remote: Story, excluding matchedIds: Set) -> Int? { + if let exactIndex = stories.firstIndex(where: { $0.id == remote.id && !matchedIds.contains($0.id) }) { + return exactIndex + } + + let remoteTitle = normalizedCatalogValue(remote.title) + let remoteAuthor = normalizedCatalogValue(remote.author) + let matchingCatalogStories = stories.indices.filter { index in + let story = stories[index] + return !matchedIds.contains(story.id) + && normalizedCatalogValue(story.title) == remoteTitle + && normalizedCatalogValue(story.author) == remoteAuthor + } + return matchingCatalogStories.first(where: { stories[$0].sourceProvider == remote.sourceProvider }) + ?? matchingCatalogStories.first + } + + private func normalizedCatalogValue(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + } + + private func validCoverImageURL(_ value: String?) -> String? { + guard + let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + let url = URL(string: value), + url.scheme?.lowercased() == "https", + let host = url.host?.lowercased(), + host != "images.unsplash.com" + else { + return nil + } + return url.absoluteString + } + /// Loads authentic chapters on demand for the active reading story func fetchChapters(for story: Story) async -> [Chapter] { if let existing = story.chapters, !existing.isEmpty { return existing } + let cached = PersistenceService.shared.cachedChapters(storyId: story.id) + if !cached.isEmpty { + return cached + } do { - let fetched = try await apiService.fetchChapters(for: story.id) + let fetched = try await apiService.fetchChapters(for: story.id, sourceProvider: story.sourceProvider.rawValue, providerId: story.providerId) if let idx = stories.firstIndex(where: { $0.id == story.id }) { stories[idx].chapters = fetched stories[idx].totalChapters = max(1, fetched.count) @@ -337,40 +653,282 @@ public final class StoryStore: ObservableObject { activeReaderStory?.chapters = fetched activeReaderStory?.totalChapters = max(1, fetched.count) } + let updatedStory = stories.first(where: { $0.id == story.id }) ?? story + PersistenceService.shared.saveStory(updatedStory) + PersistenceService.shared.saveCachedChapters(fetched, storyId: story.id) return fetched } catch { - return story.chapters ?? [] + return [] } } + func searchCatalog(query: String) async { + let search = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !search.isEmpty else { return } + + if let remoteStories = try? await apiService.fetchStories(genre: nil, search: search) { + for remote in remoteStories where remote.sourceProvider == .fableOriginal || remote.providerId?.isEmpty == false { + guard !stories.contains(where: { $0.id == remote.id }) else { continue } + var cached = remote + cached.coverImageUrl = validCoverImageURL(remote.coverImageUrl) + cached.coverImageName = nil + cached.heroImageName = nil + stories.append(cached) + PersistenceService.shared.saveStory(cached) + } + } + await fetchGutenbergPublicStories(topic: nil, search: search) + } + + func topStory(for writer: Writer) async -> Story? { + await fetchGutenbergPublicStories(topic: nil, search: writer.name) + if let remoteStories = try? await apiService.fetchStories(genre: nil, search: writer.name) { + for remote in remoteStories where remote.sourceProvider == .fableOriginal || remote.providerId?.isEmpty == false { + guard !stories.contains(where: { $0.id == remote.id }) else { continue } + var cached = remote + cached.coverImageUrl = validCoverImageURL(remote.coverImageUrl) + cached.coverImageName = nil + cached.heroImageName = nil + stories.append(cached) + PersistenceService.shared.saveStory(cached) + } + } + let author = normalizedCatalogValue(writer.name) + return stories + .filter { normalizedCatalogValue($0.author) == author } + .max { ($0.providerDownloadCount ?? 0) < ($1.providerDownloadCount ?? 0) } + } + func fetchGutenbergPublicStories(topic: String? = nil, search: String? = nil) async { do { let fetched = try await apiService.fetchGutenbergStories(topic: topic, search: search) for book in fetched { - if !stories.contains(where: { $0.id == book.id }) { - stories.append(book) - PersistenceService.shared.saveStory(book) + if let index = stories.firstIndex(where: { $0.id == book.id }) { + stories[index].synopsis = book.synopsis + stories[index].coverImageUrl = validCoverImageURL(book.coverImageUrl) + stories[index].contentFormat = book.contentFormat + stories[index].sourceProvider = book.sourceProvider + stories[index].providerId = book.providerId + stories[index].providerDownloadCount = book.providerDownloadCount + stories[index].totalChapters = max(stories[index].chapters?.count ?? 0, book.totalChapters) + PersistenceService.shared.saveStory(stories[index]) + } else { + var liveBook = book + liveBook.coverImageUrl = validCoverImageURL(book.coverImageUrl) + stories.append(liveBook) + PersistenceService.shared.saveStory(liveBook) } } } catch { - // Graceful fallback to offline local stories + // Keep the last persisted catalog available when the provider cannot be reached. } } + private var shelfStateStorageKey: String { + "fable_shelf_state_\(activeShelfOwnerKey)" + } + + private func loadShelfStateCache() -> [String: LocalShelfState] { + guard let data = UserDefaults.standard.data(forKey: shelfStateStorageKey), + let states = try? JSONDecoder().decode([String: LocalShelfState].self, from: data) else { + return [:] + } + return states + } + + private func saveShelfStateCache(_ states: [String: LocalShelfState]) { + guard let data = try? JSONEncoder().encode(states) else { return } + UserDefaults.standard.set(data, forKey: shelfStateStorageKey) + } + + private func persistShelfState(for story: Story, updatedAt: Date = Date()) { + var states = loadShelfStateCache() + states[story.id.uuidString] = LocalShelfState(story: story, updatedAtUtc: updatedAt) + saveShelfStateCache(states) + } + + private func applyShelfState(_ state: LocalShelfState, to story: inout Story) { + story.progressPercent = min(100, max(0, Int(state.readingProgress * 100))) + story.isBookmarked = state.isBookmarked + story.isCompleted = state.isCompleted + story.currentPage = max(1, state.currentPage) + story.totalPages = max(1, state.totalPages) + story.lastReadChapterId = state.lastReadChapterId + story.lastReadChapterNumber = state.lastReadChapterNumber + } + + private func applyShelfStateCache(_ states: [String: LocalShelfState]) { + for index in stories.indices { + let state = states[stories[index].id.uuidString] ?? .empty + applyShelfState(state, to: &stories[index]) + PersistenceService.shared.saveStory(stories[index]) + } + for index in profileStories.indices { + let state = states[profileStories[index].id.uuidString] ?? .empty + applyShelfState(state, to: &profileStories[index]) + } + } + + private func restoreShelfStateForCurrentSession(migrateGuestState: Bool) { + if let data = UserDefaults.standard.data(forKey: shelfStateStorageKey), + let existing = try? JSONDecoder().decode([String: LocalShelfState].self, from: data) { + applyShelfStateCache(existing) + return + } + guard let session = AuthManager.shared.currentSession else { return } + if session.isGuest && migrateGuestState { + var legacyStates: [String: LocalShelfState] = [:] + for story in stories { + let state = LocalShelfState(story: story) + if !state.isEmpty { legacyStates[story.id.uuidString] = state } + } + saveShelfStateCache(legacyStates) + } else { + applyShelfStateCache([:]) + saveShelfStateCache([:]) + } + } + + private func storeServerShelfState(_ item: ShelfSyncItem) { + var states = loadShelfStateCache() + let state = LocalShelfState( + syncItem: item, + previous: states[item.storyId.uuidString] ?? .empty + ) + states[item.storyId.uuidString] = state + saveShelfStateCache(states) + if let index = stories.firstIndex(where: { $0.id == item.storyId }) { + applyShelfState(state, to: &stories[index]) + PersistenceService.shared.saveStory(stories[index]) + } + if let index = profileStories.firstIndex(where: { $0.id == item.storyId }) { + applyShelfState(state, to: &profileStories[index]) + } + } + + private func syncShelfToAccount(_ items: [ShelfSyncItem]) async -> [ShelfSyncItem]? { + guard let session = AuthManager.shared.currentSession, !session.isGuest, + let token = KeychainStore.shared.readAccessToken() else { return nil } + do { + let requestSessionID = session.id + let reconciled = try await apiService.syncShelf( + deviceId: persistentDeviceId, + token: token, + items: items + ) + guard AuthManager.shared.currentSession?.id == requestSessionID else { return nil } + for item in reconciled { storeServerShelfState(item) } + return reconciled + } catch { + return nil + } + } + + private func gutenbergProviderID(for storyID: UUID) -> String? { + let compactID = storyID.uuidString.replacingOccurrences(of: "-", with: "") + guard compactID.prefix(24).allSatisfy({ $0 == "0" }), + let providerID = UInt32(compactID.suffix(8), radix: 16), + providerID > 0 else { return nil } + return String(providerID) + } + + private func reconcileShelfWithAccount() async { + guard let session = AuthManager.shared.currentSession, !session.isGuest, + let token = KeychainStore.shared.readAccessToken() else { return } + do { + let remoteItems = try await apiService.fetchShelf(deviceId: persistentDeviceId, token: token) + guard AuthManager.shared.currentSession?.id == session.id else { return } + for item in remoteItems where !stories.contains(where: { $0.id == item.storyId }) { + guard let providerID = gutenbergProviderID(for: item.storyId), + let providerStory = try? await apiService.fetchGutenbergStory(providerId: providerID), + AuthManager.shared.currentSession?.id == session.id else { continue } + stories.append(providerStory) + PersistenceService.shared.saveStory(providerStory) + } + let remoteByStory = Dictionary(uniqueKeysWithValues: remoteItems.map { ($0.storyId, $0) }) + var pending: [ShelfSyncItem] = [] + let localStates = loadShelfStateCache() + + for remote in remoteItems { + if let local = localStates[remote.storyId.uuidString], local.updatedAtUtc > remote.updatedAtUtc { + pending.append(local.syncItem(storyId: remote.storyId)) + } else { + storeServerShelfState(remote) + } + } + for (storyID, state) in localStates { + guard let id = UUID(uuidString: storyID), remoteByStory[id] == nil else { continue } + pending.append(state.syncItem(storyId: id)) + } + if !pending.isEmpty { _ = await syncShelfToAccount(pending) } + } catch { + // Local account cache remains available when shelf synchronization is offline. + } + } + + func sessionDidChange() { + let nextOwner = AuthManager.shared.currentSession?.id.uuidString ?? "signed_out" + guard nextOwner != activeShelfOwnerKey else { return } + if activeShelfOwnerKey != "signed_out" { + var states = loadShelfStateCache() + for story in stories { + let previous = states[story.id.uuidString] + let state = LocalShelfState(story: story, updatedAtUtc: previous?.updatedAtUtc ?? Date()) + if !state.isEmpty || previous != nil { states[story.id.uuidString] = state } + } + saveShelfStateCache(states) + } + activeShelfOwnerKey = nextOwner + if AuthManager.shared.currentSession == nil { + clearVisibleShelfState() + } else { + activeStoryAnnotations = [] + restoreShelfStateForCurrentSession(migrateGuestState: false) + restoreWriterDraft() + reloadPinnedQuotes() + reloadReadingStats() + Task { [weak self] in await self?.syncWithCloudBackend() } + } + } + + private func clearVisibleShelfState() { + for index in stories.indices { applyShelfState(.empty, to: &stories[index]) } + for index in profileStories.indices { applyShelfState(.empty, to: &profileStories[index]) } + profileStories = [] + pinnedQuotes = [] + activeStoryAnnotations = [] + readingStats = PersistenceService.ReadingStatsSummary(storiesReadCount: 0, totalMinutesRead: 0, streakDays: 0) + } + // MARK: - Actions func toggleBookmark(for story: Story) { + let nextBookmarkState = !(stories.first(where: { $0.id == story.id })?.isBookmarked ?? story.isBookmarked) if let idx = stories.firstIndex(where: { $0.id == story.id }) { - stories[idx].isBookmarked.toggle() + stories[idx].isBookmarked = nextBookmarkState } if let idx = profileStories.firstIndex(where: { $0.id == story.id }) { - profileStories[idx].isBookmarked.toggle() + profileStories[idx].isBookmarked = nextBookmarkState } - _ = PersistenceService.shared.toggleBookmark(storyId: story.id) - + PersistenceService.shared.setBookmark(storyId: story.id, isBookmarked: nextBookmarkState) + let current = stories.first(where: { $0.id == story.id }) + ?? profileStories.first(where: { $0.id == story.id }) + ?? story + persistShelfState(for: current) + if hapticFeedback { + HapticManager.notification(type: .success) + } + + let item = ShelfSyncItem( + storyId: story.id, + readingProgress: Double(current.progressPercent) / 100.0, + isBookmarked: nextBookmarkState, + isCompleted: current.isCompleted, + updatedAtUtc: Date() + ) Task { [weak self] in - guard let self = self else { return } - _ = try? await self.apiService.toggleBookmark(storyId: story.id) + guard let self else { return } + _ = await self.syncShelfToAccount([item]) } } @@ -382,6 +940,10 @@ public final class StoryStore: ObservableObject { stories[idx].progressPercent = pct if pct >= 100 { stories[idx].isCompleted = true + if autoArchiveCompletedStories { + stories[idx].isBookmarked = false + PersistenceService.shared.setBookmark(storyId: storyId, isBookmarked: false) + } } PersistenceService.shared.updateProgress( storyId: storyId, @@ -391,6 +953,7 @@ public final class StoryStore: ObservableObject { totalPages: totalPages ) reloadReadingStats() + persistShelfState(for: stories[idx]) let currentBookmarked = stories[idx].isBookmarked Task { [weak self] in @@ -402,25 +965,95 @@ public final class StoryStore: ObservableObject { isCompleted: pct >= 100, updatedAtUtc: Date() ) - _ = try? await self.apiService.syncShelf(deviceId: self.persistentDeviceId, items: [item]) + _ = await self.syncShelfToAccount([item]) } } } + func updateReadingProgress(for storyId: UUID, chapterId: String, chapterNumber: Int) { + if let index = stories.firstIndex(where: { $0.id == storyId }) { + stories[index].lastReadChapterId = chapterId + stories[index].lastReadChapterNumber = chapterNumber + } + + if let profileIndex = profileStories.firstIndex(where: { $0.id == storyId }) { + profileStories[profileIndex].lastReadChapterId = chapterId + profileStories[profileIndex].lastReadChapterNumber = chapterNumber + } + + PersistenceService.shared.updateReadingProgress( + storyId: storyId, + chapterId: chapterId, + chapterNumber: chapterNumber + ) + if let story = stories.first(where: { $0.id == storyId }) { + persistShelfState(for: story) + } + } + func logReadingSession(for story: Story, seconds: Int) { + guard seconds >= 3, let session = AuthManager.shared.currentSession else { return } + let completed = stories.first(where: { $0.id == story.id })?.isCompleted ?? story.isCompleted + let event = ReadingSessionRequest( + storyId: story.id, + secondsRead: seconds, + isCompleted: completed + ) PersistenceService.shared.logReadingSession( + id: event.id, storyId: story.id, storyTitle: story.title, seconds: seconds, - isCompleted: story.isCompleted + ownerUserId: session.id, + isCompleted: completed ) + + if !session.isGuest, let queueKey = pendingReadingSessionsKey { + var pending = (try? JSONDecoder().decode( + [ReadingSessionRequest].self, + from: UserDefaults.standard.data(forKey: queueKey) ?? Data() + )) ?? [] + pending.append(event) + if let data = try? JSONEncoder().encode(pending) { + UserDefaults.standard.set(data, forKey: queueKey) + } + } reloadReadingStats() } + private func archiveCompletedStories() { + var updates: [ShelfSyncItem] = [] + for index in stories.indices where stories[index].isCompleted || stories[index].progressPercent >= 100 { + guard stories[index].isBookmarked else { continue } + stories[index].isBookmarked = false + PersistenceService.shared.setBookmark(storyId: stories[index].id, isBookmarked: false) + persistShelfState(for: stories[index]) + updates.append(ShelfSyncItem( + storyId: stories[index].id, + readingProgress: Double(stories[index].progressPercent) / 100.0, + isBookmarked: false, + isCompleted: true, + updatedAtUtc: Date() + )) + } + for index in profileStories.indices where profileStories[index].isCompleted || profileStories[index].progressPercent >= 100 { + profileStories[index].isBookmarked = false + } + guard !updates.isEmpty else { return } + Task { [weak self] in + guard let self else { return } + _ = await self.syncShelfToAccount(updates) + } + } + func markAsFinished(storyId: UUID) { if let idx = stories.firstIndex(where: { $0.id == storyId }) { stories[idx].isCompleted = true stories[idx].progressPercent = 100 + if autoArchiveCompletedStories { + stories[idx].isBookmarked = false + PersistenceService.shared.setBookmark(storyId: storyId, isBookmarked: false) + } let pages = stories[idx].totalPages PersistenceService.shared.updateProgress( storyId: storyId, @@ -430,6 +1063,7 @@ public final class StoryStore: ObservableObject { totalPages: pages ) reloadReadingStats() + persistShelfState(for: stories[idx]) Task { [weak self] in guard let self = self else { return } @@ -440,7 +1074,7 @@ public final class StoryStore: ObservableObject { isCompleted: true, updatedAtUtc: Date() ) - _ = try? await self.apiService.syncShelf(deviceId: self.persistentDeviceId, items: [item]) + _ = await self.syncShelfToAccount([item]) } } } @@ -448,7 +1082,8 @@ public final class StoryStore: ObservableObject { func removeFromShelf(storyId: UUID) { if let idx = stories.firstIndex(where: { $0.id == storyId }) { stories[idx].isBookmarked = false - _ = PersistenceService.shared.toggleBookmark(storyId: storyId) + PersistenceService.shared.setBookmark(storyId: storyId, isBookmarked: false) + persistShelfState(for: stories[idx]) Task { [weak self] in guard let self = self else { return } @@ -459,14 +1094,17 @@ public final class StoryStore: ObservableObject { isCompleted: self.stories[idx].isCompleted, updatedAtUtc: Date() ) - _ = try? await self.apiService.syncShelf(deviceId: self.persistentDeviceId, items: [item]) + _ = await self.syncShelfToAccount([item]) } } } // MARK: - Marginalia & Annotations (Plan 02) func loadAnnotations(for storyId: UUID) { - self.activeStoryAnnotations = PersistenceService.shared.fetchAnnotations(for: storyId) + self.activeStoryAnnotations = PersistenceService.shared.fetchAnnotations( + for: storyId, + ownerUserId: AuthManager.shared.currentSession?.id + ) } func addAnnotation( @@ -490,7 +1128,10 @@ public final class StoryStore: ObservableObject { isPinnedToJournal: pinToJournal ) activeStoryAnnotations.append(annotation) - PersistenceService.shared.saveAnnotation(annotation) + PersistenceService.shared.saveAnnotation( + annotation, + ownerUserId: AuthManager.shared.currentSession?.id + ) if pinToJournal { reloadPinnedQuotes() } @@ -498,14 +1139,20 @@ public final class StoryStore: ObservableObject { func deleteAnnotation(id: UUID, storyId: UUID) { activeStoryAnnotations.removeAll(where: { $0.id == id }) - PersistenceService.shared.deleteAnnotation(id: id) + PersistenceService.shared.deleteAnnotation( + id: id, + ownerUserId: AuthManager.shared.currentSession?.id + ) reloadPinnedQuotes() } func togglePinQuote(for annotation: Annotation) { var updated = annotation updated.isPinnedToJournal.toggle() - PersistenceService.shared.saveAnnotation(updated) + PersistenceService.shared.saveAnnotation( + updated, + ownerUserId: AuthManager.shared.currentSession?.id + ) if let idx = activeStoryAnnotations.firstIndex(where: { $0.id == annotation.id }) { activeStoryAnnotations[idx] = updated } @@ -513,11 +1160,16 @@ public final class StoryStore: ObservableObject { } func clearUserStateOnSignOut() { - UserDefaults.standard.removeObject(forKey: "fable_device_id") - self.stories = [] - self.profileStories = [] - self.pinnedQuotes = [] - self.readingStats = PersistenceService.ReadingStatsSummary(storiesReadCount: 0, totalMinutesRead: 0, streakDays: 0) - self.syncWithPersistence() + if activeShelfOwnerKey != "signed_out" { + var states = loadShelfStateCache() + for story in stories { + let previous = states[story.id.uuidString] + let state = LocalShelfState(story: story, updatedAtUtc: previous?.updatedAtUtc ?? Date()) + if !state.isEmpty || previous != nil { states[story.id.uuidString] = state } + } + saveShelfStateCache(states) + } + activeShelfOwnerKey = "signed_out" + clearVisibleShelfState() } } diff --git a/frontend/FableApp/Features/Library/Views/ExploreView.swift b/frontend/FableApp/Features/Library/Views/ExploreView.swift index a2bb623..bb5156d 100644 --- a/frontend/FableApp/Features/Library/Views/ExploreView.swift +++ b/frontend/FableApp/Features/Library/Views/ExploreView.swift @@ -8,6 +8,8 @@ public struct ExploreView: View { @State private var selectedGenreForDetail: GenreCategory? @State private var selectedStoryToRead: Story? @State private var selectedWriter: Writer? + @State private var isFindingWriterTitle: Bool = false + @State private var writerTitleMessage: String? let filters = ["All", "Under 5 mins", "Community Favorites", "Quick Reads"] @@ -27,11 +29,11 @@ public struct ExploreView: View { var curatedStories: [Story] { switch selectedFilter { case "Under 5 mins": - return store.stories.filter { $0.readingTimeMinutes <= 5 } + return store.stories.filter { $0.readingTimeMinutes > 0 && $0.readingTimeMinutes <= 5 } case "Community Favorites": - return store.stories.filter { $0.rating >= 4.9 } + return store.stories.filter { (Int($0.savesCount) ?? 0) > 0 } case "Quick Reads": - return store.stories.filter { $0.readingTimeMinutes <= 3 } + return store.stories.filter { $0.readingTimeMinutes > 0 && $0.readingTimeMinutes <= 3 } default: return store.stories } @@ -260,6 +262,13 @@ public struct ExploreView: View { } .padding(.horizontal, 20) + if store.genres.isEmpty { + Text("Live genres will appear here after the catalog syncs.") + .font(.system(size: 14)) + .foregroundColor(FableTheme.textMuted) + .padding(.horizontal, 20) + } + // 2x2 Grid LazyVGrid(columns: [GridItem(.flexible(), spacing: 14), GridItem(.flexible(), spacing: 14)], spacing: 14) { ForEach(store.genres) { genre in @@ -314,6 +323,11 @@ public struct ExploreView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 18) { + if store.writers.isEmpty { + Text("Live author profiles will appear here after sync.") + .font(.system(size: 14)) + .foregroundColor(FableTheme.textMuted) + } ForEach(store.writers) { writer in Button(action: { selectedWriter = writer @@ -334,14 +348,6 @@ public struct ExploreView: View { .font(.system(size: 11)) .foregroundColor(FableTheme.textMuted) - HStack(spacing: 2) { - Image(systemName: "star.fill") - .font(.system(size: 9)) - .foregroundColor(.orange) - Text(String(format: "%.1f", writer.rating)) - .font(.system(size: 11, weight: .bold)) - .foregroundColor(FableTheme.textPrimary) - } } } .frame(width: 95) @@ -358,9 +364,15 @@ public struct ExploreView: View { } .refreshable { await store.syncWithCloudBackend() - await store.fetchGutenbergPublicStories(topic: "fiction", search: nil) } } + .task(id: searchText) { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return } + try? await Task.sleep(nanoseconds: 350_000_000) + guard !Task.isCancelled else { return } + await store.searchCatalog(query: query) + } .navigationDestination(item: $selectedGenreForDetail) { genre in GenreDetailView(genre: genre) .environmentObject(store) @@ -384,12 +396,28 @@ public struct ExploreView: View { .font(.system(size: 14)) .foregroundColor(FableTheme.textMuted) - Button("Read Top Title") { - selectedWriter = nil - if let first = store.stories.first { - selectedStoryToRead = first + if let writerTitleMessage { + Text(writerTitleMessage) + .font(.system(size: 13)) + .foregroundColor(FableTheme.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + + Button(isFindingWriterTitle ? "Searching…" : "Find a title") { + isFindingWriterTitle = true + Task { + let story = await store.topStory(for: writer) + isFindingWriterTitle = false + if let story { + selectedWriter = nil + selectedStoryToRead = story + } else { + writerTitleMessage = "No titles by this author are available in the catalog yet." + } } } + .disabled(isFindingWriterTitle) .font(.system(size: 15, weight: .semibold)) .foregroundColor(.white) .padding(.horizontal, 28) diff --git a/frontend/FableApp/Features/Library/Views/GenreDetailView.swift b/frontend/FableApp/Features/Library/Views/GenreDetailView.swift index 67c700b..6034d48 100644 --- a/frontend/FableApp/Features/Library/Views/GenreDetailView.swift +++ b/frontend/FableApp/Features/Library/Views/GenreDetailView.swift @@ -18,23 +18,38 @@ public struct GenreDetailView: View { var genreStories: [Story] { let matching = store.stories.filter { - $0.genre.rawValue.localizedCaseInsensitiveContains(genre.name) || - $0.title.localizedCaseInsensitiveContains(genre.name) || - $0.badgeText?.localizedCaseInsensitiveContains(genre.name) == true + $0.genre.rawValue.caseInsensitiveCompare(genre.name) == .orderedSame } - let list = matching.isEmpty ? store.stories : matching + let list = matching switch selectedSubcategory { case "Popular": - return list.filter { $0.rating >= 4.8 } + return list.sorted { left, right in + let leftCount = (Int(left.readsCount) ?? 0) + (Int(left.savesCount) ?? 0) + (left.providerDownloadCount ?? 0) + let rightCount = (Int(right.readsCount) ?? 0) + (Int(right.savesCount) ?? 0) + (right.providerDownloadCount ?? 0) + return leftCount > rightCount + } case "Editor's Pick": - return list.filter { $0.isSaved || $0.isCuratorSpotlight } + return list.filter(\.isCuratorSpotlight) case "Quick Reads": - return list.filter { $0.readingTimeMinutes <= 4 } + return list.filter { $0.readingTimeMinutes > 0 && $0.readingTimeMinutes <= 4 } default: return list } } + private func toggleGenreFollow() { + withAnimation { + isFollowing.toggle() + } + var followed = Set(UserDefaults.standard.stringArray(forKey: "fable_followed_genres") ?? []) + if isFollowing { + followed.insert(genre.name) + } else { + followed.remove(genre.name) + } + UserDefaults.standard.set(followed.sorted(), forKey: "fable_followed_genres") + } + public var body: some View { ZStack { FableTheme.background.ignoresSafeArea() @@ -62,11 +77,7 @@ public struct GenreDetailView: View { Spacer() HStack(spacing: 10) { - Button(action: { - withAnimation { - isFollowing.toggle() - } - }) { + Button(action: toggleGenreFollow) { Image(systemName: isFollowing ? "bookmark.fill" : "bookmark") .font(.system(size: 15)) .foregroundColor(isFollowing ? FableTheme.brandPrimary : FableTheme.textPrimary) @@ -100,20 +111,18 @@ public struct GenreDetailView: View { .font(.system(size: 28, weight: .black)) .foregroundColor(FableTheme.textPrimary) - Text("\(genre.storyCount) Titles • \(genre.readersCount) Readers • Curated Weekly") + Text("\(genre.storyCount) Titles • \(genre.readersCount) Readers") .font(.system(size: 12, weight: .medium)) .foregroundColor(FableTheme.textMuted) - Text(genre.description) - .font(.system(size: 14, weight: .regular)) - .foregroundColor(FableTheme.textPrimary.opacity(0.85)) - .lineSpacing(4) + if !genre.description.isEmpty { + Text(genre.description) + .font(.system(size: 14, weight: .regular)) + .foregroundColor(FableTheme.textPrimary.opacity(0.85)) + .lineSpacing(4) + } - Button(action: { - withAnimation { - isFollowing.toggle() - } - }) { + Button(action: toggleGenreFollow) { HStack(spacing: 6) { Image(systemName: isFollowing ? "checkmark" : "plus") .font(.system(size: 12, weight: .bold)) @@ -164,18 +173,26 @@ public struct GenreDetailView: View { .foregroundColor(FableTheme.textPrimary) .padding(.horizontal, 20) + if genreStories.isEmpty { + Text("No cached titles match this genre yet.") + .font(.system(size: 14)) + .foregroundColor(FableTheme.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + } + VStack(spacing: 12) { ForEach(genreStories) { story in - Button(action: { - selectedStoryToRead = story - }) { - HStack(alignment: .top, spacing: 14) { - FableImageView(name: story.effectiveCoverImage, placeholderIcon: "book") - .frame(width: 72, height: 90) - .clipShape(RoundedRectangle(cornerRadius: 12)) - - VStack(alignment: .leading, spacing: 5) { - HStack { + HStack(alignment: .top, spacing: 10) { + Button { + selectedStoryToRead = story + } label: { + HStack(alignment: .top, spacing: 14) { + FableImageView(name: story.effectiveCoverImage, placeholderIcon: "book") + .frame(width: 72, height: 90) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + VStack(alignment: .leading, spacing: 5) { HStack(spacing: 4) { Text(story.contentFormat.displayName.uppercased()) .font(.system(size: 8, weight: .bold)) @@ -184,7 +201,7 @@ public struct GenreDetailView: View { .background(story.contentFormat == .manga ? FableTheme.brandPrimary.opacity(0.12) : FableTheme.surfaceVariant) .foregroundColor(story.contentFormat == .manga ? FableTheme.brandPrimary : FableTheme.textSecondary) .clipShape(Capsule()) - + Text(story.badgeText ?? story.genre.rawValue.uppercased()) .font(.system(size: 9, weight: .bold)) .tracking(0.6) @@ -194,37 +211,39 @@ public struct GenreDetailView: View { .background(FableTheme.surface) .clipShape(Capsule()) } - - Spacer() - - Button(action: { - store.toggleBookmark(for: story) - }) { - Image(systemName: story.isBookmarked ? "bookmark.fill" : "bookmark") - .font(.system(size: 12)) - .foregroundColor(story.isBookmarked ? FableTheme.brandPrimary : FableTheme.textMuted) - } + + Text(story.title) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(FableTheme.textPrimary) + .lineLimit(1) + + Text(story.excerpt) + .font(.system(size: 12, weight: .regular)) + .foregroundColor(FableTheme.textPrimary.opacity(0.75)) + .lineLimit(2) + .lineSpacing(2) } - - Text(story.title) - .font(.system(size: 16, weight: .bold)) - .foregroundColor(FableTheme.textPrimary) - .lineLimit(1) - - Text(story.excerpt) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(FableTheme.textPrimary.opacity(0.75)) - .lineLimit(2) - .lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) } + .contentShape(Rectangle()) } - .padding(14) - .background(FableTheme.cardBackground) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .shadow(color: Color.black.opacity(0.03), radius: 6, y: 2) - .padding(.horizontal, 20) + .buttonStyle(.plain) + + Button { + store.toggleBookmark(for: story) + } label: { + Image(systemName: story.isBookmarked ? "bookmark.fill" : "bookmark") + .font(.system(size: 12)) + .foregroundColor(story.isBookmarked ? FableTheme.brandPrimary : FableTheme.textMuted) + .padding(6) + } + .accessibilityLabel(story.isBookmarked ? "Remove bookmark" : "Bookmark story") } - .buttonStyle(.plain) + .padding(14) + .background(FableTheme.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: Color.black.opacity(0.03), radius: 6, y: 2) + .padding(.horizontal, 20) } } } @@ -234,6 +253,10 @@ public struct GenreDetailView: View { } } .navigationBarBackButtonHidden(true) + .onAppear { + let followed = UserDefaults.standard.stringArray(forKey: "fable_followed_genres") ?? [] + isFollowing = followed.contains(genre.name) + } .fullScreenCover(item: $selectedStoryToRead) { story in ReaderView(story: story) .environmentObject(store) diff --git a/frontend/FableApp/Features/Library/Views/LibraryView.swift b/frontend/FableApp/Features/Library/Views/LibraryView.swift index 0ac31a1..396d4a7 100644 --- a/frontend/FableApp/Features/Library/Views/LibraryView.swift +++ b/frontend/FableApp/Features/Library/Views/LibraryView.swift @@ -8,7 +8,7 @@ public struct LibraryView: View { @State private var selectedStoryToRead: Story? @State private var isShowingProfileSheet: Bool = false - let filterCategories = ["All", "Manga", "Folklore", "Mythology", "Gothic", "Speculative", "Classic"] + var filterCategories: [String] { ["All"] + store.genres.map(\.name) } var filteredRecentStories: [Story] { let recents = store.stories.filter { $0.isRecentSubmission } @@ -67,7 +67,7 @@ public struct LibraryView: View { Button(action: { isShowingProfileSheet = true }) { - FableImageView(name: auth.currentSession?.avatarName ?? "avatar_roosc", placeholderIcon: "person.crop.circle") + FableImageView(name: auth.currentSession?.avatarName, placeholderIcon: "person.crop.circle") .frame(width: 40, height: 40) .clipShape(Circle()) .overlay(Circle().stroke(FableTheme.divider, lineWidth: 1.5)) @@ -99,49 +99,80 @@ public struct LibraryView: View { .padding(.horizontal, 20) } + if store.stories.isEmpty { + VStack(spacing: 12) { + Image(systemName: "books.vertical") + .font(.system(size: 32)) + .foregroundColor(FableTheme.textMuted) + Text("Your library is empty") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(FableTheme.textPrimary) + Text(store.isBackendReachable + ? "No titles are available from the live catalog yet." + : "Connect to the catalog to discover books. Previously saved books will remain available offline.") + .font(.system(size: 14)) + .multilineTextAlignment(.center) + .foregroundColor(FableTheme.textMuted) + Button("Retry catalog sync") { + Task { await store.syncWithCloudBackend() } + } + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(FableTheme.brandPrimary) + } + .frame(maxWidth: .infinity) + .padding(24) + .background(FableTheme.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.horizontal, 20) + } + // Featured Hero Card if let featuredStory = store.stories.first(where: { $0.isTaleOfTheDay }) ?? store.stories.first { - Button(action: { - selectedStoryToRead = featuredStory - }) { - VStack(alignment: .leading, spacing: 0) { - // Media Cover with Dynamic Format Badge - ZStack(alignment: .top) { - FableImageView(name: featuredStory.effectiveCoverImage ?? "cover_dracula", placeholderIcon: "square.stack") + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .top) { + Button { + selectedStoryToRead = featuredStory + } label: { + FableImageView(name: featuredStory.effectiveCoverImage, placeholderIcon: "square.stack") .frame(maxWidth: .infinity) .frame(height: 220) .clipped() - - HStack { - Text(featuredStory.contentFormat == .manga ? "FEATURED MANGA" : "FEATURED TITLE") - .font(.system(size: 10, weight: .heavy)) - .tracking(1.0) - .foregroundColor(FableTheme.brandPrimary) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Color.white.opacity(0.95)) - .clipShape(Capsule()) - - Spacer() - - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { - store.toggleBookmark(for: featuredStory) - } - }) { - Image(systemName: featuredStory.isBookmarked ? "bookmark.fill" : "bookmark") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(taleOfTheDay.isBookmarked ? FableTheme.brandPrimary : FableTheme.textPrimary) - .padding(8) - .background(Color.white.opacity(0.95)) - .clipShape(Circle()) - .shadow(color: Color.black.opacity(0.08), radius: 3, y: 1) + } + .buttonStyle(.plain) + + HStack { + Text(featuredStory.contentFormat == .manga ? "FEATURED MANGA" : "FEATURED TITLE") + .font(.system(size: 10, weight: .heavy)) + .tracking(1.0) + .foregroundColor(FableTheme.brandPrimary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.white.opacity(0.95)) + .clipShape(Capsule()) + + Spacer() + + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + store.toggleBookmark(for: featuredStory) } + } label: { + Image(systemName: featuredStory.isBookmarked ? "bookmark.fill" : "bookmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(featuredStory.isBookmarked ? FableTheme.brandPrimary : FableTheme.textPrimary) + .padding(8) + .background(Color.white.opacity(0.95)) + .clipShape(Circle()) + .shadow(color: Color.black.opacity(0.08), radius: 3, y: 1) } - .padding(14) + .accessibilityLabel(featuredStory.isBookmarked ? "Remove bookmark" : "Bookmark story") } - - // Description Area + .padding(14) + } + + Button { + selectedStoryToRead = featuredStory + } label: { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { Text(featuredStory.author) @@ -149,15 +180,15 @@ public struct LibraryView: View { .foregroundColor(FableTheme.brandPrimary) Text("•") .foregroundColor(FableTheme.textMuted) - Text(featuredStory.contentFormat == .manga ? "\(featuredStory.readingTimeMinutes)m read" : "\(featuredStory.readingTimeMinutes) min read") + Text("\(featuredStory.readingTimeMinutes) min read") .font(.system(size: 13, weight: .regular)) .foregroundColor(FableTheme.textMuted) } - + Text(featuredStory.title) .font(.system(size: 22, weight: .bold, design: .default)) .foregroundColor(FableTheme.textPrimary) - + Text(featuredStory.excerpt) .font(.system(size: 14, weight: .regular, design: .default)) .foregroundColor(FableTheme.textSecondary) @@ -167,13 +198,13 @@ public struct LibraryView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(FableTheme.cardBackground) } - .clipShape(RoundedRectangle(cornerRadius: 18)) - .shadow(color: Color.black.opacity(0.04), radius: 8, y: 2) - .padding(.horizontal, 20) + .buttonStyle(.plain) } - .buttonStyle(.plain) + .clipShape(RoundedRectangle(cornerRadius: 18)) + .shadow(color: Color.black.opacity(0.04), radius: 8, y: 2) + .padding(.horizontal, 20) } - + // Continue Reading Section if let inProgressStory = store.stories.first(where: { $0.progressPercent > 0 && !$0.isCompleted }) { VStack(alignment: .leading, spacing: 12) { @@ -277,17 +308,16 @@ public struct LibraryView: View { VStack(spacing: 12) { ForEach(filteredRecentStories) { story in - Button(action: { - selectedStoryToRead = story - }) { - HStack(alignment: .top, spacing: 14) { - FableImageView(name: story.effectiveCoverImage, placeholderIcon: "square.stack") - .frame(width: 72, height: 96) - .clipShape(RoundedRectangle(cornerRadius: 10)) - - VStack(alignment: .leading, spacing: 5) { - HStack { - // Dynamic Format Pill + HStack(alignment: .top, spacing: 10) { + Button { + selectedStoryToRead = story + } label: { + HStack(alignment: .top, spacing: 14) { + FableImageView(name: story.effectiveCoverImage, placeholderIcon: "square.stack") + .frame(width: 72, height: 96) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 5) { Text(story.contentFormat == .manga ? "MANGA" : (story.sourceProvider == .standardEbooks ? "STANDARD EBOOKS" : story.genre.rawValue.uppercased())) .font(.system(size: 9, weight: .heavy)) .tracking(0.5) @@ -296,44 +326,46 @@ public struct LibraryView: View { .padding(.vertical, 3) .background(FableTheme.surface) .clipShape(Capsule()) - - Spacer() - - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { - store.toggleBookmark(for: story) - } - }) { - Image(systemName: story.isBookmarked ? "bookmark.fill" : "bookmark") - .font(.system(size: 13)) - .foregroundColor(story.isBookmarked ? FableTheme.brandPrimary : FableTheme.textMuted) - } + + Text(story.title) + .font(.system(size: 16, weight: .bold, design: .default)) + .foregroundColor(FableTheme.textPrimary) + .lineLimit(1) + + Text(story.author) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(FableTheme.textMuted) + + Text(story.excerpt) + .font(.system(size: 12, weight: .regular)) + .foregroundColor(FableTheme.textSecondary) + .lineLimit(2) + .lineSpacing(2) } - - Text(story.title) - .font(.system(size: 16, weight: .bold, design: .default)) - .foregroundColor(FableTheme.textPrimary) - .lineLimit(1) - - Text(story.author) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(FableTheme.textMuted) - - Text(story.excerpt) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(FableTheme.textSecondary) - .lineLimit(2) - .lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) } + .contentShape(Rectangle()) } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(FableTheme.cardBackground) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .shadow(color: Color.black.opacity(0.03), radius: 6, y: 2) - .padding(.horizontal, 20) + .buttonStyle(.plain) + + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + store.toggleBookmark(for: story) + } + } label: { + Image(systemName: story.isBookmarked ? "bookmark.fill" : "bookmark") + .font(.system(size: 13)) + .foregroundColor(story.isBookmarked ? FableTheme.brandPrimary : FableTheme.textMuted) + .padding(6) + } + .accessibilityLabel(story.isBookmarked ? "Remove bookmark" : "Bookmark story") } - .buttonStyle(.plain) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(FableTheme.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: Color.black.opacity(0.03), radius: 6, y: 2) + .padding(.horizontal, 20) } } } diff --git a/frontend/FableApp/Features/Reader/Services/AudioNarratorController.swift b/frontend/FableApp/Features/Reader/Services/AudioNarratorController.swift index 1fc29f5..f2a6828 100644 --- a/frontend/FableApp/Features/Reader/Services/AudioNarratorController.swift +++ b/frontend/FableApp/Features/Reader/Services/AudioNarratorController.swift @@ -82,7 +82,7 @@ public final class AudioNarratorController: NSObject, ObservableObject, AVSpeech if let voiceId = selectedVoiceIdentifier, let voice = AVSpeechSynthesisVoice(identifier: voiceId) { utterance.voice = voice } else { - utterance.voice = AVSpeechSynthesisVoice(language: "en-US") + utterance.voice = AVSpeechSynthesisVoice(language: Locale.current.identifier) } let clampedMultiplier = max(0.5, min(2.0, playbackRateMultiplier)) diff --git a/frontend/FableApp/Features/Reader/Tests/ReaderTests.swift b/frontend/FableApp/Features/Reader/Tests/ReaderTests.swift index 0707641..bdb6237 100644 --- a/frontend/FableApp/Features/Reader/Tests/ReaderTests.swift +++ b/frontend/FableApp/Features/Reader/Tests/ReaderTests.swift @@ -56,7 +56,7 @@ public struct ReaderTests { assert(MangaReadingMode.webtoon.iconName == "arrow.up.and.down", "Webtoon Reading Mode Up/Down Icon") assert(MangaReadingMode.paged.iconName == "arrow.left.and.right", "Paged Reading Mode Left/Right Icon") - // Test 8: Manga Active Page URLs Fallback Hierarchy + // Test 8: Manga Panel and Cover Separation let testStoryId = UUID() let chapterWithPanels = Chapter( storyId: testStoryId, @@ -80,15 +80,15 @@ public struct ReaderTests { let resolvedUrlsA = chapterWithPanels.pageUrls assert(resolvedUrlsA.count == 2 && resolvedUrlsA[0] == "https://cdn.manga.org/ch1_p1.jpg", "Manga Chapter Explicit Page Resolution") - // Scenario B: Chapter without page URLs falls back to first chapter or story cover + // Scenario B: A story cover remains a cover and is not rendered as a manga panel let emptyChapter = Chapter( storyId: testStoryId, chapterNumber: 2, title: "Ch 2", pageUrls: [] ) - let fallbackUrls = [mangaWithPanels.coverImageUrl, mangaWithPanels.effectiveCoverImage].compactMap { $0 } - assert(!fallbackUrls.isEmpty && fallbackUrls.contains("https://cdn.manga.org/cover.jpg"), "Manga Empty Panels Cover Fallback Resolution") + assert(emptyChapter.pageUrls.isEmpty, "Manga Empty Panels Do Not Use Story Cover") + assert(mangaWithPanels.effectiveCoverImage == "https://cdn.manga.org/cover.jpg", "Manga Story Cover Remains Available") // Test 9: Format-Based Reader Selection Protocol let proseStory = Story( diff --git a/frontend/FableApp/Features/Reader/Views/DisplayOptionsSheet.swift b/frontend/FableApp/Features/Reader/Views/DisplayOptionsSheet.swift index 9836fba..c2ebec5 100644 --- a/frontend/FableApp/Features/Reader/Views/DisplayOptionsSheet.swift +++ b/frontend/FableApp/Features/Reader/Views/DisplayOptionsSheet.swift @@ -285,7 +285,7 @@ struct DisplayOptionsSheet: View { let voice = narrator.availableVoices.first(where: { $0.identifier == id }) { return "\(voice.name)" } - return "System Default (en-US)" + return "System Default (\(Locale.current.identifier))" } private func spacingIcon(for spacing: ReaderLineSpacing) -> String { diff --git a/frontend/FableApp/Features/Reader/Views/MangaReaderView.swift b/frontend/FableApp/Features/Reader/Views/MangaReaderView.swift index 778ce5a..14f8481 100644 --- a/frontend/FableApp/Features/Reader/Views/MangaReaderView.swift +++ b/frontend/FableApp/Features/Reader/Views/MangaReaderView.swift @@ -46,8 +46,7 @@ public struct MangaReaderView: View { if let firstChap = story.chapters?.first, !firstChap.pageUrls.isEmpty { return firstChap.pageUrls } - // Fallback to story covers - return [story.coverImageUrl, story.effectiveCoverImage].compactMap { $0 } + return [] } public var body: some View { @@ -159,6 +158,9 @@ public struct MangaReaderView: View { // Mode Selector (Webtoon vs Paging) Button(action: { + if store.hapticFeedback { + HapticManager.selection() + } withAnimation(.easeInOut(duration: 0.2)) { readingMode = (readingMode == .webtoon) ? .paged : .webtoon } @@ -209,6 +211,9 @@ public struct MangaReaderView: View { // Prev Chapter Button(action: { if currentChapterIndex > 0 { + if store.hapticFeedback { + HapticManager.impact(style: .light) + } currentChapterIndex -= 1 currentPageIndex = 0 } @@ -236,6 +241,9 @@ public struct MangaReaderView: View { // Next Chapter Button(action: { if currentChapterIndex < chapters.count - 1 { + if store.hapticFeedback { + HapticManager.impact(style: .light) + } currentChapterIndex += 1 currentPageIndex = 0 } @@ -265,6 +273,10 @@ public struct MangaReaderView: View { .task { await loadMangaChapters() } + .onChange(of: currentChapterIndex) { _, newIndex in + prefetchPanels(around: newIndex) + persistCurrentChapter(at: newIndex) + } .sheet(isPresented: $isShowingChapterSheet) { NavigationStack { List(Array(chapters.enumerated()), id: \.element.id) { index, chap in @@ -296,9 +308,12 @@ public struct MangaReaderView: View { } } + @MainActor private func loadMangaChapters() async { if let existing = story.chapters, !existing.isEmpty { self.chapters = existing + restoreSavedChapter() + prefetchPanels(around: currentChapterIndex) return } isLoading = true @@ -313,56 +328,100 @@ public struct MangaReaderView: View { title: story.title, content: "", wordCount: 0, - pageUrls: [ - story.coverImageUrl, - "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=800&auto=format&fit=crop", - "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=800&auto=format&fit=crop" - ].compactMap { $0 } + pageUrls: [] ) self.chapters = [single] } isLoading = false + restoreSavedChapter() + prefetchPanels(around: currentChapterIndex) + } + + @MainActor + private func restoreSavedChapter() { + let progress = store.stories.first(where: { $0.id == story.id }) ?? story + if let chapterId = progress.lastReadChapterId, + let savedIndex = chapters.firstIndex(where: { $0.id.uuidString == chapterId }) { + currentChapterIndex = savedIndex + } else if let chapterNumber = progress.lastReadChapterNumber, + let savedIndex = chapters.firstIndex(where: { $0.chapterNumber == chapterNumber }) { + currentChapterIndex = savedIndex + } + } + + @MainActor + private func persistCurrentChapter(at index: Int) { + guard chapters.indices.contains(index) else { return } + let chapter = chapters[index] + store.updateReadingProgress( + for: story.id, + chapterId: chapter.id.uuidString, + chapterNumber: chapter.chapterNumber + ) + } + + @MainActor + private func prefetchPanels(around chapterIndex: Int) { + let chapterIndices = [chapterIndex, chapterIndex + 1].filter { + chapters.indices.contains($0) + } + let urls = chapterIndices.flatMap { index in + chapters[index].pageUrls.compactMap { URL(string: $0) } + } + + guard !urls.isEmpty else { + return + } + + Task { @MainActor in + await DiskImageCache.shared.prefetch(urls: urls) + } } } +@MainActor private struct MangaPageView: View { let urlString: String let pageNumber: Int + private var imageURL: URL? { + guard + let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { + return nil + } + + return url + } + var body: some View { ZStack { Color.black - AsyncImage(url: URL(string: urlString)) { phase in - switch phase { - case .empty: - Rectangle() - .fill(Color(white: 0.1)) - .frame(minHeight: 480) - .overlay( - ProgressView() - .tint(.white) - ) - case .success(let image): - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - case .failure: - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 28)) - .foregroundColor(.gray) - Text("Failed to load panel \(pageNumber)") - .font(.system(size: 12)) - .foregroundColor(.gray) - } - .frame(height: 360) - @unknown default: - EmptyView() + if let imageURL { + FableRemoteImageView(url: imageURL, contentMode: .fit) { + placeholder } + .frame(maxWidth: .infinity) + .clipped() + } else { + placeholder } } + .clipped() } -} + private var placeholder: some View { + VStack(spacing: 8) { + Image(systemName: "photo") + .font(.system(size: 28)) + .foregroundColor(.gray) + Text("Panel \(pageNumber) unavailable") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, minHeight: 360) + } +} diff --git a/frontend/FableApp/Features/Reader/Views/ReaderView.swift b/frontend/FableApp/Features/Reader/Views/ReaderView.swift index b8f3ceb..baa69c6 100644 --- a/frontend/FableApp/Features/Reader/Views/ReaderView.swift +++ b/frontend/FableApp/Features/Reader/Views/ReaderView.swift @@ -370,13 +370,13 @@ public struct ReaderView: View { // Editorial Engraving Vignette (FIGMA.md Frame 2: 1:159) VStack(spacing: 8) { ZStack(alignment: .topTrailing) { - FableImageView(name: story.heroImageName ?? story.effectiveCoverImage ?? "hero_castle", placeholderIcon: "photo") + FableImageView(name: story.effectiveCoverImage ?? story.heroImageName, placeholderIcon: "photo") .frame(maxWidth: .infinity) .frame(height: 200) .clipShape(RoundedRectangle(cornerRadius: 16)) .shadow(color: Color.black.opacity(0.06), radius: 8, y: 3) - Text("FOLIO 82") + Text("FOLIO \(max(1, currentPage))") .font(.system(size: 10, weight: .bold)) .tracking(1.0) .foregroundColor(FableTheme.brandPrimary) @@ -416,6 +416,9 @@ public struct ReaderView: View { HStack(spacing: 14) { if currentChapterIndex > 0 { Button(action: { + if store.hapticFeedback { + HapticManager.impact(style: .light) + } selectChapter(at: currentChapterIndex - 1) }) { HStack(spacing: 6) { @@ -435,6 +438,9 @@ public struct ReaderView: View { if currentChapterIndex < chapters.count - 1 { Button(action: { + if store.hapticFeedback { + HapticManager.impact(style: .light) + } selectChapter(at: currentChapterIndex + 1) }) { HStack(spacing: 6) { @@ -562,6 +568,7 @@ public struct ReaderView: View { pacingEngine.startSession() if let existing = story.chapters, !existing.isEmpty { self.chapters = existing + restoreSavedChapter() loadCurrentChapterPages() } else { loadCurrentChapterPages() @@ -572,6 +579,7 @@ public struct ReaderView: View { self.isLoadingChapters = false if !fetched.isEmpty { self.chapters = fetched + restoreSavedChapter() loadCurrentChapterPages() } } @@ -814,12 +822,29 @@ public struct ReaderView: View { guard chapters.indices.contains(index) else { return } withAnimation(.easeInOut(duration: 0.25)) { currentChapterIndex = index + let chapter = chapters[index] + store.updateReadingProgress( + for: story.id, + chapterId: chapter.id.uuidString, + chapterNumber: chapter.chapterNumber + ) loadCurrentChapterPages() if audioNarrator.isPlaying { - audioNarrator.speak(story: story, chapter: chapters[index]) + audioNarrator.speak(story: story, chapter: chapter) } } } + + private func restoreSavedChapter() { + let progress = store.stories.first(where: { $0.id == story.id }) ?? story + if let chapterId = progress.lastReadChapterId, + let savedIndex = chapters.firstIndex(where: { $0.id.uuidString == chapterId }) { + currentChapterIndex = savedIndex + } else if let chapterNumber = progress.lastReadChapterNumber, + let savedIndex = chapters.firstIndex(where: { $0.chapterNumber == chapterNumber }) { + currentChapterIndex = savedIndex + } + } // Table of Contents Sheet View private var tableOfContentsSheetView: some View { @@ -928,8 +953,3 @@ public struct ReaderView: View { } } } - -#Preview { - ReaderView(story: Story.defaultSeedStories[0]) - .environmentObject(StoryStore()) -} diff --git a/frontend/FableApp/Features/Reader/Views/VoiceSelectionSheet.swift b/frontend/FableApp/Features/Reader/Views/VoiceSelectionSheet.swift index 7cd6661..2c5470f 100644 --- a/frontend/FableApp/Features/Reader/Views/VoiceSelectionSheet.swift +++ b/frontend/FableApp/Features/Reader/Views/VoiceSelectionSheet.swift @@ -50,7 +50,7 @@ public struct VoiceSelectionSheet: View { } } } header: { - Text("Available Folklore Narrators") + Text("Available System Voices") .font(.system(size: 11, weight: .bold)) .foregroundColor(FableTheme.textMuted) } footer: { @@ -78,7 +78,7 @@ public struct VoiceSelectionSheet: View { if let currentId = narrator.selectedVoiceIdentifier { return currentId == voice.identifier } - return voice.language == "en-US" && voice.name.contains("Samantha") + return AVSpeechSynthesisVoice(language: Locale.current.identifier)?.identifier == voice.identifier } private func regionName(for languageCode: String) -> String { diff --git a/frontend/FableApp/Features/Shelf/Views/ProfileView.swift b/frontend/FableApp/Features/Shelf/Views/ProfileView.swift index ccc866b..172c1a6 100644 --- a/frontend/FableApp/Features/Shelf/Views/ProfileView.swift +++ b/frontend/FableApp/Features/Shelf/Views/ProfileView.swift @@ -8,14 +8,23 @@ public struct ProfileView: View { @State private var selectedTab: String = "Published" @State private var selectedStoryToRead: Story? @State private var isShowingEditProfile: Bool = false + @State private var isSavingProfile: Bool = false + @State private var profileSaveMessage: String? - @State private var userName: String = "Roosc Zaño" - @State private var userHandle: String = "@zanoroosc" - @State private var userBio: String = "Writer of quiet lore, archivist of dusk folklore, and collector of vintage horology tales. Author of 14 published stories." + @State private var userName: String = "" + @State private var userHandle: String = "" + @State private var userBio: String = "" let tabs = ["Published", "Saved", "Reading Stats"] public init() {} + + private var shareableProfileDetails: String { + [userName, userHandle, userBio] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + } public var body: some View { NavigationStack { @@ -45,7 +54,7 @@ public struct ProfileView: View { Spacer() // Native Share Profile - ShareLink(item: "Check out \(userName)'s profile on Fable.") { + ShareLink(item: shareableProfileDetails, subject: Text("Fable profile")) { Image(systemName: "square.and.arrow.up") .font(.system(size: 15)) .foregroundColor(FableTheme.textPrimary) @@ -59,28 +68,13 @@ public struct ProfileView: View { ScrollView(showsIndicators: false) { VStack(spacing: 20) { - // Avatar with Edit Badge - ZStack(alignment: .bottomTrailing) { - FableImageView(name: auth.currentSession?.avatarName ?? "avatar_roosc", placeholderIcon: "person.crop.circle.fill") - .frame(width: 96, height: 96) - .clipShape(Circle()) - .overlay(Circle().stroke(Color.white, lineWidth: 3)) - .shadow(color: Color.black.opacity(0.08), radius: 6, y: 3) - - Button(action: { - isShowingEditProfile = true - }) { - Image(systemName: "pencil") - .font(.system(size: 11, weight: .bold)) - .foregroundColor(.white) - .frame(width: 26, height: 26) - .background(FableTheme.brandPrimary) - .clipShape(Circle()) - .overlay(Circle().stroke(Color.white, lineWidth: 2)) - } - } - .padding(.top, 8) - + FableImageView(name: auth.currentSession?.avatarName, placeholderIcon: "person.crop.circle.fill") + .frame(width: 96, height: 96) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white, lineWidth: 3)) + .shadow(color: Color.black.opacity(0.08), radius: 6, y: 3) + .padding(.top, 8) + // Name & Bio VStack(spacing: 6) { HStack(spacing: 6) { @@ -88,9 +82,6 @@ public struct ProfileView: View { .font(.system(size: 24, weight: .black)) .foregroundColor(FableTheme.textPrimary) - Image(systemName: "checkmark.seal.fill") - .font(.system(size: 14)) - .foregroundColor(FableTheme.brandPrimary) } Text(userHandle) @@ -124,11 +115,11 @@ public struct ProfileView: View { .clipShape(RoundedRectangle(cornerRadius: 12)) } - ShareLink(item: "Check out \(userName)'s profile on Fable.") { + ShareLink(item: shareableProfileDetails, subject: Text("Fable profile")) { HStack(spacing: 6) { Image(systemName: "square.and.arrow.up") .font(.system(size: 13)) - Text("Share") + Text("Share profile details") .font(.system(size: 14, weight: .semibold)) } .foregroundColor(FableTheme.textPrimary) @@ -206,10 +197,12 @@ public struct ProfileView: View { .foregroundColor(FableTheme.textMuted) HStack(spacing: 8) { - Text("★ 4.9") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.orange) - Text("• 1.2k reads") + if let rating = story.rating { + Text("★ \(rating, specifier: "%.1f")") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.orange) + } + Text("• \(story.readsCount) reads") .font(.system(size: 11)) .foregroundColor(FableTheme.textMuted) } @@ -348,14 +341,24 @@ public struct ProfileView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button("Save") { - auth.updateProfile( - name: userName, - handle: userHandle, - bio: userBio - ) - isShowingEditProfile = false + Button(isSavingProfile ? "Saving…" : "Save") { + isSavingProfile = true + Task { + let synced = await auth.updateProfile( + name: userName, + handle: userHandle, + bio: userBio + ) + isSavingProfile = false + profileSaveMessage = synced + ? "Your profile was updated." + : (auth.isGuestMode + ? "Your guest profile is saved on this device." + : "Your profile is saved on this device and queued to sync.") + isShowingEditProfile = false + } } + .disabled(isSavingProfile || userName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .font(.system(size: 15, weight: .semibold)) .foregroundColor(FableTheme.brandPrimary) } @@ -363,14 +366,23 @@ public struct ProfileView: View { } .presentationDetents([.medium]) } + .alert("Profile", isPresented: Binding( + get: { profileSaveMessage != nil }, + set: { if !$0 { profileSaveMessage = nil } } + )) { + Button("OK", role: .cancel) { profileSaveMessage = nil } + } message: { + Text(profileSaveMessage ?? "") + } + .task(id: auth.currentSession?.id) { + await store.loadMyPublishedStories() + } .onAppear { store.reloadReadingStats() if let session = auth.currentSession { self.userName = session.name self.userHandle = session.handle - if !session.bio.isEmpty { - self.userBio = session.bio - } + self.userBio = session.bio } } } diff --git a/frontend/FableApp/Features/Shelf/Views/SettingsView.swift b/frontend/FableApp/Features/Shelf/Views/SettingsView.swift index 0271d9f..779fc0a 100644 --- a/frontend/FableApp/Features/Shelf/Views/SettingsView.swift +++ b/frontend/FableApp/Features/Shelf/Views/SettingsView.swift @@ -5,13 +5,21 @@ public struct SettingsView: View { @Environment(\.dismiss) var dismiss @ObservedObject var auth = AuthManager.shared - @State private var autoArchiveStories: Bool = false - @State private var cacheCleared: Bool = false + @State private var imageCacheSizeInBytes: Int64 = 0 + @State private var isClearingImageCache: Bool = false + @State private var imageCacheMessage: String? @State private var navigateToProfile: Bool = false @State private var isShowingSignOutAlert: Bool = false @State private var isShowingDiagnostics: Bool = false public init() {} + + private var versionLabel: String { + let metadata = Bundle.main.infoDictionary ?? [:] + let version = metadata["CFBundleShortVersionString"] as? String ?? "Unknown" + let build = metadata["CFBundleVersion"] as? String ?? "Unknown" + return "Fable for iOS • Version \(version) (Build \(build))" + } public var body: some View { NavigationStack { @@ -26,13 +34,13 @@ public struct SettingsView: View { navigateToProfile = true }) { HStack(spacing: 14) { - FableImageView(name: auth.currentSession?.avatarName ?? "avatar_roosc", placeholderIcon: "person.crop.circle") + FableImageView(name: auth.currentSession?.avatarName, placeholderIcon: "person.crop.circle") .frame(width: 54, height: 54) .clipShape(Circle()) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { - Text(auth.currentSession?.name ?? "Guest Reader") + Text(auth.currentSession?.name ?? "Guest") .font(.system(size: 17, weight: .bold)) .foregroundColor(FableTheme.textPrimary) @@ -193,29 +201,38 @@ public struct SettingsView: View { VStack(spacing: 0) { HStack(spacing: 14) { VStack(alignment: .leading, spacing: 3) { - Text("Offline Library Cache") + Text("Downloaded Cover & Panel Images") .font(.system(size: 15, weight: .medium)) .foregroundColor(FableTheme.textPrimary) - Text(cacheCleared ? "Cache clean (0 MB)" : "9 Stories Cached (24 MB)") + Text(imageCacheMessage ?? ByteCountFormatter.string(fromByteCount: imageCacheSizeInBytes, countStyle: .file)) .font(.system(size: 12)) .foregroundColor(FableTheme.textMuted) } Spacer() - Button(action: { - withAnimation { - cacheCleared = true + Button { + Task { + isClearingImageCache = true + defer { isClearingImageCache = false } + do { + try await DiskImageCache.shared.clearCache() + imageCacheSizeInBytes = await DiskImageCache.shared.diskUsageInBytes() + imageCacheMessage = "Image cache cleared" + } catch { + imageCacheMessage = "Unable to clear image cache" + } } - }) { - Text(cacheCleared ? "Cleaned" : "Clear Cache") + } label: { + Text(isClearingImageCache ? "Clearing…" : "Clear") .font(.system(size: 13, weight: .semibold)) - .foregroundColor(cacheCleared ? .green : FableTheme.textPrimary) + .foregroundColor(FableTheme.textPrimary) .padding(.horizontal, 12) .padding(.vertical, 6) .background(FableTheme.surfaceVariant) .clipShape(RoundedRectangle(cornerRadius: 8)) } + .disabled(isClearingImageCache) } .padding(.horizontal, 16) .padding(.vertical, 14) @@ -223,11 +240,16 @@ public struct SettingsView: View { Divider().padding(.leading, 16) HStack(spacing: 14) { - Text("Auto-Archive Completed") - .font(.system(size: 15, weight: .medium)) - .foregroundColor(FableTheme.textPrimary) + VStack(alignment: .leading, spacing: 3) { + Text("Auto-Archive Completed") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(FableTheme.textPrimary) + Text("Remove finished books from Saved") + .font(.system(size: 12)) + .foregroundColor(FableTheme.textMuted) + } Spacer() - Toggle("", isOn: $autoArchiveStories) + Toggle("", isOn: $store.autoArchiveCompletedStories) .tint(FableTheme.brandPrimary) .labelsHidden() } @@ -329,7 +351,7 @@ public struct SettingsView: View { // App Version Info VStack(spacing: 4) { - Text("Fable for iOS • Version 1.0.0 (Build 77)") + Text(versionLabel) .font(.system(size: 12, weight: .medium)) .foregroundColor(FableTheme.textMuted) Text("Inspired by high-end independent editorial journals.") @@ -362,6 +384,9 @@ public struct SettingsView: View { .sheet(isPresented: $isShowingDiagnostics) { SystemDiagnosticsSheet() } + .task { + imageCacheSizeInBytes = await DiskImageCache.shared.diskUsageInBytes() + } } } } diff --git a/frontend/FableApp/Features/Shelf/Views/ShelfView.swift b/frontend/FableApp/Features/Shelf/Views/ShelfView.swift index 45825a2..abed61b 100644 --- a/frontend/FableApp/Features/Shelf/Views/ShelfView.swift +++ b/frontend/FableApp/Features/Shelf/Views/ShelfView.swift @@ -171,9 +171,14 @@ public struct ShelfView: View { } HStack { - Text("— \(quote.storyTitle.isEmpty ? "Fable Scribe" : quote.storyTitle) • \(quote.storyAuthor.isEmpty ? "Anonymous" : quote.storyAuthor)") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(FableTheme.textMuted) + let attribution = [quote.storyTitle, quote.storyAuthor] + .filter { !$0.isEmpty } + .joined(separator: " • ") + if !attribution.isEmpty { + Text("— \(attribution)") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(FableTheme.textMuted) + } Spacer() @@ -193,7 +198,7 @@ public struct ShelfView: View { .frame(height: 100) .tabViewStyle(.page(indexDisplayMode: .automatic)) } else { - Text("“A journey of a thousand panels begins with a single line.”") + Text("Pin a passage while reading to save it here.") .font(.system(size: 12, weight: .medium, design: .default)) .foregroundColor(FableTheme.textSecondary) .padding(.top, 4) diff --git a/frontend/FableApp/Features/Write/Views/ManuscriptEditor.swift b/frontend/FableApp/Features/Write/Views/ManuscriptEditor.swift new file mode 100644 index 0000000..8ee5e2c --- /dev/null +++ b/frontend/FableApp/Features/Write/Views/ManuscriptEditor.swift @@ -0,0 +1,102 @@ +import SwiftUI +import UIKit + +enum ManuscriptFormattingStyle { + case bold + case italic + case quote + case sceneBreak +} + +struct ManuscriptFormattingRequest: Identifiable { + let id = UUID() + let style: ManuscriptFormattingStyle +} + +@MainActor +struct ManuscriptEditor: UIViewRepresentable { + @Binding var text: String + @Binding var formattingRequest: ManuscriptFormattingRequest? + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + textView.backgroundColor = .clear + textView.font = .systemFont(ofSize: 16) + textView.textColor = UIColor(FableTheme.textPrimary) + textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4) + textView.adjustsFontForContentSizeCategory = true + textView.accessibilityLabel = "Manuscript" + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + if textView.text != text { + textView.text = text + } + guard let request = formattingRequest, + context.coordinator.lastAppliedRequestID != request.id else { return } + context.coordinator.apply(request.style, to: textView) + context.coordinator.lastAppliedRequestID = request.id + } + + @MainActor + final class Coordinator: NSObject, UITextViewDelegate { + @Binding private var text: String + var lastAppliedRequestID: UUID? + + init(text: Binding) { + _text = text + } + + func textViewDidChange(_ textView: UITextView) { + text = textView.text + } + + func apply(_ style: ManuscriptFormattingStyle, to textView: UITextView) { + let source = textView.text as NSString + let selection = NSRange( + location: min(textView.selectedRange.location, source.length), + length: min(textView.selectedRange.length, source.length - min(textView.selectedRange.location, source.length)) + ) + let selectedText = source.substring(with: selection) + var replacementRange = selection + let replacement: String + let caretLocation: Int + + switch style { + case .bold: + replacement = "**\(selectedText)**" + caretLocation = selection.location + (selectedText.isEmpty ? 2 : replacement.utf16.count) + case .italic: + replacement = "*\(selectedText)*" + caretLocation = selection.location + (selectedText.isEmpty ? 1 : replacement.utf16.count) + case .quote: + if selectedText.isEmpty { + replacement = "> " + caretLocation = selection.location + replacement.utf16.count + } else { + let quoted = selectedText + .components(separatedBy: .newlines) + .map { "> \($0)" } + .joined(separator: "\n") + replacement = quoted + caretLocation = selection.location + replacement.utf16.count + } + case .sceneBreak: + replacementRange = NSRange(location: NSMaxRange(selection), length: 0) + replacement = "\n\n---\n\n" + caretLocation = replacementRange.location + replacement.utf16.count + } + + let updated = source.replacingCharacters(in: replacementRange, with: replacement) + textView.text = updated + text = updated + textView.selectedRange = NSRange(location: min(caretLocation, (updated as NSString).length), length: 0) + } + } +} diff --git a/frontend/FableApp/Features/Write/Views/StoryPublishedSheet.swift b/frontend/FableApp/Features/Write/Views/StoryPublishedSheet.swift index 503b29d..8f508ca 100644 --- a/frontend/FableApp/Features/Write/Views/StoryPublishedSheet.swift +++ b/frontend/FableApp/Features/Write/Views/StoryPublishedSheet.swift @@ -1,64 +1,67 @@ import SwiftUI struct StoryPublishedSheet: View { - @EnvironmentObject var store: StoryStore - @Environment(\.dismiss) var dismiss - + @Environment(\.dismiss) private var dismiss + + let story: Story var onReturnToLibrary: () -> Void var onViewStory: () -> Void - + + private var shareableStoryDetails: String { + "\(story.title) by \(story.author)\n\n\(story.synopsis)" + } + + private var wordCount: Int { + story.content.split { $0.isWhitespace || $0.isNewline }.count + } + var body: some View { VStack(spacing: 24) { - // Drag indicator Capsule() .fill(Color.gray.opacity(0.3)) .frame(width: 36, height: 5) .padding(.top, 16) - + Spacer() - - // Success Icon with Modern Indigo Accent + ZStack { Circle() .fill(FableTheme.brandPrimary.opacity(0.12)) .frame(width: 90, height: 90) - + Circle() .fill(FableTheme.brandPrimary.opacity(0.20)) .frame(width: 68, height: 68) - + Image(systemName: "checkmark") .font(.system(size: 26, weight: .bold)) .foregroundColor(FableTheme.brandPrimary) - - // Sparkle ornaments + Image(systemName: "sparkle") .font(.system(size: 14)) .foregroundColor(FableTheme.brandPrimary) .offset(x: 46, y: -34) - + Image(systemName: "sparkle") .font(.system(size: 10)) .foregroundColor(FableTheme.brandPrimary.opacity(0.7)) .offset(x: -42, y: 30) } .padding(.top, 10) - - // Title & Subtitle + VStack(spacing: 12) { - Text("Story Published!") + Text("Story Published") .font(.system(size: 26, weight: .black)) .foregroundColor(FableTheme.textPrimary) - - Text("Your story is now live in the Community Library for readers to explore.") + + Text("\(story.title) is now available in the Community Library.") .font(.system(size: 15, weight: .regular)) .foregroundColor(FableTheme.textSecondary) .multilineTextAlignment(.center) .lineSpacing(4) .padding(.horizontal, 24) } - - // Metadata Pill + HStack(spacing: 8) { HStack(spacing: 4) { Circle() @@ -67,17 +70,17 @@ struct StoryPublishedSheet: View { Text("Public") .font(.system(size: 12, weight: .medium)) } - + Text("•") .foregroundColor(FableTheme.textMuted.opacity(0.4)) - - Text(store.draftGenre) + + Text(story.genre.rawValue) .font(.system(size: 12, weight: .medium)) - + Text("•") .foregroundColor(FableTheme.textMuted.opacity(0.4)) - - Text("\(store.draftWordCount) words") + + Text("\(wordCount) words") .font(.system(size: 12, weight: .medium)) } .foregroundColor(FableTheme.textPrimary) @@ -85,15 +88,14 @@ struct StoryPublishedSheet: View { .padding(.vertical, 8) .background(FableTheme.surfaceVariant) .clipShape(Capsule()) - + Spacer() - - // Action Buttons + VStack(spacing: 14) { - Button(action: { + Button { dismiss() onViewStory() - }) { + } label: { HStack { Text("View Story Now") .font(.system(size: 16, weight: .semibold)) @@ -106,24 +108,22 @@ struct StoryPublishedSheet: View { .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 14)) } - - Button(action: { + + Button { dismiss() onReturnToLibrary() - }) { + } label: { Text("Return to Library") .font(.system(size: 15, weight: .semibold)) .foregroundColor(FableTheme.textPrimary) .padding(.vertical, 8) } - - Button(action: { - // Share action - }) { + + ShareLink(item: shareableStoryDetails) { HStack(spacing: 6) { Image(systemName: "square.and.arrow.up") .font(.system(size: 13, weight: .medium)) - Text("Share story link") + Text("Share story details") .font(.system(size: 14, weight: .medium)) } .foregroundColor(FableTheme.brandPrimary) diff --git a/frontend/FableApp/Features/Write/Views/WriteView.swift b/frontend/FableApp/Features/Write/Views/WriteView.swift index 50101cd..19c3adf 100644 --- a/frontend/FableApp/Features/Write/Views/WriteView.swift +++ b/frontend/FableApp/Features/Write/Views/WriteView.swift @@ -2,19 +2,16 @@ import SwiftUI public struct WriteView: View { @EnvironmentObject var store: StoryStore + @ObservedObject private var auth = AuthManager.shared @State private var isShowingPublishSheet: Bool = false - @State private var publishedStoryToRead: Story? + @State private var publishedStory: Story? + @State private var selectedStoryToRead: Story? @State private var isShowingClearAlert: Bool = false - @FocusState private var isManuscriptFocused: Bool - - let availableGenres = [ - "Manga", "Folklore", "Mythology", "Gothic", "Classic Fiction", - "Classic Mystery", "Dark Fantasy", "Speculative", "Urban Legend" - ] - - let availableChapters = ["Prologue", "Chapter I", "Chapter II", "Chapter III", "Chapter IV", "Epilogue"] + @State private var formattingRequest: ManuscriptFormattingRequest? + var availableGenres: [String] { store.genres.map(\.name) } + public init() {} public var body: some View { @@ -32,9 +29,7 @@ public struct WriteView: View { .foregroundColor(.red.opacity(0.85)) .alert("Discard Draft?", isPresented: $isShowingClearAlert) { Button("Discard", role: .destructive) { - store.draftTitle = "" - store.draftSynopsis = "" - store.draftManuscript = "" + store.clearWriterDraft() } Button("Cancel", role: .cancel) {} } message: { @@ -59,18 +54,30 @@ public struct WriteView: View { Spacer() // Publish Button (Terracotta square with arrow up) - Button(action: { - store.publishStory() - isShowingPublishSheet = true - }) { - Image(systemName: "arrow.up") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(.white) - .frame(width: 42, height: 42) - .background(FableTheme.brandPrimary) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: FableTheme.brandPrimary.opacity(0.35), radius: 6, y: 3) + Button { + Task { + guard let story = await store.publishStory() else { return } + publishedStory = story + isShowingPublishSheet = true + } + } label: { + Group { + if store.isPublishingStory { + ProgressView() + .tint(.white) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .bold)) + } + } + .foregroundColor(.white) + .frame(width: 42, height: 42) + .background(FableTheme.brandPrimary) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: FableTheme.brandPrimary.opacity(0.35), radius: 6, y: 3) } + .disabled(store.isPublishingStory) + .accessibilityLabel(store.isPublishingStory ? "Publishing story" : "Publish story") } .padding(.horizontal, 20) .padding(.vertical, 12) @@ -116,49 +123,37 @@ public struct WriteView: View { .foregroundColor(FableTheme.textMuted) .frame(width: 44, alignment: .leading) - // Interactive Genre Picker Menu - Menu { - ForEach(availableGenres, id: \.self) { genre in - Button(genre) { - store.draftGenre = genre - } - } - } label: { - HStack(spacing: 6) { - Image(systemName: "book.closed") - .font(.system(size: 11)) - Text(store.draftGenre) - .font(.system(size: 12, weight: .semibold)) - Image(systemName: "chevron.down") - .font(.system(size: 9)) - } - .foregroundColor(FableTheme.brandPrimary) + TextField("Genre", text: $store.draftGenre) + .font(.system(size: 13, weight: .medium)) + .textInputAutocapitalization(.words) .padding(.horizontal, 10) - .padding(.vertical, 5) + .padding(.vertical, 7) .background(FableTheme.surface) .clipShape(Capsule()) - } - - // Interactive Chapter Picker Menu + Menu { - ForEach(availableChapters, id: \.self) { chapter in - Button(chapter) { - store.draftChapter = chapter + ForEach(availableGenres, id: \.self) { genre in + Button(genre) { + store.draftGenre = genre } } } label: { - HStack(spacing: 4) { - Text(store.draftChapter) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(FableTheme.textSecondary) - Image(systemName: "chevron.down") - .font(.system(size: 9)) - .foregroundColor(FableTheme.textMuted) - } + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(FableTheme.brandPrimary) + .padding(8) + .background(FableTheme.surface) + .clipShape(Circle()) } - Spacer() } + + Divider() + + TextField("Chapter title (optional)", text: $store.draftChapter) + .font(.system(size: 13, weight: .medium)) + .textInputAutocapitalization(.sentences) + .foregroundColor(FableTheme.textSecondary) } .padding(18) .background(FableTheme.cardBackground) @@ -223,7 +218,7 @@ public struct WriteView: View { // Formatting Toolbar HStack(spacing: 8) { Button(action: { - store.draftManuscript += " **bold text** " + formattingRequest = ManuscriptFormattingRequest(style: .bold) }) { Text("B") .font(.system(size: 12, weight: .bold)) @@ -232,9 +227,10 @@ public struct WriteView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .foregroundColor(FableTheme.textPrimary) } + .accessibilityLabel("Insert bold formatting markers") Button(action: { - store.draftManuscript += " *italic text* " + formattingRequest = ManuscriptFormattingRequest(style: .italic) }) { Text("I") .font(.system(size: 12, weight: .semibold)) @@ -244,9 +240,10 @@ public struct WriteView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .foregroundColor(FableTheme.textPrimary) } + .accessibilityLabel("Insert italic formatting markers") Button(action: { - store.draftManuscript += "\n> \"A whisper in the dusk...\"\n" + formattingRequest = ManuscriptFormattingRequest(style: .quote) }) { Image(systemName: "quote.opening") .font(.system(size: 10)) @@ -255,9 +252,10 @@ public struct WriteView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .foregroundColor(FableTheme.textPrimary) } + .accessibilityLabel("Insert a block quote marker") Button(action: { - store.draftManuscript += "\n\n* * *\n\n" + formattingRequest = ManuscriptFormattingRequest(style: .sceneBreak) }) { Image(systemName: "divide") .font(.system(size: 11)) @@ -266,16 +264,16 @@ public struct WriteView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .foregroundColor(FableTheme.textPrimary) } + .accessibilityLabel("Insert a scene break") } } - TextEditor(text: $store.draftManuscript) - .frame(minHeight: 280) - .font(.system(size: 16, weight: .regular)) - .foregroundColor(FableTheme.textPrimary) - .lineSpacing(6) - .scrollContentBackground(.hidden) - .focused($isManuscriptFocused) + ManuscriptEditor( + text: $store.draftManuscript, + formattingRequest: $formattingRequest + ) + .frame(minHeight: 280) + .accessibilityIdentifier("manuscriptEditor") } .padding(18) .background(FableTheme.cardBackground) @@ -314,10 +312,10 @@ public struct WriteView: View { Divider().frame(height: 18) HStack(spacing: 4) { - Image(systemName: "icloud.and.arrow.up") + Image(systemName: "iphone") .font(.system(size: 11)) .foregroundColor(FableTheme.brandPrimary) - Text("Saved") + Text("Saved locally") .font(.system(size: 11, weight: .medium)) } .foregroundColor(FableTheme.textMuted) @@ -333,22 +331,33 @@ public struct WriteView: View { .padding(.bottom, 80) // above custom tab bar } .sheet(isPresented: $isShowingPublishSheet) { - StoryPublishedSheet( - onReturnToLibrary: { - store.selectedTab = .library - }, - onViewStory: { - if let first = store.stories.first { - publishedStoryToRead = first + if let publishedStory { + StoryPublishedSheet( + story: publishedStory, + onReturnToLibrary: { + store.selectedTab = .library + }, + onViewStory: { + selectedStoryToRead = publishedStory } - } - ) - .environmentObject(store) + ) + } } - .fullScreenCover(item: $publishedStoryToRead) { story in + .alert("Couldn't publish story", isPresented: Binding( + get: { store.publishErrorMessage != nil }, + set: { if !$0 { store.publishErrorMessage = nil } } + )) { + Button("OK", role: .cancel) { store.publishErrorMessage = nil } + } message: { + Text(store.publishErrorMessage ?? "") + } + .fullScreenCover(item: $selectedStoryToRead) { story in ReaderView(story: story) .environmentObject(store) } + .task(id: auth.currentSession?.id) { + store.restoreWriterDraftForCurrentSession() + } } } }