Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e0f62c2
chore: add backend Docker and CI setup
ur1el0 Sep 26, 2026
a141167
test: stabilize backend config verification
ur1el0 Sep 26, 2026
aa28516
docs: log backend deployment hardening
ur1el0 Sep 26, 2026
65243e5
feat: cache manga panels for offline reading
ur1el0 Sep 26, 2026
82c0976
docs: log offline manga cache
ur1el0 Sep 26, 2026
14655f1
feat: persist chapter reading progress
ur1el0 Sep 26, 2026
94741ab
test: cover chapter progress decoding
ur1el0 Sep 26, 2026
ef6f5c0
feat: add contextual reading haptics
ur1el0 Sep 26, 2026
351e8b0
docs: log chapter progress and haptics
ur1el0 Sep 26, 2026
6153efa
fix: filter legacy demo media from live catalog
ur1el0 Sep 26, 2026
26526c2
fix: prioritize fetched official cover images
ur1el0 Sep 26, 2026
3c865e3
docs: record live cover hardening
ur1el0 Sep 26, 2026
b1ac62f
feat(content): serve live Gutenberg chapters on demand
ur1el0 Sep 26, 2026
00c2b3e
feat(ios): fetch and cache live book chapters
ur1el0 Sep 26, 2026
2ca4e01
fix(backend): remove demo catalog and synthetic metrics
ur1el0 Sep 26, 2026
dd6b7bc
fix(ios): honor live provider statistics
ur1el0 Sep 26, 2026
deaf7ff
fix(ios): remove bundled demo catalog fallbacks
ur1el0 Sep 26, 2026
402d455
fix(ios): preserve live genre values
ur1el0 Sep 26, 2026
ab061a2
feat(backend): persist authenticated user profiles
ur1el0 Sep 26, 2026
9eec831
fix(backend): require ownership for published stories
ur1el0 Sep 26, 2026
f49ebf1
fix(backend): scope shelf sync to authenticated accounts
ur1el0 Sep 26, 2026
6b3ebfb
feat(backend): sync account reading statistics
ur1el0 Sep 26, 2026
31bd204
fix(auth): persist and revoke bearer sessions
ur1el0 Sep 26, 2026
9f03b75
feat(backend): resolve saved Gutenberg metadata by provider id
ur1el0 Sep 26, 2026
d905a36
feat(ios): scope account sync and offline state
ur1el0 Sep 26, 2026
51ca111
fix(ios): make reader and authoring controls actionable
ur1el0 Sep 26, 2026
9222368
docs: align live data and verification status
ur1el0 Sep 26, 2026
f2d583b
fix(backend): purge retired demo catalog on startup
ur1el0 Sep 26, 2026
f648663
fix(ios): cap disk image cache usage
ur1el0 Sep 26, 2026
2adc42e
docs: record catalog purge and cache limits
ur1el0 Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.git
.env
.env.*
!.env.example
data/
.venv/
venv/
.pytest_cache/
**/__pycache__/
frontend/
docs/
*.pdf
20 changes: 14 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 .
56 changes: 28 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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).

---

Expand Down Expand Up @@ -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.

---
Expand Down Expand Up @@ -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.

---

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
35 changes: 35 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
56 changes: 47 additions & 9 deletions backend/api/v1/endpoints/auth.py
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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 <token>'"
)
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)
14 changes: 12 additions & 2 deletions backend/api/v1/endpoints/gutenberg.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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)
17 changes: 11 additions & 6 deletions backend/api/v1/endpoints/shelf.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading