diff --git a/.env.example b/.env.example deleted file mode 100644 index 5e949d0..0000000 --- a/.env.example +++ /dev/null @@ -1,101 +0,0 @@ -# libnovel scraper — environment overrides -# Copy to .env and adjust values; do NOT commit this file with real secrets. - -# ── Docker BuildKit ─────────────────────────────────────────────────────────── -# Required for the backend/Dockerfile cache mounts (--mount=type=cache). -# BuildKit is the default in Docker Engine 23+, but Colima users may need this. -# -# If you see: "the --mount option requires BuildKit", enable it one of two ways: -# -# Option A — per-project (recommended, zero restart needed): -# Uncomment the line below and copy this file to .env. -# Docker Compose reads .env automatically, so BuildKit will be active for -# every `docker compose build` / `docker compose up --build` in this project. -# -# Option B — system-wide for Colima (persists across restarts): -# echo '{"features":{"buildkit":true}}' > ~/.colima/default/daemon.json -# colima stop && colima start -# -# DOCKER_BUILDKIT=1 - -# ── Service ports (host-side) ───────────────────────────────────────────────── -# Port the scraper HTTP API listens on (default 8080) -SCRAPER_PORT=8080 - -# Port PocketBase listens on (default 8090) -POCKETBASE_PORT=8090 - -# Port MinIO S3 API listens on (default 9000) -MINIO_PORT=9000 - -# Port MinIO web console listens on (default 9001) -MINIO_CONSOLE_PORT=9001 - -# Port Browserless Chrome listens on (default 3030) -BROWSERLESS_PORT=3030 - -# Port the SvelteKit UI listens on (default 3000) -UI_PORT=3000 - -# ── Browserless ─────────────────────────────────────────────────────────────── -# Browserless API token (leave empty to disable auth) -BROWSERLESS_TOKEN= - -# Number of concurrent browser sessions in Browserless -BROWSERLESS_CONCURRENT=10 - -# Queue depth before Browserless returns 429 -BROWSERLESS_QUEUED=100 - -# Per-session timeout in ms -BROWSERLESS_TIMEOUT=60000 - -# Optional webhook URL for Browserless error alerts (leave empty to disable) -ERROR_ALERT_URL= - -# Which Browserless strategy the scraper uses: content | scrape | cdp | direct -BROWSERLESS_STRATEGY=direct - -# ── Scraper ─────────────────────────────────────────────────────────────────── -# Chapter worker goroutines (0 = NumCPU inside the container) -SCRAPER_WORKERS=0 - -# Host path to mount as the static output directory -STATIC_ROOT=./static/books - -# ── Kokoro-FastAPI TTS ──────────────────────────────────────────────────────── -# Base URL for the Kokoro-FastAPI service. When running via docker-compose the -# default (http://kokoro:8880) is wired in automatically; override here only if -# you are pointing at an external or GPU instance. -KOKORO_URL=http://kokoro:8880 - -# Default voice used for chapter narration. -# Single voices: af_bella, af_sky, af_heart, am_adam, … -# Mixed voices: af_bella+af_sky or af_bella(2)+af_sky(1) (weighted blend) -KOKORO_VOICE=af_bella - -# ── MinIO / S3 object storage ───────────────────────────────────────────────── -MINIO_ROOT_USER=admin -MINIO_ROOT_PASSWORD=changeme123 -MINIO_BUCKET_CHAPTERS=libnovel-chapters -MINIO_BUCKET_AUDIO=libnovel-audio -MINIO_BUCKET_BROWSE=libnovel-browse - -# ── PocketBase ──────────────────────────────────────────────────────────────── -# Admin credentials (used by scraper + UI server-side) -POCKETBASE_ADMIN_EMAIL=admin@libnovel.local -POCKETBASE_ADMIN_PASSWORD=changeme123 - -# ── SvelteKit UI ───────────────────────────────────────────────────────────── -# Internal URL the SvelteKit server uses to reach the scraper API. -# In docker-compose this is http://scraper:8080 (wired automatically). -# Override here only if running the UI outside of docker-compose. -SCRAPER_API_URL=http://localhost:8080 - -# Internal URL the SvelteKit server uses to reach PocketBase. -# In docker-compose this is http://pocketbase:8090 (wired automatically). -POCKETBASE_URL=http://localhost:8090 - -# Public MinIO URL reachable from the browser (for audio/presigned URLs). -# In production, point this at your MinIO reverse-proxy or CDN domain. -PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000 diff --git a/.gitea/workflows/ci-scraper.yaml b/.gitea/workflows/ci-scraper.yaml deleted file mode 100644 index c64a042..0000000 --- a/.gitea/workflows/ci-scraper.yaml +++ /dev/null @@ -1,79 +0,0 @@ -name: CI / Scraper - -on: - push: - branches: ["main", "master", "v2"] - paths: - - "scraper/**" - - ".gitea/workflows/ci-scraper.yaml" - pull_request: - branches: ["main", "master", "v2"] - paths: - - "scraper/**" - - ".gitea/workflows/ci-scraper.yaml" - -concurrency: - group: ${{ gitea.workflow }}-${{ gitea.ref }} - cancel-in-progress: true - -jobs: - # ── lint & vet ─────────────────────────────────────────────────────────────── - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: scraper/go.mod - cache-dependency-path: scraper/go.sum - - - name: go vet - working-directory: scraper - run: | - go vet ./... - go vet -tags integration ./... - - # ── tests ──────────────────────────────────────────────────────────────────── - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: scraper/go.mod - cache-dependency-path: scraper/go.sum - - - name: Run tests - working-directory: scraper - run: go test -short -race -count=1 -timeout=60s ./... - - # ── push to Docker Hub ─────────────────────────────────────────────────────── - docker: - name: Docker Push - runs-on: ubuntu-latest - needs: [lint, test] - if: gitea.event_name == 'push' - steps: - - uses: actions/checkout@v4 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: scraper - push: true - tags: | - ${{ secrets.DOCKER_USER }}/libnovel-scraper:latest - ${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }} - build-args: | - VERSION=${{ gitea.sha }} - COMMIT=${{ gitea.sha }} diff --git a/.gitea/workflows/ci-ui.yaml b/.gitea/workflows/ci-ui.yaml deleted file mode 100644 index 3339885..0000000 --- a/.gitea/workflows/ci-ui.yaml +++ /dev/null @@ -1,70 +0,0 @@ -name: CI / UI - -on: - push: - branches: ["main", "master", "v2"] - paths: - - "ui/**" - - ".gitea/workflows/ci-ui.yaml" - pull_request: - branches: ["main", "master", "v2"] - paths: - - "ui/**" - - ".gitea/workflows/ci-ui.yaml" - -concurrency: - group: ${{ gitea.workflow }}-${{ gitea.ref }} - cancel-in-progress: true - -jobs: - # ── type-check & build ─────────────────────────────────────────────────────── - build: - name: Build - runs-on: ubuntu-latest - defaults: - run: - working-directory: ui - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Type check - run: npm run check - - - name: Build - run: npm run build - - # ── push to Docker Hub ─────────────────────────────────────────────────────── - docker: - name: Docker Push - runs-on: ubuntu-latest - needs: build - if: gitea.event_name == 'push' - steps: - - uses: actions/checkout@v4 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: ui - push: true - tags: | - ${{ secrets.DOCKER_USER }}/libnovel-ui:latest - ${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }} - build-args: | - BUILD_VERSION=${{ gitea.sha }} - BUILD_COMMIT=${{ gitea.sha }} diff --git a/.gitea/workflows/ios.yaml b/.gitea/workflows/ios.yaml deleted file mode 100644 index 6d2d21d..0000000 --- a/.gitea/workflows/ios.yaml +++ /dev/null @@ -1,63 +0,0 @@ -name: iOS CI - -on: - push: - branches: ["v2", "main"] - paths: - - "ios/**" - - "justfile" - - ".gitea/workflows/ios.yaml" - - ".gitea/workflows/ios-release.yaml" - pull_request: - branches: ["v2", "main"] - paths: - - "ios/**" - - "justfile" - - ".gitea/workflows/ios.yaml" - - ".gitea/workflows/ios-release.yaml" - -concurrency: - group: ios-macos-runner - cancel-in-progress: true - -jobs: - # ── build (simulator) ───────────────────────────────────────────────────── - build: - name: Build - runs-on: macos-latest - - steps: - - uses: actions/checkout@v4 - - - name: Install just - run: command -v just || brew install just - - - name: Build (simulator) - env: - USER: runner - run: just ios-build - - # ── unit tests ──────────────────────────────────────────────────────────── - test: - name: Test - runs-on: macos-latest - needs: build - - steps: - - uses: actions/checkout@v4 - - - name: Install just - run: command -v just || brew install just - - - name: Run unit tests - env: - USER: runner - run: just ios-test - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results - path: ios/LibNovel/test-results.xml - retention-days: 7 diff --git a/.gitea/workflows/release-scraper.yaml b/.gitea/workflows/release-scraper.yaml deleted file mode 100644 index 666914d..0000000 --- a/.gitea/workflows/release-scraper.yaml +++ /dev/null @@ -1,68 +0,0 @@ -name: Release / Scraper - -on: - push: - tags: - - "v*" - -concurrency: - group: ${{ gitea.workflow }}-${{ gitea.ref }} - cancel-in-progress: true - -jobs: - # ── lint & test ────────────────────────────────────────────────────────────── - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: scraper/go.mod - cache-dependency-path: scraper/go.sum - - - name: go vet - working-directory: scraper - run: | - go vet ./... - go vet -tags integration ./... - - - name: Run tests - working-directory: scraper - run: go test -short -race -count=1 -timeout=60s ./... - - # ── docker build & push ────────────────────────────────────────────────────── - docker: - name: Docker - runs-on: ubuntu-latest - needs: [test] - steps: - - uses: actions/checkout@v4 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKER_USER }}/libnovel-scraper - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: scraper - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - VERSION=${{ steps.meta.outputs.version }} - COMMIT=${{ gitea.sha }} diff --git a/.gitea/workflows/release-ui.yaml b/.gitea/workflows/release-ui.yaml deleted file mode 100644 index 4e535d0..0000000 --- a/.gitea/workflows/release-ui.yaml +++ /dev/null @@ -1,71 +0,0 @@ -name: Release / UI - -on: - push: - tags: - - "v*" - -concurrency: - group: ${{ gitea.workflow }}-${{ gitea.ref }} - cancel-in-progress: true - -jobs: - # ── type-check & build ─────────────────────────────────────────────────────── - build: - name: Build - runs-on: ubuntu-latest - defaults: - run: - working-directory: ui - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Type check - run: npm run check - - - name: Build - run: npm run build - - # ── docker build & push ────────────────────────────────────────────────────── - docker: - name: Docker - runs-on: ubuntu-latest - needs: [build] - steps: - - uses: actions/checkout@v4 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKER_USER }}/libnovel-ui - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: ui - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - BUILD_VERSION=${{ steps.meta.outputs.version }} - BUILD_COMMIT=${{ gitea.sha }} diff --git a/.gitea/workflows/release-v2.yaml b/.gitea/workflows/release-v2.yaml deleted file mode 100644 index b11a450..0000000 --- a/.gitea/workflows/release-v2.yaml +++ /dev/null @@ -1,163 +0,0 @@ -name: Release / v2 - -on: - push: - tags: - - "v*" - -concurrency: - group: ${{ gitea.workflow }}-${{ gitea.ref }} - cancel-in-progress: true - -jobs: - # ── backend: lint & test ───────────────────────────────────────────────────── - test-backend: - name: Test backend - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: backend/go.mod - cache-dependency-path: backend/go.sum - - - name: go vet - working-directory: backend - run: go vet ./... - - - name: Run tests - working-directory: backend - run: go test -short -race -count=1 -timeout=60s ./... - - # ── ui-v2: type-check & build ──────────────────────────────────────────────── - build-ui: - name: Build ui-v2 - runs-on: ubuntu-latest - defaults: - run: - working-directory: ui-v2 - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui-v2/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Type check - run: npm run check - - - name: Build - run: npm run build - - # ── docker: backend ────────────────────────────────────────────────────────── - docker-backend: - name: Docker / backend - runs-on: ubuntu-latest - needs: [test-backend] - steps: - - uses: actions/checkout@v4 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKER_USER }}/libnovel-backend - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: backend - target: backend - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - VERSION=${{ steps.meta.outputs.version }} - COMMIT=${{ gitea.sha }} - - # ── docker: runner ─────────────────────────────────────────────────────────── - docker-runner: - name: Docker / runner - runs-on: ubuntu-latest - needs: [test-backend] - steps: - - uses: actions/checkout@v4 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKER_USER }}/libnovel-runner - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: backend - target: runner - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - VERSION=${{ steps.meta.outputs.version }} - COMMIT=${{ gitea.sha }} - - # ── docker: ui-v2 ──────────────────────────────────────────────────────────── - docker-ui: - name: Docker / ui-v2 - runs-on: ubuntu-latest - needs: [build-ui] - steps: - - uses: actions/checkout@v4 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKER_USER }}/libnovel-ui-v2 - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: ui-v2 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - BUILD_VERSION=${{ steps.meta.outputs.version }} - BUILD_COMMIT=${{ gitea.sha }} diff --git a/.gitignore b/.gitignore index 02102a6..e09cc6b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,16 +5,16 @@ /dist/ # ── Compiled binaries ────────────────────────────────────────────────────────── -scraper/bin/ -scraper/scraper +backend/bin/ -# ── Scraped output (large, machine-generated) ────────────────────────────────── - -/static/books # ── Environment & secrets ────────────────────────────────────────────────────── +# Secrets are managed by Doppler — never commit .env files. .env .env.* -!.env.example +.env.local + +# ── CrowdSec — generated bouncer API key ────────────────────────────────────── +crowdsec/.crowdsec.env # ── OS artefacts ─────────────────────────────────────────────────────────────── .DS_Store diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 55c814a..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,182 +0,0 @@ -# libnovel Project - -Go web scraper for novelfire.net with TTS support via Kokoro-FastAPI. Structured data in PocketBase, binary blobs (chapters, audio, browse snapshots) in MinIO. SvelteKit frontend. - -## Architecture - -``` -scraper/ -├── cmd/scraper/main.go # Entry point: run | refresh | serve | save-browse -├── internal/ -│ ├── orchestrator/orchestrator.go # Catalogue walk → per-book metadata goroutines → chapter worker pool -│ ├── browser/ # BrowserClient interface + direct HTTP (production) + Browserless variants -│ ├── novelfire/scraper.go # novelfire.net scraping (catalogue, metadata, chapters, ranking) -│ ├── server/ # HTTP API server (server.go + 6 handler files) -│ │ ├── server.go # Server struct, route registration, ListenAndServe -│ │ ├── handlers_scrape.go # POST /scrape, /scrape/book, /scrape/book/range; job status/tasks -│ │ ├── handlers_browse.go # GET /api/browse, /api/search, /api/cover — MinIO-cached browse pages -│ │ ├── handlers_preview.go # GET /api/book-preview, /api/chapter-text-preview — live scrape, no store writes -│ │ ├── handlers_audio.go # POST /api/audio, GET /api/audio-proxy, voice samples, presign -│ │ ├── handlers_progress.go # GET/POST/DELETE /api/progress -│ │ ├── handlers_ranking.go # GET /api/ranking, /api/cover -│ │ └── helpers.go # stripMarkdown, hardcoded voice list fallback -│ ├── storage/ # Persistence layer (PocketBase + MinIO) -│ │ ├── store.go # Store interface — single abstraction for server + orchestrator -│ │ ├── hybrid.go # HybridStore: routes structured data → PocketBase, blobs → MinIO -│ │ ├── pocketbase.go # PocketBase REST admin client (7 collections, auth, schema bootstrap) -│ │ ├── minio.go # MinIO client (3 buckets: chapters, audio, browse) -│ │ └── coverutil.go # Best-effort cover image downloader → browse bucket -│ └── scraper/ -│ ├── interfaces.go # NovelScraper interface + domain types (BookMeta, ChapterRef, etc.) -│ └── htmlutil/htmlutil.go # HTML parsing helpers (NodeToMarkdown, ResolveURL, etc.) -``` - -## Key Concepts - -- **Orchestrator**: Catalogue stream → per-book goroutines (metadata + chapter list) → shared chapter work channel → N worker goroutines (chapter text). Scrape jobs tracked in PocketBase `scraping_tasks`. -- **Storage**: `HybridStore` implements the `Store` interface. PocketBase holds structured records (`books`, `chapters_idx`, `ranking`, `progress`, `audio_cache`, `app_users`, `scraping_tasks`). MinIO holds blobs (chapter markdown, audio MP3s, browse HTML snapshots, cover images). -- **Browser Client**: Production uses `NewDirectHTTPClient` (plain HTTP, no Browserless). Browserless variants (content/scrape/cdp) exist in `browser/` but are only wired for the `save-browse` subcommand. -- **Preview**: `GET /api/book-preview/{slug}` scrapes metadata + chapter list live without persisting anything — used when a book is not yet in the library. On first visit, metadata and chapter index are auto-saved to PocketBase in the background. -- **Server**: 24 HTTP endpoints. Async scrape jobs (mutex, 409 on concurrent), in-flight dedup for audio generation, MinIO-backed browse page cache with mem-cache fallback. - -## Commands - -```bash -# Build -cd scraper && go build -o bin/scraper ./cmd/scraper - -# Full catalogue scrape (one-shot) -./bin/scraper run - -# Single book -./bin/scraper run --url https://novelfire.net/book/xxx - -# Re-scrape a book already in the DB (uses stored source_url) -./bin/scraper refresh - -# HTTP server -./bin/scraper serve - -# Capture browse pages to MinIO via SingleFile CLI (requires SINGLEFILE_PATH + BROWSERLESS_URL) -./bin/scraper save-browse - -# Tests (unit only — integration tests require live services) -cd scraper && go test ./... -short - -# All tests (requires MinIO + PocketBase + Browserless) -cd scraper && go test ./... -``` - -## Environment Variables - -### Scraper (Go) - -| Variable | Description | Default | -|----------|-------------|---------| -| `LOG_LEVEL` | `debug\|info\|warn\|error` | `info` | -| `SCRAPER_HTTP_ADDR` | HTTP listen address | `:8080` | -| `SCRAPER_WORKERS` | Chapter goroutines | `NumCPU` | -| `SCRAPER_TIMEOUT` | Per-request HTTP timeout (seconds) | `90` | -| `KOKORO_URL` | Kokoro-FastAPI TTS base URL | `https://kokoro.kalekber.cc` | -| `KOKORO_VOICE` | Default TTS voice | `af_bella` | -| `MINIO_ENDPOINT` | MinIO S3 API host:port | `localhost:9000` | -| `MINIO_PUBLIC_ENDPOINT` | Public MinIO endpoint for presigned URLs | `""` | -| `MINIO_ACCESS_KEY` | MinIO access key | `admin` | -| `MINIO_SECRET_KEY` | MinIO secret key | `changeme123` | -| `MINIO_USE_SSL` | TLS for internal MinIO connection | `false` | -| `MINIO_PUBLIC_USE_SSL` | TLS for public presigned URL endpoint | `true` | -| `MINIO_BUCKET_CHAPTERS` | Chapter markdown bucket | `libnovel-chapters` | -| `MINIO_BUCKET_AUDIO` | Audio MP3 bucket | `libnovel-audio` | -| `MINIO_BUCKET_BROWSE` | Browse HTML + cover image bucket | `libnovel-browse` | -| `POCKETBASE_URL` | PocketBase base URL | `http://localhost:8090` | -| `POCKETBASE_ADMIN_EMAIL` | PocketBase admin email | `admin@libnovel.local` | -| `POCKETBASE_ADMIN_PASSWORD` | PocketBase admin password | `changeme123` | -| `BROWSERLESS_URL` | Browserless WS endpoint (save-browse only) | `http://localhost:3030` | -| `SINGLEFILE_PATH` | SingleFile CLI binary path (save-browse only) | `single-file` | - -### UI (SvelteKit) - -| Variable | Description | Default | -|----------|-------------|---------| -| `AUTH_SECRET` | HMAC signing secret for auth tokens | `dev_secret_change_in_production` | -| `SCRAPER_API_URL` | Internal URL of the Go scraper | `http://localhost:8080` | -| `POCKETBASE_URL` | PocketBase base URL | `http://localhost:8090` | -| `POCKETBASE_ADMIN_EMAIL` | PocketBase admin email | `admin@libnovel.local` | -| `POCKETBASE_ADMIN_PASSWORD` | PocketBase admin password | `changeme123` | -| `PUBLIC_MINIO_PUBLIC_URL` | Browser-visible MinIO URL (presigned links) | `http://localhost:9000` | - -## Docker - -```bash -docker-compose up -d # Starts: minio, minio-init, pocketbase, pb-init, scraper, ui -``` - -Services: - -| Service | Port(s) | Role | -|---------|---------|------| -| `minio` | `9000` (S3 API), `9001` (console) | Object storage | -| `minio-init` | — | One-shot bucket creation then exits | -| `pocketbase` | `8090` | Structured data store | -| `pb-init` | — | One-shot PocketBase collection bootstrap then exits | -| `scraper` | `8080` | Go scraper HTTP API | -| `ui` | `5252` → internal `3000` | SvelteKit frontend | - -Kokoro and Browserless are **external services** — not in docker-compose. - -## HTTP API Endpoints (Go scraper) - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/health` | Liveness probe | -| `POST` | `/scrape` | Enqueue full catalogue scrape | -| `POST` | `/scrape/book` | Enqueue single-book scrape `{url}` | -| `POST` | `/scrape/book/range` | Enqueue range scrape `{url, from, to?}` | -| `GET` | `/api/scrape/status` | Current scrape job status | -| `GET` | `/api/scrape/tasks` | All scrape task records | -| `GET` | `/api/browse` | Browse novelfire catalogue (MinIO-cached) | -| `GET` | `/api/search` | Search local + remote `?q=` | -| `GET` | `/api/ranking` | Ranking list | -| `GET` | `/api/cover/{domain}/{slug}` | Proxy cover image from MinIO | -| `GET` | `/api/book-preview/{slug}` | Live metadata + chapter list (no store write) | -| `GET` | `/api/chapter-text-preview/{slug}/{n}` | Live chapter text (no store write) | -| `POST` | `/api/reindex/{slug}` | Rebuild chapters_idx from MinIO | -| `GET` | `/api/chapter-text/{slug}/{n}` | Chapter text (markdown stripped) | -| `POST` | `/api/audio/{slug}/{n}` | Trigger Kokoro TTS generation | -| `GET` | `/api/audio-proxy/{slug}/{n}` | Proxy generated audio | -| `POST` | `/api/audio/voice-samples` | Pre-generate voice samples | -| `GET` | `/api/voices` | List available Kokoro voices | -| `GET` | `/api/presign/chapter/{slug}/{n}` | Presigned MinIO URL for chapter | -| `GET` | `/api/presign/audio/{slug}/{n}` | Presigned MinIO URL for audio | -| `GET` | `/api/presign/voice-sample/{voice}` | Presigned MinIO URL for voice sample | -| `GET` | `/api/progress` | Get reading progress (session-scoped) | -| `POST` | `/api/progress/{slug}` | Set reading progress | -| `DELETE` | `/api/progress/{slug}` | Delete reading progress | - -## Code Patterns - -- `log/slog` for structured logging throughout -- Context-based cancellation on all network calls and goroutines -- Worker pool pattern in orchestrator (buffered channel + WaitGroup) -- Single async scrape job enforced by mutex; 409 on concurrent requests; job state persisted to `scraping_tasks` in PocketBase -- `Store` interface decouples all persistence — pass it around, never touch MinIO/PocketBase clients directly outside `storage/` -- Auth: custom HMAC-signed token (`userId:username:role.`) in `libnovel_auth` cookie; signed with `AUTH_SECRET` - -## AI Context Tips - -- **Primary files to modify**: `orchestrator.go`, `server/handlers_*.go`, `novelfire/scraper.go`, `storage/hybrid.go`, `storage/pocketbase.go` -- **To add a new scrape source**: implement `NovelScraper` from `internal/scraper/interfaces.go` -- **To add a new API endpoint**: add handler in the appropriate `handlers_*.go` file, register in `server.go` `ListenAndServe()` -- **Storage changes**: update `Store` interface in `store.go`, implement on `HybridStore` (hybrid.go) and `PocketBaseStore`/`MinioClient` as needed; update mock in `orchestrator_test.go` -- **Skip**: `scraper/bin/` (compiled binary), MinIO/PocketBase data volumes - -## iOS App - -See `ios/AGENTS.md` for full iOS/SwiftUI conventions. - -## Documentation Tools - -This project has two MCP-backed documentation tools available. Use them proactively: - -- **`context7`** — Live Apple SwiftUI/Swift docs, Go stdlib, SvelteKit, and any other library docs. Use before implementing anything non-trivial in Swift/SwiftUI. Example: `use context7 to look up NavigationStack`. -- **`gh_grep`** — Search real-world code on GitHub for implementation patterns. Example: `use gh_grep to find examples of background URLSession in Swift`. diff --git a/v3/Caddyfile b/Caddyfile similarity index 100% rename from v3/Caddyfile rename to Caddyfile diff --git a/README.md b/README.md new file mode 100644 index 0000000..47563c7 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# LibNovel + +Self-hosted audiobook platform. Go backend + SvelteKit UI + MinIO/PocketBase/Meilisearch. + +## Requirements + +- Docker + Docker Compose +- [just](https://github.com/casey/just) +- [Doppler CLI](https://docs.doppler.com/docs/install-cli) + +## Setup + +```sh +doppler login +doppler setup # project=libnovel, config=prd +``` + +## Usage + +```sh +just up # start everything +just down # stop +just logs # tail all logs +just log backend # tail one service +just build # rebuild images +just restart # down + up +just secrets # view/edit secrets +``` + +## Secrets + +Managed via Doppler (`project=libnovel`, `config=prd`). No `.env` files. + +To add or update a secret: + +```sh +doppler secrets set MY_SECRET=value +``` diff --git a/backend/bin/runner b/backend/bin/runner deleted file mode 100755 index 6de49f4..0000000 Binary files a/backend/bin/runner and /dev/null differ diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 678c0c3..4643122 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -2,7 +2,7 @@ // // It exposes all endpoints consumed by the SvelteKit UI: book/chapter reads, // scrape-task creation, presigned MinIO URLs, audio-task creation, reading -// progress, live novelfire.net browse/search, and Kokoro voice list. +// progress, live novelfire.net search, and Kokoro voice list. // // All heavy lifting (scraping, TTS generation) is delegated to the runner // binary via PocketBase task records. The backend never scrapes directly. @@ -19,10 +19,13 @@ import ( "os" "os/signal" "syscall" + "time" + "github.com/getsentry/sentry-go" "github.com/libnovel/backend/internal/backend" "github.com/libnovel/backend/internal/config" "github.com/libnovel/backend/internal/kokoro" + "github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/storage" ) @@ -42,6 +45,19 @@ func main() { func run() error { cfg := config.Load() + // ── Sentry / GlitchTip error tracking ──────────────────────────────────── + if dsn := os.Getenv("GLITCHTIP_DSN"); dsn != "" { + if err := sentry.Init(sentry.ClientOptions{ + Dsn: dsn, + Release: version + "@" + commit, + TracesSampleRate: 0.1, + }); err != nil { + fmt.Fprintf(os.Stderr, "backend: sentry init warning: %v\n", err) + } else { + defer sentry.Flush(2 * time.Second) + } + } + // ── Logger ─────────────────────────────────────────────────────────────── log := buildLogger(cfg.LogLevel) log.Info("backend starting", @@ -70,6 +86,16 @@ func run() error { kokoroClient = &noopKokoro{} } + // ── Meilisearch (search reads only; indexing is the runner's job) ──────── + var searchIndex meili.Client + if cfg.Meilisearch.URL != "" { + searchIndex = meili.New(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey) + log.Info("meilisearch search enabled", "url", cfg.Meilisearch.URL) + } else { + log.Info("MEILI_URL not set — search will use PocketBase substring fallback") + searchIndex = meili.NoopClient{} + } + // ── Backend server ─────────────────────────────────────────────────────── srv := backend.New( backend.Config{ @@ -84,9 +110,10 @@ func run() error { AudioStore: store, PresignStore: store, ProgressStore: store, - BrowseStore: store, + CoverStore: store, Producer: store, TaskReader: store, + SearchIndex: searchIndex, Kokoro: kokoroClient, Log: log, }, diff --git a/backend/cmd/runner/main.go b/backend/cmd/runner/main.go index 923bbb8..45ebef4 100644 --- a/backend/cmd/runner/main.go +++ b/backend/cmd/runner/main.go @@ -19,9 +19,11 @@ import ( "syscall" "time" + "github.com/getsentry/sentry-go" "github.com/libnovel/backend/internal/browser" "github.com/libnovel/backend/internal/config" "github.com/libnovel/backend/internal/kokoro" + "github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/novelfire" "github.com/libnovel/backend/internal/runner" "github.com/libnovel/backend/internal/storage" @@ -43,6 +45,19 @@ func main() { func run() error { cfg := config.Load() + // ── Sentry / GlitchTip error tracking ──────────────────────────────────── + if dsn := os.Getenv("GLITCHTIP_DSN"); dsn != "" { + if err := sentry.Init(sentry.ClientOptions{ + Dsn: dsn, + Release: version + "@" + commit, + TracesSampleRate: 0.1, + }); err != nil { + fmt.Fprintf(os.Stderr, "runner: sentry init warning: %v\n", err) + } else { + defer sentry.Flush(2 * time.Second) + } + } + // ── Logger ────────────────────────────────────────────────────────────── log := buildLogger(cfg.LogLevel) log.Info("runner starting", @@ -74,7 +89,6 @@ func run() error { browserClient := browser.NewDirectClient(browser.Config{ MaxConcurrent: workers, Timeout: timeout, - ProxyURL: cfg.Runner.ProxyURL, }) novel := novelfire.New(browserClient, log) @@ -88,20 +102,39 @@ func run() error { kokoroClient = &noopKokoro{} } + // ── Meilisearch ───────────────────────────────────────────────────────── + var searchIndex meili.Client + if cfg.Meilisearch.URL != "" { + if err := meili.Configure(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey); err != nil { + log.Warn("meilisearch configure failed — search indexing disabled", "err", err) + searchIndex = meili.NoopClient{} + } else { + searchIndex = meili.New(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey) + log.Info("meilisearch enabled", "url", cfg.Meilisearch.URL) + } + } else { + log.Info("MEILI_URL not set — search indexing disabled") + searchIndex = meili.NoopClient{} + } + // ── Runner ────────────────────────────────────────────────────────────── rCfg := runner.Config{ - WorkerID: cfg.Runner.WorkerID, - PollInterval: cfg.Runner.PollInterval, - MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape, - MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio, - OrchestratorWorkers: workers, + WorkerID: cfg.Runner.WorkerID, + PollInterval: cfg.Runner.PollInterval, + MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape, + MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio, + OrchestratorWorkers: workers, + MetricsAddr: cfg.Runner.MetricsAddr, + CatalogueRefreshInterval: cfg.Runner.CatalogueRefreshInterval, + SkipInitialCatalogueRefresh: cfg.Runner.SkipInitialCatalogueRefresh, } deps := runner.Dependencies{ Consumer: store, BookWriter: store, BookReader: store, AudioStore: store, - BrowseStore: store, + CoverStore: store, + SearchIndex: searchIndex, Novel: novel, Kokoro: kokoroClient, Log: log, diff --git a/backend/go.mod b/backend/go.mod index c3f7f52..f108996 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,19 +8,27 @@ require ( ) require ( + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/getsentry/sentry-go v0.43.0 // indirect github.com/go-ini/ini v1.67.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/klauspost/crc32 v1.3.0 // indirect + github.com/meilisearch/meilisearch-go v0.36.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/tinylib/msgp v1.6.1 // indirect + go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index f4750f9..6026879 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,9 +1,19 @@ +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= +github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= @@ -13,6 +23,8 @@ github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4O github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M= +github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -23,12 +35,18 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= @@ -41,5 +59,6 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/healthcheck b/backend/healthcheck deleted file mode 100755 index 9d0e8ec..0000000 Binary files a/backend/healthcheck and /dev/null differ diff --git a/backend/internal/backend/handlers.go b/backend/internal/backend/handlers.go index 6292c1e..5c8a4d2 100644 --- a/backend/internal/backend/handlers.go +++ b/backend/internal/backend/handlers.go @@ -7,8 +7,7 @@ package backend // handleScrapeStatus, handleScrapeTasks // handleBrowse, handleSearch // handleGetRanking, handleGetCover -// handleBookPreview, handleChapterText, handleReindex -// handleChapterText, handleReindex +// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex // handleAudioGenerate, handleAudioStatus, handleAudioProxy // handleVoices // handlePresignChapter, handlePresignAudio, handlePresignVoiceSample @@ -29,6 +28,8 @@ package backend // by the runner after each catalogue scrape). // - GET /api/book-preview returns stored data when in library, or enqueues a // scrape task and returns 202 when not. The backend never scrapes directly. +// - GET /api/chapter-text-preview scrapes a chapter live from novelfire.net +// directly (no runner task, no store writes). Used for unscraped books. import ( "context" @@ -44,6 +45,9 @@ import ( "github.com/libnovel/backend/internal/domain" "github.com/libnovel/backend/internal/kokoro" + "github.com/libnovel/backend/internal/meili" + "github.com/libnovel/backend/internal/novelfire/htmlutil" + "github.com/libnovel/backend/internal/scraper" ) const ( @@ -172,82 +176,11 @@ type NovelListing struct { URL string `json:"url"` } -// handleBrowse handles GET /api/browse. -// Fetches novelfire.net live (no MinIO cache in the new backend). -// Query params: page (default 1), genre (default "all"), sort (default "popular"), -// status (default "all"), type (default "all-novel") -func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - page := q.Get("page") - if page == "" { - page = "1" - } - genre := q.Get("genre") - if genre == "" { - genre = "all" - } - sortBy := q.Get("sort") - if sortBy == "" { - sortBy = "popular" - } - status := q.Get("status") - if status == "" { - status = "all" - } - novelType := q.Get("type") - if novelType == "" { - novelType = "all-novel" - } - - pageNum, _ := strconv.Atoi(page) - if pageNum <= 0 { - pageNum = 1 - } - - // ── Try MinIO cache first ───────────────────────────────────────────── - // Only page 1 is cached; higher pages fall through to live fetch. - if pageNum == 1 && s.deps.BrowseStore != nil { - if data, ok, err := s.deps.BrowseStore.GetBrowsePage(r.Context(), genre, sortBy, status, novelType, 1); err == nil && ok { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "public, max-age=300") - _, _ = w.Write(data) - return - } - } - - // ── Fall back to live novelfire.net fetch ────────────────────────────── - ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) - defer cancel() - - targetURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%d", - novelFireBase, genre, sortBy, status, novelType, pageNum) - - novels, hasNext, err := s.fetchBrowsePage(ctx, targetURL) - if err != nil { - // Live fetch also failed — return empty list with cached=false flag so - // the UI can show a "not ready yet" state instead of a hard error. - s.deps.Log.Error("handleBrowse: fetch failed (no cache)", "url", targetURL, "err", err) - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, 0, map[string]any{ - "novels": []any{}, - "page": pageNum, - "hasNext": false, - "cached": false, - }) - return - } - - w.Header().Set("Cache-Control", "public, max-age=300") - writeJSON(w, 0, map[string]any{ - "novels": novels, - "page": pageNum, - "hasNext": hasNext, - "cached": false, - }) -} - // handleSearch handles GET /api/search. // Query params: q (min 2 chars), source ("local"|"remote"|"all", default "all") +// +// Local search is powered by Meilisearch when configured; falls back to a +// substring match against PocketBase book records otherwise. func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") if len([]rune(q)) < 2 { @@ -265,22 +198,35 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { var localResults, remoteResults []NovelListing - // Local search (PocketBase books) + // Local search: Meilisearch → PocketBase substring fallback if source == "local" || source == "all" { - books, err := s.deps.BookReader.ListBooks(ctx) - if err != nil { - s.deps.Log.Warn("search: ListBooks failed", "err", err) + meiliBooks, meiliErr := s.deps.SearchIndex.Search(ctx, q, 50) + if meiliErr == nil && len(meiliBooks) > 0 { + for _, b := range meiliBooks { + localResults = append(localResults, NovelListing{ + Slug: b.Slug, + Title: b.Title, + Cover: b.Cover, + URL: b.SourceURL, + }) + } } else { - qLower := strings.ToLower(q) - for _, b := range books { - if strings.Contains(strings.ToLower(b.Title), qLower) || - strings.Contains(strings.ToLower(b.Author), qLower) { - localResults = append(localResults, NovelListing{ - Slug: b.Slug, - Title: b.Title, - Cover: b.Cover, - URL: b.SourceURL, - }) + // Fallback: substring match against PocketBase + books, err := s.deps.BookReader.ListBooks(ctx) + if err != nil { + s.deps.Log.Warn("search: ListBooks failed", "err", err) + } else { + qLower := strings.ToLower(q) + for _, b := range books { + if strings.Contains(strings.ToLower(b.Title), qLower) || + strings.Contains(strings.ToLower(b.Author), qLower) { + localResults = append(localResults, NovelListing{ + Slug: b.Slug, + Title: b.Title, + Cover: b.Cover, + URL: b.SourceURL, + }) + } } } } @@ -341,18 +287,34 @@ func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) { } // handleGetCover handles GET /api/cover/{domain}/{slug}. -// The new backend does not cache covers in MinIO. Instead it redirects the -// client to the novelfire.net source URL. The domain path segment is kept for -// API compatibility with the old scraper. +// Serves the cover image directly from MinIO when available; falls back to a +// redirect to the novelfire CDN when the cover has not yet been downloaded. func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") if slug == "" { http.Error(w, "missing slug", http.StatusBadRequest) return } - // Redirect to the standard novelfire cover CDN URL. If the caller has the - // actual cover URL stored in metadata they should use it directly; this - // endpoint is a best-effort fallback. + + // Fast path: serve from MinIO if the cover has been downloaded. + if s.deps.CoverStore != nil { + data, ct, ok, err := s.deps.CoverStore.GetCover(r.Context(), slug) + if err != nil { + s.deps.Log.Warn("handleGetCover: GetCover error", "slug", slug, "err", err) + } + if ok && len(data) > 0 { + if ct == "" { + ct = "image/jpeg" + } + w.Header().Set("Content-Type", ct) + w.Header().Set("Cache-Control", "public, max-age=86400") + _, _ = w.Write(data) + return + } + } + + // Fallback: redirect to the CDN. The caller sees a working image; the + // cover will be populated on the next catalogue refresh run. coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug) http.Redirect(w, r, coverURL, http.StatusFound) } @@ -469,6 +431,117 @@ func (s *Server) handleChapterMarkdown(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, raw) } +// handleChapterTextPreview handles GET /api/chapter-text-preview/{slug}/{n}. +// +// Fetches a chapter live from novelfire.net and returns its plain text without +// writing anything to PocketBase or MinIO. This is the preview path used when +// a chapter has not yet been scraped into the library. +// +// Optional query params: +// +// chapter_url — the canonical chapter URL (preferred over constructing one) +// title — hint for the chapter title (used when the page title is empty) +// +// Response: {"slug":string,"number":int,"title":string,"text":string,"url":string} +func (s *Server) handleChapterTextPreview(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 || slug == "" { + jsonError(w, http.StatusBadRequest, "invalid slug or chapter number") + return + } + + // Determine the chapter URL to fetch. + chapterURL := r.URL.Query().Get("chapter_url") + if chapterURL == "" { + // Best-effort: novelfire chapter URLs follow /book/{slug}/chapter-{n} + chapterURL = fmt.Sprintf("%s/book/%s/chapter-%d", novelFireBase, slug, n) + } + + titleHint := r.URL.Query().Get("title") + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + // Fetch the chapter page. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, chapterURL, nil) + if err != nil { + s.deps.Log.Error("chapter-text-preview: build request failed", "url", chapterURL, "err", err) + jsonError(w, http.StatusInternalServerError, "failed to build request") + return + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + s.deps.Log.Warn("chapter-text-preview: fetch failed", "url", chapterURL, "err", err) + jsonError(w, http.StatusBadGateway, "failed to fetch chapter") + return + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + jsonError(w, http.StatusNotFound, "chapter not found") + return + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + s.deps.Log.Warn("chapter-text-preview: upstream error", + "url", chapterURL, "status", resp.StatusCode, "body_snippet", string(body)) + jsonError(w, http.StatusBadGateway, fmt.Sprintf("upstream returned %d", resp.StatusCode)) + return + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + s.deps.Log.Error("chapter-text-preview: read body failed", "err", err) + jsonError(w, http.StatusInternalServerError, "failed to read response") + return + } + + // Parse HTML and extract the #content node. + root, err := htmlutil.ParseHTML(string(bodyBytes)) + if err != nil { + s.deps.Log.Error("chapter-text-preview: html parse failed", "err", err) + jsonError(w, http.StatusInternalServerError, "failed to parse chapter HTML") + return + } + + container := htmlutil.FindFirst(root, scraper.Selector{ID: "content"}) + if container == nil { + s.deps.Log.Warn("chapter-text-preview: #content not found", "url", chapterURL) + jsonError(w, http.StatusNotFound, "chapter content not found on page") + return + } + + markdownText := htmlutil.NodeToMarkdown(container) + plainText := stripMarkdown(markdownText) + + // Extract the chapter title from the page or <h1> if not hinted. + chapterTitle := titleHint + if chapterTitle == "" { + // Try <h1 class="chapter-title"> first, then <h2 class="chapter-title"> + for _, tag := range []string{"h1", "h2", "h3"} { + if node := htmlutil.FindFirst(root, scraper.Selector{Tag: tag, Class: "chapter-title"}); node != nil { + chapterTitle = strings.TrimSpace(htmlutil.TextContent(node)) + break + } + } + } + if chapterTitle == "" { + chapterTitle = fmt.Sprintf("Chapter %d", n) + } + + writeJSON(w, 0, map[string]any{ + "slug": slug, + "number": n, + "title": chapterTitle, + "text": plainText, + "url": chapterURL, + }) +} + // handleReindex handles POST /api/reindex/{slug}. // Rebuilds the chapters_idx PocketBase collection for a book from MinIO objects. func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) { @@ -685,7 +758,13 @@ func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) { writeJSON(w, 0, map[string]string{"url": u}) } +// voiceSampleText is the phrase synthesised for every voice sample. +const voiceSampleText = "Hello! This is a preview of what I sound like. I hope you enjoy listening to your stories with my voice." + // handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}. +// If the sample has not been generated yet it synthesises it on the fly via +// Kokoro, stores the result in MinIO, and returns the presigned URL — so the +// caller always gets a playable URL in a single request. func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) { voice := r.PathValue("voice") if voice == "" { @@ -694,9 +773,21 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request } key := kokoro.VoiceSampleKey(voice) + + // Generate sample on demand when it is not in MinIO yet. if !s.deps.AudioStore.AudioExists(r.Context(), key) { - http.NotFound(w, r) - return + s.deps.Log.Info("generating voice sample on demand", "voice", voice) + mp3, err := s.deps.Kokoro.GenerateAudio(r.Context(), voiceSampleText, voice) + if err != nil { + s.deps.Log.Error("voice sample generation failed", "voice", voice, "err", err) + jsonError(w, http.StatusInternalServerError, "voice sample generation failed") + return + } + if err := s.deps.AudioStore.PutAudio(r.Context(), key, mp3); err != nil { + s.deps.Log.Error("voice sample upload failed", "voice", voice, "err", err) + jsonError(w, http.StatusInternalServerError, "voice sample upload failed") + return + } } u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour) @@ -708,6 +799,59 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request writeJSON(w, 0, map[string]string{"url": u}) } +// handleAvatarUpload handles PUT /api/avatar-upload/{userId}. +// The request body must be the raw image bytes; Content-Type must be +// image/jpeg, image/png, or image/webp. +// +// This endpoint is called by the SvelteKit server (not the browser directly), +// so MinIO credentials and internal networking are not a concern. +// +// Returns: { "key": "<objectKey>" } +func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) { + userID := r.PathValue("userId") + if userID == "" { + jsonError(w, http.StatusBadRequest, "missing userId") + return + } + + ct := r.Header.Get("Content-Type") + var ext string + switch { + case strings.HasPrefix(ct, "image/jpeg"): + ext = "jpg" + case strings.HasPrefix(ct, "image/png"): + ext = "png" + case strings.HasPrefix(ct, "image/webp"): + ext = "webp" + default: + jsonError(w, http.StatusBadRequest, "unsupported content-type; use image/jpeg, image/png, or image/webp") + return + } + + const maxSize = 5 << 20 // 5 MiB + data, err := io.ReadAll(io.LimitReader(r.Body, maxSize+1)) + if err != nil { + jsonError(w, http.StatusBadRequest, "failed to read body") + return + } + if len(data) > maxSize { + jsonError(w, http.StatusRequestEntityTooLarge, "image too large (max 5 MiB)") + return + } + if len(data) == 0 { + jsonError(w, http.StatusBadRequest, "empty body") + return + } + + key, err := s.deps.PresignStore.PutAvatar(r.Context(), userID, ext, ct, data) + if err != nil { + s.deps.Log.Error("avatar upload failed", "userId", userID, "err", err) + jsonError(w, http.StatusInternalServerError, "upload failed") + return + } + writeJSON(w, 0, map[string]string{"key": key}) +} + // handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}. // Query params: ext (jpg|png|webp, defaults to jpg) func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) { @@ -826,6 +970,82 @@ func (s *Server) handleDeleteProgress(w http.ResponseWriter, r *http.Request) { writeJSON(w, 0, map[string]string{}) } +// ── Catalogue (Meilisearch-backed browse + search) ──────────────────────────── + +// handleCatalogue handles GET /api/catalogue. +// +// Provides unified browse + search over the locally-indexed book catalogue +// via Meilisearch. Unlike /api/browse this never fetches novelfire.net live — +// it is entirely served from the Meilisearch index populated by the runner. +// +// Query params: +// +// q — full-text search query (optional) +// genre — genre filter, e.g. "fantasy" or "all" (default "all") +// status — status filter: "ongoing", "completed", or "all" (default "all") +// sort — "popular" (default) | "new" | "top-rated" | "rank" +// page — 1-indexed page number (default 1) +// limit — items per page (default 20, max 100) +func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + genre := q.Get("genre") + if genre == "" { + genre = "all" + } + status := q.Get("status") + if status == "" { + status = "all" + } + sort := q.Get("sort") + if sort == "" { + sort = "popular" + } + + page, _ := strconv.Atoi(q.Get("page")) + if page <= 0 { + page = 1 + } + limit, _ := strconv.Atoi(q.Get("limit")) + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + cq := meili.CatalogueQuery{ + Q: q.Get("q"), + Genre: genre, + Status: status, + Sort: sort, + Page: page, + Limit: limit, + } + + books, total, facets, err := s.deps.SearchIndex.Catalogue(r.Context(), cq) + if err != nil { + s.deps.Log.Error("handleCatalogue: Catalogue query failed", "err", err) + jsonError(w, http.StatusInternalServerError, "search failed") + return + } + + hasNext := int64(page*limit) < total + + w.Header().Set("Cache-Control", "public, max-age=60") + writeJSON(w, 0, map[string]any{ + "books": books, + "page": page, + "limit": limit, + "total": total, + "has_next": hasNext, + "facets": map[string]any{ + "genres": facets.Genres, + "statuses": facets.Statuses, + }, + }) +} + // ── Browse page parsing helpers ──────────────────────────────────────────────── // fetchBrowsePage fetches pageURL and parses NovelListings from the HTML. diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index 57b8de6..21e7084 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -6,7 +6,7 @@ // picks up and executes those tasks asynchronously // - Presigned MinIO URLs for media playback/upload // - Session-scoped reading progress -// - Live novelfire.net browse/search (no scraper interface needed; direct HTTP) +// - Live novelfire.net search (no scraper interface needed; direct HTTP) // - Kokoro voice list // // The backend never scrapes directly. All scraping (metadata, chapter list, @@ -28,8 +28,10 @@ import ( "sync" "time" + sentryhttp "github.com/getsentry/sentry-go/http" "github.com/libnovel/backend/internal/bookstore" "github.com/libnovel/backend/internal/kokoro" + "github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/taskqueue" ) @@ -46,12 +48,16 @@ type Dependencies struct { PresignStore bookstore.PresignStore // ProgressStore reads/writes per-session reading progress. ProgressStore bookstore.ProgressStore - // BrowseStore reads cached browse page snapshots from MinIO. - BrowseStore bookstore.BrowseStore + // CoverStore reads and writes book cover images from MinIO. + // If nil, the cover endpoint falls back to a CDN redirect. + CoverStore bookstore.CoverStore // Producer creates scrape/audio tasks in PocketBase. Producer taskqueue.Producer // TaskReader reads scrape/audio task records from PocketBase. TaskReader taskqueue.Reader + // SearchIndex provides full-text book search via Meilisearch. + // If nil, the local-only fallback search is used. + SearchIndex meili.Client // Kokoro is the TTS client (used for voice list only in the backend; // audio generation is done by the runner). Kokoro kokoro.Client @@ -88,6 +94,9 @@ func New(cfg Config, deps Dependencies) *Server { if deps.Log == nil { deps.Log = slog.Default() } + if deps.SearchIndex == nil { + deps.SearchIndex = meili.NoopClient{} + } return &Server{cfg: cfg, deps: deps} } @@ -112,10 +121,12 @@ func (s *Server) ListenAndServe(ctx context.Context) error { // Cancel a pending task (scrape or audio) mux.HandleFunc("POST /api/cancel-task/{id}", s.handleCancelTask) - // Browse & search (live novelfire.net) - mux.HandleFunc("GET /api/browse", s.handleBrowse) + // Browse & search mux.HandleFunc("GET /api/search", s.handleSearch) + // Catalogue (Meilisearch-backed browse + search — preferred path for UI) + mux.HandleFunc("GET /api/catalogue", s.handleCatalogue) + // Ranking (from PocketBase) mux.HandleFunc("GET /api/ranking", s.handleGetRanking) @@ -131,6 +142,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error { // Use this instead of presign+fetch to avoid SvelteKit→MinIO network path. mux.HandleFunc("GET /api/chapter-markdown/{slug}/{n}", s.handleChapterMarkdown) + // Chapter text preview — live scrape from novelfire.net, no store writes. + // Used when the chapter is not yet in the library (preview mode). + mux.HandleFunc("GET /api/chapter-text-preview/{slug}/{n}", s.handleChapterTextPreview) + // Reindex chapters_idx from MinIO mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex) @@ -148,6 +163,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample) mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload) mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar) + mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload) // Reading progress mux.HandleFunc("GET /api/progress", s.handleGetProgress) @@ -156,7 +172,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { srv := &http.Server{ Addr: s.cfg.Addr, - Handler: mux, + Handler: sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle(mux), ReadTimeout: 15 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second, diff --git a/backend/internal/bookstore/bookstore.go b/backend/internal/bookstore/bookstore.go index 4481a00..d638d36 100644 --- a/backend/internal/bookstore/bookstore.go +++ b/backend/internal/bookstore/bookstore.go @@ -105,6 +105,10 @@ type PresignStore interface { // Returns ("", false, nil) when no avatar exists. PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) + // PutAvatar stores raw image bytes for a user avatar directly in MinIO. + // ext should be "jpg", "png", or "webp". Returns the object key. + PutAvatar(ctx context.Context, userID, ext, contentType string, data []byte) (key string, err error) + // DeleteAvatar removes all avatar objects for a user. DeleteAvatar(ctx context.Context, userID string) error } @@ -124,14 +128,16 @@ type ProgressStore interface { DeleteProgress(ctx context.Context, sessionID, slug string) error } -// BrowseStore covers browse page snapshot storage. -// The runner writes snapshots; the backend reads them. -type BrowseStore interface { - // PutBrowsePage stores a raw JSON snapshot for a browse page. - // genre, sort, status, novelType and page identify the page. - PutBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int, data []byte) error +// CoverStore covers book cover image storage in MinIO. +// The runner writes covers during catalogue refresh; the backend reads them. +type CoverStore interface { + // PutCover stores a raw cover image for a book identified by slug. + PutCover(ctx context.Context, slug string, data []byte, contentType string) error - // GetBrowsePage retrieves a raw JSON snapshot. Returns (nil, false, nil) - // when no snapshot exists for the given parameters. - GetBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int) ([]byte, bool, error) + // GetCover retrieves the cover image for a book. Returns (nil, false, nil) + // when no cover exists for the given slug. + GetCover(ctx context.Context, slug string) ([]byte, string, bool, error) + + // CoverExists returns true when a cover image is stored for slug. + CoverExists(ctx context.Context, slug string) bool } diff --git a/backend/internal/bookstore/bookstore_test.go b/backend/internal/bookstore/bookstore_test.go index 2bbc5d0..aec804c 100644 --- a/backend/internal/bookstore/bookstore_test.go +++ b/backend/internal/bookstore/bookstore_test.go @@ -68,6 +68,9 @@ func (m *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, func (m *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) { return "", false, nil } +func (m *mockStore) PutAvatar(_ context.Context, _, _, _ string, _ []byte) (string, error) { + return "", nil +} func (m *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil } // ProgressStore diff --git a/backend/internal/browser/browser.go b/backend/internal/browser/browser.go index 4de0147..9c4d669 100644 --- a/backend/internal/browser/browser.go +++ b/backend/internal/browser/browser.go @@ -1,7 +1,4 @@ // Package browser provides a rate-limited HTTP client for web scraping. -// The Client interface is the only thing the rest of the codebase depends on; -// the concrete DirectClient can be swapped for any other implementation -// (e.g. a Browserless-backed client) without touching callers. package browser import ( @@ -10,7 +7,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strconv" "sync" "time" @@ -51,9 +47,6 @@ type Config struct { MaxConcurrent int // Timeout is the per-request deadline. Defaults to 90s when 0. Timeout time.Duration - // ProxyURL is an optional outbound proxy, e.g. "http://user:pass@host:3128". - // Falls back to HTTP_PROXY / HTTPS_PROXY environment variables when empty. - ProxyURL string } // DirectClient is a plain net/http-based Client with a concurrency semaphore. @@ -75,14 +68,6 @@ func NewDirectClient(cfg Config) *DirectClient { MaxIdleConnsPerHost: cfg.MaxConcurrent * 2, DisableCompression: false, } - if cfg.ProxyURL != "" { - proxyParsed, err := url.Parse(cfg.ProxyURL) - if err == nil { - transport.Proxy = http.ProxyURL(proxyParsed) - } - } else { - transport.Proxy = http.ProxyFromEnvironment - } return &DirectClient{ http: &http.Client{ diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4f516fb..2d05f6d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -63,6 +63,22 @@ type HTTP struct { Addr string } +// Meilisearch holds connection settings for the Meilisearch full-text search service. +type Meilisearch struct { + // URL is the base URL of the Meilisearch instance, e.g. http://localhost:7700 + // An empty string disables Meilisearch indexing and search. + URL string + // APIKey is the Meilisearch master/search API key. + APIKey string +} + +// Valkey holds connection settings for the Valkey/Redis presign URL cache. +type Valkey struct { + // Addr is the host:port of the Valkey instance, e.g. localhost:6379 + // An empty string disables the Valkey cache (falls through to MinIO directly). + Addr string +} + // Runner holds settings specific to the runner/worker binary. type Runner struct { // PollInterval is how often the runner checks PocketBase for pending tasks. @@ -78,17 +94,29 @@ type Runner struct { Workers int // Timeout is the per-request HTTP timeout for scraping. Timeout time.Duration - // ProxyURL is an optional outbound proxy for scraper HTTP requests. - ProxyURL string + // MetricsAddr is the listen address for the runner /metrics HTTP endpoint. + // Defaults to ":9091". Set to "" to disable. + MetricsAddr string + // CatalogueRefreshInterval is how often the runner walks the full catalogue, + // scrapes per-book metadata, downloads covers, and re-indexes in Meilisearch. + // Defaults to 24h. Set to 0 to use the default. + CatalogueRefreshInterval time.Duration + // SkipInitialCatalogueRefresh prevents the runner from running a full + // catalogue walk on startup. Useful for quick restarts where the catalogue + // is already indexed and a 24h walk would be wasteful. + // Controlled by RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true. + SkipInitialCatalogueRefresh bool } // Config is the top-level configuration struct consumed by both binaries. type Config struct { - PocketBase PocketBase - MinIO MinIO - Kokoro Kokoro - HTTP HTTP - Runner Runner + PocketBase PocketBase + MinIO MinIO + Kokoro Kokoro + HTTP HTTP + Runner Runner + Meilisearch Meilisearch + Valkey Valkey // LogLevel is one of "debug", "info", "warn", "error". LogLevel string } @@ -117,10 +145,10 @@ func Load() Config { SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), UseSSL: envBool("MINIO_USE_SSL", false), PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true), - BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), - BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), - BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"), - BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"), + BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "chapters"), + BucketAudio: envOr("MINIO_BUCKET_AUDIO", "audio"), + BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "avatars"), + BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "catalogue"), }, Kokoro: Kokoro{ @@ -133,13 +161,24 @@ func Load() Config { }, Runner: Runner{ - PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second), - MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1), - MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1), - WorkerID: envOr("RUNNER_WORKER_ID", workerID), - Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU() - Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second), - ProxyURL: envOr("SCRAPER_PROXY", ""), + PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second), + MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1), + MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1), + WorkerID: envOr("RUNNER_WORKER_ID", workerID), + Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU() + Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second), + MetricsAddr: envOr("RUNNER_METRICS_ADDR", ":9091"), + CatalogueRefreshInterval: envDuration("RUNNER_CATALOGUE_REFRESH_INTERVAL", 0), + SkipInitialCatalogueRefresh: envBool("RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH", false), + }, + + Meilisearch: Meilisearch{ + URL: envOr("MEILI_URL", ""), + APIKey: envOr("MEILI_API_KEY", ""), + }, + + Valkey: Valkey{ + Addr: envOr("VALKEY_ADDR", ""), }, } } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 7f09925..d281b44 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -19,7 +19,7 @@ func TestLoad_Defaults(t *testing.T) { "KOKORO_URL", "KOKORO_VOICE", "BACKEND_HTTP_ADDR", "RUNNER_POLL_INTERVAL", "RUNNER_MAX_CONCURRENT_SCRAPE", "RUNNER_MAX_CONCURRENT_AUDIO", - "RUNNER_WORKER_ID", "RUNNER_WORKERS", "RUNNER_TIMEOUT", "SCRAPER_PROXY", + "RUNNER_WORKER_ID", "RUNNER_WORKERS", "RUNNER_TIMEOUT", } for _, k := range unset { t.Setenv(k, "") @@ -33,8 +33,8 @@ func TestLoad_Defaults(t *testing.T) { if cfg.PocketBase.URL != "http://localhost:8090" { t.Errorf("PocketBase.URL: want http://localhost:8090, got %q", cfg.PocketBase.URL) } - if cfg.MinIO.BucketChapters != "libnovel-chapters" { - t.Errorf("MinIO.BucketChapters: want libnovel-chapters, got %q", cfg.MinIO.BucketChapters) + if cfg.MinIO.BucketChapters != "chapters" { + t.Errorf("MinIO.BucketChapters: want chapters, got %q", cfg.MinIO.BucketChapters) } if cfg.MinIO.UseSSL != false { t.Errorf("MinIO.UseSSL: want false, got %v", cfg.MinIO.UseSSL) diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index 256c50c..a582e17 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -19,10 +19,16 @@ type BookMeta struct { TotalChapters int `json:"total_chapters,omitempty"` SourceURL string `json:"source_url"` Ranking int `json:"ranking,omitempty"` + Rating float64 `json:"rating,omitempty"` + // MetaUpdated is the Unix timestamp (seconds) when the book record was last + // updated in PocketBase. Populated on read; not sent on write (PocketBase + // manages its own updated field). + MetaUpdated int64 `json:"meta_updated,omitempty"` } // CatalogueEntry is a lightweight book reference returned by catalogue pages. type CatalogueEntry struct { + Slug string `json:"slug"` Title string `json:"title"` URL string `json:"url"` } diff --git a/v3/backend/internal/meili/client.go b/backend/internal/meili/client.go similarity index 100% rename from v3/backend/internal/meili/client.go rename to backend/internal/meili/client.go diff --git a/backend/internal/novelfire/scraper.go b/backend/internal/novelfire/scraper.go index 7122b9a..b089677 100644 --- a/backend/internal/novelfire/scraper.go +++ b/backend/internal/novelfire/scraper.go @@ -111,7 +111,7 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueE select { case <-ctx.Done(): return - case entries <- domain.CatalogueEntry{Title: title, URL: bookURL}: + case entries <- domain.CatalogueEntry{Slug: slugFromURL(bookURL), Title: title, URL: bookURL}: } } @@ -194,8 +194,12 @@ func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.Bo // ── ChapterListProvider ─────────────────────────────────────────────────────── -// ScrapeChapterList returns all chapter references for a book, ordered ascending. -func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]domain.ChapterRef, error) { +// ScrapeChapterList returns chapter references for a book, ordered ascending. +// upTo > 0 stops pagination as soon as at least upTo chapter numbers have been +// collected — use this for range scrapes so we don't paginate 100 pages just +// to discover refs we'll never scrape. upTo == 0 fetches all pages. +// Each page fetch uses retryGet with 429-aware exponential backoff. +func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string, upTo int) ([]domain.ChapterRef, error) { var refs []domain.ChapterRef baseChapterURL := strings.TrimRight(bookURL, "/") + "/chapters" page := 1 @@ -210,7 +214,7 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]doma pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page) s.log.Info("scraping chapter list", "page", page, "url", pageURL) - raw, err := s.client.GetContent(ctx, pageURL) + raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 6*time.Second) if err != nil { return refs, fmt.Errorf("chapter list page %d: %w", page, err) } @@ -255,6 +259,13 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]doma }) } + // Early-stop: if we have seen at least upTo chapter numbers, we have + // enough refs to cover the requested range — no need to paginate further. + if upTo > 0 && len(refs) > 0 && refs[len(refs)-1].Number >= upTo { + s.log.Debug("chapter list early-stop reached", "upTo", upTo, "collected", len(refs)) + break + } + page++ } diff --git a/backend/internal/orchestrator/orchestrator.go b/backend/internal/orchestrator/orchestrator.go index d23dbe9..dd7214d 100644 --- a/backend/internal/orchestrator/orchestrator.go +++ b/backend/internal/orchestrator/orchestrator.go @@ -5,6 +5,8 @@ // - RunBook scrapes one book (metadata + chapter list + chapter texts) end-to-end. // - N worker goroutines pull chapter refs from a shared queue and call ScrapeChapterText. // - The caller (runner poll loop) owns the outer task-claim / finish cycle. +// - An optional PostMetadata hook (set in Config) is called after WriteMetadata +// succeeds. The runner uses this to upsert books into Meilisearch. package orchestrator import ( @@ -25,14 +27,19 @@ type Config struct { // Workers is the number of goroutines used to scrape chapters in parallel. // Defaults to runtime.NumCPU() when 0. Workers int + // PostMetadata is an optional hook called with the scraped BookMeta after + // WriteMetadata succeeds. Errors from the hook are logged but not fatal. + // Used by the runner to index books in Meilisearch. + PostMetadata func(ctx context.Context, meta domain.BookMeta) } // Orchestrator runs a single-book scrape pipeline. type Orchestrator struct { - novel scraper.NovelScraper - store bookstore.BookWriter - log *slog.Logger - workers int + novel scraper.NovelScraper + store bookstore.BookWriter + log *slog.Logger + workers int + postMetadata func(ctx context.Context, meta domain.BookMeta) } // New returns a new Orchestrator. @@ -44,7 +51,13 @@ func New(cfg Config, novel scraper.NovelScraper, store bookstore.BookWriter, log if workers <= 0 { workers = runtime.NumCPU() } - return &Orchestrator{novel: novel, store: store, log: log, workers: workers} + return &Orchestrator{ + novel: novel, + store: store, + log: log, + workers: workers, + postMetadata: cfg.PostMetadata, + } } // RunBook scrapes a single book described by task. It handles: @@ -84,12 +97,16 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma result.Errors++ } else { result.BooksFound = 1 + // Fire optional post-metadata hook (e.g. Meilisearch indexing). + if o.postMetadata != nil { + o.postMetadata(ctx, meta) + } } o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title) // ── Step 2: Chapter list ────────────────────────────────────────────────── - refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL) + refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL, task.ToChapter) if err != nil { o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err) result.ErrorMessage = fmt.Sprintf("chapter list: %v", err) diff --git a/backend/internal/orchestrator/orchestrator_test.go b/backend/internal/orchestrator/orchestrator_test.go index b1edaf9..90aa9d1 100644 --- a/backend/internal/orchestrator/orchestrator_test.go +++ b/backend/internal/orchestrator/orchestrator_test.go @@ -34,7 +34,7 @@ func (s *stubScraper) ScrapeMetadata(_ context.Context, _ string) (domain.BookMe return s.meta, s.metaErr } -func (s *stubScraper) ScrapeChapterList(_ context.Context, _ string) ([]domain.ChapterRef, error) { +func (s *stubScraper) ScrapeChapterList(_ context.Context, _ string, _ int) ([]domain.ChapterRef, error) { return s.refs, s.refsErr } diff --git a/v3/backend/internal/presigncache/cache.go b/backend/internal/presigncache/cache.go similarity index 100% rename from v3/backend/internal/presigncache/cache.go rename to backend/internal/presigncache/cache.go diff --git a/backend/internal/runner/browse_refresh.go b/backend/internal/runner/browse_refresh.go deleted file mode 100644 index c742005..0000000 --- a/backend/internal/runner/browse_refresh.go +++ /dev/null @@ -1,176 +0,0 @@ -package runner - -// browse_refresh.go — independent 6-hour loop that fetches novelfire.net -// browse page snapshots and stores them in MinIO. -// -// Design: -// - Runs on its own ticker (BrowseRefreshInterval, default 6h) inside Run(). -// - Fetches page 1 for each combination of the standard genre/sort/status -// filter values and stores the parsed JSON blob in MinIO via BrowseStore. -// - The backend's handleBrowse then serves from MinIO instead of calling -// novelfire.net live, which avoids IP-based rate-limiting on the server. - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "regexp" - "strings" - "time" -) - -// browseNovelListing mirrors backend.NovelListing for JSON serialisation. -type browseNovelListing struct { - Slug string `json:"slug"` - Title string `json:"title"` - Cover string `json:"cover"` - URL string `json:"url"` -} - -// browseSnapshot is the JSON structure stored in MinIO. -type browseSnapshot struct { - Novels []browseNovelListing `json:"novels"` - Page int `json:"page"` - HasNext bool `json:"hasNext"` - // CachedAt is the UTC time the snapshot was written (ISO 8601). - CachedAt string `json:"cachedAt"` -} - -// browseCombos lists the filter combinations to pre-fetch. -// Each entry is (genre, sort, status, novelType). -var browseCombos = []struct{ genre, sort, status, novelType string }{ - {"all", "popular", "all", "all-novel"}, - {"all", "popular", "ongoing", "all-novel"}, - {"all", "popular", "completed", "all-novel"}, - {"all", "new", "all", "all-novel"}, - {"all", "new", "ongoing", "all-novel"}, - {"all", "new", "completed", "all-novel"}, - {"all", "top-rated", "all", "all-novel"}, - {"all", "top-rated", "ongoing", "all-novel"}, - {"all", "top-rated", "completed", "all-novel"}, -} - -const novelFireBrowseBase = "https://novelfire.net" - -// runBrowseRefresh fetches all browse combos from novelfire.net and stores -// the results in MinIO. Errors per-combo are logged but do not abort the -// whole refresh cycle. -func (r *Runner) runBrowseRefresh(ctx context.Context) { - if r.deps.BrowseStore == nil { - r.deps.Log.Warn("runner: browse refresh skipped — BrowseStore not configured") - return - } - - log := r.deps.Log.With("op", "browse_refresh") - log.Info("runner: browse refresh starting", "combos", len(browseCombos)) - - ok, fail := 0, 0 - for _, c := range browseCombos { - if ctx.Err() != nil { - break - } - novels, hasNext, err := fetchBrowsePage(ctx, c.genre, c.sort, c.status, c.novelType) - if err != nil { - log.Warn("runner: browse fetch failed", - "genre", c.genre, "sort", c.sort, "status", c.status, "err", err) - fail++ - continue - } - - snap := browseSnapshot{ - Novels: novels, - Page: 1, - HasNext: hasNext, - CachedAt: time.Now().UTC().Format(time.RFC3339), - } - data, _ := json.Marshal(snap) - if err := r.deps.BrowseStore.PutBrowsePage(ctx, c.genre, c.sort, c.status, c.novelType, 1, data); err != nil { - log.Warn("runner: browse put failed", - "genre", c.genre, "sort", c.sort, "status", c.status, "err", err) - fail++ - continue - } - ok++ - } - - log.Info("runner: browse refresh finished", "ok", ok, "failed", fail) -} - -// fetchBrowsePage calls novelfire.net and returns a list of novel listings -// plus a hasNext flag. Mirrors the logic in backend/handlers.go. -func fetchBrowsePage(ctx context.Context, genre, sort, status, novelType string) ([]browseNovelListing, bool, error) { - pageURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=1", - novelFireBrowseBase, genre, sort, status, novelType) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) - if err != nil { - return nil, false, fmt.Errorf("build request: %w", err) - } - req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - - httpClient := &http.Client{Timeout: 45 * time.Second} - resp, err := httpClient.Do(req) - if err != nil { - return nil, false, fmt.Errorf("fetch %s: %w", pageURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - _, _ = io.Copy(io.Discard, resp.Body) - return nil, false, fmt.Errorf("upstream returned %d for %s", resp.StatusCode, pageURL) - } - - return parseBrowseHTML(resp.Body) -} - -// parseBrowseHTML parses a novelfire HTML response body. Mirrors parseBrowsePage -// in backend/handlers.go — kept separate to avoid coupling packages. -func parseBrowseHTML(r io.Reader) ([]browseNovelListing, bool, error) { - data, err := io.ReadAll(r) - if err != nil { - return nil, false, err - } - body := string(data) - - hasNext := strings.Contains(body, `rel="next"`) || - strings.Contains(body, `aria-label="Next"`) || - strings.Contains(body, `class="next"`) - - slugRe := regexp.MustCompile(`href="/book/([^/"]+)"`) - titleRe := regexp.MustCompile(`class="novel-title[^"]*"[^>]*>([^<]+)<`) - coverRe := regexp.MustCompile(`data-src="(https?://[^"]+)"`) - - slugMatches := slugRe.FindAllStringSubmatch(body, -1) - titleMatches := titleRe.FindAllStringSubmatch(body, -1) - coverMatches := coverRe.FindAllStringSubmatch(body, -1) - - var novels []browseNovelListing - seen := make(map[string]bool) - for i, sm := range slugMatches { - slug := sm[1] - if seen[slug] { - continue - } - seen[slug] = true - - item := browseNovelListing{ - Slug: slug, - URL: novelFireBrowseBase + "/book/" + slug, - } - if i < len(titleMatches) { - item.Title = strings.TrimSpace(titleMatches[i][1]) - } - if i < len(coverMatches) { - item.Cover = coverMatches[i][1] - } - if item.Title != "" { - novels = append(novels, item) - } - } - - return novels, hasNext, nil -} diff --git a/v3/backend/internal/runner/catalogue_refresh.go b/backend/internal/runner/catalogue_refresh.go similarity index 100% rename from v3/backend/internal/runner/catalogue_refresh.go rename to backend/internal/runner/catalogue_refresh.go diff --git a/v3/backend/internal/runner/metrics.go b/backend/internal/runner/metrics.go similarity index 100% rename from v3/backend/internal/runner/metrics.go rename to backend/internal/runner/metrics.go diff --git a/backend/internal/runner/runner.go b/backend/internal/runner/runner.go index 887bb33..24f4757 100644 --- a/backend/internal/runner/runner.go +++ b/backend/internal/runner/runner.go @@ -8,6 +8,9 @@ // - Audio tasks fetch chapter text, call Kokoro, upload to MinIO, and report // the result back (up to MaxConcurrentAudio goroutines). // - The runner is stateless between ticks; all state lives in PocketBase. +// - Atomic task counters are exposed via /metrics (see metrics.go). +// - Books are indexed in Meilisearch via an orchestrator.Config.PostMetadata +// hook injected at construction time. package runner import ( @@ -16,11 +19,13 @@ import ( "log/slog" "os" "sync" + "sync/atomic" "time" "github.com/libnovel/backend/internal/bookstore" "github.com/libnovel/backend/internal/domain" "github.com/libnovel/backend/internal/kokoro" + "github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/orchestrator" "github.com/libnovel/backend/internal/scraper" "github.com/libnovel/backend/internal/taskqueue" @@ -44,9 +49,18 @@ type Config struct { // StaleTaskThreshold is how old a heartbeat must be (or absent) before the // task is considered orphaned and reset to pending. Defaults to 2m when 0. StaleTaskThreshold time.Duration - // BrowseRefreshInterval is how often the runner pre-fetches browse page - // snapshots from novelfire.net and stores them in MinIO. Defaults to 6h. - BrowseRefreshInterval time.Duration + // CatalogueRefreshInterval is how often the runner walks the full catalogue, + // scrapes per-book metadata, downloads covers, and re-indexes everything in + // Meilisearch. Defaults to 24h (expensive — full catalogue walk). + CatalogueRefreshInterval time.Duration + // SkipInitialCatalogueRefresh suppresses the immediate catalogue walk that + // otherwise fires at startup. The periodic ticker (CatalogueRefreshInterval) + // still fires normally. Set RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true for + // quick restarts where the catalogue is already up to date. + SkipInitialCatalogueRefresh bool + // MetricsAddr is the HTTP listen address for the /metrics endpoint. + // Defaults to ":9091". Set to "" to disable. + MetricsAddr string } // Dependencies are the external services the runner depends on. @@ -59,8 +73,11 @@ type Dependencies struct { BookReader bookstore.BookReader // AudioStore persists generated audio and checks key existence. AudioStore bookstore.AudioStore - // BrowseStore stores browse page snapshots in MinIO. - BrowseStore bookstore.BrowseStore + // CoverStore stores book cover images in MinIO. + CoverStore bookstore.CoverStore + // SearchIndex indexes books in Meilisearch after scraping. + // If nil a no-op is used. + SearchIndex meili.Client // Novel is the scraper implementation. Novel scraper.NovelScraper // Kokoro is the TTS client. @@ -73,10 +90,16 @@ type Dependencies struct { type Runner struct { cfg Config deps Dependencies + + // Atomic task counters — read by /metrics without locking. + tasksRunning atomic.Int64 + tasksCompleted atomic.Int64 + tasksFailed atomic.Int64 + + startedAt time.Time } // New creates a Runner from cfg and deps. -// Any zero/nil field in deps will cause a panic at construction time to fail fast. func New(cfg Config, deps Dependencies) *Runner { if cfg.PollInterval <= 0 { cfg.PollInterval = 30 * time.Second @@ -96,63 +119,63 @@ func New(cfg Config, deps Dependencies) *Runner { if cfg.StaleTaskThreshold <= 0 { cfg.StaleTaskThreshold = 2 * time.Minute } - if cfg.BrowseRefreshInterval <= 0 { - cfg.BrowseRefreshInterval = 6 * time.Hour + if cfg.CatalogueRefreshInterval <= 0 { + cfg.CatalogueRefreshInterval = 24 * time.Hour + } + if cfg.MetricsAddr == "" { + cfg.MetricsAddr = ":9091" } if deps.Log == nil { deps.Log = slog.Default() } - return &Runner{cfg: cfg, deps: deps} -} - -// livenessFile is the path written on every successful poll so that the Docker -// healthcheck (CMD /healthcheck file /tmp/runner.alive <max_age>) can verify -// the runner is still making progress. -const livenessFile = "/tmp/runner.alive" - -// touchAlive writes the current UTC time to livenessFile. Errors are logged but -// never fatal — liveness is best-effort and should not crash the runner. -func (r *Runner) touchAlive() { - data := []byte(time.Now().UTC().Format(time.RFC3339)) - if err := os.WriteFile(livenessFile, data, 0o644); err != nil { - r.deps.Log.Warn("runner: failed to write liveness file", "err", err) + if deps.SearchIndex == nil { + deps.SearchIndex = meili.NoopClient{} } + return &Runner{cfg: cfg, deps: deps, startedAt: time.Now()} } -// Run starts the poll loop, blocking until ctx is cancelled. -// On each tick it claims and executes all available pending tasks. -// Scrape and audio tasks run in separate goroutine pools bounded by -// MaxConcurrentScrape and MaxConcurrentAudio respectively. +// Run starts the poll loop and the metrics HTTP server, blocking until ctx is +// cancelled. func (r *Runner) Run(ctx context.Context) error { r.deps.Log.Info("runner: starting", "worker_id", r.cfg.WorkerID, "poll_interval", r.cfg.PollInterval, "max_scrape", r.cfg.MaxConcurrentScrape, "max_audio", r.cfg.MaxConcurrentAudio, - "browse_refresh_interval", r.cfg.BrowseRefreshInterval, + "catalogue_refresh_interval", r.cfg.CatalogueRefreshInterval, + "metrics_addr", r.cfg.MetricsAddr, ) + // Start metrics HTTP server in background if configured. + if r.cfg.MetricsAddr != "" { + ms := newMetricsServer(r.cfg.MetricsAddr, r, r.deps.Log) + go func() { + if err := ms.ListenAndServe(ctx); err != nil { + r.deps.Log.Error("runner: metrics server error", "err", err) + } + }() + } + scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape) audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio) var wg sync.WaitGroup - // Write liveness file immediately so the first healthcheck passes before - // the first poll completes. - r.touchAlive() - tick := time.NewTicker(r.cfg.PollInterval) defer tick.Stop() - browseTick := time.NewTicker(r.cfg.BrowseRefreshInterval) - defer browseTick.Stop() + catalogueTick := time.NewTicker(r.cfg.CatalogueRefreshInterval) + defer catalogueTick.Stop() - // Run one browse refresh and one poll immediately on startup. - go r.runBrowseRefresh(ctx) + // Run one catalogue refresh immediately on startup (unless skipped by flag). + if !r.cfg.SkipInitialCatalogueRefresh { + go r.runCatalogueRefresh(ctx) + } else { + r.deps.Log.Info("runner: skipping initial catalogue refresh (RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true)") + } // Run one poll immediately on startup, then on each tick. for { r.poll(ctx, scrapeSem, audioSem, &wg) - r.touchAlive() select { case <-ctx.Done(): @@ -169,16 +192,24 @@ func (r *Runner) Run(ctx context.Context) error { r.deps.Log.Warn("runner: drain timeout exceeded, forcing exit") } return nil - case <-browseTick.C: - go r.runBrowseRefresh(ctx) + case <-catalogueTick.C: + go r.runCatalogueRefresh(ctx) case <-tick.C: } } } // poll claims all available pending tasks and dispatches them to goroutines. -// It claims tasks in a tight loop until no more are available. func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg *sync.WaitGroup) { + // ── Heartbeat file ──────────────────────────────────────────────────── + // Touch /tmp/runner.alive so the Docker health check can confirm the + // runner is actively polling. Failure is non-fatal — just log it. + if f, err := os.Create("/tmp/runner.alive"); err != nil { + r.deps.Log.Warn("runner: could not write heartbeat file", "err", err) + } else { + f.Close() + } + // ── Reap orphaned tasks ─────────────────────────────────────────────── if n, err := r.deps.Consumer.ReapStaleTasks(ctx, r.cfg.StaleTaskThreshold); err != nil { r.deps.Log.Warn("runner: reap stale tasks failed", "err", err) @@ -197,23 +228,21 @@ func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg break } if !ok { - break // queue empty + break } - // Acquire semaphore (non-blocking when full — leave task running). select { case scrapeSem <- struct{}{}: default: - // Too many concurrent scrapes — the task stays claimed but we can't - // run it right now. Log and break; the next poll will pick it up if - // still running (it won't be re-claimed while status=running). r.deps.Log.Warn("runner: scrape semaphore full, will retry next tick", "task_id", task.ID) break } + r.tasksRunning.Add(1) wg.Add(1) go func(t domain.ScrapeTask) { defer wg.Done() defer func() { <-scrapeSem }() + defer r.tasksRunning.Add(-1) r.runScrapeTask(ctx, t) }(task) } @@ -229,7 +258,7 @@ func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg break } if !ok { - break // queue empty + break } select { case audioSem <- struct{}{}: @@ -238,22 +267,36 @@ func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg "task_id", task.ID) break } + r.tasksRunning.Add(1) wg.Add(1) go func(t domain.AudioTask) { defer wg.Done() defer func() { <-audioSem }() + defer r.tasksRunning.Add(-1) r.runAudioTask(ctx, t) }(task) } } +// newOrchestrator builds an orchestrator with the Meilisearch post-hook wired in. +func (r *Runner) newOrchestrator() *orchestrator.Orchestrator { + oCfg := orchestrator.Config{ + Workers: r.cfg.OrchestratorWorkers, + PostMetadata: func(ctx context.Context, meta domain.BookMeta) { + if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil { + r.deps.Log.Warn("runner: meilisearch upsert failed", + "slug", meta.Slug, "err", err) + } + }, + } + return orchestrator.New(oCfg, r.deps.Novel, r.deps.BookWriter, r.deps.Log) +} + // runScrapeTask executes one scrape task end-to-end and reports the result. func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) { log := r.deps.Log.With("task_id", task.ID, "kind", task.Kind, "url", task.TargetURL) log.Info("runner: scrape task starting") - // Heartbeat goroutine: periodically PATCH heartbeat_at so the reaper knows - // this task is still alive. Cancelled when the task finishes. hbCtx, hbCancel := context.WithCancel(ctx) defer hbCancel() go func() { @@ -271,9 +314,7 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) { } }() - oCfg := orchestrator.Config{Workers: r.cfg.OrchestratorWorkers} - o := orchestrator.New(oCfg, r.deps.Novel, r.deps.BookWriter, r.deps.Log) - + o := r.newOrchestrator() var result domain.ScrapeResult switch task.Kind { @@ -289,6 +330,13 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) { if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil { log.Error("runner: FinishScrapeTask failed", "err", err) } + + if result.ErrorMessage != "" { + r.tasksFailed.Add(1) + } else { + r.tasksCompleted.Add(1) + } + log.Info("runner: scrape task finished", "scraped", result.ChaptersScraped, "skipped", result.ChaptersSkipped, @@ -296,8 +344,7 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) { ) } -// runCatalogueTask runs a full catalogue scrape by iterating catalogue entries -// and running a book task for each one. +// runCatalogueTask runs a full catalogue scrape. func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o *orchestrator.Orchestrator, log *slog.Logger) domain.ScrapeResult { entries, errCh := r.deps.Novel.ScrapeCatalogue(ctx) var result domain.ScrapeResult @@ -328,17 +375,11 @@ func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o return result } -// runAudioTask executes one audio-generation task: -// 1. Read chapter text from MinIO. -// 2. Call Kokoro to generate audio. -// 3. Upload MP3 to MinIO under the standard audio object key. -// 4. Report result back to PocketBase. +// runAudioTask executes one audio-generation task. func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) { log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "chapter", task.Chapter, "voice", task.Voice) log.Info("runner: audio task starting") - // Heartbeat goroutine: periodically PATCH heartbeat_at so the reaper knows - // this task is still alive. Cancelled when the task finishes. hbCtx, hbCancel := context.WithCancel(ctx) defer hbCancel() go func() { @@ -358,13 +399,13 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) { fail := func(msg string) { log.Error("runner: audio task failed", "reason", msg) + r.tasksFailed.Add(1) result := domain.AudioResult{ErrorMessage: msg} if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil { log.Error("runner: FinishAudioTask failed", "err", err) } } - // Step 1: read chapter text. raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter) if err != nil { fail(fmt.Sprintf("read chapter: %v", err)) @@ -376,7 +417,6 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) { return } - // Step 2: generate audio. if r.deps.Kokoro == nil { fail("kokoro client not configured") return @@ -387,14 +427,13 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) { return } - // Step 3: upload to MinIO. key := r.deps.AudioStore.AudioObjectKey(task.Slug, task.Chapter, task.Voice) if err := r.deps.AudioStore.PutAudio(ctx, key, audioData); err != nil { fail(fmt.Sprintf("put audio: %v", err)) return } - // Step 4: report success. + r.tasksCompleted.Add(1) result := domain.AudioResult{ObjectKey: key} if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil { log.Error("runner: FinishAudioTask failed", "err", err) diff --git a/backend/internal/runner/runner_test.go b/backend/internal/runner/runner_test.go index 2fa8888..9770089 100644 --- a/backend/internal/runner/runner_test.go +++ b/backend/internal/runner/runner_test.go @@ -146,7 +146,7 @@ func (s *stubNovelScraper) ScrapeMetadata(_ context.Context, _ string) (domain.B return domain.BookMeta{Slug: "test-book", Title: "Test Book", SourceURL: "https://example.com/book/test-book"}, nil } -func (s *stubNovelScraper) ScrapeChapterList(_ context.Context, _ string) ([]domain.ChapterRef, error) { +func (s *stubNovelScraper) ScrapeChapterList(_ context.Context, _ string, _ int) ([]domain.ChapterRef, error) { return s.chapters, nil } diff --git a/backend/internal/scraper/scraper.go b/backend/internal/scraper/scraper.go index bba8f13..a8081f7 100644 --- a/backend/internal/scraper/scraper.go +++ b/backend/internal/scraper/scraper.go @@ -20,8 +20,10 @@ type MetadataProvider interface { } // ChapterListProvider can enumerate all chapters of a book. +// upTo > 0 stops pagination once at least upTo chapter numbers have been +// collected (early-exit optimisation for range scrapes). upTo == 0 fetches all pages. type ChapterListProvider interface { - ScrapeChapterList(ctx context.Context, bookURL string) ([]domain.ChapterRef, error) + ScrapeChapterList(ctx context.Context, bookURL string, upTo int) ([]domain.ChapterRef, error) } // ChapterTextProvider can extract the readable text from a single chapter page. diff --git a/backend/internal/storage/minio.go b/backend/internal/storage/minio.go index 4f843c5..3f5217a 100644 --- a/backend/internal/storage/minio.go +++ b/backend/internal/storage/minio.go @@ -119,10 +119,10 @@ func AvatarObjectKey(userID, ext string) string { return fmt.Sprintf("%s/%s.%s", userID, ext, ext) } -// BrowseObjectKey returns the MinIO object key for a cached browse page snapshot. -// Format: browse/{genre}/{sort}/{status}/{type}/page-{n}.json -func BrowseObjectKey(genre, sort, status, novelType string, page int) string { - return fmt.Sprintf("browse/%s/%s/%s/%s/page-%d.json", genre, sort, status, novelType, page) +// CoverObjectKey returns the MinIO object key for a book cover image. +// Format: covers/{slug}.jpg +func CoverObjectKey(slug string) string { + return fmt.Sprintf("covers/%s.jpg", slug) } // chapterNumberFromKey extracts the chapter number from a MinIO object key. @@ -201,16 +201,16 @@ func (m *minioClient) listObjectKeys(ctx context.Context, bucket, prefix string) return keys, nil } -// ── Browse operations ───────────────────────────────────────────────────────── +// ── Cover operations ────────────────────────────────────────────────────────── -// putBrowse stores raw JSON bytes for a browse page snapshot. -func (m *minioClient) putBrowse(ctx context.Context, key string, data []byte) error { - return m.putObject(ctx, m.bucketBrowse, key, "application/json", data) +// putCover stores a raw cover image in the browse bucket under covers/{slug}.jpg. +func (m *minioClient) putCover(ctx context.Context, key, contentType string, data []byte) error { + return m.putObject(ctx, m.bucketBrowse, key, contentType, data) } -// getBrowse retrieves a browse page snapshot. Returns (nil, false, nil) when -// the object does not exist. -func (m *minioClient) getBrowse(ctx context.Context, key string) ([]byte, bool, error) { +// getCover retrieves a cover image. Returns (nil, "", false, nil) when the +// object does not exist. +func (m *minioClient) getCover(ctx context.Context, key string) ([]byte, bool, error) { if !m.objectExists(ctx, m.bucketBrowse, key) { return nil, false, nil } @@ -220,3 +220,25 @@ func (m *minioClient) getBrowse(ctx context.Context, key string) ([]byte, bool, } return data, true, nil } + +// coverExists returns true when the cover image object exists. +func (m *minioClient) coverExists(ctx context.Context, key string) bool { + return m.objectExists(ctx, m.bucketBrowse, key) +} + +// coverContentType inspects the first bytes of data to determine if it is +// a JPEG or PNG image. Falls back to "image/jpeg". +func coverContentType(data []byte) string { + if len(data) >= 4 { + // PNG magic: 0x89 0x50 0x4E 0x47 + if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + return "image/png" + } + // WebP: starts with "RIFF" at 0..3 and "WEBP" at 8..11 + if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && + data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' { + return "image/webp" + } + } + return "image/jpeg" +} diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index b041fb8..c24c4b5 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -50,7 +50,7 @@ var _ bookstore.RankingStore = (*Store)(nil) var _ bookstore.AudioStore = (*Store)(nil) var _ bookstore.PresignStore = (*Store)(nil) var _ bookstore.ProgressStore = (*Store)(nil) -var _ bookstore.BrowseStore = (*Store)(nil) +var _ bookstore.CoverStore = (*Store)(nil) var _ taskqueue.Producer = (*Store)(nil) var _ taskqueue.Consumer = (*Store)(nil) var _ taskqueue.Reader = (*Store)(nil) @@ -69,6 +69,7 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error { "total_chapters": meta.TotalChapters, "source_url": meta.SourceURL, "ranking": meta.Ranking, + "rating": meta.Rating, } // Upsert via filter: if exists PATCH, otherwise POST. existing, err := s.getBookBySlug(ctx, meta.Slug) @@ -138,10 +139,15 @@ type pbBook struct { TotalChapters int `json:"total_chapters"` SourceURL string `json:"source_url"` Ranking int `json:"ranking"` + Rating float64 `json:"rating"` Updated string `json:"updated"` } func (b pbBook) toDomain() domain.BookMeta { + var metaUpdated int64 + if t, err := time.Parse(time.RFC3339, b.Updated); err == nil { + metaUpdated = t.Unix() + } return domain.BookMeta{ Slug: b.Slug, Title: b.Title, @@ -153,6 +159,8 @@ func (b pbBook) toDomain() domain.BookMeta { TotalChapters: b.TotalChapters, SourceURL: b.SourceURL, Ranking: b.Ranking, + Rating: b.Rating, + MetaUpdated: metaUpdated, } } @@ -401,6 +409,17 @@ func (s *Store) PresignAvatarURL(ctx context.Context, userID string) (string, bo return "", false, nil } +func (s *Store) PutAvatar(ctx context.Context, userID, ext, contentType string, data []byte) (string, error) { + // Delete existing avatar objects for this user before writing the new one + // so old extensions don't linger (e.g. old .png after uploading a .jpg). + _ = s.mc.deleteObjects(ctx, s.mc.bucketAvatars, userID+"/") + key := AvatarObjectKey(userID, ext) + if err := s.mc.putObject(ctx, s.mc.bucketAvatars, key, contentType, data); err != nil { + return "", fmt.Errorf("put avatar: %w", err) + } + return key, nil +} + func (s *Store) DeleteAvatar(ctx context.Context, userID string) error { return s.mc.deleteObjects(ctx, s.mc.bucketAvatars, userID+"/") } @@ -770,21 +789,32 @@ func parseAudioTask(raw json.RawMessage) (domain.AudioTask, error) { }, nil } -// ── BrowseStore ──────────────────────────────────────────────────────────────── +// ── CoverStore ───────────────────────────────────────────────────────────────── -func (s *Store) PutBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int, data []byte) error { - key := BrowseObjectKey(genre, sort, status, novelType, page) - if err := s.mc.putBrowse(ctx, key, data); err != nil { - return fmt.Errorf("PutBrowsePage: %w", err) +func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error { + key := CoverObjectKey(slug) + if contentType == "" { + contentType = coverContentType(data) + } + if err := s.mc.putCover(ctx, key, contentType, data); err != nil { + return fmt.Errorf("PutCover: %w", err) } return nil } -func (s *Store) GetBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int) ([]byte, bool, error) { - key := BrowseObjectKey(genre, sort, status, novelType, page) - data, ok, err := s.mc.getBrowse(ctx, key) +func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool, error) { + key := CoverObjectKey(slug) + data, ok, err := s.mc.getCover(ctx, key) if err != nil { - return nil, false, fmt.Errorf("GetBrowsePage: %w", err) + return nil, "", false, fmt.Errorf("GetCover: %w", err) } - return data, ok, nil + if !ok { + return nil, "", false, nil + } + ct := coverContentType(data) + return data, ct, true, nil +} + +func (s *Store) CoverExists(ctx context.Context, slug string) bool { + return s.mc.coverExists(ctx, CoverObjectKey(slug)) } diff --git a/v3/caddy/Dockerfile b/caddy/Dockerfile similarity index 100% rename from v3/caddy/Dockerfile rename to caddy/Dockerfile diff --git a/v3/caddy/errors/502.html b/caddy/errors/502.html similarity index 100% rename from v3/caddy/errors/502.html rename to caddy/errors/502.html diff --git a/v3/caddy/errors/503.html b/caddy/errors/503.html similarity index 100% rename from v3/caddy/errors/503.html rename to caddy/errors/503.html diff --git a/v3/caddy/errors/504.html b/caddy/errors/504.html similarity index 100% rename from v3/caddy/errors/504.html rename to caddy/errors/504.html diff --git a/v3/crowdsec/acquis.yaml b/crowdsec/acquis.yaml similarity index 100% rename from v3/crowdsec/acquis.yaml rename to crowdsec/acquis.yaml diff --git a/docker-compose-new.yml b/docker-compose-new.yml deleted file mode 100644 index 8ae4a0a..0000000 --- a/docker-compose-new.yml +++ /dev/null @@ -1,211 +0,0 @@ -services: - # ─── MinIO (object storage: chapters, audio, avatars) ──────────────────────── - minio: - image: minio/minio:latest - restart: unless-stopped - command: server /data --console-address ":9001" - environment: - MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}" - MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}" - ports: - - "${MINIO_PORT:-9000}:9000" # S3 API - - "${MINIO_CONSOLE_PORT:-9001}:9001" # Web console - volumes: - - minio_data:/data - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 10s - timeout: 5s - retries: 5 - - # ─── MinIO bucket initialisation ───────────────────────────────────────────── - minio-init: - image: minio/mc:latest - depends_on: - minio: - condition: service_healthy - entrypoint: > - /bin/sh -c " - mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-admin} $${MINIO_ROOT_PASSWORD:-changeme123}; - mc mb --ignore-existing local/libnovel-chapters; - mc mb --ignore-existing local/libnovel-audio; - mc mb --ignore-existing local/libnovel-avatars; - mc mb --ignore-existing local/libnovel-browse; - echo 'buckets ready'; - " - environment: - MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}" - MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}" - - # ─── PocketBase (auth + structured data) ───────────────────────────────────── - pocketbase: - image: ghcr.io/muchobien/pocketbase:latest - restart: unless-stopped - environment: - PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" - PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" - ports: - - "${POCKETBASE_PORT:-8090}:8090" - volumes: - - pb_data:/pb_data - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"] - interval: 10s - timeout: 5s - retries: 5 - - # ─── PocketBase collection bootstrap ───────────────────────────────────────── - pb-init: - image: alpine:3.19 - depends_on: - pocketbase: - condition: service_healthy - environment: - POCKETBASE_URL: "http://pocketbase:8090" - POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" - POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" - volumes: - - ./scripts/pb-init-v2.sh:/pb-init.sh:ro - entrypoint: ["sh", "/pb-init.sh"] - - # ─── Backend API ────────────────────────────────────────────────────────────── - backend: - build: - context: ./backend - dockerfile: Dockerfile - target: backend - args: - VERSION: "${GIT_TAG:-dev}" - COMMIT: "${GIT_COMMIT:-unknown}" - restart: unless-stopped - stop_grace_period: 35s - depends_on: - pb-init: - condition: service_completed_successfully - pocketbase: - condition: service_healthy - minio: - condition: service_healthy - environment: - BACKEND_HTTP_ADDR: ":8080" - LOG_LEVEL: "${LOG_LEVEL:-info}" - # MinIO - MINIO_ENDPOINT: "minio:9000" - MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}" - MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}" - MINIO_USE_SSL: "false" - MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}" - MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}" - MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}" - MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}" - MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" - MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-false}" - # PocketBase - POCKETBASE_URL: "http://pocketbase:8090" - POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" - POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" - ports: - - "${BACKEND_PORT:-8080}:8080" - healthcheck: - test: ["CMD", "/healthcheck", "http://localhost:8080/health"] - interval: 15s - timeout: 5s - retries: 3 - - # ─── Runner (background task worker) ───────────────────────────────────────── - runner: - build: - context: ./backend - dockerfile: Dockerfile - target: runner - args: - VERSION: "${GIT_TAG:-dev}" - COMMIT: "${GIT_COMMIT:-unknown}" - restart: unless-stopped - stop_grace_period: 135s - depends_on: - pb-init: - condition: service_completed_successfully - pocketbase: - condition: service_healthy - minio: - condition: service_healthy - environment: - LOG_LEVEL: "${LOG_LEVEL:-info}" - # Runner tuning - RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL:-30s}" - # RUNNER_MAX_CONCURRENT_SCRAPE controls how many books are scraped in parallel. - # Default is 1 (sequential). Increase for faster catalogue scrapes at the - # cost of higher CPU/network load on the novelfire.net target. - RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE:-1}" - RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO:-1}" - RUNNER_WORKER_ID: "${RUNNER_WORKER_ID:-runner-1}" - RUNNER_WORKERS: "${RUNNER_WORKERS:-0}" - RUNNER_TIMEOUT: "${RUNNER_TIMEOUT:-90s}" - SCRAPER_PROXY: "${SCRAPER_PROXY:-}" - # Kokoro-FastAPI TTS endpoint - KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc}" - KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}" - # MinIO - MINIO_ENDPOINT: "minio:9000" - MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}" - MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}" - MINIO_USE_SSL: "false" - MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}" - MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}" - MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}" - MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}" - MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" - MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-false}" - # PocketBase - POCKETBASE_URL: "http://pocketbase:8090" - POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" - POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" - healthcheck: - # The runner has no HTTP server. It writes /tmp/runner.alive on every poll. - # 120s = 2× the default 30s poll interval with generous headroom. - test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"] - interval: 60s - timeout: 5s - retries: 3 - - # ─── SvelteKit UI ───────────────────────────────────────────────────────────── - ui: - build: - context: ./ui-v2 - dockerfile: Dockerfile - args: - BUILD_VERSION: "${GIT_TAG:-dev}" - BUILD_COMMIT: "${GIT_COMMIT:-unknown}" - restart: unless-stopped - stop_grace_period: 35s - depends_on: - pb-init: - condition: service_completed_successfully - backend: - condition: service_healthy - pocketbase: - condition: service_healthy - environment: - # ORIGIN must match the URL the browser uses to reach the UI. - # adapter-node uses this for SvelteKit's built-in CSRF origin check. - # When running behind a reverse proxy or non-standard port, set this via - # the ORIGIN env var (e.g. https://libnovel.example.com). - ORIGIN: "${ORIGIN:-http://localhost:5252}" - SCRAPER_API_URL: "http://backend:8080" - POCKETBASE_URL: "http://pocketbase:8090" - POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" - POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" - AUTH_SECRET: "${AUTH_SECRET:-dev_secret_change_in_production}" - PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" - ports: - - "${UI_PORT:-5252}:3000" - healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] - interval: 15s - timeout: 5s - retries: 3 - -volumes: - minio_data: - pb_data: diff --git a/docker-compose.yml b/docker-compose.yml index e08957b..bb258a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,39 @@ -version: "3.9" +# ── Shared environment fragments ────────────────────────────────────────────── +# These YAML anchors eliminate duplication between backend and runner. +# All values come from Doppler — no fallbacks needed here. +# Run commands via: just up / just build / etc. (see justfile) +x-infra-env: &infra-env + # MinIO + MINIO_ENDPOINT: "minio:9000" + MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}" + MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}" + MINIO_USE_SSL: "false" + MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}" + MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}" + # PocketBase + POCKETBASE_URL: "http://pocketbase:8090" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}" + # Meilisearch + MEILI_URL: "http://meilisearch:7700" + MEILI_API_KEY: "${MEILI_MASTER_KEY}" + # Valkey + VALKEY_ADDR: "valkey:6379" services: - # ─── MinIO (object storage for chapter .md files + audio cache) ───────────── + # ─── MinIO (object storage: chapters, audio, avatars, browse) ──────────────── minio: image: minio/minio:latest - #container_name: libnovel-minio restart: unless-stopped command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}" - MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}" - ports: - - "${MINIO_PORT:-9000}:9000" # S3 API - - "${MINIO_CONSOLE_PORT:-9001}:9001" # Web console + MINIO_ROOT_USER: "${MINIO_ROOT_USER}" + MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD}" + # No public port — all presigned URL traffic goes through backend or a + # separately-exposed MINIO_PUBLIC_ENDPOINT (e.g. storage.libnovel.cc). + expose: + - "9000" + - "9001" volumes: - minio_data:/data healthcheck: @@ -22,37 +43,34 @@ services: retries: 5 # ─── MinIO bucket initialisation ───────────────────────────────────────────── - # Runs once to create the default buckets and then exits. minio-init: image: minio/mc:latest - #container_name: libnovel-minio-init depends_on: minio: condition: service_healthy entrypoint: > /bin/sh -c " - mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-admin} $${MINIO_ROOT_PASSWORD:-changeme123}; - mc mb --ignore-existing local/libnovel-chapters; - mc mb --ignore-existing local/libnovel-audio; - mc mb --ignore-existing local/libnovel-browse; - mc mb --ignore-existing local/libnovel-avatars; + mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; + mc mb --ignore-existing local/chapters; + mc mb --ignore-existing local/audio; + mc mb --ignore-existing local/avatars; + mc mb --ignore-existing local/catalogue; echo 'buckets ready'; " environment: - MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}" - MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}" + MINIO_ROOT_USER: "${MINIO_ROOT_USER}" + MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD}" - # ─── PocketBase (auth + structured data: books, chapters index, ranking, progress) ── + # ─── PocketBase (auth + structured data) ───────────────────────────────────── pocketbase: image: ghcr.io/muchobien/pocketbase:latest - #container_name: libnovel-pocketbase restart: unless-stopped environment: - # Auto-create superuser on first boot (used by entrypoint.sh) - PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" - PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" - ports: - - "${POCKETBASE_PORT:-8090}:8090" + PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}" + PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}" + # No public port — accessed only by backend/runner on the internal network. + expose: + - "8090" volumes: - pb_data:/pb_data healthcheck: @@ -61,9 +79,7 @@ services: timeout: 5s retries: 5 - # ─── PocketBase collection bootstrap ──────────────────────────────────────── - # One-shot init container: creates all required collections via the admin API - # and exits. Idempotent — safe to run on every `docker compose up`. + # ─── PocketBase collection bootstrap ───────────────────────────────────────── pb-init: image: alpine:3.19 depends_on: @@ -71,22 +87,58 @@ services: condition: service_healthy environment: POCKETBASE_URL: "http://pocketbase:8090" - POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" - POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}" volumes: - - ./scripts/pb-init.sh:/pb-init.sh:ro + - ./scripts/pb-init-v3.sh:/pb-init.sh:ro entrypoint: ["sh", "/pb-init.sh"] - # ─── Scraper ───────────────────────────────────────────────────────────────── - scraper: - build: - context: ./scraper - dockerfile: Dockerfile - args: - VERSION: "${GIT_TAG:-dev}" - COMMIT: "${GIT_COMMIT:-unknown}" - #container_name: libnovel-scraper + # ─── Meilisearch (full-text search) ────────────────────────────────────────── + meilisearch: + image: getmeili/meilisearch:latest restart: unless-stopped + environment: + MEILI_MASTER_KEY: "${MEILI_MASTER_KEY}" + MEILI_ENV: "${MEILI_ENV}" + # No public port — backend/runner reach it via internal network. + expose: + - "7700" + volumes: + - meili_data:/meili_data + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7700/health"] + interval: 10s + timeout: 5s + retries: 5 + + # ─── Valkey (presign URL cache) ─────────────────────────────────────────────── + valkey: + image: valkey/valkey:7-alpine + restart: unless-stopped + # No public port — backend/runner/ui reach it via internal network. + expose: + - "6379" + volumes: + - valkey_data:/data + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # ─── Backend API ────────────────────────────────────────────────────────────── + backend: + build: + context: ./backend + dockerfile: Dockerfile + target: backend + args: + VERSION: "${GIT_TAG}" + COMMIT: "${GIT_COMMIT}" + labels: + com.centurylinklabs.watchtower.enable: "true" + restart: unless-stopped + stop_grace_period: 35s depends_on: pb-init: condition: service_completed_successfully @@ -94,72 +146,426 @@ services: condition: service_healthy minio: condition: service_healthy + meilisearch: + condition: service_healthy + valkey: + condition: service_healthy + # No public port — all traffic is routed via Caddy. + expose: + - "8080" environment: - # 0 → defaults to NumCPU inside the container. - SCRAPER_WORKERS: "${SCRAPER_WORKERS:-0}" - SCRAPER_HTTP_ADDR: ":8080" - LOG_LEVEL: "debug" - # Kokoro-FastAPI TTS endpoint. - KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc}" - KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}" - # MinIO / S3 object storage - MINIO_ENDPOINT: "minio:9000" - MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}" - MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}" - MINIO_USE_SSL: "false" - MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}" - MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}" - MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}" - MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}" - # Public endpoint used to sign presigned audio URLs so browsers can reach them. - # Leave empty to use MINIO_ENDPOINT (fine for local dev). - MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}" - MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-true}" - # SingleFile CLI path for save-browse subcommand - SINGLEFILE_PATH: "${SINGLEFILE_PATH:-single-file}" - # PocketBase - POCKETBASE_URL: "http://pocketbase:8090" - POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" - POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" - ports: - - "${SCRAPER_PORT:-8080}:8080" + <<: *infra-env + BACKEND_HTTP_ADDR: ":8080" + LOG_LEVEL: "${LOG_LEVEL}" + KOKORO_URL: "${KOKORO_URL}" + KOKORO_VOICE: "${KOKORO_VOICE}" healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] + test: ["CMD", "/healthcheck", "http://localhost:8080/health"] interval: 15s timeout: 5s retries: 3 - # ─── SvelteKit UI ──────────────────────────────────────────────────────────── + # ─── Runner (background task worker) ───────────────────────────────────────── + runner: + build: + context: ./backend + dockerfile: Dockerfile + target: runner + args: + VERSION: "${GIT_TAG}" + COMMIT: "${GIT_COMMIT}" + labels: + com.centurylinklabs.watchtower.enable: "true" + restart: unless-stopped + stop_grace_period: 135s + depends_on: + pb-init: + condition: service_completed_successfully + pocketbase: + condition: service_healthy + minio: + condition: service_healthy + meilisearch: + condition: service_healthy + valkey: + condition: service_healthy + # Metrics endpoint — internal only; expose publicly via Caddy if needed. + expose: + - "9091" + environment: + <<: *infra-env + LOG_LEVEL: "${LOG_LEVEL}" + # Runner tuning + RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}" + RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}" + RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}" + RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}" + RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}" + RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}" + # Suppress the on-startup catalogue walk — catalogue_refresh now skips + # books already in Meilisearch, so a full walk on every restart is wasteful. + # The 24h periodic ticker (CatalogueRefreshInterval) still fires normally. + RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true" + # Kokoro-FastAPI TTS endpoint + KOKORO_URL: "${KOKORO_URL}" + KOKORO_VOICE: "${KOKORO_VOICE}" + healthcheck: + # The runner writes /tmp/runner.alive on every poll. + # 120s = 2× the default 30s poll interval with generous headroom. + test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"] + interval: 60s + timeout: 5s + retries: 3 + + # ─── SvelteKit UI ───────────────────────────────────────────────────────────── ui: build: context: ./ui dockerfile: Dockerfile args: - BUILD_VERSION: "${GIT_TAG:-dev}" - BUILD_COMMIT: "${GIT_COMMIT:-unknown}" - # container_name: libnovel-ui + BUILD_VERSION: "${GIT_TAG}" + BUILD_COMMIT: "${GIT_COMMIT}" + labels: + com.centurylinklabs.watchtower.enable: "true" restart: unless-stopped + stop_grace_period: 35s depends_on: pb-init: condition: service_completed_successfully - scraper: + backend: condition: service_healthy pocketbase: condition: service_healthy + valkey: + condition: service_healthy + # No public port — all traffic via Caddy. + expose: + - "3000" environment: - SCRAPER_API_URL: "http://scraper:8080" + # ORIGIN must match the public URL Caddy serves on. + # adapter-node uses this for SvelteKit's built-in CSRF origin check. + ORIGIN: "${ORIGIN}" + BACKEND_API_URL: "http://backend:8080" POCKETBASE_URL: "http://pocketbase:8090" - POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" - POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" - PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" - ports: - - "${UI_PORT:-5252}:3000" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}" + AUTH_SECRET: "${AUTH_SECRET}" + PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}" + # Valkey + VALKEY_ADDR: "valkey:6379" healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] interval: 15s timeout: 5s retries: 3 + # ─── CrowdSec (threat detection + IP blocking) ─────────────────────────────── + # Reads Caddy JSON access logs from the shared caddy_logs volume and enforces + # decisions via the Caddy bouncer plugin. + crowdsec: + image: crowdsecurity/crowdsec:latest + restart: unless-stopped + environment: + GID: "1000" + COLLECTIONS: "crowdsecurity/caddy crowdsecurity/http-dos crowdsecurity/base-http-scenarios" + volumes: + - crowdsec_data:/var/lib/crowdsec/data + - ./crowdsec/acquis.yaml:/etc/crowdsec/acquis.yaml:ro + - caddy_logs:/var/log/caddy:ro + expose: + - "8080" + healthcheck: + test: ["CMD", "cscli", "version"] + interval: 20s + timeout: 10s + retries: 5 + + # ─── CrowdSec bouncer registration ─────────────────────────────────────────── + # One-shot: registers the Caddy bouncer with the CrowdSec LAPI and writes the + # generated API key to crowdsec/.crowdsec.env, which Caddy reads via env_file. + # Uses the Docker socket to exec cscli inside the running crowdsec container. + crowdsec-init: + image: docker:cli + depends_on: + crowdsec: + condition: service_healthy + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./crowdsec:/crowdsec-out + entrypoint: > + /bin/sh -c " + out=/crowdsec-out/.crowdsec.env; + existing=$$(grep -s '^CROWDSEC_API_KEY=.' \"$$out\" | cut -d= -f2-); + if [ -n \"$$existing\" ]; then + echo 'crowdsec-init: key already present, skipping registration'; + exit 0; + fi; + container=$$(docker ps --filter name=crowdsec --filter status=running --format '{{.Names}}' | grep -v init | head -1); + echo \"crowdsec-init: using container $$container\"; + docker exec $$container cscli bouncers delete caddy-bouncer 2>/dev/null || true; + key=$$(docker exec $$container cscli bouncers add caddy-bouncer -o raw 2>&1); + if [ -z \"$$key\" ]; then + echo 'crowdsec-init: ERROR — failed to obtain bouncer key' >&2; + exit 1; + fi; + printf 'CROWDSEC_API_KEY=%s\n' \"$$key\" > \"$$out\"; + echo \"crowdsec-init: bouncer key written (key length: $${#key})\"; + " + restart: "no" + + + # ─── Caddy (reverse proxy + automatic HTTPS) ────────────────────────────────── + # Custom build includes github.com/mholt/caddy-ratelimit and + # github.com/hslatman/caddy-crowdsec-bouncer/http. + caddy: + build: + context: ./caddy + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ui: + condition: service_healthy + crowdsec-init: + condition: service_completed_successfully + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 (QUIC) + environment: + DOMAIN: "${DOMAIN}" + CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL}" + env_file: + - path: ./crowdsec/.crowdsec.env + required: false + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./caddy/errors:/srv/errors:ro + - caddy_data:/data + - caddy_config:/config + - caddy_logs:/var/log/caddy + + # ─── Watchtower (auto-redeploy custom services on new images) ──────────────── + # Only watches services labelled com.centurylinklabs.watchtower.enable=true. + # Third-party infra images (minio, pocketbase, meilisearch, etc.) are excluded. + watchtower: + image: containrrr/watchtower:latest + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: --label-enable --interval 300 --cleanup + environment: + WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}" + WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}" + DOCKER_API_VERSION: "1.44" + + # ─── Shared PostgreSQL (Fider + GlitchTip + Umami) ─────────────────────────── + # A single Postgres instance hosting three separate databases. + # PocketBase uses its own embedded SQLite; this postgres is only for the + # three new services below. + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + POSTGRES_DB: postgres + expose: + - "5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + + # ─── Postgres database initialisation ──────────────────────────────────────── + # One-shot: creates the fider, glitchtip, and umami databases if missing. + postgres-init: + image: postgres:16-alpine + depends_on: + postgres: + condition: service_healthy + environment: + PGPASSWORD: "${POSTGRES_PASSWORD}" + entrypoint: > + /bin/sh -c " + psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='fider'\" | grep -q 1 || + psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE fider\"; + psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='glitchtip'\" | grep -q 1 || + psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE glitchtip\"; + psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='umami'\" | grep -q 1 || + psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE umami\"; + echo 'postgres-init: databases ready'; + " + restart: "no" + + # ─── Fider (user feedback & feature requests) ───────────────────────────────── + fider: + image: getfider/fider:stable + restart: unless-stopped + depends_on: + postgres-init: + condition: service_completed_successfully + postgres: + condition: service_healthy + expose: + - "3000" + environment: + BASE_URL: "${FIDER_BASE_URL}" + DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable" + JWT_SECRET: "${FIDER_JWT_SECRET}" + # Email: noreply mode — emails are suppressed (logged to stdout). + # Fider still requires SMTP vars to be non-empty even in noreply mode. + EMAIL_NOREPLY: "noreply@libnovel.cc" + EMAIL_SMTP_HOST: "localhost" + EMAIL_SMTP_PORT: "25" + # Disable outbound email — set real SMTP values to enable. + EMAIL_NOREPLY_MODE: "true" + + # ─── GlitchTip DB migration (one-shot) ─────────────────────────────────────── + glitchtip-migrate: + image: glitchtip/glitchtip:latest + depends_on: + postgres-init: + condition: service_completed_successfully + postgres: + condition: service_healthy + environment: + DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip" + SECRET_KEY: "${GLITCHTIP_SECRET_KEY}" + GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}" + EMAIL_URL: "consolemail://" + DEFAULT_FROM_EMAIL: "errors@libnovel.cc" + VALKEY_URL: "redis://valkey:6379/1" + command: "./manage.py migrate" + restart: "no" + + # ─── GlitchTip web (error tracking UI + API) ───────────────────────────────── + glitchtip-web: + image: glitchtip/glitchtip:latest + restart: unless-stopped + depends_on: + glitchtip-migrate: + condition: service_completed_successfully + valkey: + condition: service_healthy + expose: + - "8000" + environment: + DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip" + SECRET_KEY: "${GLITCHTIP_SECRET_KEY}" + GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}" + EMAIL_URL: "consolemail://" + DEFAULT_FROM_EMAIL: "errors@libnovel.cc" + VALKEY_URL: "redis://valkey:6379/1" + PORT: "8000" + ENABLE_USER_REGISTRATION: "false" + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"] + interval: 15s + timeout: 5s + retries: 5 + + # ─── GlitchTip worker (background task processor) ───────────────────────────── + glitchtip-worker: + image: glitchtip/glitchtip:latest + restart: unless-stopped + depends_on: + glitchtip-migrate: + condition: service_completed_successfully + valkey: + condition: service_healthy + environment: + DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip" + SECRET_KEY: "${GLITCHTIP_SECRET_KEY}" + GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}" + EMAIL_URL: "consolemail://" + DEFAULT_FROM_EMAIL: "errors@libnovel.cc" + VALKEY_URL: "redis://valkey:6379/1" + SERVER_ROLE: "worker" + + # ─── Umami (page analytics) ─────────────────────────────────────────────────── + umami: + image: ghcr.io/umami-software/umami:postgresql-latest + restart: unless-stopped + depends_on: + postgres-init: + condition: service_completed_successfully + postgres: + condition: service_healthy + expose: + - "3000" + environment: + DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/umami" + APP_SECRET: "${UMAMI_APP_SECRET}" + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:3000/api/heartbeat"] + interval: 15s + timeout: 5s + retries: 5 + + # ─── Dozzle (Docker log viewer) ─────────────────────────────────────────────── + dozzle: + image: amir20/dozzle:latest + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./dozzle/users.yml:/data/users.yml:ro + expose: + - "8080" + environment: + DOZZLE_AUTH_PROVIDER: simple + DOZZLE_HOSTNAME: "logs.libnovel.cc" + healthcheck: + test: ["CMD", "/dozzle", "healthcheck"] + interval: 15s + timeout: 5s + retries: 5 + + # ─── Uptime Kuma (uptime monitoring) ────────────────────────────────────────── + uptime-kuma: + image: louislam/uptime-kuma:1 + restart: unless-stopped + volumes: + - uptime_kuma_data:/app/data + expose: + - "3001" + healthcheck: + test: ["CMD", "extra/healthcheck"] + interval: 15s + timeout: 5s + retries: 5 + + # ─── Gotify (push notifications) ────────────────────────────────────────────── + gotify: + image: gotify/server:latest + restart: unless-stopped + volumes: + - gotify_data:/app/data + expose: + - "80" + environment: + GOTIFY_DEFAULTUSER_NAME: "${GOTIFY_ADMIN_USER}" + GOTIFY_DEFAULTUSER_PASS: "${GOTIFY_ADMIN_PASS}" + GOTIFY_SERVER_PORT: "80" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:80/health"] + interval: 15s + timeout: 5s + retries: 5 + volumes: minio_data: pb_data: + meili_data: + valkey_data: + caddy_data: + caddy_config: + caddy_logs: + crowdsec_data: + postgres_data: + uptime_kuma_data: + gotify_data: diff --git a/v3/docs/api-endpoints.md b/docs/api-endpoints.md similarity index 100% rename from v3/docs/api-endpoints.md rename to docs/api-endpoints.md diff --git a/docs/architecture.d2 b/docs/architecture.d2 deleted file mode 100644 index 4f0f89e..0000000 --- a/docs/architecture.d2 +++ /dev/null @@ -1,99 +0,0 @@ -direction: right - -# ─── External ───────────────────────────────────────────────────────────────── - -novelfire: novelfire.net { - shape: cloud - style.fill: "#f0f4ff" -} - -kokoro: Kokoro-FastAPI TTS { - shape: cloud - style.fill: "#f0f4ff" -} - -browser: Browser / iOS App { - shape: person - style.fill: "#fff9e6" -} - -# ─── Init containers (one-shot) ─────────────────────────────────────────────── - -init: Init containers { - style.fill: "#f5f5f5" - style.stroke-dash: 4 - - minio-init: minio-init { - shape: rectangle - label: "minio-init\n(mc: create buckets)" - } - - pb-init: pb-init { - shape: rectangle - label: "pb-init\n(bootstrap collections)" - } -} - -# ─── Storage ────────────────────────────────────────────────────────────────── - -storage: Storage { - style.fill: "#eaf7ea" - - minio: MinIO { - shape: cylinder - label: "MinIO :9000\n\nbuckets:\n libnovel-chapters\n libnovel-audio\n libnovel-avatars\n libnovel-browse" - } - - pocketbase: PocketBase { - shape: cylinder - label: "PocketBase :8090\n\ncollections:\n books chapters_idx\n audio_cache progress\n scrape_jobs app_users\n ranking" - } -} - -# ─── Application ────────────────────────────────────────────────────────────── - -app: Application { - style.fill: "#eef3ff" - - backend: backend { - shape: rectangle - label: "Backend API :8080\n(Go — HTTP API server)" - } - - runner: runner { - shape: rectangle - label: "Runner\n(Go — background worker\nscraping + TTS jobs)" - } - - ui: ui { - shape: rectangle - label: "SvelteKit UI :5252\n(adapter-node)" - } -} - -# ─── Init → Storage deps ────────────────────────────────────────────────────── - -init.minio-init -> storage.minio: create buckets {style.stroke-dash: 4} -init.pb-init -> storage.pocketbase: bootstrap schema {style.stroke-dash: 4} - -# ─── App → Storage ──────────────────────────────────────────────────────────── - -app.backend -> storage.minio: blobs (chapters, audio,\navatars, browse) -app.backend -> storage.pocketbase: structured records\n(books, progress, jobs…) - -app.runner -> storage.minio: write chapter markdown\n& audio MP3s -app.runner -> storage.pocketbase: read/update scrape jobs\nwrite book records - -# ─── App internal ───────────────────────────────────────────────────────────── - -app.ui -> app.backend: REST API calls\n(server-side) - -# ─── External → App ─────────────────────────────────────────────────────────── - -app.runner -> novelfire: scrape\n(HTTP GET) -app.runner -> kokoro: TTS generation\n(HTTP POST) - -# ─── Browser ────────────────────────────────────────────────────────────────── - -browser -> app.ui: HTTPS :5252 -browser -> storage.minio: presigned URLs\n(audio / chapter downloads) diff --git a/docs/architecture.mermaid.md b/docs/architecture.mermaid.md deleted file mode 100644 index f553ef5..0000000 --- a/docs/architecture.mermaid.md +++ /dev/null @@ -1,47 +0,0 @@ -```mermaid -graph LR - %% ── External ────────────────────────────────────────────────────────── - NF([novelfire.net]) - KK([Kokoro-FastAPI TTS]) - CL([Browser / iOS App]) - - %% ── Init containers ─────────────────────────────────────────────────── - subgraph INIT["Init containers (one-shot)"] - MI[minio-init\nmc: create buckets] - PI[pb-init\nbootstrap collections] - end - - %% ── Storage ─────────────────────────────────────────────────────────── - subgraph STORAGE["Storage"] - MN[(MinIO :9000\nchapters · audio\navatars · browse)] - PB[(PocketBase :8090\nbooks · chapters_idx\naudio_cache · progress\nscrape_jobs · app_users · ranking)] - end - - %% ── Application ─────────────────────────────────────────────────────── - subgraph APP["Application"] - BE[Backend API :8080\nGo HTTP server] - RN[Runner\nGo background worker] - UI[SvelteKit UI :5252] - end - - %% ── Init → Storage ──────────────────────────────────────────────────── - MI -.->|create buckets| MN - PI -.->|bootstrap schema| PB - - %% ── App → Storage ───────────────────────────────────────────────────── - BE -->|blobs| MN - BE -->|structured records| PB - RN -->|chapter markdown & audio| MN - RN -->|read/update jobs & books| PB - - %% ── App internal ────────────────────────────────────────────────────── - UI -->|REST API| BE - - %% ── Runner → External ───────────────────────────────────────────────── - RN -->|scrape HTTP GET| NF - RN -->|TTS HTTP POST| KK - - %% ── Client ──────────────────────────────────────────────────────────── - CL -->|HTTPS :5252| UI - CL -->|presigned URLs| MN -``` diff --git a/docs/architecture.svg b/docs/architecture.svg deleted file mode 100644 index ecd63cf..0000000 --- a/docs/architecture.svg +++ /dev/null @@ -1,119 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-d2-version="0.7.1" preserveAspectRatio="xMinYMin meet" viewBox="0 0 2551 1374"><svg class="d2-1368901215 d2-svg" width="2551" height="1374" viewBox="-101 -101 2551 1374"><rect x="-101.000000" y="-101.000000" width="2551.000000" height="1374.000000" rx="0.000000" fill="#FFFFFF" class=" fill-N7" stroke-width="0" /><style type="text/css"><![CDATA[ -.d2-1368901215 .text { - font-family: "d2-1368901215-font-regular"; -} -@font-face { - font-family: d2-1368901215-font-regular; - src: url("data:application/font-woff;base64,d09GRgABAAAAABRgAAoAAAAAHnwAAguFAAAAAAAAAAAAAAAAAAAAAAAAAABPUy8yAAAA9AAAAGAAAABgXd/Vo2NtYXAAAAFUAAAA5AAAAVIn1ClAZ2x5ZgAAAjgAAA0JAAAR9NRygQ5oZWFkAAAPRAAAADYAAAA2G4Ue32hoZWEAAA98AAAAJAAAACQKhAYAaG10eAAAD6AAAADbAAAA+HOVDHNsb2NhAAAQfAAAAH4AAAB+lBCPhG1heHAAABD8AAAAIAAAACAAVgD2bmFtZQAAERwAAAMjAAAIFAbDVU1wb3N0AAAUQAAAAB0AAAAg/9EAMgADAgkBkAAFAAACigJYAAAASwKKAlgAAAFeADIBIwAAAgsFAwMEAwICBGAAAvcAAAADAAAAAAAAAABBREJPAEAAIP//Au7/BgAAA9gBESAAAZ8AAAAAAeYClAAAACAAA3iclM5JTpoBHMbh52MoLYVCWzpPHx0otA4IosJOE02MMRoT18QbGFceS/cO8RRu8RIG3fxNcKNL3/2T94dEVoKSXLKGtlROVaqh5b8p02bMauvq6RtYsW7Ttl1D+w7TWtqIYCKaj0RHz5KBZas2bNkxtOfgXsSVrIqKvIKMYtzGTYzjOsZxEedxFqdxEpdxHEej+qg8aXzaEi2Lk/8589r6/j2o68jIysl7puC5F4peKil7paLqtQVvvFXzznsffPTJZ1989c13P6Tqfvrltz8a/mrq6XIHAAD//wEAAP//0nsyPnicdFd7bFvndT/fR4qULOpBk5cPic97JV4+RYqX915SfEl86WFRlEjJlmRLsmzZsvyaraLxnDh217iJkWUZi6ZIkRmtu3ZYuzVo2gJuAyPDFjeeujTJBhRN0sRu0AFa0KTrKrBrk0aXw72kZLrb/uLFx+/7zjm/8zu/cz5ogjkAzOJnQAYt0AF7gQBg1HZ1r52mKSXP8Dyll/E0Uivn0LtCGaHRkJzj5P2pD1IXr1xBs5fxM9unB66urr6yeOGC8Beb7wtB9Pr7gEEGgM24DC2gBtAoGdrhoCmFQqZhNBRNKV+1vmLda+uUd9h+dm/x3lzi10n0Jysr/JlI5Iwwj8vb5zY2AABkMA+Ae3AZ1NAFlOgbE9TpCK1CSUg/CkrGBDk25KAo9c7H/O30sUi/P7oveW7s8vL0WD5/bH1mcWH/Oi7bcgP9hQ5560RmcL8bXRwIRgLblWQqHgEAJNpC93AZWiUMCDvBEBRhJ+bRw8LbH32E+nE59/rwfw7v7v2JFN/9vWpp58cf43LuXk74mbQvVK1gB74ONoAm0uFgQxzHBHV6pcNBkQoFodUxQY5nKZZRKxTo0P7HxyeuHcgsmPqMqWDqCHt+jRrU/Pmb1jXV1JdOnfxSkbFwXT1DDxUvfZ7Y+/dZ4UO7p+4LDu74LWaPUVNqu3q+hPqnp4U3cFn4FdJsn0Os8OqO7/A8Los5EvfPl0TA6/ckcRlUtXUGMUoNJVMS8yUZUi++9quFH57HZeH7aPRj4SSa+dy/7uDwY1yGprptYr6ErLi8/X0Rpvqdj+IymKX/NTqdnuE4XiN6GOJ4SimjZDSl0xHq+ZXLKr1KriJUl45NNMvkoUv8pZBcpsRl4a/JLElmSbS4fQ6teU95vih8C01/0XPKKzwLAFjC+Bi+Dh2ilQaUJYLQQU6iBSmCrUPjxSvDw1eKpcsjI5dL0QOBk7OzJwOzqunn1taenZp6dm3tuenR9MXiw08//XDxYhqk+8UYWiV8tQ0MpCj1fcrdGjubePz06SP7Swf2L+Jyz8zI6orwCRoZyg3zu3fYcBnaQd/IYg0la7zmtfSJ6GTmbxe/fOFsvljMn8VlaiozvqAWfoEI4QM0lxwcCtVwdVcr6Nf4OvikiGlep6td4qDpPvwgy8S49XoLFtFAndmHPEFqiRkaMfdbF61xF7sYja5QPstoH5+2B7sWHPEebkXFegd6fdEA6TS1u9rcqUCw4PP1cGZ7yGt1dbU6O31D/aGZICAwAaBPcBmUYlQUayco9S/uoPfu4LFcbvtmzdcD1Qruw2VRW6TsqBm1VvKXkz4VCpROn0qUXFmPN+eaTJxUcZfW0J8JjxYOOhwHC+gx4craJa6WZ/QdtAVd0AOgJ8U08yEpRCUtBUyoKVFkaLGkpOJ6OT71l3+l9jjdY2YbeXRgbjKjlJFTOipBXVwOqkaHJmfU1jBl00Z0rjMHhZ8OmNwp0vpER8zv6gUMxWoF/QFvgKZevTSlpNQMoazZqnGrRi1RjZCLHLXJlKkithecS0eiS7lYIZq1DlK2pMpuDuKNl2fN9OPnSw8lsqvzk0dJW9Wkr+HTV62gb6MtEcv/XyP0CgXaO3giNnQqEcga3YTf7M3SpTQ5oOuxT6pi65PF9Rip5zQG/0y4tGrW8ma7yDt/tYLe3omhhpl0Oc0yO2Dx7K6h3x88G13m3QmbvJRRykzjxsGYNWKhk46c6nMXC59KWLpKt7bDEZMrmxZMen8pfOAoYMn/f0FbYADrAxGIBLfrdryX2SWokH7oZCK5wi8cQ1j4QdOBHBXtNlsLryJ5MsJMqeLrhcn1xKUTbcaW/CFCzWktyDGWL0g4WQBQEv+k1qMolmdDdZwokpD093AqlR3Vuzv3dpsyq6voa4mm/NiBFmVStZhPCwtSP/FVbehDtAX9EIf8LotYR8OPdClDUPUGQ9K1HNRzLgvelxNNvW5JR23Pf8+dc9j3GkmNgQ5O92t72r65otYHJoM02ba3t39xZiZ2dtwdj3k8sTiXm2b80+32zi7DvvcySWtEJ291mqx9bXJtxsNOuJVNyU7WGhp3qVu7tXoLH/eN+9F3kiwbi7FsUrgWd5BdcrnGTdB9EjZFAPQm3qgr1A5HRZWV+KkuFmVUPpgfLnoDvdFevPHyit2/vCD8GLkyCUevcAOqVcgCwPfwTeyAKAAoIHYJAKrV6ltVGr4rrcdr64/Ars1NvLHbLzRiv6CVRHFK9sbBr704//RBvCFYENwW7v7y5GfqZ6oVeAtvQEcNe0kK6gT5Zp+r2N4iVypbm3WqCIuPbz+jUSOUkMtrtvBv0BbYJVtiExGz9ECUyt3fYkYps417wskOx4R332jR28dlil4/l0GbOcrf73WFdkLfJ9yo/+xgiLbqGNZtNGKYUcqoiV0QpcsewLBeC/+FtqADuv/PXrTLHdQRXU0mV6Ox48nk8Vgyn08mJibqdRxbL06uxzKrpekTJ6ZLqyBpEYP+gLbqdXzfO4mhDlpPaBq1SPTUXvAsHokuhck0iS9IUpTssSdew98Lm5xPnC8+lLB0zXwdKf5Ii0QMFtGWOLXtYlBXohoAxhGXWd+p0nZY00a0OdvH7RmRy4MJYaN23lStoMfQFril/Db2Jakt/VFXqjWlfwstUi5bxhMI2JluMuWeK/gmTE4jZ+vzWALdVMbnKqhoE2+0+6xGUr+nzc66ogWbPqQxuE16M9HaZuf76JRTsm+oVlAWnxW7rMQviuV5RhKHXZ59MBEfGd+Tfewxu7vNourU+lXzI6gt0XTtWlrY8vW3yBPKVumufdUKeh1tinx4gKvqunS+lx8peQKOKCniQo6rlhdQSHgzk6A9aE7oGncGAIm1gf4ZbUIbACNrmH9kt749c6hV3ypv1e85NPUttCl82DNCUSM9SCt0iXEA4JtoU+J747mGGyhZbaZWyr7yxPRIc7tS3tzZsm9yvEXdLG/uUA5PfHYl19LRIm/u3JNBm8J/kGmSTJPI2PDVhZqoTG9vlhI+AQTtAOgFtAlGAIanGX3dFM8o9VR9flcq27/yhbmhVkObvFXXGt3/hS/PDbd1tcvbDKqU8P4pjVurdWtO/ea353VegvDoz0uaa6tWcBFfh1ZgYQhAoxULt0ZWTb1C+MamoVDqxB08U/tSOkR5pWvYS98vqdt9abO91+uJHgz2DpBavSPri2e86V7bbJ/F35HXRGgy0a0j887exe/u48ikKTBHkQGMuwcsliGP2cNt/8hfZL0ZTu/K93hzrpEBTybcHVyiXcvhwQshva05s6fXRLpu8SmT0b3CmuKAwVutoFfwk9C6w65Q3eHGuv7o8Jkzh5fOnFkKZzLhcDarev7GV7/xja/eeD515amnHnnkqaeuSNzqAUA/xE+KKsGwCVwvXfo+BBzHMITz8OO5WNyZMfmdBxNzx9OfHu8KG1/sP/z5TzN8zmfze9nVmdgjTxSwfBgQdFUr6B/wk/+79ih2dwi+b2LnBfXh+HGb2zwRHhij58YzBTLKONNmb+98uHR6MDQwGV5S8RRn6RtkHRFb0sbZ/VyPOUT5ZvIDY1p5WykVLnoBgbFaQf+EL9enjPu2JZMaO0Ep78vgL8dX7E7zeDg6NZaw+81eAiV/p9b3mfk5Ln5Exdk5k6+QTo1pNSbEDL+kavfMZrPLwdp70FWtoA38JFjBCxHJVo0/7IMEImrKImuc/WV1IZJa9cexRZ7iLRQXKDKlZZNTaw7amAW1jRpgvVFXpimcDRT6HExB5ZsMuof6O+XGkWD/mOvwmD3q75B3euMe/4QPnTAPUv5U2O8IUsKdZL8r5NhrzHnZbO3d4KxW0D9KfHFKrK/xt8b6OkIcL+l3Y5P4dDRqG7Y2j8T7hmaZfFeflreIvd8y6SweDc0wyZVI9ix6KTHq9C0s57d/T5tCelPoT487vEePxA+FMtdWP3O9/vYaqlbgB7Auvgsb2fqokaKMBopSUd1mijJ3U+Jef3U/3IF12AugpzmOVpBUw5G01hNAWIENVI/R1pv7u4Am6URmU7c15BtcFmeEmi30DqYhAIDWQCH+VquwWK3IPgXr4ICAGYCGADoNAEoI4FuSjy54B3WgLvEtyrMM4dp8J5msrf8c/w4Zaut2woVf/3kkIupp9bPo/eqL4rqetRMq9O5lnpfmiknUgt8VdVZfG+b0Up71P03kcglmIBIZeOHY3atX760Ylu6ur99dAgSO6iTcrZ+hJaaIWSG0ijlpP5PI5V6o7zas3Lt69S4g2FM9jKbwHck+YtAe1BoTfntDdvyT52qYk3ACvYG9IubSs56Vmg/x1s2bQzdvnriduH07cbveZ+HraHPnDV4sok1R96s/wmPA45vieXVDAgxWq8FgteIxs9FgsRiMZgAkzWV/gzbrs9ROzUlPKpuut03dYmjrMRRjbzc3JWRNjBebt/99bPZ/AAAA//8BAAD//wvX1CIAAAAAAQAAAAILhYgYvs1fDzz1AAMD6AAAAADYXaChAAAAAN1mLzb+Ov7bCG8DyAAAAAMAAgAAAAAAAAABAAAD2P7vAAAImP46/joIbwABAAAAAAAAAAAAAAAAAAAAPnicHM4vS3NhHIfx6/s94YGHoU2mjHHQIR7/7JSDYjCImDT9ingLA6MvxKbd7lswm2exGPQVGNUbZG7pyExXucLHt5wzBpcUPiV5kaQPkq9ofEPSP5KPSHon+ZXkOxrvkrxP8hKb7tLzNWea0rgiNGboLWp9MdSAvqbsuCSYcMxP+6ZPgpYoDgivEe7//aELQvf0FHRdcqIXOn6mq0cWfEmpzLYyA2VWlFlWplJmgxmHzKjnLR4YaY+qKKn0TUc1oZp1jfivzCoTAtqnueMXAAD//wEAAP//tpg1VAAAAAAsACwAUACGAJwAsADiAPoBBgEgATABYgGSAbQB3AIgAjICVgKOAsIC8AMiA1YDeAPkBAYEEgQeBDgEVASGBKgE1AUIBSgFaAWOBbAFzAYGBjIGlga8BuYHJAdYB64H7ggECCQIMAhACEwIWAhkCH4ImAioCLwIyAjeCPoAAAABAAAAPgCMAAwAZgAHAAEAAAAAAAAAAAAAAAAABAADeJyclN1OG1cUhT8H221UNRcVisgNOpdtlYzdCKIErkwJilWEU4/TH6mqNHjGP2I8M/IMUKo+QK/7Fn2LXPU5+hBVr6uzvA02qhSBELDOnL33WWevtQ+wyb9sUKs/BP5q/mC4xnZzz/ADHjWfGt7guPG34fpKTIO48ZvhJl82+oY/4n39D8Mfs1P/2fBDtupHhj/heX3T8Kcbjn8MP2KH9wtcg5f8brjGFoXhB2zyk+ENHmM1a3Ue0zbc4DO2DTfZBgZMqUiZkjHGMWLKmHPmJJSEJMyZMiIhxtGlQ0qlrxmRkGP8v18jQirmRKo4ocKREpISUTKxir8qK+etThxpNbe9DhUTIk6VcUZEhiNnTE5GwpnqVFQU7NGiRclQfAsqSgJKpqQE5MwZ06LHEccMmDClxHGkSp5ZSM6Iiksine8swndmSEJGaazOyYjF04lfouwuxzh6FIpdrXy8VuEpju+U7bnliv2KQL9uhdn6uUs2ERfqZ6qupNq5lIIT7fpzO3wrXLGHu1d/1pl8uEex/leqfMq59I+lVCYmGc5t0SGUg0L3BMeB1l1CdeR7ugx4Q493DLTu0KdPhxMGdHmt3B59HF/T44RDZXSFF3tHcswJP+L4hq5ifO3E+rNQLOEXCnN3KY5z3WNGoZ575oHumuiGd1fYz1C+5o5SOUPNkY900i/TnEWMzRWFGM7Uy6U3SutfbI6Y6S5e25t9Pw0XNnvLKb4i1wx7ty44eeUWjD6kanDLM5f6CYiIyTlVxJCcGS0qrsT7LRHnpDgO1b03mpKKznWOP+dKLkmYiUGXTHXmFPobmW9C4z5c872ztyRWvmd6dn2r+5zi1Ksbjd6pe8u90LqcrCjQMlXzFTcNxTUz7yeaqVX+oXJLvW45z+iTSPVUN7j9DjwnoM0Ou+wz0TlD7VzYG9HWO9HmFfvqwRmJokZydWIVdgl4wS67vOLFWs0OhxzQY/8OHBdZPQ54fWtnXadlFWd1/hSbtvg6nl2vXt5br8/v4MsvNFE3L2Nf2vhuX1i1G/+fEDHzXNzW6p3cE4L/AAAA//8BAAD//wdbTDAAeJxiYGYAg//nGIwYsAAAAAAA//8BAAD//y8BAgMAAAA="); -} -.d2-1368901215 .text-bold { - font-family: "d2-1368901215-font-bold"; -} -@font-face { - font-family: d2-1368901215-font-bold; - src: url("data:application/font-woff;base64,d09GRgABAAAAABQ8AAoAAAAAHkAAAguFAAAAAAAAAAAAAAAAAAAAAAAAAABPUy8yAAAA9AAAAGAAAABgXxHXrmNtYXAAAAFUAAAA5AAAAVIn1ClAZ2x5ZgAAAjgAAAzjAAARoGYuHetoZWFkAAAPHAAAADYAAAA2G38e1GhoZWEAAA9UAAAAJAAAACQKfwX9aG10eAAAD3gAAADaAAAA+HrFClFsb2NhAAAQVAAAAH4AAAB+kMqMVm1heHAAABDUAAAAIAAAACAAVgD3bmFtZQAAEPQAAAMoAAAIKgjwVkFwb3N0AAAUHAAAAB0AAAAg/9EAMgADAioCvAAFAAACigJYAAAASwKKAlgAAAFeADIBKQAAAgsHAwMEAwICBGAAAvcAAAADAAAAAAAAAABBREJPACAAIP//Au7/BgAAA9gBESAAAZ8AAAAAAfAClAAAACAAA3iclM5JTpoBHMbh52MoLYVCWzpPHx0otA4IosJOE02MMRoT18QbGFceS/cO8RRu8RIG3fxNcKNL3/2T94dEVoKSXLKGtlROVaqh5b8p02bMauvq6RtYsW7Ttl1D+w7TWtqIYCKaj0RHz5KBZas2bNkxtOfgXsSVrIqKvIKMYtzGTYzjOsZxEedxFqdxEpdxHEej+qg8aXzaEi2Lk/8589r6/j2o68jIysl7puC5F4peKil7paLqtQVvvFXzznsffPTJZ1989c13P6Tqfvrltz8a/mrq6XIHAAD//wEAAP//0nsyPnichFdrcBvXdT73AsSK4FIksFgsAOK9wC4eJAhgsQAfIEGK4EMUwIcoPmTxIXEcWRYpSpGoiHaoqDOW7SZB6qSUVSlqbUdjt2nGau1qOqOmo840beyqUfOjdqo/iWR7PBonnrHpFEljm8R27oKkKPVHfwB3586995zzne87516ogCEAPIsvgAYqoQaMwAJIBo/BL4kiT6WkVIrnNCkRGaghbCy99qoY1AaD2pD7kuvpmRmUn8YX1ucP5Gdnfz/T2lp66R9+XPoOOvVjAKx8AYC7cAEqwQDAUJIoCCKv02kYieFFnrpf++2a6rpqLW394vabt/888HYA9afTsQUpcaz0LC6sL165AgCggTwATuMCGMAGXuKbFDebWZOOYtVBx2ukeFJOCDxvkOLqmL+Xne9oCMS7sid7Z7qTsXiiZ+SpdNsILjh6MuGRGm31ns6ufUH0fIgX3KWJibAfABE76FNcgCo1ftbDSizPetg8ulT68u5dVIMLy898/eIybK79SI1t29o8ulL6wwcf4MLyny2vg7ouohRxFl8CF0CFVxDkRDIpxc0cJQi8V8eazFI8mZJ5WTLodOjY/u+Njn93vPewO29tCuUOTh4wCfT8J96v0rkXj81fHk54ps3OE7OPn9DrTyyV3vVEyn7g0U2fJVaSJQNv4A35lfcvXHgfF778cn0R1ZZWN32G93ABNOpaQ36FgLxxxjwuAF2elxhJw/Aais2vaH9y9ae//sHLOVwo/Q5VldZKS4h5/G834/8AF6CivMfD5lcQxoX11WXYtIWv4QKJm5xoNnNSMpliJANPIEjxFMWLIu/ELJv/wZN6o16rN+ifeOU5qlKjlaeGpxJa7Q4KF0p37e1OZ7sdedcXP3UPDrmufP75FdfQoPtTAKxi+w18CWqg7iF0VVKIZUqoIKOxiWf37Hl2ovzfNTDQ1TUwQO+9fHTuxcHBi0ePXt57bnF2dmFhdnaRnEt8j6p4mLaxTcfz7Ba/3us73dOz2D3ct9SRzuKCODmYm238Fdp7RAqR+MtnjOAC7ARuO2MJ8ckpZbrmP+4+mc3IF147O5xraWtryeGCf2Kgb4orffnxx+hgLBoVCJa8UsR6fAlCapRiymwuHyCKEfx/CMVxZW+RqeNcfB8/Fog0SOFRT1pofTLbdCK0x90hCg3NoX2tPS0LdDTyFafgdbgcRt/Oxp7G5ESiPjRlrXPZnU6D17KvOznZBAisAJjBBaBIJLzsYXnD7evoi+u4dnl5fbWc791KEQ+qGiU+ygbJoHJb/dChgXPPXWhJpdJ/8gx98VU0XVo5mMsdRMdKV1+9CBhCShG9i9bACjwA5yVpTKnhUKIaHGvgSd1IEanoSIz/lB06v4L5oKvDJzfOtcwcXtJrXb07rH5mIO2ixzMDEzUe0cIecvgWTpY+lOz8SY4Z14cdFk7NTadSxGZ8E0wbqhR5ijdILPUIeXgvKTCo29Pl0NKnVrSOrDc90ZiemRCSY/VBU4D2uGV88/WczdH+1dzoU5mlntxzDT8z7lTx8ClFdBOtge1R3T+QPafTIWv38c6+r2UjvfZu3i1nMlFLhGnxj9Ftp/eOLLY5uRlHrrMjz9YcdNeVeSUqRbSGbwID7k2s1INFIvwtlDbJ8NvJ460ziWCTVbeypNfaerBFNDJhE59spL/91PDpdrsl99frXTEbv2Sy/sy4s6t3dzdg1ff30RpYHqlaKoM9hH3Ed42kygu5ek/u6ppv7Z1q1OLSHX1PTE7GhOnvXxfrvUm6fXHv8GImM5dl/JVJybPf5kQtQbmxzBkLAFrEt8hIeJV6hMukpBoe27XLN9TlStTWVdvoOuf+/ejssYo6eSxB6+YrKjyC81TpGdIjvEoDptAaNEIr9KvICHKCAEHIJG+GwEksvyFmr6jmgdDLpNNptlULZkOdXkFd8tuW6aZeps5tsQVbpuV6z98PUpWJiZTDZfQGhyYPZZf7HaLocIhiMN4h+iWrh65re8fWVJ8OaKsDrrp4rdaYDacHA/RcldfU3O/T15gZY2uXNBxBt0JBMRgIBEOlFZ+Vq9VoLFa7o4xNJ0m2ylG1rlObQjCoXlKGzhXKvic+vHvF4bYHLPjm6/ut4bmp0m3kSQasXOlNUBRIAcCv8DtYgFYAoCAN3wJQFOU/lDTcU+fbNuYLWzad+OZWH0hJpF5RbOcL2r945W/+8eUTGXyztPDT26Vf/nPv02S9UkRGfBNqykzc1Dwhx7/lWlcMlRWUzkj76QN7ML9+hzMidKyCKtvRONAaeFQ7pDGQrD8UIbU1dhJt98TkTsbTHxvas+Jw+6PkrxGtdrgawgFvbDPsaOnNjWETP7S2gd+Gje34Lem17vwWgGg142x4CL+yDlRO/f/9xZw5ns0ez2QWstmFTEMk0hBpaNjQcNviyN7TbWfyHZ05IuVy/enDZrQGDDgBuAfeqbQURI5lHpQf4qdjt/jYkfRM0p22VQwKybFwyBS4gX8Ys/HfPDW6lKmzDn4P+baKjxo7egGtgfEhfMuqKkdelxNYu95Sba21t5nQ6ng8VlFxTqsNxkvvAQJWKaKX0RqIal4f9Buh3G+2DiPdxolZk+6d2BPCLm/G5XE6IjZna+DJ0eZx1y5bwtbcLLjbgkdowTVpreMYg5nR077mYPeYaJkwmUWLdWcV3xzpmipz3qAU0QJeJB2T9BCZl1MpSb1YPSiYMDmYzRmePnOGd9BWPcek6KNjt47pzp8/9XbIr9PO6ejyWWmliP6AVkn+H+KmYaNM/tfw7hWn2y6YV5aqNK5+em4KJUr35KDNgfpKtd3+ekBEB0hBq1ANIGkkbuMOk5I01//qQoee0WsrGX3nd66i1d/486KY9/+mVLtZ1/AqWlX5vX3fthP4jbswRV1Y/tOoTq/TUtWVqXNNlTWUlqqkGv/4zOsNVDWlpaqoerR6398nCP38fXXs898v1b7F9wQCPfxbqj2S9CJaJX1aYsRtZijugZ2dl154qV5v1mt3GHd4L3338ktRmqO1laZKEeFPhtgwy4bZIeWzvWw9y4bNe0lNtSpF/E18CaogARkAxkREWiY9U1ZDaksThLyUmSxISeUvShBEnU4ksKfUz/er9HzUzFm9ht5Dsd4ow4aHkv1jgXavvctnFejnjLLgarbygdFQ8HAhGQ4G/d0Oxoo+MQZMbMTD2cX1D6XReHbUxXe7GvONQ9lQl8y522zugUjrglTLas/s8Fpc/L/4IzZX1mcQ1H7pV4roY/w8VG1ouMxf1kT0q3Ka96pXCjPacfjs2cPkZw1wXMBqCVgsAfpHV6++9trVqz866Z8eH5/0eifHx6fVt4ITAH2Inwc7gCS34zIqG/eVMhjJpCSx/uGzPbGgN2UZapzNZqbl1smEJW3+o335s082NMZE22Bcih9ok48fT2oqlsm5ZqWI7uHnIfio9nh5s+Bs3ooePID+O3+Mzzp6Ao1N9v7usY6A4E05++tnW2afSkmp3s45Oh6YsvtEnz1oPtIoePxO22NC+MBIrMesrc23t46Ey5rhlCJ6D3+ddP2HbKs2GQ/LU1vR/c/AvLDLkQ3EWprq7X7HLiM68usqj5A60NR5lE74p2z+eCwa32kMoc7lMzWh8WzP4wn1LUfueh+puImQ2KqqDzr09rA0j/ZkwiJSbqnM4y2ZBn80Mdk6fjTuiXQ0fcUuBn2OUJr2R73pAGtvoesHpZZ+i9beF08OhmYGI71mrXUgEx+KoG80RP0NPr9YX/qFGLD7HQZGdoQaAYNXKaL7KleCKttJvRA22L6BRjKl1uiHOsEPoz6rxOhTXne0LTPlHKhN2n3NPmzd7UiOxlsONrcvdO8+jf4uHnJ49zTHSnTEOWGs84/3u3yh8a5dB6XOb5342uXdgKBNKcKncI285bhtTL0oSJIgSBItiwFZDohy+V3ZjgCukRrPicmk6PXy27bknE0tCGsxn0wK8cTkTwZMnf5wQIj0d+5dAnIHKNtCn2ERogAoCzoyKgrklKLmRbgGAkRrAESIogPqHSGK/1LlSBhuIQ+KkTdkSpbY8O9vHTlSnv85/h2qL8972DD+959PT5Maqiyij5S3yTwne1ga/bIwMgII+pQ8CuB7pLZyZQpwam6525nu7sxkKh5PXX/i7vnzd58QDt2ZO3pnFhBElTyq3dgjquolWWFNusJkUzzeNJnp7r4uzN45OnfnkKDuBQTVykGUxG+p9hlJU33r4K1XNIfXvk989sI0+gQnCd7qG1wuN5v/fOON+TfemL5x5MaNIzc2+im8i1Y3382dK2i1VAtIuYabYQS/Q/YbtoHvj0T8/kgEN4d4PkR+pI2QO9cv0CrUPqQt9XWk87mCNTY9o3dwK+78v+7QzWu0YhB9VmKSj6XgfwEAAP//AQAA//9a4r34AAABAAAAAguFViWMQV8PPPUAAQPoAAAAANhdoIQAAAAA3WYvNv43/sQIbQPxAAEAAwACAAAAAAAAAAEAAAPY/u8AAAiY/jf+NwhtAAEAAAAAAAAAAAAAAAAAAAA+eJwczrFK+3AUR/Fzv4Hyh3/Un1BjuxSpEaGJwU3BZribQi8IKtTBUR9D38Bd3Jx1cfUFXFz0VZy6RNrpLJ/h6J1zPkFtt9ANoZJQTuieRi+EHRC6I9QjtCD0SqNLQreEanZVM9QTZ9pgoha3H0q17KtHaVcMVDDWCW59jqzovlTiNsKza1xTXNXKuz3i9sGWPbCpY6b6T579YyCxrmeGSuwpMVKiUGJbiYkSY6tpraZZNvtmZjOqbIfKfsltzqnNObQL1la2j0P3tvz4AwAA//8BAAD//7qaJHgAAAAAACwALABQAIQAmgCuAN4A9AEAARoBKgFcAYgBqgHQAhACIgJAAngCqgLWAwgDPANiA8oD7AP4BAQEHAQ4BGoEjAS4BOgFCAVEBWoFjAWoBeAGDAZuBpoGxgcEBzYHhAfEB9oH+ggGCBYIIgguCDoIVAhuCHwIkAicCLII0AAAAAEAAAA+AJAADABjAAcAAQAAAAAAAAAAAAAAAAAEAAN4nJyUz24bVRTGf05s0wrBAkVVuonugkWR6NhUSdU2K4fUikUUB48LQkJIE8/4jzKeGXkmDuEJWPMWvEVXPATPgVij+Xzs2AXRJoqSfHfu+fOdc75zgR3+ZptK9SHwRz0xXGGvfm54iwf1E8PbtOtbhqs8qf1puEZYmxuu83mtZ/gj3lZ/M/yA/epPhh+yW20b/phn1R3Dn2w7/jL8Kfu8XeAKvOBXwxV2yQxvscOPhrd5hMWsVHlE03CNz9gzXGcP6DOhIGZCwgjHkAkjrpgRkeMTMWPCkIgQR4cWMYW+JgRCjtF/fg3wKZgRKOKYAkeMT0xAztgi/iKvlHNlHOo0s7sWBWMCLuRxSUCCI2VESkLEpeIUFGS8okGDnIH4ZhTkeORMiPFImTGiQZc2p/QZMyHH0VakkplPypCCawLld2ZRdmZAREJurK5ICMXTiV8k7w6nOLpksl2PfLoR4Usc38m75JbK9is8/bo1Zpt5l2wC5upnrK7EurnWBMe6LfO2+Fa44BXuXv3ZZPL+HoX6XyjyBVeaf6hJJWKS4NwuLXwpyHePcRzp3MFXR76nQ58Turyhr3OLHj1anNGnw2v5dunh+JouZxzLoyO8uGtLMWf8gOMbOrIpY0fWn8XEIn4mM3Xn4jhTHVMy9bxk7qnWSBXefcLlDqUb6sjlM9AelZZO80u0ZwEjU0UmhlP1cqmN3PoXmiKmqqWc7e19uQ1z273lFt+QaodLtS44lZNbMHrfVL13NHOtH4+AkJQLWQxImdKg4Ea8zwm4IsZxrO6daEsKWiufMs+NVBIxFYMOieLMyPQ3MN34xn2woXtnb0ko/5Lp5aqq+2Rx6tXtjN6oe8s737ocrU2gYVNN19Q0ENfEtB9pp9b5+/LN9bqlPOWIlJjwXy/AMzya7HPAIWNlGOhmbq9DUy9Ek5ccqvpLIlkNpefIIhzg8ZwDDnjJ83f6uGTijItbcVnP3eKYI7ocflAVC/suR7xeffv/rL+LaVO1OJ6uTi/uPcUnd1DrF9qz2/eyp4mVk5hbtNutOCNgWnJxu+s1ucd4/wAAAP//AQAA///0t09ReJxiYGYAg//nGIwYsAAAAAAA//8BAAD//y8BAgMAAAA="); -} -.d2-1368901215 .text-italic { - font-family: "d2-1368901215-font-italic"; -} -@font-face { - font-family: d2-1368901215-font-italic; - src: url("data:application/font-woff;base64,d09GRgABAAAAABS4AAoAAAAAH1AAARhRAAAAAAAAAAAAAAAAAAAAAAAAAABPUy8yAAAA9AAAAGAAAABgW1SVeGNtYXAAAAFUAAAA5AAAAVIn1ClAZ2x5ZgAAAjgAAA1bAAASqObuwnFoZWFkAAAPlAAAADYAAAA2G7Ur2mhoZWEAAA/MAAAAJAAAACQLeAjiaG10eAAAD/AAAADeAAAA+G/yBWtsb2NhAAAQ0AAAAH4AAAB+mWqUoG1heHAAABFQAAAAIAAAACAAVgD2bmFtZQAAEXAAAAMmAAAIMgntVzNwb3N0AAAUmAAAACAAAAAg/8YAMgADAeEBkAAFAAACigJY//EASwKKAlgARAFeADIBIwAAAgsFAwMEAwkCBCAAAHcAAAADAAAAAAAAAABBREJPAAEAIP//Au7/BgAAA9gBESAAAZMAAAAAAeYClAAAACAAA3iclM5JTpoBHMbh52MoLYVCWzpPHx0otA4IosJOE02MMRoT18QbGFceS/cO8RRu8RIG3fxNcKNL3/2T94dEVoKSXLKGtlROVaqh5b8p02bMauvq6RtYsW7Ttl1D+w7TWtqIYCKaj0RHz5KBZas2bNkxtOfgXsSVrIqKvIKMYtzGTYzjOsZxEedxFqdxEpdxHEej+qg8aXzaEi2Lk/8589r6/j2o68jIysl7puC5F4peKil7paLqtQVvvFXzznsffPTJZ1989c13P6Tqfvrltz8a/mrq6XIHAAD//wEAAP//0nsyPnicfFh5cBvXeX/vLYTlAR4ggF0CwkFggV0ci3MBLEBcBAGCJAjwFA+Jh6jTOhiFlkzFruQ4tlKNopkosMedTjNu6sZuJx1Pa43cdprE40zrTE3blafTUVo7ttW6tulUaiY2B1WOMXc7uwBJSH/kn+WbXbz3fd/v/X6/7z2CPcAOADqDngUYaAYdoAvoAOA0VgzjeJ4iMY5hKBznGY0Gtz8F15/6riJ34FPn879lLYrBJ/9q5H+XX0LPbq3Abyw+8YQwf+XYsdm7dwU3/Pe7AACAxLcBgD9DFdAM1ABocI6haYZSKiHkNBRD4R/3vt6iaFEoDJzwL/DogdJE1y9OwsdWV8OnYvGHhAlU2Vq9eRMADFAAoB5UAWpgkMachgsROq1SieOE/JfCuFA0Eqap3QF16eWDK56cHXKFwYujvUtLBwaK86fPLp0pDz+CKsVBNs82KVTZ2PAiC88P8t7Q1p2BUigl5Q3leJ2oAlplLHArzuEUbsWpS/BUm/Cx+4v2X3GQbkeV7M/67/U3/L654fdY/dfeL9o+T6JK9pN+4T+kteNiFSXQc8AGwB4bHQmnERciSJymKVs70mkJggtFeZ7iOUyphOz5xwPzT04kJvS8hnemDw/YqVLGHtc4rrS9E7cvqZ5+dPTZrxV4t6uHSR19NNm7FO3u/Ls+4ROzY6eOk9t1cJhVw2GUxopRl0Zj0BkrXxrNCO+mUUW4C3VbqzAmrNfmgCqqAKw2h7o0eknaiJ31ZlEFqGrfOMjhGgrDcerSaBaDw3P3/mji69/yoorwKsx/KazAw5c/2J4Hn0EVsKeehzThPNS2ocrWjW38XkUVoJe/a0iOlzONRnkKxyhM4gyOUZcW44Si8PripZFSs0GlGPtHNkUolO1NRVQRvnflCjy8tQrPsqc8zwgvwoVn2JOscA0gkBSraAk9BzpBTyPihE7bjphQGklsqSEPLWfWfDNrheKxsG/mkVxkNm0rjkrPYdUfXxyprA3kL0yNPL02kEseXosfWkscXutdPi/xXM7fK+OsbWAnRWGaXTr+cOFs8cl9J8PZg8dOlYaOoUpxZvyhoPAbODg+FufAzjoMqoA2QOyuI0F830p/u/DwmalzUytn+fyRpaMjQ8uoUpiaP6MWPoaEcAdOTxai/hqPVWIVCug54AaAtNEML5MrEqYZhqYj4Wh0h3lKpU5LkGRNTZ/lVp1x0zSfnPA6Su5EZCGRWLZw+oLPETEF7SV/OHFc1dvr8YTyMXuI8BmG+dBkKOz0mV2WwF7aT3iNg3zvfBhAsAgAiqAKwKVqKN6KU9hfrr3WBt9u+8kaKudyW6/U8hwTq+g4qkhVyzsUlfZfSknSgkZWgvnoaaWiODrS3DcQO6CbKE0an1KdPK7z6+Gq8C2vrVBeOA2fEU5fe0zCkRGr8DdwE2glRMldlXE8h1E8pVQyksZ2JPdKX4ktLnFMSq3QpA9lmhTUXBc9Zmd1IaM9F7EEVfPThccWOKc1JRiGHP4+n/892uYeXgxlUrV9s4hV+DlaBzrJTSWkKZzScDjOyRDfxzTZs+4wKTWmzVwrMwSy7/PK4SP2XMQccNkmKJ+WUzmtKbT+2rLJc2BGCt3nHl7k0im34zPaBiBwiFV4A24C433V7e6kBBupVL47dpQtH4qwScKroU2BmWi8tydK2Axl1fHF/Llpv00fIHX51Vx/waAOaR21WhixipiGWnax+/3g9XZhnXS5Ukdv1PEgekzPwde2Yg/Ch+RafgI3gQE4GuPJzLcqie1aMC4qMVeq8JOZk96RhQCfNav2CD9t7sm5TXHSbJr4ExFhXS4qsqQ6dWhgdZL1jYeMXHtm3KFXczoLdLR2txmDlmkAgQcA+G10C5AyLzOoUQm4bN6e6UxrtrNjNGVwd+1t2au2uprUh1VHpuEP4nsmilNtrTzeEvJMpYU5CTMo2uEm3AQW4GtUGs8rldT97FMqsfvQeyk4Q9mNA850sV1P7/Onxj3DC0E6rcY0meOac3FqwuYhgkYqy5n9H9CmCGkr9Z2g2Znp3CP7QxIfsYPHodXj/lfa5irMBRKJmp4sAMB30XrdT3d5iMumGglLZWKWa+VAp8I1yaYjTelSUqEYMg75BtD63RTlz8YsduEtyGq720bcPuEHoiitCX6HbiAaSEGUIDkEABBF8ZsiA34tv0/V3g/s5vBLtL7TKzRSr2Bw3HKtvIx+O/dPa6OLqwa0LpggfFv49JdnLwAIWLEKfofWQZeEYiRck75OW6fAV7LKC+XHIVRjShy2EKqMWo9Obz2NN2NdECUUip246A7clDxPilkrnawDoLwPgUYwDmVwBT1F9wb3+OccqahCkS6nFIpB3RA7IGFTIIY8A3Bj2B7knSyXjanN2kZ8dke7+MNN0N2Yw4PwSxFdk7770JcjPAj+ji7h+3ATdABTo05q5iJroy7+W2NLbHEpNHaQHVlyeye4aEh6qE7MD5yb9tWeff2r+f7B3Gq+vyCf0+6JHPwcbtY0jzdk3I4o2c1wzX3+1XI1o8Qc0z5Z+iE6qUFdlr9o9K+b6JU+i7cufMuJP4OwbmD0LxzWXX5chJugswEjEqe3sWlVmEpevW5vp8FesqTgxiKbas43ZRLCTQDFL8UqfBxuAubBvvZgW5O6Wq2pvRBc1AfIPtqdcsV8cXaY9RWNPg1npYPRnnQ4MKkKO2mL00cZGIsh7fJkHXazU2vwWsx0ly3JevMOKeekWIVzaGXHd6O85B6c7BgNvvvDvrACxgdbS/bs3guqx+OY0dZuaFV3+lUZb4ehDXbF91y+nBbudHWZzS17eLxDWjsmVuGv4IakWXK3B9bZr6lb70s7zBwyDbIDJalZOfep+nm1RQOjwi2NXqIMnBMMRYqr4ZwAAH4EN0AbAJIKCYLkotKC8KnBkl2hVCjUds13ysIW3BA+o0Yo+7Ad6gVDbW4BAPQG3ADWB+bujjAKq53pcewkVeqEECo69nZ+Y0SNEFS0GzqfGPrwYLv81tRxHm4I/23L22x5GzQ3jAywhRqy24co4R6A4i0A4L/VcKA0DEfWQ/EcTlL1+wOOsz+fH3U3teOKjp6O6an1I2Nsk7pF0WnTLEH0yQrB6LQu3cr/fXGW8BEES56T7hAdYhXl0XOgFQQlp9JoyW1GYzLcUX6bO/LNgpA8g+ekEYnTjFLJ1LaXlsafttIZptvg6Q0W5/0lT1dg1BeNRvf5dFmHK2UoWEoB3t+TSruXvxP3UQM9TD/py8L/olnSF6NI68jWe1OF6HjakIjGZoMpH1dOm8JHnJ6jvf0Xwqw6rY3YmBd8MaPec4K3DEvad4tV+A66CtTSTpA2+vccYtdDgw738HIkVLC7hg8GmVzYxPrkpyp2JL3/zy8O9h5JH3j+QiGVf/hKPjc78PCVfP8sgOI9AOBP0VVgAIDi01hd7MyOEeBWvKVp+dqSn4v0ZG0MOxuYnHNPXpyCWpVv4sLh/T42abUEaNf+fGRpeXWoHwAo/lqswjfRVeB8QKcUv+NYOLPtzLqaUH+cPWbmyGIwP7vvmGpsnglxppyJmVocnx0pRhKpk6qs12kLj8S5/l5XyuyOGkkuM96fWtAp1EOh1P6gxFsgeSX6unTi3427E1BjxSl8Z6dfzS6ZQkQ25h5iM2EL22Mdh562/wmr3fqhg7kzqozXZQ27y1w62ak2QG//q02q6anSV6VehwGrWIU/R1eBBXhAfHtnolE+suNDtcrMSIqk2U5BpyWwWj4SmXRa4rZvNubJM0ZzeDbkGvYVeK3TmDxkciRjbraQNtv7nK4cE+ofVtmHY8FiRK0wJhi+7O7JhvpmLIo2V8zWO+WFx7qLIX84EQklhB+ZYk4H59IZR2J8/YzaKVbhG+gq6ACsxH6ZzNFwvW1oZJQkkLaPLNvZt6Nvh0Pd4xRMhBxJe2d4QBvuSYdmMm3WKWthml9K8GMe7/hX4F/zWZu/lVQNTPuLgiVgdfXmLkw6qLnRzMk+/nDm0Itf6695CilWwRWwIt1Jaz5X24cCoWeMRLdDZSQMrInQsxJ/PhIXQAWsSD1CYiJPNUzQtvrjOCLMlMlgPPCirytpNxB6xm4eXpXPJXKcdfghYkAA9MGHgRIE5PdTYhWbASuABoEkAAwIwD8EAOAggN6p31Vvwxaol+6/kulQqvfbbm+fqyjwEboNydo3K061ouutH0Wj0jfxA/Gb8Lr499I3nLfi9lb4ZsvFUEielxXH4Sx6H3QAQNYpQirlyz75B91W/kTRe2qlWdt+ve+FybU3f7yovyz85/d8x5dpad1b4ji4U5/LRLtk0vA1qUDvqdPNXR0haYnrhsvQ+qf+4wdpTd/3J9fe+pE09x/EZfh99M9yTpCDQ/BGTCg/jx3/8ru1egLgIfgWckt7wUeoCBfhdJyO0t3+m5eTL19/6I3466/H36j3anATbmz/T8ByqHwYbshNAoJBNAJuoBvSGpqG7XlUY6ZIrYlCIySht3YT+h4A5bPiO3BDqgXfPSXLfS5IUmp9i7bTaG35avmr7bn3WprjSjzoQfatDwsz/w8AAP//AQAA///Xf+08AAABAAAAARhRXEKW0V8PPPUAAQPoAAAAANhdoMwAAAAA3WYvN/69/t0IHQPJAAIAAwACAAAAAAAAAAEAAAPY/u8AAAhA/r39vAgdA+gAwv/RAAAAAAAAAAAAAAA+eJwcziFLQ2EUx+Hf/9yo4sBw1fIix+sFr6BRccXgVgSDYrObTBa7n8DvYbLYVCyCYFoRFt5h1rCwiYwdmempj12xzitoGm/WxTXG9YHbEXt2iTPBbRvXO27PuF3Ttg3cGly/zGvKuV1wbCvUtkzSPZWV1BpQaZXGlpDNkfgi8R036pP4YatIJFsgWUFtZYx0RtJtTHRI21rs6ol9e6Gju+hZh0VlNpVjpBxjZVBmTZkWQ0qGMZhZPHKiBi+E6zP6cg5URU+n8aDMzv8BurPHHwAAAP//AQAA//8Ymj+UAAAAAAAuAC4AUgCKAKIAuADuAQgBFgEyAUIBcAGiAcYB7gIuAkICagKiAtoDCANAA3oDogPqBBQEIAQsBEYEaASqBNQFAgU8BVoFlgXEBfAGDgZIBnQG0gcEBy4HageeB/QIOAhOCGwIeAiICJYIpAiyCNAI7gj+CRIJIAk2CVQAAAABAAAAPgCMAAwAZgAHAAEAAAAAAAAAAAAAAAAABAADeJyclNtOG1cUhj8H2216uqhQRG7QvkylZEyjECXhypSgjIpw6nF6kKpKgz0+iPHMyDOYkifodd+ib5GrPkafoup1tX8vgx1FQSAE/Hv2OvxrrX9tYJP/2KBWvwv83ZwbrrHd/NnwHb5oHhneYL/5meE6Dxv/GG4waLw13ORBo2v4E97V/zT8KU/qvxm+y1b90PDnPK5vGv5yw/Gv4a94wrsFrsEz/jBcY4vC8B02+dXwBvewmLU699gx3OBrtg032QZ6TKhImZAxwjFkwogzZiSURCTMmDAkYYAjpE1Kpa8ZsZBj9MGvMREVM2JFHFPhSIlIiSkZW8S38sp5rYxDnWZ216ZiTMyJPE6JyXDkjMjJSDhVnIqKghe0aFHSF9+CipKAkgkpATkzRrTocMgRPcZMKHEcKpJnFpEzpOKcWPmdWfjO9EnIKI3VGRkD8XTil8g75AhHh0K2q5GP1iI8xPGjvD23XLbfEujXrTBbz7tkEzNXP1N1JdXNuSY41q3P2+YH4YoXuFv1Z53J9T0a6H+lyCecaf4DTSoTkwzntmgTSUGRu49jX+eQSB35iZAer+jwhp7Obbp0aXNMj5CX8u3QxfEdHY45kEcovLg7lGKO+QXH94Sy8bET689iYgm/U5i6S3GcqY4phXrumQeqNVGFN5+w36F8TR2lfPraI2/pNL9MexYzMlUUYjhVL5faKK1/A1PEVLX42V7d+22Y2+4tt/iCXDvs1brg5Ce3YHTdVIP3NHOun4CYATknsuiTM6VFxYV4vybmjBTHgbr3SltS0b708XkupJKEqRiEZIozo9Df2HQTGff+mu6dvSUD+Xump5dV3SaLU6+uZvRG3VveRdblZGUCLZtqvqKmvrhmpv1EO7XKP5Jvqdct5xGh4i52+0OvwA7P2WWPsbL0dTO/vPOvhLfYUwdOSWQ1lKZ9DY8J2CXgKbvs8pyn7/VyycYZH7fGZzV/mwP26bB3bTUL2w77vFyL9vHMf4ntjupxPLo8Pbv1NB/cQLXfaN+u3s2uJuenMbdoV9txTMzUc3FbqzW5+wT/AwAA//8BAAD//3KhUUAAAAADAAD/9QAA/84AMgAAAAAAAAAAAAAAAAAAAAAAAAAA"); -}]]></style><style type="text/css"><![CDATA[.shape { - shape-rendering: geometricPrecision; - stroke-linejoin: round; -} -.connection { - stroke-linecap: round; - stroke-linejoin: round; -} -.blend { - mix-blend-mode: multiply; - opacity: 0.5; -} - - .d2-1368901215 .fill-N1{fill:#0A0F25;} - .d2-1368901215 .fill-N2{fill:#676C7E;} - .d2-1368901215 .fill-N3{fill:#9499AB;} - .d2-1368901215 .fill-N4{fill:#CFD2DD;} - .d2-1368901215 .fill-N5{fill:#DEE1EB;} - .d2-1368901215 .fill-N6{fill:#EEF1F8;} - .d2-1368901215 .fill-N7{fill:#FFFFFF;} - .d2-1368901215 .fill-B1{fill:#0D32B2;} - .d2-1368901215 .fill-B2{fill:#0D32B2;} - .d2-1368901215 .fill-B3{fill:#E3E9FD;} - .d2-1368901215 .fill-B4{fill:#E3E9FD;} - .d2-1368901215 .fill-B5{fill:#EDF0FD;} - .d2-1368901215 .fill-B6{fill:#F7F8FE;} - .d2-1368901215 .fill-AA2{fill:#4A6FF3;} - .d2-1368901215 .fill-AA4{fill:#EDF0FD;} - .d2-1368901215 .fill-AA5{fill:#F7F8FE;} - .d2-1368901215 .fill-AB4{fill:#EDF0FD;} - .d2-1368901215 .fill-AB5{fill:#F7F8FE;} - .d2-1368901215 .stroke-N1{stroke:#0A0F25;} - .d2-1368901215 .stroke-N2{stroke:#676C7E;} - .d2-1368901215 .stroke-N3{stroke:#9499AB;} - .d2-1368901215 .stroke-N4{stroke:#CFD2DD;} - .d2-1368901215 .stroke-N5{stroke:#DEE1EB;} - .d2-1368901215 .stroke-N6{stroke:#EEF1F8;} - .d2-1368901215 .stroke-N7{stroke:#FFFFFF;} - .d2-1368901215 .stroke-B1{stroke:#0D32B2;} - .d2-1368901215 .stroke-B2{stroke:#0D32B2;} - .d2-1368901215 .stroke-B3{stroke:#E3E9FD;} - .d2-1368901215 .stroke-B4{stroke:#E3E9FD;} - .d2-1368901215 .stroke-B5{stroke:#EDF0FD;} - .d2-1368901215 .stroke-B6{stroke:#F7F8FE;} - .d2-1368901215 .stroke-AA2{stroke:#4A6FF3;} - .d2-1368901215 .stroke-AA4{stroke:#EDF0FD;} - .d2-1368901215 .stroke-AA5{stroke:#F7F8FE;} - .d2-1368901215 .stroke-AB4{stroke:#EDF0FD;} - .d2-1368901215 .stroke-AB5{stroke:#F7F8FE;} - .d2-1368901215 .background-color-N1{background-color:#0A0F25;} - .d2-1368901215 .background-color-N2{background-color:#676C7E;} - .d2-1368901215 .background-color-N3{background-color:#9499AB;} - .d2-1368901215 .background-color-N4{background-color:#CFD2DD;} - .d2-1368901215 .background-color-N5{background-color:#DEE1EB;} - .d2-1368901215 .background-color-N6{background-color:#EEF1F8;} - .d2-1368901215 .background-color-N7{background-color:#FFFFFF;} - .d2-1368901215 .background-color-B1{background-color:#0D32B2;} - .d2-1368901215 .background-color-B2{background-color:#0D32B2;} - .d2-1368901215 .background-color-B3{background-color:#E3E9FD;} - .d2-1368901215 .background-color-B4{background-color:#E3E9FD;} - .d2-1368901215 .background-color-B5{background-color:#EDF0FD;} - .d2-1368901215 .background-color-B6{background-color:#F7F8FE;} - .d2-1368901215 .background-color-AA2{background-color:#4A6FF3;} - .d2-1368901215 .background-color-AA4{background-color:#EDF0FD;} - .d2-1368901215 .background-color-AA5{background-color:#F7F8FE;} - .d2-1368901215 .background-color-AB4{background-color:#EDF0FD;} - .d2-1368901215 .background-color-AB5{background-color:#F7F8FE;} - .d2-1368901215 .color-N1{color:#0A0F25;} - .d2-1368901215 .color-N2{color:#676C7E;} - .d2-1368901215 .color-N3{color:#9499AB;} - .d2-1368901215 .color-N4{color:#CFD2DD;} - .d2-1368901215 .color-N5{color:#DEE1EB;} - .d2-1368901215 .color-N6{color:#EEF1F8;} - .d2-1368901215 .color-N7{color:#FFFFFF;} - .d2-1368901215 .color-B1{color:#0D32B2;} - .d2-1368901215 .color-B2{color:#0D32B2;} - .d2-1368901215 .color-B3{color:#E3E9FD;} - .d2-1368901215 .color-B4{color:#E3E9FD;} - .d2-1368901215 .color-B5{color:#EDF0FD;} - .d2-1368901215 .color-B6{color:#F7F8FE;} - .d2-1368901215 .color-AA2{color:#4A6FF3;} - .d2-1368901215 .color-AA4{color:#EDF0FD;} - .d2-1368901215 .color-AA5{color:#F7F8FE;} - .d2-1368901215 .color-AB4{color:#EDF0FD;} - .d2-1368901215 .color-AB5{color:#F7F8FE;}.appendix text.text{fill:#0A0F25}.md{--color-fg-default:#0A0F25;--color-fg-muted:#676C7E;--color-fg-subtle:#9499AB;--color-canvas-default:#FFFFFF;--color-canvas-subtle:#EEF1F8;--color-border-default:#0D32B2;--color-border-muted:#0D32B2;--color-neutral-muted:#EEF1F8;--color-accent-fg:#0D32B2;--color-accent-emphasis:#0D32B2;--color-attention-subtle:#676C7E;--color-danger-fg:red;}.sketch-overlay-B1{fill:url(#streaks-darker-d2-1368901215);mix-blend-mode:lighten}.sketch-overlay-B2{fill:url(#streaks-darker-d2-1368901215);mix-blend-mode:lighten}.sketch-overlay-B3{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.sketch-overlay-B4{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.sketch-overlay-B5{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.sketch-overlay-B6{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.sketch-overlay-AA2{fill:url(#streaks-dark-d2-1368901215);mix-blend-mode:overlay}.sketch-overlay-AA4{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.sketch-overlay-AA5{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.sketch-overlay-AB4{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.sketch-overlay-AB5{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.sketch-overlay-N1{fill:url(#streaks-darker-d2-1368901215);mix-blend-mode:lighten}.sketch-overlay-N2{fill:url(#streaks-dark-d2-1368901215);mix-blend-mode:overlay}.sketch-overlay-N3{fill:url(#streaks-normal-d2-1368901215);mix-blend-mode:color-burn}.sketch-overlay-N4{fill:url(#streaks-normal-d2-1368901215);mix-blend-mode:color-burn}.sketch-overlay-N5{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.sketch-overlay-N6{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.sketch-overlay-N7{fill:url(#streaks-bright-d2-1368901215);mix-blend-mode:darken}.light-code{display: block}.dark-code{display: none}]]></style><g class="bm92ZWxmaXJl"><g class="shape" ><path d="M 2155 29 C 2155 30 2154 31 2153 31 C 2139 32 2128 43 2128 57 C 2128 72 2141 84 2157 84 H 2265 C 2282 84 2296 71 2296 56 C 2296 41 2283 29 2267 28 C 2266 28 2265 27 2264 26 C 2260 11 2244 0 2224 0 C 2211 0 2200 5 2192 12 C 2191 13 2190 13 2189 13 C 2186 12 2183 12 2180 12 C 2167 12 2156 19 2155 29 Z" stroke="#0D32B2" fill="#f0f4ff" class=" stroke-B1" style="stroke-width:2;" /></g><text x="2210.796000" y="63.516000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px">novelfire.net</text></g><g class="a29rb3Jv"><g class="shape" ><path d="M 2135 219 C 2135 220 2133 221 2132 221 C 2113 222 2098 233 2098 247 C 2098 262 2115 274 2136 274 H 2282 C 2305 274 2324 261 2324 246 C 2324 231 2307 219 2285 218 C 2283 218 2282 217 2282 216 C 2277 201 2255 190 2228 190 C 2211 190 2195 195 2185 202 C 2184 203 2182 203 2181 203 C 2177 202 2173 202 2169 202 C 2151 202 2137 209 2135 219 Z" stroke="#0D32B2" fill="#f0f4ff" class=" stroke-B1" style="stroke-width:2;" /></g><text x="2210.547000" y="253.516000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px">Kokoro-FastAPI TTS</text></g><g class="YnJvd3Nlcg=="><g class="shape" ><path d="M 140 548 H 0 V 547 C 0 531 15 517 40 510 C 26 504 18 495 18 485 C 18 469 41 455 70 455 C 98 455 122 469 122 485 C 122 495 114 504 101 510 C 125 517 141 531 141 547 V 548 H 140 Z" stroke="#0D32B2" fill="#fff9e6" class=" stroke-B1" style="stroke-width:2;" /></g><text x="70.000000" y="569.000000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px">Browser / iOS App</text></g><g class="aW5pdA=="><g class="shape" ><rect x="1238.000000" y="881.000000" width="265.000000" height="291.000000" stroke="#0D32B2" fill="#f5f5f5" class=" stroke-B2" style="stroke-width:2;stroke-dasharray:8.000000,7.892511;" /></g><text x="1370.500000" y="868.000000" fill="#0A0F25" class="text fill-N1" style="text-anchor:middle;font-size:28px">Init containers</text></g><g class="c3RvcmFnZQ=="><g class="shape" ><rect x="2074.000000" y="451.000000" width="275.000000" height="681.000000" stroke="#0D32B2" fill="#eaf7ea" class=" stroke-B1" style="stroke-width:2;" /></g><text x="2211.500000" y="438.000000" fill="#0A0F25" class="text fill-N1" style="text-anchor:middle;font-size:28px">Storage</text></g><g class="YXBw"><g class="shape" ><rect x="531.000000" y="367.000000" width="983.000000" height="428.000000" stroke="#0D32B2" fill="#eef3ff" class=" stroke-B1" style="stroke-width:2;" /></g><text x="1022.500000" y="354.000000" fill="#0A0F25" class="text fill-N1" style="text-anchor:middle;font-size:28px">Application</text></g><g class="aW5pdC5taW5pby1pbml0"><g class="shape" ><rect x="1276.000000" y="911.000000" width="188.000000" height="82.000000" stroke="#0D32B2" fill="#EDF0FD" class=" stroke-B1 fill-B5" style="stroke-width:2;" /></g><text x="1370.000000" y="949.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px"><tspan x="1370.000000" dy="0.000000">minio-init</tspan><tspan x="1370.000000" dy="18.500000">(mc: create buckets)</tspan></text></g><g class="aW5pdC5wYi1pbml0"><g class="shape" ><rect x="1268.000000" y="1060.000000" width="205.000000" height="82.000000" stroke="#0D32B2" fill="#EDF0FD" class=" stroke-B1 fill-B5" style="stroke-width:2;" /></g><text x="1370.500000" y="1098.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px"><tspan x="1370.500000" dy="0.000000">pb-init</tspan><tspan x="1370.500000" dy="18.500000">(bootstrap collections)</tspan></text></g><g class="c3RvcmFnZS5taW5pbw=="><g class="shape" ><path d="M 2123 505 C 2123 481 2203 481 2212 481 C 2220 481 2300 481 2300 505 V 671 C 2300 695 2220 695 2212 695 C 2203 695 2123 695 2123 671 V 505 Z" stroke="#0D32B2" fill="#F7F8FE" class=" stroke-B1 fill-AA5" style="stroke-width:2;" /><path d="M 2123 505 C 2123 529 2203 529 2212 529 C 2220 529 2300 529 2300 505" stroke="#0D32B2" fill="#F7F8FE" class=" stroke-B1 fill-AA5" style="stroke-width:2;" /></g><text x="2211.500000" y="557.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px"><tspan x="2211.500000" dy="0.000000">MinIO :9000</tspan><tspan x="2211.500000" dy="16.714286"> </tspan><tspan x="2211.500000" dy="16.714286">buckets:</tspan><tspan x="2211.500000" dy="16.714286"> libnovel-chapters</tspan><tspan x="2211.500000" dy="16.714286"> libnovel-audio</tspan><tspan x="2211.500000" dy="16.714286"> libnovel-avatars</tspan><tspan x="2211.500000" dy="16.714286"> libnovel-browse</tspan></text></g><g class="c3RvcmFnZS5wb2NrZXRiYXNl"><g class="shape" ><path d="M 2104 912 C 2104 888 2201 888 2212 888 C 2222 888 2319 888 2319 912 V 1078 C 2319 1102 2222 1102 2212 1102 C 2201 1102 2104 1102 2104 1078 V 912 Z" stroke="#0D32B2" fill="#F7F8FE" class=" stroke-B1 fill-AA5" style="stroke-width:2;" /><path d="M 2104 912 C 2104 936 2201 936 2212 936 C 2222 936 2319 936 2319 912" stroke="#0D32B2" fill="#F7F8FE" class=" stroke-B1 fill-AA5" style="stroke-width:2;" /></g><text x="2211.500000" y="964.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px"><tspan x="2211.500000" dy="0.000000">PocketBase :8090</tspan><tspan x="2211.500000" dy="16.714286"> </tspan><tspan x="2211.500000" dy="16.714286">collections:</tspan><tspan x="2211.500000" dy="16.714286"> books chapters_idx</tspan><tspan x="2211.500000" dy="16.714286"> audio_cache progress</tspan><tspan x="2211.500000" dy="16.714286"> scrape_jobs app_users</tspan><tspan x="2211.500000" dy="16.714286"> ranking</tspan></text></g><g class="YXBwLmJhY2tlbmQ="><g class="shape" ><rect x="1268.000000" y="683.000000" width="204.000000" height="82.000000" stroke="#0D32B2" fill="#EDF0FD" class=" stroke-B1 fill-B5" style="stroke-width:2;" /></g><text x="1370.000000" y="721.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px"><tspan x="1370.000000" dy="0.000000">Backend API :8080</tspan><tspan x="1370.000000" dy="18.500000">(Go — HTTP API server)</tspan></text></g><g class="YXBwLnJ1bm5lcg=="><g class="shape" ><rect x="1256.000000" y="397.000000" width="228.000000" height="98.000000" stroke="#0D32B2" fill="#EDF0FD" class=" stroke-B1 fill-B5" style="stroke-width:2;" /></g><text x="1370.000000" y="435.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px"><tspan x="1370.000000" dy="0.000000">Runner</tspan><tspan x="1370.000000" dy="17.666667">(Go — background worker</tspan><tspan x="1370.000000" dy="17.666667">scraping + TTS jobs)</tspan></text></g><g class="YXBwLnVp"><g class="shape" ><rect x="561.000000" y="683.000000" width="170.000000" height="82.000000" stroke="#0D32B2" fill="#EDF0FD" class=" stroke-B1 fill-B5" style="stroke-width:2;" /></g><text x="646.000000" y="721.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px"><tspan x="646.000000" dy="0.000000">SvelteKit UI :5252</tspan><tspan x="646.000000" dy="18.500000">(adapter-node)</tspan></text></g><g class="KGluaXQubWluaW8taW5pdCAtJmd0OyBzdG9yYWdlLm1pbmlvKVswXQ=="><marker id="mk-d2-1368901215-2177206569" markerWidth="10.000000" markerHeight="12.000000" refX="7.000000" refY="6.000000" viewBox="0.000000 0.000000 10.000000 12.000000" orient="auto" markerUnits="userSpaceOnUse"> <polygon points="0.000000,0.000000 10.000000,6.000000 0.000000,12.000000" fill="#0D32B2" class="connection fill-B2" stroke-width="2" /> </marker><path d="M 1466.000000 952.000000 C 1570.400024 952.000000 1635.800049 919.000000 1694.000000 869.500000 C 1752.199951 820.000000 2012.599976 762.799988 2119.992395 668.637102" stroke="#0D32B2" fill="none" class="connection stroke-B2" style="stroke-width:2;stroke-dasharray:8.000000,7.892511;" marker-end="url(#mk-d2-1368901215-2177206569)" mask="url(#d2-1368901215)" /><text x="1791.500000" y="817.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px">create buckets</text></g><g class="KGluaXQucGItaW5pdCAtJmd0OyBzdG9yYWdlLnBvY2tldGJhc2UpWzBd"><path d="M 1475.000000 1100.500000 C 1572.199951 1100.500000 1635.800049 1100.500000 1694.000000 1100.500000 C 1752.199951 1100.500000 2008.800049 1089.400024 2100.374879 1046.690709" stroke="#0D32B2" fill="none" class="connection stroke-B2" style="stroke-width:2;stroke-dasharray:8.000000,7.892511;" marker-end="url(#mk-d2-1368901215-2177206569)" mask="url(#d2-1368901215)" /><text x="1793.500000" y="1104.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px">bootstrap schema</text></g><g class="KGFwcC5iYWNrZW5kIC0mZ3Q7IHN0b3JhZ2UubWluaW8pWzBd"><marker id="mk-d2-1368901215-3488378134" markerWidth="10.000000" markerHeight="12.000000" refX="7.000000" refY="6.000000" viewBox="0.000000 0.000000 10.000000 12.000000" orient="auto" markerUnits="userSpaceOnUse"> <polygon points="0.000000,0.000000 10.000000,6.000000 0.000000,12.000000" fill="#0D32B2" class="connection fill-B1" stroke-width="2" /> </marker><path d="M 1439.712129 681.966262 C 1565.199951 606.200012 1635.800049 587.000000 1694.000000 587.000000 C 1752.199951 587.000000 2012.599976 587.200012 2119.000105 587.971016" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-1368901215-3488378134)" mask="url(#d2-1368901215)" /><text x="1768.500000" y="585.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px"><tspan x="1768.500000" dy="0.000000">blobs (chapters, audio,</tspan><tspan x="1768.500000" dy="18.500000">avatars, browse)</tspan></text></g><g class="KGFwcC5iYWNrZW5kIC0mZ3Q7IHN0b3JhZ2UucG9ja2V0YmFzZSlbMF0="><path d="M 1473.981757 737.769518 C 1572.000000 751.099976 1635.800049 791.400024 1694.000000 846.750000 C 1752.199951 902.099976 2008.800049 944.799988 2100.113736 967.052926" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-1368901215-3488378134)" mask="url(#d2-1368901215)" /><text x="1762.500000" y="901.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px"><tspan x="1762.500000" dy="0.000000">structured records</tspan><tspan x="1762.500000" dy="18.500000">(books, progress, jobs…)</tspan></text></g><g class="KGFwcC5ydW5uZXIgLSZndDsgc3RvcmFnZS5taW5pbylbMF0="><path d="M 1485.949073 473.448459 C 1574.400024 493.799988 1635.800049 499.000000 1694.000000 499.000000 C 1752.199951 499.000000 2012.599976 506.200012 2119.129531 533.990313" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-1368901215-3488378134)" mask="url(#d2-1368901215)" /><text x="1804.000000" y="498.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px"><tspan x="1804.000000" dy="0.000000">write chapter markdown</tspan><tspan x="1804.000000" dy="18.500000">& audio MP3s</tspan></text></g><g class="KGFwcC5ydW5uZXIgLSZndDsgc3RvcmFnZS5wb2NrZXRiYXNlKVswXQ=="><path d="M 1433.427128 495.901180 C 1564.000000 624.099976 1635.800049 701.599976 1694.000000 769.250000 C 1752.199951 836.900024 2008.800049 893.799988 2100.416289 939.223201" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-1368901215-3488378134)" mask="url(#d2-1368901215)" /><text x="1719.500000" y="796.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px"><tspan x="1719.500000" dy="0.000000">read/update scrape jobs</tspan><tspan x="1719.500000" dy="18.500000">write book records</tspan></text></g><g class="YXBwLih1aSAtJmd0OyBiYWNrZW5kKVswXQ=="><path d="M 733.000000 724.000000 C 895.799988 724.000000 1168.000000 724.000000 1264.000000 724.000000" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-1368901215-3488378134)" mask="url(#d2-1368901215)" /><text x="1000.000000" y="722.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px"><tspan x="1000.000000" dy="0.000000">REST API calls</tspan><tspan x="1000.000000" dy="18.500000">(server-side)</tspan></text></g><g class="KGFwcC5ydW5uZXIgLSZndDsgbm92ZWxmaXJlKVswXQ=="><path d="M 1443.655837 396.378304 C 1566.000000 313.500000 1635.800049 242.399994 1694.000000 167.250000 C 1752.199951 92.099998 2014.599976 42.000000 2129.000000 42.000000" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-1368901215-3488378134)" mask="url(#d2-1368901215)" /><text x="1736.000000" y="111.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px"><tspan x="1736.000000" dy="0.000000">scrape</tspan><tspan x="1736.000000" dy="18.500000">(HTTP GET)</tspan></text></g><g class="KGFwcC5ydW5uZXIgLSZndDsga29rb3JvKVswXQ=="><path d="M 1485.905630 409.892897 C 1574.400024 381.700012 1635.800049 346.000000 1694.000000 303.250000 C 1752.199951 260.500000 2009.000000 232.000000 2101.000000 232.000000" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-1368901215-3488378134)" mask="url(#d2-1368901215)" /><text x="1774.500000" y="256.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px"><tspan x="1774.500000" dy="0.000000">TTS generation</tspan><tspan x="1774.500000" dy="18.500000">(HTTP POST)</tspan></text></g><g class="KGJyb3dzZXIgLSZndDsgYXBwLnVpKVswXQ=="><path d="M 89.520596 549.299149 C 252.800003 688.799988 470.600006 724.000000 557.000000 724.000000" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-1368901215-3488378134)" mask="url(#d2-1368901215)" /><text x="299.000000" y="702.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px">HTTPS :5252</text></g><g class="KGJyb3dzZXIgLSZndDsgc3RvcmFnZS5taW5pbylbMF0="><path d="M 102.038869 459.290980 C 255.399994 207.000000 324.799988 143.500000 371.000000 143.500000 C 417.200012 143.500000 487.600006 143.500000 547.000000 143.500000 C 606.400024 143.500000 704.200012 143.500000 791.500000 143.500000 C 878.799988 143.500000 978.200012 143.500000 1040.000000 143.500000 C 1101.800049 143.500000 1188.400024 143.500000 1256.500000 143.500000 C 1324.599976 143.500000 1415.400024 143.500000 1483.500000 143.500000 C 1551.599976 143.500000 1635.800049 196.300003 1694.000000 275.500000 C 1752.199951 354.700012 2012.800049 426.000000 2120.669958 497.783964" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-1368901215-3488378134)" mask="url(#d2-1368901215)" /><text x="1092.000000" y="141.000000" fill="#676C7E" class="text-italic fill-N2" style="text-anchor:middle;font-size:16px"><tspan x="1092.000000" dy="0.000000">presigned URLs</tspan><tspan x="1092.000000" dy="18.500000">(audio / chapter downloads)</tspan></text></g><mask id="d2-1368901215" maskUnits="userSpaceOnUse" x="-101" y="-101" width="2551" height="1374"> -<rect x="-101" y="-101" width="2551" height="1374" fill="white"></rect> -<rect x="1741.000000" y="801.000000" width="101" height="21" fill="black"></rect> -<rect x="1732.000000" y="1088.000000" width="123" height="21" fill="black"></rect> -<rect x="1692.000000" y="569.000000" width="153" height="37" fill="black"></rect> -<rect x="1682.000000" y="885.000000" width="161" height="37" fill="black"></rect> -<rect x="1721.000000" y="482.000000" width="166" height="37" fill="black"></rect> -<rect x="1637.000000" y="780.000000" width="165" height="37" fill="black"></rect> -<rect x="952.000000" y="706.000000" width="96" height="37" fill="black"></rect> -<rect x="1697.000000" y="95.000000" width="78" height="37" fill="black"></rect> -<rect x="1723.000000" y="240.000000" width="103" height="37" fill="black"></rect> -<rect x="256.000000" y="686.000000" width="86" height="21" fill="black"></rect> -<rect x="997.000000" y="125.000000" width="190" height="37" fill="black"></rect> -</mask></svg></svg> diff --git a/v3/docs/d2/api-routing.d2 b/docs/d2/api-routing.d2 similarity index 100% rename from v3/docs/d2/api-routing.d2 rename to docs/d2/api-routing.d2 diff --git a/v3/docs/d2/api-routing.svg b/docs/d2/api-routing.svg similarity index 100% rename from v3/docs/d2/api-routing.svg rename to docs/d2/api-routing.svg diff --git a/v3/docs/d2/architecture.d2 b/docs/d2/architecture.d2 similarity index 100% rename from v3/docs/d2/architecture.d2 rename to docs/d2/architecture.d2 diff --git a/v3/docs/d2/architecture.svg b/docs/d2/architecture.svg similarity index 100% rename from v3/docs/d2/architecture.svg rename to docs/d2/architecture.svg diff --git a/v3/docs/mermaid/architecture.mermaid.md b/docs/mermaid/architecture.mermaid.md similarity index 100% rename from v3/docs/mermaid/architecture.mermaid.md rename to docs/mermaid/architecture.mermaid.md diff --git a/v3/docs/mermaid/data-flow.mermaid.md b/docs/mermaid/data-flow.mermaid.md similarity index 100% rename from v3/docs/mermaid/data-flow.mermaid.md rename to docs/mermaid/data-flow.mermaid.md diff --git a/v3/docs/mermaid/request-flow.mermaid.md b/docs/mermaid/request-flow.mermaid.md similarity index 100% rename from v3/docs/mermaid/request-flow.mermaid.md rename to docs/mermaid/request-flow.mermaid.md diff --git a/v3/dozzle/users.yml b/dozzle/users.yml similarity index 100% rename from v3/dozzle/users.yml rename to dozzle/users.yml diff --git a/ios/LibNovel/.gitignore b/ios/LibNovel/.gitignore deleted file mode 100644 index 930f1d0..0000000 --- a/ios/LibNovel/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Fastlane -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output -fastlane/README.md - -# Bundler -.bundle -vendor/bundle diff --git a/ios/LibNovel/ExportOptions.plist b/ios/LibNovel/ExportOptions.plist deleted file mode 100644 index 804a0c9..0000000 --- a/ios/LibNovel/ExportOptions.plist +++ /dev/null @@ -1,21 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>method</key> - <string>app-store</string> - <key>teamID</key> - <string>GHZXC6FVMU</string> - <key>uploadBitcode</key> - <false/> - <key>uploadSymbols</key> - <true/> - <key>signingStyle</key> - <string>manual</string> - <key>provisioningProfiles</key> - <dict> - <key>com.kalekber.LibNovel</key> - <string>LibNovel Distribution</string> - </dict> -</dict> -</plist> diff --git a/ios/LibNovel/Gemfile b/ios/LibNovel/Gemfile deleted file mode 100644 index 7a118b4..0000000 --- a/ios/LibNovel/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "fastlane" diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj deleted file mode 100644 index 781ac45..0000000 --- a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj +++ /dev/null @@ -1,772 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXBuildFile section */ - 032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */; }; - 07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */; }; - 08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */; }; - 0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762E378B9BC2161A7AA2CC36 /* Models.swift */; }; - 0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */; }; - 192F82518CB8763775E33B38 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79133D9FA697D1909C8D3973 /* SearchView.swift */; }; - 1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */; }; - 1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */; }; - 2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */; }; - 2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; }; - 3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; }; - 367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5C115992F1CE2326236765 /* RootTabView.swift */; }; - 41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC6F837FF2E902E334ED72E /* String+App.swift */; }; - 4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; }; - 58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; }; - 5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; }; - 5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */; }; - 62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */; }; - 64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; }; - 65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */; }; - 749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; }; - 774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175390266E8C6CF1437A229 /* DownloadsView.swift */; }; - 7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; }; - 7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; }; - 880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */; }; - 8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */; }; - 9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */; }; - 94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; }; - 9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; }; - 9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F247DE25991F4DB98DF717AA /* UserProfileView.swift */; }; - A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B9AFE90719BDBC718F0621 /* CommentsView.swift */; }; - A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; }; - BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; }; - C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; }; - CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; }; - DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */; }; - E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; }; - E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; }; - ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */; }; - EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; }; - F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; }; - F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; }; - FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B593F179EC3E9112126B540B /* APIClient.swift */; }; - FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = A10A669C0C8B43078C0FEE9F /* Project object */; - proxyType = 1; - remoteGlobalIDString = D039EDECDE3998D8534BB680; - remoteInfo = LibNovel; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = "<group>"; }; - 16B9AFE90719BDBC718F0621 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; }; - 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadQueueButton.swift; sourceTree = "<group>"; }; - 1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = "<group>"; }; - 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; }; - 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; }; - 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; }; - 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAudioButton.swift; sourceTree = "<group>"; }; - 39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; }; - 3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; }; - 4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; - 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; }; - 5A776719B77EDDB5E44743B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; - 762E378B9BC2161A7AA2CC36 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; }; - 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; }; - 79133D9FA697D1909C8D3973 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; }; - 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = "<group>"; }; - 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; }; - 8175390266E8C6CF1437A229 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = "<group>"; }; - 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = "<group>"; }; - 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; }; - 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = "<group>"; }; - 8E89FD8F46747CA653C5203D /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = "<group>"; }; - 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; }; - 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = "<group>"; }; - 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; }; - 9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; }; - A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = "<group>"; }; - AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverViewModel.swift; sourceTree = "<group>"; }; - AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = "<group>"; }; - B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; }; - B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; }; - C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; }; - C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; }; - CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionViewModel.swift; sourceTree = "<group>"; }; - D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; - DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; }; - DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; }; - F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineBanner.swift; sourceTree = "<group>"; }; - F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; }; - F247DE25991F4DB98DF717AA /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; }; - FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; }; - FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - EFE3211B202EDF04EB141EFB /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 2C0FB0EDFF9B3E24B97F4214 /* Resources */ = { - isa = PBXGroup; - children = ( - 5A776719B77EDDB5E44743B0 /* Assets.xcassets */, - ); - path = Resources; - sourceTree = "<group>"; - }; - 2C57B93EAF19A3B18E7B7E87 /* Views */ = { - isa = PBXGroup; - children = ( - 2F18D1275D6022B9847E310E /* Auth */, - FB5C0D4925633786D28C6DE3 /* BookDetail */, - 8E8AAA58A33084ADB8AEA80C /* Browse */, - 4EAB87A1ED4943A311F26F84 /* ChapterReader */, - 5D5809803A3D74FAE19DB218 /* Common */, - 9180FAFE96724B8AACFA9859 /* Components */, - 3881CBFE9730C6422BE6F03D /* Downloads */, - 811FC0F6B9C209D6EC8543BD /* Home */, - FA994FD601E79EC811D822A4 /* Library */, - 89F2CB14192E7D7565A588E0 /* Player */, - 3DB66C5703A4CCAFFA1B7AFE /* Profile */, - 474BE4FC0353C2DD8D8425D1 /* Search */, - ); - path = Views; - sourceTree = "<group>"; - }; - 2F18D1275D6022B9847E310E /* Auth */ = { - isa = PBXGroup; - children = ( - 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */, - ); - path = Auth; - sourceTree = "<group>"; - }; - 3881CBFE9730C6422BE6F03D /* Downloads */ = { - isa = PBXGroup; - children = ( - 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */, - 8175390266E8C6CF1437A229 /* DownloadsView.swift */, - ); - path = Downloads; - sourceTree = "<group>"; - }; - 3DB66C5703A4CCAFFA1B7AFE /* Profile */ = { - isa = PBXGroup; - children = ( - AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */, - 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */, - C0B17D50389C6C98FC78BDBC /* ProfileView.swift */, - F247DE25991F4DB98DF717AA /* UserProfileView.swift */, - A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */, - ); - path = Profile; - sourceTree = "<group>"; - }; - 426F7C5465758645B93A1AB1 /* Networking */ = { - isa = PBXGroup; - children = ( - B593F179EC3E9112126B540B /* APIClient.swift */, - ); - path = Networking; - sourceTree = "<group>"; - }; - 474BE4FC0353C2DD8D8425D1 /* Search */ = { - isa = PBXGroup; - children = ( - 79133D9FA697D1909C8D3973 /* SearchView.swift */, - ); - path = Search; - sourceTree = "<group>"; - }; - 4EAB87A1ED4943A311F26F84 /* ChapterReader */ = { - isa = PBXGroup; - children = ( - 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */, - 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */, - ); - path = ChapterReader; - sourceTree = "<group>"; - }; - 5D5809803A3D74FAE19DB218 /* Common */ = { - isa = PBXGroup; - children = ( - 8E89FD8F46747CA653C5203D /* CommonViews.swift */, - ); - path = Common; - sourceTree = "<group>"; - }; - 6318D3C6F0DC6C8E2C377103 /* Products */ = { - isa = PBXGroup; - children = ( - 1B8BF3DB582A658386E402C7 /* LibNovel.app */, - 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */, - ); - name = Products; - sourceTree = "<group>"; - }; - 646952B9CE927F8038FF0A13 /* LibNovelTests */ = { - isa = PBXGroup; - children = ( - B4C918833E173D6B44D06955 /* LibNovelTests.swift */, - ); - path = LibNovelTests; - sourceTree = "<group>"; - }; - 80148B5E27BD0A3DEDB3ADAA /* Models */ = { - isa = PBXGroup; - children = ( - 762E378B9BC2161A7AA2CC36 /* Models.swift */, - ); - path = Models; - sourceTree = "<group>"; - }; - 811FC0F6B9C209D6EC8543BD /* Home */ = { - isa = PBXGroup; - children = ( - D6268D60803940CBD38FB921 /* HomeView.swift */, - ); - path = Home; - sourceTree = "<group>"; - }; - 89F2CB14192E7D7565A588E0 /* Player */ = { - isa = PBXGroup; - children = ( - DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */, - ); - path = Player; - sourceTree = "<group>"; - }; - 8E8AAA58A33084ADB8AEA80C /* Browse */ = { - isa = PBXGroup; - children = ( - 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */, - ); - path = Browse; - sourceTree = "<group>"; - }; - 9180FAFE96724B8AACFA9859 /* Components */ = { - isa = PBXGroup; - children = ( - F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */, - ); - path = Components; - sourceTree = "<group>"; - }; - 9AF55E5D62F980C72431782A = { - isa = PBXGroup; - children = ( - A28A184E73B15138A4D13F31 /* LibNovel */, - 646952B9CE927F8038FF0A13 /* LibNovelTests */, - 6318D3C6F0DC6C8E2C377103 /* Products */, - ); - indentWidth = 4; - sourceTree = "<group>"; - tabWidth = 4; - usesTabs = 0; - }; - A28A184E73B15138A4D13F31 /* LibNovel */ = { - isa = PBXGroup; - children = ( - FE92158CC5DA9AD446062724 /* App */, - FD5EDEE9747643D45CA6423E /* Extensions */, - 80148B5E27BD0A3DEDB3ADAA /* Models */, - 426F7C5465758645B93A1AB1 /* Networking */, - 2C0FB0EDFF9B3E24B97F4214 /* Resources */, - DA6F6F625578875F3E74F1D3 /* Services */, - B6916C5C762A37AB1279DF44 /* ViewModels */, - 2C57B93EAF19A3B18E7B7E87 /* Views */, - ); - path = LibNovel; - sourceTree = "<group>"; - }; - B6916C5C762A37AB1279DF44 /* ViewModels */ = { - isa = PBXGroup; - children = ( - 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */, - 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */, - 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */, - AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */, - 3AB2E843D93461074A89A171 /* HomeViewModel.swift */, - FC338B05EA6DB22900712000 /* LibraryViewModel.swift */, - 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */, - 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */, - CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */, - ); - path = ViewModels; - sourceTree = "<group>"; - }; - DA6F6F625578875F3E74F1D3 /* Services */ = { - isa = PBXGroup; - children = ( - 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */, - DB13E89E50529E3081533A66 /* AudioPlayerService.swift */, - F219788AE5ACBD6F240674F5 /* AuthStore.swift */, - 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */, - 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */, - ); - path = Services; - sourceTree = "<group>"; - }; - FA994FD601E79EC811D822A4 /* Library */ = { - isa = PBXGroup; - children = ( - C21107BECA55C07416E0CB8B /* LibraryView.swift */, - ); - path = Library; - sourceTree = "<group>"; - }; - FB5C0D4925633786D28C6DE3 /* BookDetail */ = { - isa = PBXGroup; - children = ( - 39DE056C37FBC5EED8771821 /* BookDetailView.swift */, - 16B9AFE90719BDBC718F0621 /* CommentsView.swift */, - ); - path = BookDetail; - sourceTree = "<group>"; - }; - FD5EDEE9747643D45CA6423E /* Extensions */ = { - isa = PBXGroup; - children = ( - 9D83BB88C4306BE7A4F947CB /* Color+App.swift */, - 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */, - FEC6F837FF2E902E334ED72E /* String+App.swift */, - ); - path = Extensions; - sourceTree = "<group>"; - }; - FE92158CC5DA9AD446062724 /* App */ = { - isa = PBXGroup; - children = ( - 4B820081FA4817765A39939A /* ContentView.swift */, - 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */, - 2D5C115992F1CE2326236765 /* RootTabView.swift */, - ); - path = App; - sourceTree = "<group>"; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */; - buildPhases = ( - 247D45B3DB26CAC41FA78A0B /* Sources */, - ); - buildRules = ( - ); - dependencies = ( - 9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */, - ); - name = LibNovelTests; - packageProductDependencies = ( - ); - productName = LibNovelTests; - productReference = 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - D039EDECDE3998D8534BB680 /* LibNovel */ = { - isa = PBXNativeTarget; - buildConfigurationList = 29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */; - buildPhases = ( - 48661ADCA15B54E048CF694C /* Sources */, - 27446CA4728C022832398376 /* Resources */, - EFE3211B202EDF04EB141EFB /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = LibNovel; - packageProductDependencies = ( - 09584EAB68A07B47F876A062 /* Kingfisher */, - ); - productName = LibNovel; - productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - A10A669C0C8B43078C0FEE9F /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1600; - }; - buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - Base, - en, - ); - mainGroup = 9AF55E5D62F980C72431782A; - minimizedProjectReferenceProxies = 1; - packageReferences = ( - AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */, - ); - preferredProjectObjectVersion = 77; - productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - D039EDECDE3998D8534BB680 /* LibNovel */, - 5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 27446CA4728C022832398376 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 247D45B3DB26CAC41FA78A0B /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 48661ADCA15B54E048CF694C /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */, - ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */, - 65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */, - A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */, - 5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */, - 9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */, - 0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */, - 749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */, - 7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */, - 1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */, - 08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */, - 2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */, - FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */, - 2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */, - E2572692178FD17145FDAF77 /* Color+App.swift in Sources */, - A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */, - F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */, - 94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */, - 07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */, - DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */, - 880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */, - 774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */, - EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */, - C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */, - 032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */, - E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */, - 3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */, - 0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */, - F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */, - 5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */, - 62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */, - BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */, - 64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */, - 58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */, - 367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */, - 192F82518CB8763775E33B38 /* SearchView.swift in Sources */, - 41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */, - 9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */, - 9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */, - 8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */, - 1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D039EDECDE3998D8534BB680 /* LibNovel */; - targetProxy = 698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 428871329DC9E7B31FA1664B /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel"; - }; - name = Release; - }; - 49CBF0D367E562629E002A4B /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel"; - }; - name = Debug; - }; - 8098D4A97F989064EC71E5A1 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = GHZXC6FVMU; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = LibNovel/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 9C182367114E72FF84D54A2F /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_PREVIEWS = YES; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "DEBUG=1", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; - MARKETING_VERSION = 1.0.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.10; - }; - name = Debug; - }; - D9977A0FA70F052FD0C126D3 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = GHZXC6FVMU; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = LibNovel/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel; - PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f"; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - F9ED141CFB1E2EC6F5E9F089 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_PREVIEWS = YES; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; - MARKETING_VERSION = 1.0.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.10; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8098D4A97F989064EC71E5A1 /* Debug */, - D9977A0FA70F052FD0C126D3 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; - 964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 49CBF0D367E562629E002A4B /* Debug */, - 428871329DC9E7B31FA1664B /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; - D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 9C182367114E72FF84D54A2F /* Debug */, - F9ED141CFB1E2EC6F5E9F089 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; -/* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/onevcat/Kingfisher"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 8.0.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 09584EAB68A07B47F876A062 /* Kingfisher */ = { - isa = XCSwiftPackageProductDependency; - package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */; - productName = Kingfisher; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = A10A669C0C8B43078C0FEE9F /* Project object */; -} diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Workspace - version = "1.0"> - <FileRef - location = "self:"> - </FileRef> -</Workspace> diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 25d7acb..0000000 --- a/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240", - "pins" : [ - { - "identity" : "kingfisher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/onevcat/Kingfisher", - "state" : { - "revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008", - "version" : "8.8.0" - } - } - ], - "version" : 3 -} diff --git a/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme b/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme deleted file mode 100644 index f271d0d..0000000 --- a/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme +++ /dev/null @@ -1,113 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Scheme - LastUpgradeVersion = "1600" - version = "1.7"> - <BuildAction - parallelizeBuildables = "YES" - buildImplicitDependencies = "YES" - runPostActionsOnFailure = "NO"> - <BuildActionEntries> - <BuildActionEntry - buildForTesting = "YES" - buildForRunning = "YES" - buildForProfiling = "YES" - buildForArchiving = "YES" - buildForAnalyzing = "YES"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "D039EDECDE3998D8534BB680" - BuildableName = "LibNovel.app" - BlueprintName = "LibNovel" - ReferencedContainer = "container:LibNovel.xcodeproj"> - </BuildableReference> - </BuildActionEntry> - </BuildActionEntries> - </BuildAction> - <TestAction - buildConfiguration = "Debug" - selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" - selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - onlyGenerateCoverageForSpecifiedTargets = "NO"> - <MacroExpansion> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "D039EDECDE3998D8534BB680" - BuildableName = "LibNovel.app" - BlueprintName = "LibNovel" - ReferencedContainer = "container:LibNovel.xcodeproj"> - </BuildableReference> - </MacroExpansion> - <Testables> - <TestableReference - skipped = "NO" - parallelizable = "NO"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "5E6D3E8266BFCF0AAF5EC79D" - BuildableName = "LibNovelTests.xctest" - BlueprintName = "LibNovelTests" - ReferencedContainer = "container:LibNovel.xcodeproj"> - </BuildableReference> - </TestableReference> - </Testables> - <CommandLineArguments> - </CommandLineArguments> - </TestAction> - <LaunchAction - buildConfiguration = "Debug" - selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" - selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - launchStyle = "0" - useCustomWorkingDirectory = "NO" - ignoresPersistentStateOnLaunch = "NO" - debugDocumentVersioning = "YES" - debugServiceExtension = "internal" - allowLocationSimulation = "YES"> - <BuildableProductRunnable - runnableDebuggingMode = "0"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "D039EDECDE3998D8534BB680" - BuildableName = "LibNovel.app" - BlueprintName = "LibNovel" - ReferencedContainer = "container:LibNovel.xcodeproj"> - </BuildableReference> - </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> - <EnvironmentVariables> - <EnvironmentVariable - key = "LIBNOVEL_BASE_URL" - value = "["value": "https://v2.libnovel.kalekber.cc", "isEnabled": true]" - isEnabled = "YES"> - </EnvironmentVariable> - </EnvironmentVariables> - </LaunchAction> - <ProfileAction - buildConfiguration = "Release" - shouldUseLaunchSchemeArgsEnv = "YES" - savedToolIdentifier = "" - useCustomWorkingDirectory = "NO" - debugDocumentVersioning = "YES"> - <BuildableProductRunnable - runnableDebuggingMode = "0"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "D039EDECDE3998D8534BB680" - BuildableName = "LibNovel.app" - BlueprintName = "LibNovel" - ReferencedContainer = "container:LibNovel.xcodeproj"> - </BuildableReference> - </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> - </ProfileAction> - <AnalyzeAction - buildConfiguration = "Debug"> - </AnalyzeAction> - <ArchiveAction - buildConfiguration = "Release" - revealArchiveInOrganizer = "YES"> - </ArchiveAction> -</Scheme> diff --git a/ios/LibNovel/LibNovel/App/ContentView.swift b/ios/LibNovel/LibNovel/App/ContentView.swift deleted file mode 100644 index fcf5237..0000000 --- a/ios/LibNovel/LibNovel/App/ContentView.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI - -struct ContentView: View { - @EnvironmentObject var authStore: AuthStore - @EnvironmentObject var audioPlayer: AudioPlayerService - - var body: some View { - Group { - if authStore.isAuthenticated { - RootTabView() - } else { - AuthView() - } - } - } -} diff --git a/ios/LibNovel/LibNovel/App/LibNovelApp.swift b/ios/LibNovel/LibNovel/App/LibNovelApp.swift deleted file mode 100644 index 4f7c203..0000000 --- a/ios/LibNovel/LibNovel/App/LibNovelApp.swift +++ /dev/null @@ -1,19 +0,0 @@ -import SwiftUI - -@main -struct LibNovelApp: App { - @StateObject private var authStore = AuthStore() - @StateObject private var audioPlayer = AudioPlayerService() - @StateObject private var downloadService = AudioDownloadService.shared - @StateObject private var networkMonitor = NetworkMonitor() - - var body: some Scene { - WindowGroup { - ContentView() - .environmentObject(authStore) - .environmentObject(audioPlayer) - .environmentObject(downloadService) - .environmentObject(networkMonitor) - } - } -} diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift deleted file mode 100644 index 65e4a09..0000000 --- a/ios/LibNovel/LibNovel/App/RootTabView.swift +++ /dev/null @@ -1,90 +0,0 @@ -import SwiftUI - -// MARK: - Root tab container with persistent mini-player overlay - -struct RootTabView: View { - @EnvironmentObject var authStore: AuthStore - @EnvironmentObject var audioPlayer: AudioPlayerService - - @State private var selectedTab: Tab = .home - @State private var showFullPlayer: Bool = false - @State private var readerIsActive: Bool = false - - /// Live drag offset while the user is dragging the full player down. - @State private var fullPlayerDragOffset: CGFloat = 0 - - enum Tab: Hashable { - case home, library, browse, search - } - - var body: some View { - ZStack(alignment: .bottom) { - TabView(selection: $selectedTab) { - HomeView() - .tabItem { Label("Home", systemImage: "house.fill") } - .tag(Tab.home) - - LibraryView() - .tabItem { Label("Library", systemImage: "book.pages.fill") } - .tag(Tab.library) - - BrowseView() - .tabItem { Label("Discover", systemImage: "sparkles") } - .tag(Tab.browse) - - SearchView() - .tabItem { Label("Search", systemImage: "magnifyingglass") } - .tag(Tab.search) - } - - // Mini player bar — sits above the tab bar, hidden while full player is open - // or while the chapter reader is active (it has its own audio chrome). - if audioPlayer.isActive && !showFullPlayer && !readerIsActive { - MiniPlayerBar(showFullPlayer: $showFullPlayer) - // Lift above the tab bar (approx 49 pt on all devices) - .padding(.bottom, 49) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) - } - - // Full player — slides up from the bottom as a custom overlay. - if showFullPlayer { - FullPlayerView(onDismiss: { - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { - showFullPlayer = false - fullPlayerDragOffset = 0 - } - }) - .offset(y: max(fullPlayerDragOffset, 0)) - .gesture( - DragGesture(minimumDistance: 10) - .onChanged { value in - if value.translation.height > 0 { - fullPlayerDragOffset = value.translation.height - } - } - .onEnded { value in - let velocity = value.predictedEndTranslation.height - value.translation.height - if value.translation.height > 120 || velocity > 400 { - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { - showFullPlayer = false - fullPlayerDragOffset = 0 - } - } else { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - fullPlayerDragOffset = 0 - } - } - } - ) - .transition(.move(edge: .bottom)) - .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) - .ignoresSafeArea() - } - } - .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) - .onPreferenceChange(HideMiniPlayerKey.self) { hide in - readerIsActive = hide - } - } -} diff --git a/ios/LibNovel/LibNovel/Extensions/Color+App.swift b/ios/LibNovel/LibNovel/Extensions/Color+App.swift deleted file mode 100644 index 7f4fe40..0000000 --- a/ios/LibNovel/LibNovel/Extensions/Color+App.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -// MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b) -extension Color { - static let amber = Color(red: 0.96, green: 0.62, blue: 0.04) -} - -extension ShapeStyle where Self == Color { - static var amber: Color { .amber } -} diff --git a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift deleted file mode 100644 index f334016..0000000 --- a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift +++ /dev/null @@ -1,168 +0,0 @@ -import SwiftUI - -// MARK: - Navigation destination enum used across all tabs - -enum NavDestination: Hashable { - case book(String) // slug - case chapter(String, Int) // slug + chapter number - case userProfile(String) // username - case browseCategory(sort: String, genre: String, status: String, title: String) // Browse with filters -} - -// MARK: - View extensions for shared navigation + error alert patterns - -extension View { - /// Registers the app-wide navigation destinations for NavDestination values. - /// Apply once per NavigationStack instead of repeating the switch in every tab. - func appNavigationDestination() -> some View { - modifier(AppNavigationDestinationModifier()) - } - - /// Presents a standard "Error" alert driven by an optional String binding. - /// Dismissing the alert sets the binding back to nil. - /// Silently suppresses network errors when offline (banner shows instead). - func errorAlert(_ error: Binding<String?>) -> some View { - self.modifier(ErrorAlertModifier(error: error)) - } -} - -// MARK: - Error Alert Modifier - -private struct ErrorAlertModifier: ViewModifier { - @Binding var error: String? - @EnvironmentObject var networkMonitor: NetworkMonitor - - private var shouldShowAlert: Bool { - guard let errorMessage = error else { return false } - - // If offline, suppress common network error messages - if !networkMonitor.isConnected { - let networkKeywords = [ - "internet", - "offline", - "network", - "connection", - "unreachable", - "timed out", - "no data" - ] - - let lowercased = errorMessage.lowercased() - let isNetworkError = networkKeywords.contains { lowercased.contains($0) } - - if isNetworkError { - // Clear the error silently - DispatchQueue.main.async { - self.error = nil - } - return false - } - } - - return true - } - - func body(content: Content) -> some View { - content - .alert("Error", isPresented: Binding( - get: { shouldShowAlert }, - set: { if !$0 { error = nil } } - )) { - Button("OK") { error = nil } - } message: { - Text(error ?? "") - } - } -} - -// MARK: - Navigation destination modifier - -private struct AppNavigationDestinationModifier: ViewModifier { - @Namespace private var zoomNamespace - - func body(content: Content) -> some View { - if #available(iOS 18.0, *) { - content - .navigationDestination(for: NavDestination.self) { dest in - switch dest { - case .book(let slug): - BookDetailView(slug: slug) - .navigationTransition(.zoom(sourceID: slug, in: zoomNamespace)) - case .chapter(let slug, let n): - ChapterReaderView(slug: slug, chapterNumber: n) - case .userProfile(let username): - UserProfileView(username: username) - case .browseCategory(let sort, let genre, let status, let title): - BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) - } - } - // Expose namespace to child views via environment - .environment(\.bookZoomNamespace, zoomNamespace) - } else { - content - .navigationDestination(for: NavDestination.self) { dest in - switch dest { - case .book(let slug): BookDetailView(slug: slug) - case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) - case .userProfile(let username): UserProfileView(username: username) - case .browseCategory(let sort, let genre, let status, let title): - BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) - } - } - } - } -} - -// MARK: - Environment key for zoom namespace - -struct BookZoomNamespaceKey: EnvironmentKey { - static var defaultValue: Namespace.ID? { nil } -} - -extension EnvironmentValues { - var bookZoomNamespace: Namespace.ID? { - get { self[BookZoomNamespaceKey.self] } - set { self[BookZoomNamespaceKey.self] = newValue } - } -} - -// MARK: - Preference key: suppress mini player overlay (used by ChapterReaderView) - -struct HideMiniPlayerKey: PreferenceKey { - static var defaultValue = false - static func reduce(value: inout Bool, nextValue: () -> Bool) { - value = value || nextValue() - } -} - -extension View { - /// Signal to the root overlay that the mini player should be hidden. - func hideMiniPlayer() -> some View { - preference(key: HideMiniPlayerKey.self, value: true) - } -} - -// MARK: - Cover card zoom source modifier - -/// Apply this to any cover image that should be a zoom source for book navigation. -/// Falls back to a no-op on iOS 17 or when no namespace is available. -struct BookCoverZoomSource: ViewModifier { - let slug: String - @Environment(\.bookZoomNamespace) private var namespace - - func body(content: Content) -> some View { - if #available(iOS 18.0, *), let ns = namespace { - content.matchedTransitionSource(id: slug, in: ns) - } else { - content - } - } -} - -extension View { - /// Marks a cover image as the zoom source for a book's navigation transition. - func bookCoverZoomSource(slug: String) -> some View { - modifier(BookCoverZoomSource(slug: slug)) - } -} - diff --git a/ios/LibNovel/LibNovel/Extensions/String+App.swift b/ios/LibNovel/LibNovel/Extensions/String+App.swift deleted file mode 100644 index d2287e5..0000000 --- a/ios/LibNovel/LibNovel/Extensions/String+App.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -// MARK: - String helpers for display purposes - -extension String { - /// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago", - /// or "(One)4 years ago" where the number is attached without a preceding space). - func strippingTrailingDate() -> String { - let units = ["second", "minute", "hour", "day", "week", "month", "year"] - let lower = self.lowercased() - for unit in units { - for suffix in [unit + "s ago", unit + " ago"] { - guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue } - // Everything before the suffix - let before = String(self[self.startIndex ..< suffixRange.lowerBound]) - let trimmed = before.trimmingCharacters(in: .whitespaces) - // Strip trailing digits (the numeric count, which may be attached without a space) - var result = trimmed - while let last = result.last, last.isNumber { - result.removeLast() - } - result = result.trimmingCharacters(in: .whitespaces) - if result != trimmed { - // We actually stripped some digits — return cleaned result - return result - } - // Fallback: number preceded by space - if let spaceIdx = trimmed.lastIndex(of: " ") { - let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...]) - if Int(potentialNum) != nil { - return String(trimmed[trimmed.startIndex ..< spaceIdx]) - .trimmingCharacters(in: .whitespaces) - } - } else if Int(trimmed) != nil { - return "" - } - } - } - return self - } -} diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift deleted file mode 100644 index 2205330..0000000 --- a/ios/LibNovel/LibNovel/Models/Models.swift +++ /dev/null @@ -1,395 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - Book - -struct Book: Identifiable, Codable, Hashable { - let id: String - let slug: String - let title: String - let author: String - let cover: String - let status: String - let genres: [String] - let summary: String - let totalChapters: Int - let sourceURL: String - let ranking: Int - let metaUpdated: String - - enum CodingKeys: String, CodingKey { - case id, slug, title, author, cover, status, genres, summary - case totalChapters = "total_chapters" - case sourceURL = "source_url" - case ranking - case metaUpdated = "meta_updated" - } - - // PocketBase returns genres as either a JSON string array or a real array - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - slug = try container.decode(String.self, forKey: .slug) - title = try container.decode(String.self, forKey: .title) - author = try container.decode(String.self, forKey: .author) - cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? "" - status = try container.decodeIfPresent(String.self, forKey: .status) ?? "" - totalChapters = try container.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0 - sourceURL = try container.decodeIfPresent(String.self, forKey: .sourceURL) ?? "" - ranking = try container.decodeIfPresent(Int.self, forKey: .ranking) ?? 0 - metaUpdated = try container.decodeIfPresent(String.self, forKey: .metaUpdated) ?? "" - summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "" - - // genres is sometimes a JSON-encoded string, sometimes a real array - if let arr = try? container.decode([String].self, forKey: .genres) { - genres = arr - } else if let str = try? container.decode(String.self, forKey: .genres), - let data = str.data(using: .utf8), - let arr = try? JSONDecoder().decode([String].self, from: data) { - genres = arr - } else { - genres = [] - } - } -} - -// MARK: - ChapterIndex - -struct ChapterIndex: Identifiable, Codable, Hashable { - let id: String - let slug: String - let number: Int - let title: String - let dateLabel: String - - enum CodingKeys: String, CodingKey { - case id, slug, number, title - case dateLabel = "date_label" - } -} - -struct ChapterIndexBrief: Codable, Hashable { - let number: Int - let title: String -} - -// MARK: - User Settings - -struct UserSettings: Codable { - var id: String? - var autoNext: Bool - var voice: String - var speed: Double - - // Server sends/expects camelCase: { autoNext, voice, speed } - // (No CodingKeys needed — Swift synthesises the same names by default) - - static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0) -} - -// MARK: - Reading Display Settings (local only — stored in UserDefaults) - -enum ReaderTheme: String, CaseIterable, Codable { - case white, sepia, night - - var backgroundColor: Color { - switch self { - case .white: return Color(.sRGB, white: 1.0, opacity: 1) - case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82) - case .night: return Color(red: 0.10, green: 0.10, blue: 0.12) - } - } - - var textColor: Color { - switch self { - case .white: return Color(.sRGB, white: 0.1, opacity: 1) - case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08) - case .night: return Color(red: 0.85, green: 0.85, blue: 0.87) - } - } - - var colorScheme: ColorScheme? { - switch self { - case .white: return nil // follows system - case .sepia: return .light - case .night: return .dark - } - } -} - -enum ReaderFont: String, CaseIterable, Codable { - case system = "System" - case georgia = "Georgia" - case newYork = "New York" - - var fontName: String? { - switch self { - case .system: return nil - case .georgia: return "Georgia" - case .newYork: return "NewYorkMedium-Regular" - } - } -} - -struct ReaderSettings: Codable, Equatable { - var fontSize: CGFloat - var lineSpacing: CGFloat - var font: ReaderFont - var theme: ReaderTheme - var scrollMode: Bool - - static let `default` = ReaderSettings( - fontSize: 17, - lineSpacing: 1.7, - font: .system, - theme: .white, - scrollMode: false - ) - - static let userDefaultsKey = "readerSettings" - - static func load() -> ReaderSettings { - guard let data = UserDefaults.standard.data(forKey: userDefaultsKey), - let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data) - else { return .default } - return decoded - } - - func save() { - if let data = try? JSONEncoder().encode(self) { - UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey) - } - } -} - -// MARK: - User - -struct AppUser: Codable, Identifiable { - let id: String - let username: String - let role: String - let created: String - let avatarURL: String? - - var isAdmin: Bool { role == "admin" } - - enum CodingKeys: String, CodingKey { - case id, username, role, created - case avatarURL = "avatar_url" - } - - init(id: String, username: String, role: String, created: String, avatarURL: String?) { - self.id = id - self.username = username - self.role = role - self.created = created - self.avatarURL = avatarURL - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - username = try c.decode(String.self, forKey: .username) - role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" - created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" - avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL) - } -} - -// MARK: - Ranking - -struct RankingItem: Codable, Identifiable { - var id: String { slug } - let rank: Int - let slug: String - let title: String - let author: String - let cover: String - let status: String - let genres: [String] - let sourceURL: String - - enum CodingKeys: String, CodingKey { - case rank, slug, title, author, cover, status, genres - case sourceURL = "source_url" - } -} - -// MARK: - Home - -struct ContinueReadingItem: Identifiable { - var id: String { book.id } - let book: Book - let chapter: Int -} - -struct HomeStats: Codable { - let totalBooks: Int - let totalChapters: Int - let booksInProgress: Int -} - -// MARK: - Session - -struct UserSession: Codable, Identifiable { - let id: String - let userAgent: String - let ip: String - let createdAt: String - let lastSeen: String - var isCurrent: Bool - - enum CodingKeys: String, CodingKey { - case id - case userAgent = "user_agent" - case ip - case createdAt = "created_at" - case lastSeen = "last_seen" - case isCurrent = "is_current" - } -} - -struct PreviewChapter: Codable, Identifiable { - var id: Int { number } - let number: Int - let title: String - let url: String -} - -struct BookBrief: Codable { - let slug: String - let title: String - let cover: String -} - -// MARK: - Comments - -struct BookComment: Identifiable, Codable, Hashable { - let id: String - let slug: String - let userId: String - let username: String - let body: String - var upvotes: Int - var downvotes: Int - let created: String - let parentId: String // empty = top-level; non-empty = reply - var replies: [BookComment]? // populated client-side from the API response - - enum CodingKeys: String, CodingKey { - case id, slug, username, body, upvotes, downvotes, created, replies - case userId = "user_id" - case parentId = "parent_id" - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" - userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? "" - username = try c.decodeIfPresent(String.self, forKey: .username) ?? "" - body = try c.decodeIfPresent(String.self, forKey: .body) ?? "" - upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0 - downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0 - created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" - parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? "" - replies = try c.decodeIfPresent([BookComment].self, forKey: .replies) - } -} - -struct CommentsResponse: Decodable { - let comments: [BookComment] - let myVotes: [String: String] - let avatarUrls: [String: String] - - enum CodingKeys: String, CodingKey { - case comments - case myVotes = "myVotes" - case avatarUrls = "avatarUrls" - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - comments = try c.decode([BookComment].self, forKey: .comments) - myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:] - avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:] - } -} - -// MARK: - User Profile (public) - -struct PublicUserProfile: Decodable, Identifiable { - let id: String - let username: String - let avatarUrl: String? - let created: String - let followerCount: Int - let followingCount: Int - let isSubscribed: Bool - let isSelf: Bool - - enum CodingKeys: String, CodingKey { - case id, username, created - case avatarUrl = "avatarUrl" - case followerCount = "followerCount" - case followingCount = "followingCount" - case isSubscribed = "isSubscribed" - case isSelf = "isSelf" - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - username = try c.decode(String.self, forKey: .username) - avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl) - created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" - followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0 - followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0 - isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false - isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false - } -} - -// MARK: - Subscription Feed - -struct SubscriptionFeedItem: Identifiable, Decodable { - var id: String { book.id + readerUsername } - let book: Book - let readerUsername: String - - enum CodingKeys: String, CodingKey { - case book - case readerUsername = "readerUsername" - } -} - -// MARK: - Public User Library - -struct PublicLibraryItem: Decodable, Identifiable { - var id: String { book.id } - let book: Book - let lastChapter: Int? - let saved: Bool - - enum CodingKeys: String, CodingKey { - case book - case lastChapter = "last_chapter" - case saved - } -} - -struct PublicUserLibraryResponse: Decodable { - let currentlyReading: [PublicLibraryItem] - let library: [PublicLibraryItem] - - enum CodingKeys: String, CodingKey { - case currentlyReading = "currently_reading" - case library - } -} - -// MARK: - Audio - -enum NextPrefetchStatus { - case none, prefetching, prefetched, failed -} diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift deleted file mode 100644 index 58956dc..0000000 --- a/ios/LibNovel/LibNovel/Networking/APIClient.swift +++ /dev/null @@ -1,580 +0,0 @@ -import Foundation - -// MARK: - API Client -// Communicates with the SvelteKit UI server (not directly with the Go scraper). -// The SvelteKit layer handles auth, PocketBase queries, and MinIO presigning. -// For the iOS app we talk to the same /api/* endpoints the web UI uses, -// so we reuse the exact same HMAC-cookie auth flow. - -actor APIClient { - static let shared = APIClient() - - var baseURL: URL - private var authCookie: String? // raw "libnovel_auth=<token>" header value - - // URLSession with persistent cookie storage - private let session: URLSession = { - let config = URLSessionConfiguration.default - config.httpCookieAcceptPolicy = .always - config.httpShouldSetCookies = true - config.httpCookieStorage = HTTPCookieStorage.shared - return URLSession(configuration: config) - }() - - private init() { - // Default: point at the UI server. Override via Settings bundle or compile flag. - let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String - ?? "https://v2.libnovel.kalekber.cc" - baseURL = URL(string: urlString)! - } - - // MARK: - Auth cookie management - - func setAuthCookie(_ value: String?) { - authCookie = value - if let value { - // Also inject into shared cookie storage so redirects carry the cookie - let cookieProps: [HTTPCookiePropertyKey: Any] = [ - .name: "libnovel_auth", - .value: value, - .domain: baseURL.host ?? "localhost", - .path: "/" - ] - if let cookie = HTTPCookie(properties: cookieProps) { - HTTPCookieStorage.shared.setCookie(cookie) - } - } else { - // Clear - let cookieStorage = HTTPCookieStorage.shared - cookieStorage.cookies(for: baseURL)?.forEach { cookieStorage.deleteCookie($0) } - } - } - - // MARK: - Low-level request builder - - private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest { - // Build URL by appending the path string directly to the base URL string. - // appendingPathComponent() percent-encodes slashes, which breaks multi-segment - // paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly. - let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - + "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard let url = URL(string: urlString) else { - throw APIError.invalidResponse - } - var req = URLRequest(url: url) - req.httpMethod = method - req.setValue("application/json", forHTTPHeaderField: "Accept") - if let body { - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.httpBody = try JSONEncoder().encode(body) - } - return req - } - - // MARK: - Generic fetch - - func fetch<T: Decodable>(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T { - let req = try makeRequest(path, method: method, body: body) - let (data, response) = try await session.data(for: req) - guard let http = response as? HTTPURLResponse else { - throw APIError.invalidResponse - } - let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>" - guard (200..<300).contains(http.statusCode) else { - throw APIError.httpError(http.statusCode, rawBody) - } - do { - return try JSONDecoder.iso8601.decode(T.self, from: data) - } catch { - throw APIError.decodingError(error) - } - } - - /// Like `fetch` but discards the response body — use for endpoints that return 204 No Content. - func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws { - let req = try makeRequest(path, method: method, body: body) - let (data, response) = try await session.data(for: req) - guard let http = response as? HTTPURLResponse else { - throw APIError.invalidResponse - } - guard (200..<300).contains(http.statusCode) else { - let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>" - throw APIError.httpError(http.statusCode, rawBody) - } - } - - // MARK: - Auth - - struct LoginRequest: Encodable { - let username: String - let password: String - } - - struct LoginResponse: Decodable { - let token: String - let user: AppUser - } - - func login(username: String, password: String) async throws -> LoginResponse { - try await fetch("/api/auth/login", method: "POST", - body: LoginRequest(username: username, password: password)) - } - - func register(username: String, password: String) async throws -> LoginResponse { - try await fetch("/api/auth/register", method: "POST", - body: LoginRequest(username: username, password: password)) - } - - func logout() async throws { - let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST") - setAuthCookie(nil) - } - - // MARK: - Home - - func homeData() async throws -> HomeDataResponse { - try await fetch("/api/home") - } - - // MARK: - Library - - func library() async throws -> [LibraryItem] { - try await fetch("/api/library") - } - - func saveBook(slug: String) async throws { - let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST") - } - - func unsaveBook(slug: String) async throws { - let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE") - } - - // MARK: - Book Detail - - func bookDetail(slug: String) async throws -> BookDetailResponse { - try await fetch("/api/book/\(slug)") - } - - // MARK: - Chapter - - func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse { - try await fetch("/api/chapter/\(slug)/\(chapter)") - } - - // MARK: - Browse - - func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse { - let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)" - return try await fetch("/api/browse-page\(query)") - } - - func search(query: String) async throws -> SearchResponse { - let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query - return try await fetch("/api/search?q=\(encoded)") - } - - func ranking() async throws -> [RankingItem] { - try await fetch("/api/ranking") - } - - // MARK: - Progress - - func progress() async throws -> [ProgressEntry] { - try await fetch("/api/progress") - } - - func setProgress(slug: String, chapter: Int) async throws { - struct Body: Encodable { let chapter: Int } - let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter)) - } - - func deleteProgress(slug: String) async throws { - let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE") - } - - func audioTime(slug: String, chapter: Int) async throws -> Double? { - struct Response: Decodable { let audioTime: Double?; enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } } - let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)") - return r.audioTime - } - - func setAudioTime(slug: String, chapter: Int, time: Double) async throws { - struct Body: Encodable { let slug: String; let chapter: Int; let audioTime: Double; enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" } } - let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH", body: Body(slug: slug, chapter: chapter, audioTime: time)) - } - - // MARK: - Audio - - func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse { - struct Body: Encodable { let voice: String; let speed: Double } - return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed)) - } - - /// Poll GET /api/audio/status/{slug}/{n}?voice=... until the job is done or failed. - /// Returns the presigned/proxy URL on success, throws on failure or cancellation. - func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String { - let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)" - struct StatusResponse: Decodable { - let status: String - let url: String? - let error: String? - } - while true { - try Task.checkCancellation() - let r: StatusResponse = try await fetch(path) - switch r.status { - case "done": - guard let url = r.url, !url.isEmpty else { - throw URLError(.badServerResponse) - } - return url - case "failed": - throw NSError( - domain: "AudioGeneration", - code: 0, - userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"] - ) - default: - // pending / generating / idle — keep polling - try await Task.sleep(nanoseconds: 2_000_000_000) // 2 s - } - } - } - - func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String { - struct Response: Decodable { let url: String } - let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)") - return r.url - } - - func presignVoiceSample(voice: String) async throws -> String { - struct Response: Decodable { let url: String } - let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)") - return r.url - } - - func voices() async throws -> [String] { - struct Response: Decodable { let voices: [String] } - let r: Response = try await fetch("/api/voices") - return r.voices - } - - // MARK: - Settings - - func settings() async throws -> UserSettings { - try await fetch("/api/settings") - } - - func updateSettings(_ settings: UserSettings) async throws { - let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings) - } - - // MARK: - Sessions - - func sessions() async throws -> [UserSession] { - struct Response: Decodable { let sessions: [UserSession] } - let r: Response = try await fetch("/api/sessions") - return r.sessions - } - - func revokeSession(id: String) async throws { - let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE") - } - - // MARK: - Avatar - - struct AvatarPresignResponse: Decodable { - let uploadURL: String - let key: String - enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key } - } - - struct AvatarResponse: Decodable { - let avatarURL: String? - enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" } - } - - /// Upload a profile avatar using a two-step presigned PUT flow: - /// 1. POST /api/profile/avatar → get a presigned PUT URL + object key - /// 2. PUT image bytes directly to MinIO via the presigned URL - /// 3. PATCH /api/profile/avatar with the key to record it in PocketBase - /// Returns the presigned GET URL for the uploaded avatar. - func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? { - // Step 1: request a presigned PUT URL from the SvelteKit server - let presign: AvatarPresignResponse = try await fetch( - "/api/profile/avatar", - method: "POST", - body: ["mime_type": mimeType] - ) - - // Step 2: PUT the image bytes directly to MinIO - guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse } - var putReq = URLRequest(url: putURL) - putReq.httpMethod = "PUT" - putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type") - putReq.httpBody = imageData - - let (_, putResp) = try await session.data(for: putReq) - guard let putHttp = putResp as? HTTPURLResponse, - (200..<300).contains(putHttp.statusCode) else { - let code = (putResp as? HTTPURLResponse)?.statusCode ?? 0 - throw APIError.httpError(code, "MinIO PUT failed") - } - - // Step 3: record the key in PocketBase and get back a presigned GET URL - let result: AvatarResponse = try await fetch( - "/api/profile/avatar", - method: "PATCH", - body: ["key": presign.key] - ) - return result.avatarURL - } - - /// Fetches a fresh presigned GET URL for the current user's avatar. - /// Returns nil if the user has no avatar set. - /// Used on cold launch / session restore to convert the stored raw key into a viewable URL. - func fetchAvatarPresignedURL() async throws -> String? { - let result: AvatarResponse = try await fetch("/api/profile/avatar") - return result.avatarURL - } - - // MARK: - User Profiles & Subscriptions - - func fetchUserProfile(username: String) async throws -> PublicUserProfile { - try await fetch("/api/users/\(username)") - } - - @discardableResult - func subscribeUser(username: String) async throws -> Bool { - struct Response: Decodable { let subscribed: Bool } - let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST") - return r.subscribed - } - - @discardableResult - func unsubscribeUser(username: String) async throws -> Bool { - struct Response: Decodable { let subscribed: Bool } - let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE") - return r.subscribed - } - - func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse { - try await fetch("/api/users/\(username)/library") - } - - // MARK: - Comments - - func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse { - try await fetch("/api/comments/\(slug)?sort=\(sort)") - } - - struct PostCommentBody: Encodable { - let body: String - let parent_id: String? - } - - func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment { - try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId)) - } - - struct VoteBody: Encodable { let vote: String } - - /// Cast, change, or toggle-off a vote on a comment. - /// Returns the updated BookComment (with refreshed upvotes/downvotes counts). - func voteComment(commentId: String, vote: String) async throws -> BookComment { - try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote)) - } - - /// Delete a comment (and its replies) by ID. Only the owner can delete. - func deleteComment(commentId: String) async throws { - try await fetchVoid("/api/comment/\(commentId)", method: "DELETE") - } -} - -// MARK: - Response types - -struct HomeDataResponse: Decodable { - struct ContinueItem: Decodable { - let book: Book - let chapter: Int - } - let continueReading: [ContinueItem] - let recentlyUpdated: [Book] - let stats: HomeStats - let subscriptionFeed: [SubscriptionFeedItem] - - enum CodingKeys: String, CodingKey { - case continueReading = "continue_reading" - case recentlyUpdated = "recently_updated" - case stats - case subscriptionFeed = "subscription_feed" - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? [] - recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? [] - stats = try c.decode(HomeStats.self, forKey: .stats) - subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? [] - } -} - -struct LibraryItem: Decodable, Identifiable { - var id: String { book.id } - let book: Book - let savedAt: String - let lastChapter: Int? - - enum CodingKeys: String, CodingKey { - case book - case savedAt = "saved_at" - case lastChapter = "last_chapter" - } -} - -struct BookDetailResponse: Decodable { - let book: Book - let chapters: [ChapterIndex] - let previewChapters: [PreviewChapter]? - let inLib: Bool - let saved: Bool - let lastChapter: Int? - - enum CodingKeys: String, CodingKey { - case book, chapters - case previewChapters = "preview_chapters" - case inLib = "in_lib" - case saved - case lastChapter = "last_chapter" - } -} - -struct ChapterResponse: Decodable { - let book: BookBrief - let chapter: ChapterIndex - let html: String - let voices: [String] - let prev: Int? - let next: Int? - let chapters: [ChapterIndexBrief] - let isPreview: Bool - - enum CodingKeys: String, CodingKey { - case book, chapter, html, voices, prev, next, chapters - case isPreview = "is_preview" - } -} - -struct BrowseResponse: Decodable { - let novels: [BrowseNovel] - let page: Int - let hasNext: Bool -} - -struct BrowseNovel: Decodable, Identifiable, Hashable { - var id: String { slug.isEmpty ? url : slug } - let slug: String - let title: String - let cover: String - let rank: String - let rating: String - let chapters: String - let url: String - let author: String - let status: String - let genres: [String] - - enum CodingKeys: String, CodingKey { - case slug, title, cover, rank, rating, chapters, url, author, status, genres - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" - title = try c.decode(String.self, forKey: .title) - cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" - rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? "" - rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? "" - chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? "" - url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" - author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" - status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" - genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? [] - } -} - -struct SearchResponse: Decodable { - let results: [BrowseNovel] - let localCount: Int - let remoteCount: Int - - enum CodingKeys: String, CodingKey { - case results - case localCount = "local_count" - case remoteCount = "remote_count" - } -} - -/// Returned by POST /api/audio/{slug}/{n}. -/// - 202 Accepted: job enqueued → poll via pollAudioStatus() -/// - 200 OK: audio already cached → url is ready to play -struct AudioTriggerResponse: Decodable { - let jobId: String? // present on 202 - let status: String? // present on 202: "pending" | "generating" - let url: String? // present on 200: proxy URL ready to play - let filename: String? // present on 200 - - enum CodingKeys: String, CodingKey { - case jobId = "job_id" - case status, url, filename - } - - /// True when the server accepted the request and created an async job. - var isAsync: Bool { jobId != nil } -} - -struct ProgressEntry: Decodable, Identifiable { - var id: String { slug } - let slug: String - let chapter: Int - let audioTime: Double? - let updated: String - - enum CodingKeys: String, CodingKey { - case slug, chapter, updated - case audioTime = "audio_time" - } -} - -struct EmptyResponse: Decodable {} - -// MARK: - API Error - -enum APIError: LocalizedError { - case invalidResponse - case httpError(Int, String) - case decodingError(Error) - case unauthorized - case networkError(Error) - - var errorDescription: String? { - switch self { - case .invalidResponse: return "Invalid server response" - case .httpError(let code, let msg): return "HTTP \(code): \(msg)" - case .decodingError(let e): return "Decode error: \(e.localizedDescription)" - case .unauthorized: return "Not authenticated" - case .networkError(let e): return e.localizedDescription - } - } -} - -// MARK: - JSONDecoder helper - -extension JSONDecoder { - static let iso8601: JSONDecoder = { - let d = JSONDecoder() - d.dateDecodingStrategy = .iso8601 - return d - }() -} diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index e2be29f..0000000 --- a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" } - }, - "idiom": "universal" - } - ], - "info": { "author": "xcode", "version": 1 } -} diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 27a4f38..0000000 --- a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "icon-1024.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png deleted file mode 100644 index 820557a..0000000 Binary files a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png and /dev/null differ diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/Contents.json deleted file mode 100644 index 319a86b..0000000 --- a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/Contents.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "info": { "author": "xcode", "version": 1 } -} diff --git a/ios/LibNovel/LibNovel/Resources/Info.plist b/ios/LibNovel/LibNovel/Resources/Info.plist deleted file mode 100644 index aa594f9..0000000 --- a/ios/LibNovel/LibNovel/Resources/Info.plist +++ /dev/null @@ -1,45 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>CFBundleDisplayName</key> - <string>LibNovel</string> - <key>CFBundleExecutable</key> - <string>$(EXECUTABLE_NAME)</string> - <key>CFBundleIdentifier</key> - <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> - <key>CFBundleName</key> - <string>LibNovel</string> - <key>CFBundlePackageType</key> - <string>APPL</string> - <key>CFBundleShortVersionString</key> - <string>$(MARKETING_VERSION)</string> - <key>CFBundleVersion</key> - <string>1000</string> - <key>LIBNOVEL_BASE_URL</key> - <string>$(LIBNOVEL_BASE_URL)</string> - <key>LSRequiresIPhoneOS</key> - <true/> - <key>UIBackgroundModes</key> - <array> - <string>audio</string> - <string>fetch</string> - <string>processing</string> - </array> - <key>UILaunchScreen</key> - <dict/> - <key>UISupportedInterfaceOrientations</key> - <array> - <string>UIInterfaceOrientationPortrait</string> - <string>UIInterfaceOrientationLandscapeLeft</string> - <string>UIInterfaceOrientationLandscapeRight</string> - </array> - <key>UISupportedInterfaceOrientations~ipad</key> - <array> - <string>UIInterfaceOrientationPortrait</string> - <string>UIInterfaceOrientationPortraitUpsideDown</string> - <string>UIInterfaceOrientationLandscapeLeft</string> - <string>UIInterfaceOrientationLandscapeRight</string> - </array> -</dict> -</plist> diff --git a/ios/LibNovel/LibNovel/Services/AudioDownloadService.swift b/ios/LibNovel/LibNovel/Services/AudioDownloadService.swift deleted file mode 100644 index c98f25b..0000000 --- a/ios/LibNovel/LibNovel/Services/AudioDownloadService.swift +++ /dev/null @@ -1,318 +0,0 @@ -import Foundation -import Combine - -// MARK: - AudioDownloadService -// Manages offline TTS audio downloads with progress tracking and persistent storage. -// Downloads are saved to the app's Documents directory, organized by slug/chapter/voice. - -@MainActor -final class AudioDownloadService: NSObject, ObservableObject { - static let shared = AudioDownloadService() - - // MARK: - Published State - - @Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice" - @Published var downloadedChapters: Set<String> = [] // key: "slug::chapter::voice" - - // MARK: - Private - - private var session: URLSession! - private var activeTasks: [String: URLSessionDownloadTask] = [:] - private let fileManager = FileManager.default - private let metadataKey = "downloadedChaptersMetadata" - - // MARK: - Init - - private override init() { - super.init() - let config = URLSessionConfiguration.background(withIdentifier: "cc.kalekber.libnovel.audio-downloads") - config.isDiscretionary = false - config.sessionSendsLaunchEvents = true - session = URLSession(configuration: config, delegate: self, delegateQueue: nil) - loadMetadata() - } - - // MARK: - Public API - - /// Check if a chapter's audio is downloaded offline - func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool { - let key = makeKey(slug: slug, chapter: chapter, voice: voice) - return downloadedChapters.contains(key) - } - - /// Get the local file URL for a downloaded chapter (nil if not downloaded) - func localURL(slug: String, chapter: Int, voice: String) -> URL? { - guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil } - return audioFileURL(slug: slug, chapter: chapter, voice: voice) - } - - /// Start downloading a chapter's audio - func download(slug: String, chapter: Int, voice: String) async throws { - let key = makeKey(slug: slug, chapter: chapter, voice: voice) - - print("📥 AudioDownload: Starting download - slug: \(slug), chapter: \(chapter), voice: \(voice)") - - // Already downloaded or in progress - if downloadedChapters.contains(key) { - print("⚠️ AudioDownload: Already downloaded - key: \(key)") - return - } - if activeTasks[key] != nil { - print("⚠️ AudioDownload: Already in progress - key: \(key)") - return - } - - // Get presigned URL from API - print("🔗 AudioDownload: Fetching presigned URL...") - let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) - guard let url = URL(string: urlString) else { - print("❌ AudioDownload: Invalid URL - \(urlString)") - throw URLError(.badURL) - } - - print("🔗 AudioDownload: Presigned URL obtained: \(url.absoluteString)") - - // Create download task - let task = session.downloadTask(with: url) - task.taskDescription = key // Use taskDescription to identify the download - activeTasks[key] = task - - // Initialize progress tracking - downloads[key] = DownloadProgress( - slug: slug, - chapter: chapter, - voice: voice, - progress: 0, - totalBytes: 0, - downloadedBytes: 0, - status: .downloading - ) - - print("🚀 AudioDownload: Starting download task - key: \(key)") - task.resume() - } - - /// Cancel an ongoing download - func cancelDownload(slug: String, chapter: Int, voice: String) { - let key = makeKey(slug: slug, chapter: chapter, voice: voice) - activeTasks[key]?.cancel() - activeTasks.removeValue(forKey: key) - downloads.removeValue(forKey: key) - } - - /// Delete a downloaded chapter - func deleteDownload(slug: String, chapter: Int, voice: String) throws { - let key = makeKey(slug: slug, chapter: chapter, voice: voice) - let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice) - - if fileManager.fileExists(atPath: fileURL.path) { - try fileManager.removeItem(at: fileURL) - } - - downloadedChapters.remove(key) - downloads.removeValue(forKey: key) - saveMetadata() - } - - /// Get total storage used by downloads (in bytes) - func getTotalStorageUsed() -> Int64 { - guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { - return 0 - } - - let audioDir = documentsURL.appendingPathComponent("audio") - guard let enumerator = fileManager.enumerator(at: audioDir, includingPropertiesForKeys: [.fileSizeKey]) else { - return 0 - } - - var totalSize: Int64 = 0 - for case let fileURL as URL in enumerator { - if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { - totalSize += Int64(fileSize) - } - } - return totalSize - } - - /// Delete all downloads - func deleteAllDownloads() throws { - guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { - return - } - - let audioDir = documentsURL.appendingPathComponent("audio") - if fileManager.fileExists(atPath: audioDir.path) { - try fileManager.removeItem(at: audioDir) - } - - downloadedChapters.removeAll() - downloads.removeAll() - activeTasks.values.forEach { $0.cancel() } - activeTasks.removeAll() - saveMetadata() - } - - /// Get list of all book slugs that have offline downloads - func getOfflineBookSlugs() -> [String] { - let slugs = downloadedChapters.compactMap { key -> String? in - let components = key.split(separator: "::") - guard components.count == 3 else { return nil } - return String(components[0]) - } - return Array(Set(slugs)).sorted() - } - - /// Get count of downloaded chapters for a specific book - func getDownloadedChapterCount(for slug: String) -> Int { - return downloadedChapters.filter { key in - let components = key.split(separator: "::") - guard components.count == 3 else { return false } - return String(components[0]) == slug - }.count - } - - // MARK: - Private Helpers - - /// Build the canonical download key used for both in-memory tracking and UserDefaults. - /// Uses `::` as separator so slugs that contain `-` are unambiguous. - func makeKey(slug: String, chapter: Int, voice: String) -> String { - "\(slug)::\(chapter)::\(voice)" - } - - nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL { - guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { - fatalError("Could not access documents directory") - } - - return documentsURL - .appendingPathComponent("audio") - .appendingPathComponent(slug) - .appendingPathComponent("\(chapter)-\(voice).mp3") - } - - private func loadMetadata() { - if let data = UserDefaults.standard.data(forKey: metadataKey), - let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) { - downloadedChapters = decoded - } - } - - private func saveMetadata() { - if let encoded = try? JSONEncoder().encode(downloadedChapters) { - UserDefaults.standard.set(encoded, forKey: metadataKey) - } - } -} - -// MARK: - URLSessionDownloadDelegate - -extension AudioDownloadService: URLSessionDownloadDelegate { - nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - guard let key = downloadTask.taskDescription else { - print("⚠️ AudioDownload: No task description") - return - } - - print("✅ AudioDownload: Finished downloading - key: \(key)") - - let components = key.split(separator: "::") - guard components.count == 3, - let chapter = Int(components[1]) else { - print("⚠️ AudioDownload: Invalid key format: \(key)") - return - } - - let slug = String(components[0]) - let voice = String(components[2]) - - let destinationURL = audioFileURL(slug: slug, chapter: chapter, voice: voice) - - print("📁 AudioDownload: Moving from \(location.path) to \(destinationURL.path)") - - do { - // Create directory if needed - let directory = destinationURL.deletingLastPathComponent() - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - - // Move file from temp location to permanent storage - if fileManager.fileExists(atPath: destinationURL.path) { - print("📁 AudioDownload: Removing existing file at destination") - try fileManager.removeItem(at: destinationURL) - } - try fileManager.moveItem(at: location, to: destinationURL) - - print("✅ AudioDownload: File moved successfully") - - Task { @MainActor in - print("✅ AudioDownload: Marking as completed - key: \(key)") - self.downloadedChapters.insert(key) - self.downloads.removeValue(forKey: key) // Remove from active downloads - self.activeTasks.removeValue(forKey: key) - self.saveMetadata() - print("✅ AudioDownload: Metadata saved, downloadedChapters count: \(self.downloadedChapters.count)") - } - } catch { - print("❌ AudioDownload: Failed to move file - \(error.localizedDescription)") - Task { @MainActor in - self.downloads[key]?.status = .failed(error.localizedDescription) - self.activeTasks.removeValue(forKey: key) - } - } - } - - nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - guard let key = downloadTask.taskDescription else { return } - - let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0 - - if Int(progress * 100) % 10 == 0 { // Log every 10% - print("📊 AudioDownload: Progress for \(key): \(Int(progress * 100))% (\(totalBytesWritten)/\(totalBytesExpectedToWrite) bytes)") - } - - Task { @MainActor in - if var progressData = self.downloads[key] { - progressData.downloadedBytes = totalBytesWritten - progressData.totalBytes = totalBytesExpectedToWrite - progressData.progress = progress - self.downloads[key] = progressData - } - } - } - - nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let key = task.taskDescription else { return } - - if let error = error { - let nsError = error as NSError - if nsError.code != NSURLErrorCancelled { - print("❌ AudioDownload: Task completed with error - key: \(key), error: \(error.localizedDescription)") - Task { @MainActor in - self.downloads[key]?.status = .failed(error.localizedDescription) - self.activeTasks.removeValue(forKey: key) - } - } else { - print("⚠️ AudioDownload: Task cancelled - key: \(key)") - } - } else { - print("✅ AudioDownload: Task completed without error - key: \(key)") - } - } -} - -// MARK: - Supporting Types - -struct DownloadProgress: Equatable { - let slug: String - let chapter: Int - let voice: String - var progress: Double - var totalBytes: Int64 - var downloadedBytes: Int64 - var status: DownloadStatus -} - -enum DownloadStatus: Equatable { - case downloading - case completed - case failed(String) -} diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift deleted file mode 100644 index d30fd4c..0000000 --- a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift +++ /dev/null @@ -1,627 +0,0 @@ -import Foundation -import AVFoundation -import MediaPlayer -import Combine -import Kingfisher - -// MARK: - PlaybackProgress -// Isolated ObservableObject for high-frequency playback state (currentTime, -// duration, isPlaying). Keeping these separate from AudioPlayerService means -// the 0.5-second time-observer ticks only invalidate views that explicitly -// observe PlaybackProgress — menus and other stable UI are unaffected. - -@MainActor -final class PlaybackProgress: ObservableObject { - @Published var currentTime: Double = 0 - @Published var duration: Double = 0 - @Published var isPlaying: Bool = false -} - -// MARK: - AudioPlayerService -// Central singleton that owns AVPlayer, drives audio state, handles lock-screen -// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the -// next chapter audio. - -@MainActor -final class AudioPlayerService: ObservableObject { - - // MARK: - Published state - - @Published var slug: String = "" - @Published var chapter: Int = 0 - @Published var chapterTitle: String = "" - @Published var bookTitle: String = "" - @Published var coverURL: String = "" - @Published var voice: String = "af_bella" - @Published var speed: Double = 1.0 - @Published var chapters: [ChapterIndexBrief] = [] - - @Published var status: AudioPlayerStatus = .idle - @Published var audioURL: String = "" - @Published var errorMessage: String = "" - @Published var generationProgress: Double = 0 - - /// High-frequency playback state (currentTime / duration / isPlaying). - /// Views that only need the seek bar or play-pause button should observe - /// this directly so they don't trigger re-renders of menu-bearing parents. - let progress = PlaybackProgress() - - // Convenience forwarders so non-view call sites keep compiling unchanged. - var currentTime: Double { - get { progress.currentTime } - set { progress.currentTime = newValue } - } - var duration: Double { - get { progress.duration } - set { progress.duration = newValue } - } - var isPlaying: Bool { - get { progress.isPlaying } - set { progress.isPlaying = newValue } - } - - @Published var autoNext: Bool = false - @Published var nextChapter: Int? = nil - @Published var prevChapter: Int? = nil - - @Published var sleepTimer: SleepTimerOption? = nil - /// Human-readable countdown string shown in the full player near the moon button. - /// e.g. "38:12" for minute-based, "2 ch left" for chapter-based, "" when off. - @Published var sleepTimerRemainingText: String = "" - - @Published var nextPrefetchStatus: NextPrefetchStatus = .none - @Published var nextAudioURL: String = "" - @Published var nextPrefetchedChapter: Int? = nil - - var isActive: Bool { - switch status { - case .idle: return false - default: return true - } - } - - // MARK: - Private - - private var player: AVPlayer? - private var playerItem: AVPlayerItem? - private var timeObserver: Any? - private var statusObserver: AnyCancellable? - private var durationObserver: AnyCancellable? - private var finishObserver: AnyCancellable? - private var generationTask: Task<Void, Never>? - private var prefetchTask: Task<Void, Never>? - - // Cached cover image — downloaded once per chapter load, reused on every - // updateNowPlaying() call so we don't re-download on every play/pause/seek. - private var cachedCoverArtwork: MPMediaItemArtwork? - private var cachedCoverURL: String = "" - - // Sleep timer tracking - private var sleepTimerTask: Task<Void, Never>? - private var sleepTimerStartChapter: Int = 0 - /// Absolute deadline for minute-based timers (nil when not active or chapter-based). - private var sleepTimerDeadline: Date? = nil - /// 1-second tick task that keeps sleepTimerRemainingText up-to-date. - private var sleepTimerCountdownTask: Task<Void, Never>? = nil - - // MARK: - Init - - init() { - configureAudioSession() - setupRemoteCommandCenter() - } - - // MARK: - Public API - - /// Load audio for a specific chapter. Triggers TTS generation if not cached. - func load(slug: String, chapter: Int, chapterTitle: String, - bookTitle: String, coverURL: String, voice: String, speed: Double, - chapters: [ChapterIndexBrief], nextChapter: Int?, prevChapter: Int?) { - generationTask?.cancel() - prefetchTask?.cancel() - stop() - - self.slug = slug - self.chapter = chapter - self.chapterTitle = chapterTitle - self.bookTitle = bookTitle - self.coverURL = coverURL - self.voice = voice - self.speed = speed - self.chapters = chapters - self.nextChapter = nextChapter - self.prevChapter = prevChapter - self.nextPrefetchStatus = .none - self.nextAudioURL = "" - self.nextPrefetchedChapter = nil - - // Reset sleep timer start chapter if it's a chapter-based timer - if case .chapters = sleepTimer { - sleepTimerStartChapter = chapter - } - - status = .generating - generationProgress = 0 - - // Invalidate cover cache if the book changed. - if coverURL != cachedCoverURL { - cachedCoverArtwork = nil - cachedCoverURL = coverURL - prefetchCoverArtwork(from: coverURL) - } - - generationTask = Task { await generateAudio() } - } - - func play() { - player?.play() - player?.rate = Float(speed) - isPlaying = true - updateNowPlaying() - } - - func pause() { - player?.pause() - isPlaying = false - updateNowPlaying() - } - - func togglePlayPause() { - isPlaying ? pause() : play() - } - - func seek(to seconds: Double) { - let time = CMTime(seconds: seconds, preferredTimescale: 600) - currentTime = seconds // optimistic UI update - player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in - guard let self else { return } - Task { @MainActor in self.updateNowPlaying() } - } - } - - func skip(by seconds: Double) { - seek(to: max(0, min(currentTime + seconds, duration))) - } - - func setSpeed(_ newSpeed: Double) { - speed = newSpeed - if isPlaying { player?.rate = Float(newSpeed) } - updateNowPlaying() - } - - func setSleepTimer(_ option: SleepTimerOption?) { - // Cancel existing timer + countdown - sleepTimerTask?.cancel() - sleepTimerTask = nil - sleepTimerCountdownTask?.cancel() - sleepTimerCountdownTask = nil - sleepTimerDeadline = nil - - sleepTimer = option - - guard let option else { - sleepTimerRemainingText = "" - return - } - - // Start timer based on option - switch option { - case .chapters(let count): - sleepTimerStartChapter = chapter - // Update display immediately; chapter changes are tracked in handlePlaybackFinished. - updateChapterTimerLabel(chaptersRemaining: count) - - case .minutes(let minutes): - let deadline = Date().addingTimeInterval(Double(minutes) * 60) - sleepTimerDeadline = deadline - // Stop playback when the deadline is reached. - sleepTimerTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000) - guard let self, !Task.isCancelled else { return } - await MainActor.run { - self.stop() - self.sleepTimer = nil - self.sleepTimerRemainingText = "" - } - } - // 1-second tick to keep the countdown label fresh. - sleepTimerCountdownTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 1_000_000_000) - guard let self, !Task.isCancelled else { return } - await MainActor.run { - guard let deadline = self.sleepTimerDeadline else { return } - let remaining = max(0, deadline.timeIntervalSinceNow) - self.sleepTimerRemainingText = Self.formatCountdown(remaining) - } - } - } - // Set initial label without waiting for the first tick. - sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60) - } - } - - private func updateChapterTimerLabel(chaptersRemaining: Int) { - sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left" - } - - private static func formatCountdown(_ seconds: Double) -> String { - let s = Int(max(0, seconds)) - let m = s / 60 - let sec = s % 60 - return "\(m):\(String(format: "%02d", sec))" - } - - func stop() { - player?.pause() - teardownPlayer() - isPlaying = false - currentTime = 0 - duration = 0 - audioURL = "" - status = .idle - - // Cancel sleep timer + countdown - sleepTimerTask?.cancel() - sleepTimerTask = nil - sleepTimerCountdownTask?.cancel() - sleepTimerCountdownTask = nil - sleepTimerDeadline = nil - sleepTimer = nil - sleepTimerRemainingText = "" - } - - // MARK: - Audio generation - - private func generateAudio() async { - guard !slug.isEmpty, chapter > 0 else { return } - - // Check if audio is downloaded locally first - if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) { - audioURL = localURL.absoluteString - status = .ready - generationProgress = 100 - await playURL(localURL.absoluteString) - await prefetchNext() - return - } - - do { - // Fast path: audio already in MinIO — get a presigned URL and play immediately. - if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) { - audioURL = presignedURL - status = .ready - generationProgress = 100 - await playURL(presignedURL) - await prefetchNext() - return - } - - // Slow path: trigger TTS generation (async — returns 202 immediately). - status = .generating - generationProgress = 10 - let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed) - - let playableURL: String - if trigger.isAsync { - // 202 Accepted: poll until done. - generationProgress = 30 - playableURL = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: chapter, voice: voice) - } else { - // 200: already cached URL returned inline. - guard let url = trigger.url, !url.isEmpty else { - throw URLError(.badServerResponse) - } - playableURL = url - } - - audioURL = playableURL - status = .ready - generationProgress = 100 - await playURL(playableURL) - await prefetchNext() - } catch is CancellationError { - // Cancelled — no-op - } catch { - status = .error(error.localizedDescription) - errorMessage = error.localizedDescription - } - } - - // MARK: - Prefetch next chapter - // Always prefetch regardless of autoNext — faster playback when the user - // manually navigates forward. autoNext only controls whether we auto-navigate. - - private func prefetchNext() async { - guard let next = nextChapter, !Task.isCancelled else { return } - nextPrefetchStatus = .prefetching - nextPrefetchedChapter = next - do { - // Fast path: already in MinIO. - if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: next, voice: voice) { - nextAudioURL = presignedURL - nextPrefetchStatus = .prefetched - return - } - // Slow path: trigger generation; poll until done (background — won't block playback). - let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed) - let url: String - if trigger.isAsync { - url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice) - } else { - guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) } - url = u - } - nextAudioURL = url - nextPrefetchStatus = .prefetched - } catch { - nextPrefetchStatus = .failed - } - } - - // MARK: - AVPlayer management - - private func playURL(_ urlString: String) async { - // Resolve relative paths (e.g. "/api/audio/...") to absolute URLs. - let resolved: URL? - if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") { - resolved = URL(string: urlString) - } else { - resolved = URL(string: urlString, relativeTo: await APIClient.shared.baseURL)?.absoluteURL - } - guard let url = resolved else { return } - teardownPlayer() - let item = AVPlayerItem(url: url) - playerItem = item - player = AVPlayer(playerItem: item) - - // KVO: update duration as soon as asset metadata is loaded. - durationObserver = item.publisher(for: \.duration) - .receive(on: RunLoop.main) - .sink { [weak self] dur in - guard let self else { return } - let secs = dur.seconds - if secs.isFinite && secs > 0 { - self.duration = secs - self.updateNowPlaying() - } - } - - // KVO: set playback rate once the item is ready. - // Do NOT call player?.play() unconditionally — let readyToPlay trigger it - // so we don't race between AVPlayer's internal buffering and our call. - statusObserver = item.publisher(for: \.status) - .receive(on: RunLoop.main) - .sink { [weak self] itemStatus in - guard let self else { return } - if itemStatus == .readyToPlay { - self.player?.rate = Float(self.speed) - self.isPlaying = true - self.updateNowPlaying() - } else if itemStatus == .failed { - self.status = .error(item.error?.localizedDescription ?? "Playback failed") - self.errorMessage = item.error?.localizedDescription ?? "Playback failed" - } - } - - // Periodic time observer for seek bar position. - timeObserver = player?.addPeriodicTimeObserver( - forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), - queue: .main - ) { [weak self] time in - guard let self else { return } - Task { @MainActor in - let secs = time.seconds - if secs.isFinite && secs >= 0 { - self.currentTime = secs - } - } - } - - // Observe when playback ends. - finishObserver = NotificationCenter.default - .publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item) - .sink { [weak self] _ in - Task { @MainActor in - self?.handlePlaybackFinished() - } - } - - // Kick off buffering — actual playback starts via statusObserver above. - player?.play() - } - - private func teardownPlayer() { - if let observer = timeObserver { player?.removeTimeObserver(observer) } - timeObserver = nil - statusObserver = nil - durationObserver = nil - finishObserver = nil - player = nil - playerItem = nil - } - - private func handlePlaybackFinished() { - isPlaying = false - - guard let next = nextChapter else { return } - - // Check chapter-based sleep timer - if case .chapters(let count) = sleepTimer { - let chaptersPlayed = chapter - sleepTimerStartChapter + 1 - if chaptersPlayed >= count { - stop() - return - } - // Update the remaining chapters label. - let remaining = count - chaptersPlayed - updateChapterTimerLabel(chaptersRemaining: remaining) - } - - // Always notify the view that the chapter finished (it may update UI). - NotificationCenter.default.post( - name: .audioDidFinishChapter, - object: nil, - userInfo: ["next": next, "autoNext": autoNext] - ) - - // If autoNext is on, load the next chapter internally right away. - // We already have the metadata in `chapters`, so we can reconstruct - // everything without waiting for the view to navigate. - guard autoNext else { return } - - let nextTitle = chapters.first(where: { $0.number == next })?.title ?? "" - let nextNextChapter = chapters.first(where: { $0.number > next })?.number - let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one - - // If we already prefetched a URL for the next chapter, skip straight to - // playback and kick off generation in the background for the one after. - if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty { - let url = nextAudioURL - - // Advance state before tearing down the current player. - chapter = next - chapterTitle = nextTitle - nextChapter = nextNextChapter - prevChapter = nextPrevChapter - nextPrefetchStatus = .none - nextAudioURL = "" - nextPrefetchedChapter = nil - audioURL = url - status = .ready - generationProgress = 100 - - // Update sleep timer start chapter if using chapter-based timer - if case .chapters = sleepTimer { - sleepTimerStartChapter = next - } - - generationTask = Task { - await playURL(url) - await prefetchNext() - } - } else { - // No prefetch available — do a full load. - load( - slug: slug, - chapter: next, - chapterTitle: nextTitle, - bookTitle: bookTitle, - coverURL: coverURL, - voice: voice, - speed: speed, - chapters: chapters, - nextChapter: nextNextChapter, - prevChapter: nextPrevChapter - ) - } - } - - // MARK: - Cover art prefetch - - private func prefetchCoverArtwork(from urlString: String) { - guard !urlString.isEmpty, let url = URL(string: urlString) else { return } - KingfisherManager.shared.retrieveImage(with: url) { [weak self] result in - guard let self else { return } - if case .success(let value) = result { - let image = value.image - let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } - Task { @MainActor in - self.cachedCoverArtwork = artwork - self.updateNowPlaying() - } - } - } - } - - // MARK: - Audio Session - - private func configureAudioSession() { - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) - try AVAudioSession.sharedInstance().setActive(true) - } catch { - // Non-fatal - } - } - - // MARK: - Lock Screen / Control Center - - private func setupRemoteCommandCenter() { - let center = MPRemoteCommandCenter.shared() - center.playCommand.addTarget { [weak self] _ in - self?.play() - return .success - } - center.pauseCommand.addTarget { [weak self] _ in - self?.pause() - return .success - } - center.togglePlayPauseCommand.addTarget { [weak self] _ in - self?.togglePlayPause() - return .success - } - center.skipForwardCommand.preferredIntervals = [15] - center.skipForwardCommand.addTarget { [weak self] _ in - self?.skip(by: 15) - return .success - } - center.skipBackwardCommand.preferredIntervals = [15] - center.skipBackwardCommand.addTarget { [weak self] _ in - self?.skip(by: -15) - return .success - } - center.changePlaybackPositionCommand.addTarget { [weak self] event in - if let e = event as? MPChangePlaybackPositionCommandEvent { - self?.seek(to: e.positionTime) - } - return .success - } - } - - private func updateNowPlaying() { - var info: [String: Any] = [ - MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle, - MPMediaItemPropertyArtist: bookTitle, - MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, - MPMediaItemPropertyPlaybackDuration: duration, - MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0 - ] - // Use cached artwork — downloaded once in prefetchCoverArtwork(). - if let artwork = cachedCoverArtwork { - info[MPMediaItemPropertyArtwork] = artwork - } - MPNowPlayingInfoCenter.default().nowPlayingInfo = info - } -} - -// MARK: - Supporting types - -enum AudioPlayerStatus: Equatable { - case idle - case generating // covers both "loading" and "generating TTS" phases - case ready - case error(String) - - static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool { - switch (lhs, rhs) { - case (.idle, .idle), (.generating, .generating), (.ready, .ready): - return true - case (.error(let a), .error(let b)): - return a == b - default: - return false - } - } -} - -enum SleepTimerOption: Equatable { - case chapters(Int) // Stop after N chapters - case minutes(Int) // Stop after N minutes -} - -extension Notification.Name { - static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter") - static let skipToNextChapter = Notification.Name("skipToNextChapter") - static let skipToPrevChapter = Notification.Name("skipToPrevChapter") -} diff --git a/ios/LibNovel/LibNovel/Services/AuthStore.swift b/ios/LibNovel/LibNovel/Services/AuthStore.swift deleted file mode 100644 index 88505f4..0000000 --- a/ios/LibNovel/LibNovel/Services/AuthStore.swift +++ /dev/null @@ -1,159 +0,0 @@ -import Foundation -import Combine - -// MARK: - AuthStore -// Owns the authenticated user, the HMAC auth token, and user settings. -// Persists the token to Keychain so the user stays logged in across launches. - -@MainActor -final class AuthStore: ObservableObject { - @Published var user: AppUser? - @Published var settings: UserSettings = .default - @Published var isLoading: Bool = false - @Published var error: String? - - var isAuthenticated: Bool { user != nil } - - private let keychainKey = "libnovel_auth_token" - - init() { - // Restore token from Keychain and validate it on launch - if let token = loadToken() { - Task { await validateToken(token) } - } - } - - // MARK: - Login / Register - - func login(username: String, password: String) async { - isLoading = true - error = nil - do { - let response = try await APIClient.shared.login(username: username, password: password) - await APIClient.shared.setAuthCookie(response.token) - saveToken(response.token) - user = response.user - await loadSettings() - } catch { - self.error = error.localizedDescription - } - isLoading = false - } - - func register(username: String, password: String) async { - isLoading = true - error = nil - do { - let response = try await APIClient.shared.register(username: username, password: password) - await APIClient.shared.setAuthCookie(response.token) - saveToken(response.token) - user = response.user - await loadSettings() - } catch { - self.error = error.localizedDescription - } - isLoading = false - } - - func logout() async { - do { - try await APIClient.shared.logout() - } catch { - // Best-effort; clear local state regardless - } - clearToken() - user = nil - settings = .default - } - - // MARK: - Settings - - func loadSettings() async { - do { - settings = try await APIClient.shared.settings() - } catch { - // Use defaults if settings endpoint fails - } - } - - func saveSettings(_ updated: UserSettings) async { - do { - try await APIClient.shared.updateSettings(updated) - settings = updated - } catch { - self.error = error.localizedDescription - } - } - - // MARK: - Token validation - - /// Re-validates the current session and refreshes `user` + `settings`. - /// Call this after any operation that may change the user record (e.g. avatar upload). - func validateToken() async { - guard let token = loadToken() else { return } - await validateToken(token) - } - - private func validateToken(_ token: String) async { - await APIClient.shared.setAuthCookie(token) - // Use /api/auth/me to restore the user record and confirm the token is still valid - do { - async let me: AppUser = APIClient.shared.fetch("/api/auth/me") - async let s: UserSettings = APIClient.shared.settings() - var (restoredUser, restoredSettings) = try await (me, s) - // /api/auth/me returns the raw MinIO object key for avatar_url, not a presigned URL. - // Exchange the key for a fresh presigned GET URL so KFImage can display it. - if let key = restoredUser.avatarURL, !key.hasPrefix("http") { - if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() { - restoredUser = AppUser( - id: restoredUser.id, - username: restoredUser.username, - role: restoredUser.role, - created: restoredUser.created, - avatarURL: presignedURL - ) - } - } - user = restoredUser - settings = restoredSettings - } catch let e as APIError { - if case .httpError(let code, _) = e, code == 401 { - clearToken() - } - } catch {} - } - - // MARK: - Keychain helpers - - private func saveToken(_ token: String) { - let data = Data(token.utf8) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: keychainKey, - kSecValueData as String: data - ] - SecItemDelete(query as CFDictionary) - SecItemAdd(query as CFDictionary, nil) - } - - private func loadToken() -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: keychainKey, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - var item: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, - let data = item as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - private func clearToken() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: keychainKey - ] - SecItemDelete(query as CFDictionary) - } -} diff --git a/ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift b/ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift deleted file mode 100644 index 2d6b500..0000000 --- a/ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation - -// MARK: - Book Voice Preferences Service -// Manages per-book voice overrides with global fallback - -@MainActor -final class BookVoicePreferences: ObservableObject { - static let shared = BookVoicePreferences() - - @Published private(set) var bookVoices: [String: String] = [:] // slug -> voice - - private let userDefaults = UserDefaults.standard - private let storageKey = "bookVoicePreferences" - - private init() { - loadPreferences() - } - - // MARK: - Public API - - /// Get the voice for a specific book (returns nil if no override set) - func voice(for slug: String) -> String? { - return bookVoices[slug] - } - - /// Get the voice for a book with fallback to global user voice - func voiceWithFallback(for slug: String, globalVoice: String) -> String { - return bookVoices[slug] ?? globalVoice - } - - /// Set a voice override for a specific book - func setVoice(_ voice: String, for slug: String) { - print("📚 BookVoicePreferences: Setting voice '\(voice)' for book '\(slug)'") - bookVoices[slug] = voice - savePreferences() - } - - /// Remove voice override for a book (will use global voice) - func removeVoice(for slug: String) { - print("📚 BookVoicePreferences: Removing voice override for book '\(slug)'") - bookVoices.removeValue(forKey: slug) - savePreferences() - } - - /// Check if a book has a voice override - func hasOverride(for slug: String) -> Bool { - return bookVoices[slug] != nil - } - - /// Clear all book voice overrides - func clearAll() { - print("📚 BookVoicePreferences: Clearing all book voice overrides") - bookVoices.removeAll() - savePreferences() - } - - // MARK: - Persistence - - private func loadPreferences() { - if let data = userDefaults.data(forKey: storageKey), - let decoded = try? JSONDecoder().decode([String: String].self, from: data) { - bookVoices = decoded - print("📚 BookVoicePreferences: Loaded \(bookVoices.count) book voice overrides") - } - } - - private func savePreferences() { - if let encoded = try? JSONEncoder().encode(bookVoices) { - userDefaults.set(encoded, forKey: storageKey) - print("📚 BookVoicePreferences: Saved \(bookVoices.count) book voice overrides") - } - } -} diff --git a/ios/LibNovel/LibNovel/Services/NetworkMonitor.swift b/ios/LibNovel/LibNovel/Services/NetworkMonitor.swift deleted file mode 100644 index 26bdfbf..0000000 --- a/ios/LibNovel/LibNovel/Services/NetworkMonitor.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import Network - -// MARK: - Network Monitor -// Monitors network connectivity and provides offline state across the app - -@MainActor -final class NetworkMonitor: ObservableObject { - static let shared = NetworkMonitor() - - @Published var isConnected: Bool = true - @Published var connectionType: NWInterface.InterfaceType? - - private let monitor: NWPathMonitor - private let queue = DispatchQueue(label: "NetworkMonitor") - - init() { - monitor = NWPathMonitor() - startMonitoring() - } - - private func startMonitoring() { - monitor.pathUpdateHandler = { [weak self] path in - Task { @MainActor [weak self] in - self?.isConnected = path.status == .satisfied - self?.connectionType = path.availableInterfaces.first?.type - - if path.status == .satisfied { - print("🌐 Network: Connected (\(path.availableInterfaces.first?.type.debugDescription ?? "unknown"))") - } else { - print("📴 Network: Offline") - } - } - } - monitor.start(queue: queue) - } - - deinit { - monitor.cancel() - } -} - -extension NWInterface.InterfaceType { - var debugDescription: String { - switch self { - case .wifi: return "Wi-Fi" - case .cellular: return "Cellular" - case .wiredEthernet: return "Ethernet" - case .loopback: return "Loopback" - case .other: return "Other" - @unknown default: return "Unknown" - } - } -} diff --git a/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift deleted file mode 100644 index a98dd5b..0000000 --- a/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -@MainActor -final class BookDetailViewModel: ObservableObject { - let slug: String - - @Published var book: Book? - @Published var chapters: [ChapterIndex] = [] - @Published var saved: Bool = false - @Published var lastChapter: Int? - @Published var isLoading = false - @Published var error: String? - - init(slug: String) { - self.slug = slug - } - - func load() async { - isLoading = true - error = nil - do { - let detail = try await APIClient.shared.bookDetail(slug: slug) - book = detail.book - chapters = detail.chapters - saved = detail.saved - lastChapter = detail.lastChapter - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } - - func toggleSaved() async { - do { - if saved { - try await APIClient.shared.unsaveBook(slug: slug) - } else { - try await APIClient.shared.saveBook(slug: slug) - } - saved.toggle() - } catch { - self.error = error.localizedDescription - } - } -} diff --git a/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift deleted file mode 100644 index 558c7a1..0000000 --- a/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation - -@MainActor -final class BrowseViewModel: ObservableObject { - @Published var novels: [BrowseNovel] = [] - @Published var sort: String = "popular" - @Published var genre: String = "all" - @Published var status: String = "all" - @Published var searchQuery: String = "" - @Published var isLoading = false - @Published var hasNext = false - @Published var error: String? - - private var currentPage = 1 - private var isSearchMode = false - - func loadFirstPage() async { - currentPage = 1 - novels = [] - isSearchMode = false - await loadPage(1) - } - - func loadNextPage() async { - guard hasNext, !isLoading else { return } - await loadPage(currentPage + 1) - } - - func search() async { - guard !searchQuery.isEmpty else { await loadFirstPage(); return } - isLoading = true - isSearchMode = true - novels = [] - error = nil - do { - let result = try await APIClient.shared.search(query: searchQuery) - novels = result.results - hasNext = false - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } - - func clearSearch() { - searchQuery = "" - Task { await loadFirstPage() } - } - - private func loadPage(_ page: Int) async { - isLoading = true - error = nil - do { - let result = try await APIClient.shared.browse( - page: page, genre: genre, sort: sort, status: status - ) - if page == 1 { - novels = result.novels - } else { - novels.append(contentsOf: result.novels) - } - hasNext = result.hasNext - currentPage = page - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } -} diff --git a/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift deleted file mode 100644 index cf0b39a..0000000 --- a/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation - -@MainActor -final class ChapterReaderViewModel: ObservableObject { - let slug: String - private(set) var chapter: Int - - @Published var content: ChapterResponse? - @Published var isLoading = false - @Published var error: String? - - init(slug: String, chapter: Int) { - self.slug = slug - self.chapter = chapter - } - - /// Switch to a different chapter in-place: resets state and updates `chapter` - /// so that `.task(id: currentChapter)` in the View re-fires `load()`. - func switchChapter(to newChapter: Int) { - guard newChapter != chapter else { return } - chapter = newChapter - content = nil - error = nil - } - - func load() async { - isLoading = true - error = nil - do { - content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter) - // Record reading progress - try? await APIClient.shared.setProgress(slug: slug, chapter: chapter) - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } - - func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) { - guard let content else { return } - - // Only treat as "current" if the player is active (not idle/stopped). - // If the user stopped playback, isActive is false — we must re-load. - let isCurrent = audioPlayer.isActive && - audioPlayer.slug == slug && - audioPlayer.chapter == chapter - - if isCurrent { - audioPlayer.togglePlayPause() - } else { - let nextChapter: Int? = content.next - let prevChapter: Int? = content.prev - - // Use per-book voice override, fallback to global voice - let voice = BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: settings.voice) - - audioPlayer.load( - slug: slug, - chapter: chapter, - chapterTitle: content.chapter.title, - bookTitle: content.book.title, - coverURL: content.book.cover, - voice: voice, - speed: settings.speed, - chapters: content.chapters, - nextChapter: nextChapter, - prevChapter: prevChapter - ) - } - } -} diff --git a/ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift deleted file mode 100644 index b128aa6..0000000 --- a/ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -@MainActor -final class DiscoverViewModel: ObservableObject { - @Published var trending: [BrowseNovel] = [] - @Published var topRated: [BrowseNovel] = [] - @Published var recentlyUpdated: [BrowseNovel] = [] - @Published var newReleases: [BrowseNovel] = [] - @Published var genreShelves: [GenreShelf] = [] - @Published var isLoading = false - @Published var error: String? - - struct GenreShelf: Identifiable { - let id: String - let name: String - let genre: String - var novels: [BrowseNovel] = [] - } - - // Popular genres to show as shelves - private let featuredGenres = [ - ("fantasy", "Fantasy"), - ("romance", "Romance"), - ("action", "Action"), - ("sci-fi", "Sci-Fi"), - ("mystery", "Mystery") - ] - - func load() async { - guard !isLoading else { return } - isLoading = true - error = nil - - async let trendingTask = loadShelf(sort: "popular", limit: 20) - async let topRatedTask = loadShelf(sort: "rating", limit: 20) - async let recentlyUpdatedTask = loadShelf(sort: "updated", limit: 20) - async let newReleasesTask = loadShelf(sort: "new", limit: 20) - - do { - trending = try await trendingTask - topRated = try await topRatedTask - recentlyUpdated = try await recentlyUpdatedTask - newReleases = try await newReleasesTask - - // Load genre shelves - await loadGenreShelves() - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - - isLoading = false - } - - private func loadShelf(sort: String, genre: String = "all", status: String = "all", limit: Int = 20) async throws -> [BrowseNovel] { - let result = try await APIClient.shared.browse(page: 1, genre: genre, sort: sort, status: status) - return Array(result.novels.prefix(limit)) - } - - private func loadGenreShelves() async { - var shelves: [GenreShelf] = [] - - for (genre, name) in featuredGenres { - do { - let novels = try await loadShelf(sort: "popular", genre: genre, limit: 15) - if !novels.isEmpty { - shelves.append(GenreShelf(id: genre, name: name, genre: genre, novels: novels)) - } - } catch { - // Skip failed genres silently - continue - } - } - - genreShelves = shelves - } -} diff --git a/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift deleted file mode 100644 index 503d971..0000000 --- a/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -@MainActor -final class HomeViewModel: ObservableObject { - @Published var continueReading: [ContinueReadingItem] = [] - @Published var recentlyUpdated: [Book] = [] - @Published var stats: HomeStats? - @Published var subscriptionFeed: [SubscriptionFeedItem] = [] - @Published var isLoading = false - @Published var error: String? - - func load() async { - isLoading = true - error = nil - do { - let data = try await APIClient.shared.homeData() - continueReading = data.continueReading.map { - ContinueReadingItem(book: $0.book, chapter: $0.chapter) - } - recentlyUpdated = data.recentlyUpdated - stats = data.stats - subscriptionFeed = data.subscriptionFeed - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } -} diff --git a/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift deleted file mode 100644 index 50dd87d..0000000 --- a/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -@MainActor -final class LibraryViewModel: ObservableObject { - @Published var items: [LibraryItem] = [] - @Published var isLoading = false - @Published var error: String? - - func load() async { - isLoading = true - error = nil - do { - items = try await APIClient.shared.library() - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } -} diff --git a/ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift deleted file mode 100644 index adde55f..0000000 --- a/ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -@MainActor -final class ProfileViewModel: ObservableObject { - @Published var sessions: [UserSession] = [] - @Published var voices: [String] = [] - @Published var sessionsLoading = false - @Published var error: String? - - func loadSessions() async { - sessionsLoading = true - do { - sessions = try await APIClient.shared.sessions() - } catch { - self.error = error.localizedDescription - } - sessionsLoading = false - } - - func loadVoices() async { - guard voices.isEmpty else { return } - do { - voices = try await APIClient.shared.voices() - } catch { - // Use hardcoded fallback — same as Go server helpers.go - voices = ["af_bella", "af_sky", "af_sarah", "af_nicole", - "am_adam", "am_michael", "bf_emma", "bf_isabella", - "bm_george", "bm_lewis"] - } - } - - func revokeSession(id: String) async { - do { - try await APIClient.shared.revokeSession(id: id) - sessions.removeAll { $0.id == id } - } catch { - self.error = error.localizedDescription - } - } -} diff --git a/ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift deleted file mode 100644 index ce27872..0000000 --- a/ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation - -@MainActor -final class UserProfileViewModel: ObservableObject { - let username: String - - @Published var profile: PublicUserProfile? - @Published var currentlyReading: [PublicLibraryItem] = [] - @Published var library: [PublicLibraryItem] = [] - @Published var isLoading = false - @Published var isTogglingSubscribe = false - @Published var error: String? - - init(username: String) { - self.username = username - } - - func load() async { - guard !isLoading else { return } - isLoading = true - error = nil - do { - async let profileFetch = APIClient.shared.fetchUserProfile(username: username) - async let libraryFetch = APIClient.shared.fetchUserLibrary(username: username) - let (p, lib) = try await (profileFetch, libraryFetch) - profile = p - currentlyReading = lib.currentlyReading - library = lib.library - } catch let apiError as APIError { - switch apiError { - case .httpError(404, _): error = "User not found." - default: error = apiError.localizedDescription - } - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } - - func toggleSubscribe() async { - guard let p = profile, !p.isSelf, !isTogglingSubscribe else { return } - isTogglingSubscribe = true - defer { isTogglingSubscribe = false } - do { - if p.isSubscribed { - try await APIClient.shared.unsubscribeUser(username: username) - profile = PublicUserProfile( - id: p.id, username: p.username, avatarUrl: p.avatarUrl, - created: p.created, - followerCount: max(0, p.followerCount - 1), - followingCount: p.followingCount, - isSubscribed: false, isSelf: p.isSelf - ) - } else { - try await APIClient.shared.subscribeUser(username: username) - profile = PublicUserProfile( - id: p.id, username: p.username, avatarUrl: p.avatarUrl, - created: p.created, - followerCount: p.followerCount + 1, - followingCount: p.followingCount, - isSubscribed: true, isSelf: p.isSelf - ) - } - } catch { - self.error = error.localizedDescription - } - } -} - -// MARK: - Convenience memberwise init for PublicUserProfile (used in optimistic updates) - -private extension PublicUserProfile { - init(id: String, username: String, avatarUrl: String?, created: String, - followerCount: Int, followingCount: Int, isSubscribed: Bool, isSelf: Bool) { - // Encode then decode to go through the standard Decodable path without duplicating code - var dict: [String: Any] = [ - "id": id, "username": username, "created": created, - "followerCount": followerCount, "followingCount": followingCount, - "isSubscribed": isSubscribed, "isSelf": isSelf - ] - if let url = avatarUrl { dict["avatarUrl"] = url } - let data = try! JSONSerialization.data(withJSONObject: dict) - self = try! JSONDecoder().decode(PublicUserProfile.self, from: data) - } -} diff --git a/ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift deleted file mode 100644 index 785c722..0000000 --- a/ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift +++ /dev/null @@ -1,127 +0,0 @@ -import Foundation -import AVFoundation - -@MainActor -class VoiceSelectionViewModel: ObservableObject { - @Published var voices: [String] = [] - @Published var isLoading = false - @Published var error: String? - @Published var playingVoice: String? - - private var audioPlayer: AVPlayer? - // Store the opaque token returned by the block-based addObserver so we can - // actually remove it later. removeObserver(self, ...) does nothing when the - // block-based API was used — the token is the observer, not `self`. - private var endObserverToken: NSObjectProtocol? - - // Voice label formatting (matches web UI logic) - func voiceLabel(_ voice: String) -> String { - let parts = voice.split(separator: "_") - guard parts.count >= 2 else { return voice } - - let prefix = String(parts[0]) - let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") - - var info = "" - switch prefix { - case "af": info = "US F" - case "am": info = "US M" - case "bf": info = "UK F" - case "bm": info = "UK M" - default: info = prefix.uppercased() - } - - return "\(name) (\(info))" - } - - func voiceId(_ voice: String) -> String { voice } - - // Load available voices from API - func loadVoices() async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let fetchedVoices = try await APIClient.shared.voices() - voices = fetchedVoices.isEmpty ? fallbackVoices() : fetchedVoices - } catch { - self.error = "Failed to load voices: \(error.localizedDescription)" - voices = fallbackVoices() - } - } - - // Play voice sample - func playSample(_ voice: String) async { - if playingVoice == voice { - stopSample() - return - } - - stopSample() - playingVoice = voice - - do { - let presignedURL = try await APIClient.shared.presignVoiceSample(voice: voice) - guard let url = URL(string: presignedURL) else { - throw NSError(domain: "VoiceSelection", code: -1, - userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]) - } - - let playerItem = AVPlayerItem(url: url) - audioPlayer = AVPlayer(playerItem: playerItem) - - // Block-based addObserver returns a token — store it so we can remove it. - endObserverToken = NotificationCenter.default.addObserver( - forName: .AVPlayerItemDidPlayToEndTime, - object: playerItem, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.stopSample() - } - } - - audioPlayer?.play() - } catch { - // Sample might not be generated yet — silently ignore. - print("Voice sample not available for \(voice): \(error)") - playingVoice = nil - } - } - - // Stop currently playing sample - func stopSample() { - audioPlayer?.pause() - audioPlayer = nil - playingVoice = nil - if let token = endObserverToken { - NotificationCenter.default.removeObserver(token) - endObserverToken = nil - } - } - - private func fallbackVoices() -> [String] { - ["af_bella", "af_sarah", "af_nicole", - "am_adam", "am_michael", - "bf_emma", "bf_isabella", - "bm_george", "bm_lewis", - "af_sky"] - } - - // deinit: must NOT dispatch a Task capturing self. - // A Task strongly retains self, which causes "deallocated with non-zero retain - // count 2" → SIGABRT. Instead capture just the two values we need (player and - // token) and clean up without touching self at all. - nonisolated deinit { - // Capture locals — self is going away, do not reference it after this point. - // audioPlayer and endObserverToken are actor-isolated, but we can read their - // stored value directly in deinit because deinit is the last exclusive owner. - // Suppress the "actor-isolated" warning with an unowned reference pattern: - // Swift SE-0371 allows nonisolated deinit to access stored properties directly. - audioPlayer?.pause() - if let token = endObserverToken { - NotificationCenter.default.removeObserver(token) - } - } -} diff --git a/ios/LibNovel/LibNovel/Views/Auth/AuthView.swift b/ios/LibNovel/LibNovel/Views/Auth/AuthView.swift deleted file mode 100644 index e0c2d3d..0000000 --- a/ios/LibNovel/LibNovel/Views/Auth/AuthView.swift +++ /dev/null @@ -1,123 +0,0 @@ -import SwiftUI - -struct AuthView: View { - @EnvironmentObject var authStore: AuthStore - @State private var mode: Mode = .login - @State private var username: String = "" - @State private var password: String = "" - @State private var confirmPassword: String = "" - @FocusState private var focusedField: Field? - - enum Mode { case login, register } - enum Field { case username, password, confirmPassword } - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - // Logo / header - VStack(spacing: 8) { - Image(systemName: "books.vertical.fill") - .font(.system(size: 56)) - .foregroundStyle(.amber) - Text("LibNovel") - .font(.largeTitle.bold()) - } - .padding(.top, 60) - .padding(.bottom, 40) - - // Tab switcher - Picker("Mode", selection: $mode) { - Text("Sign In").tag(Mode.login) - Text("Create Account").tag(Mode.register) - } - .pickerStyle(.segmented) - .padding(.horizontal, 24) - .padding(.bottom, 32) - - // Form - VStack(spacing: 16) { - TextField("Username", text: $username) - .textFieldStyle(.roundedBorder) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($focusedField, equals: .username) - .submitLabel(.next) - .onSubmit { focusedField = .password } - - SecureField("Password", text: $password) - .textFieldStyle(.roundedBorder) - .focused($focusedField, equals: .password) - .submitLabel(mode == .register ? .next : .go) - .onSubmit { - if mode == .register { focusedField = .confirmPassword } - else { submit() } - } - - if mode == .register { - SecureField("Confirm Password", text: $confirmPassword) - .textFieldStyle(.roundedBorder) - .focused($focusedField, equals: .confirmPassword) - .submitLabel(.go) - .onSubmit { submit() } - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - .padding(.horizontal, 24) - .animation(.easeInOut(duration: 0.2), value: mode) - - if let error = authStore.error { - Text(error) - .font(.footnote) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) - .padding(.top, 8) - } - - Button(action: submit) { - Group { - if authStore.isLoading { - ProgressView() - .progressViewStyle(.circular) - .tint(.white) - } else { - Text(mode == .login ? "Sign In" : "Create Account") - .fontWeight(.semibold) - } - } - .frame(maxWidth: .infinity) - .frame(height: 50) - } - .buttonStyle(.borderedProminent) - .tint(.amber) - .padding(.horizontal, 24) - .padding(.top, 24) - .disabled(authStore.isLoading || !formIsValid) - - Spacer() - } - .toolbar(.hidden, for: .navigationBar) - } - .onChange(of: mode) { _, _ in - authStore.error = nil - confirmPassword = "" - } - } - - private var formIsValid: Bool { - let base = !username.isEmpty && password.count >= 4 - if mode == .register { return base && password == confirmPassword } - return base - } - - private func submit() { - focusedField = nil - Task { - if mode == .login { - await authStore.login(username: username, password: password) - } else { - await authStore.register(username: username, password: password) - } - } - } -} diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift deleted file mode 100644 index 17df86b..0000000 --- a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift +++ /dev/null @@ -1,708 +0,0 @@ -import SwiftUI -import Kingfisher - -struct BookDetailView: View { - let slug: String - @StateObject private var vm: BookDetailViewModel - @EnvironmentObject var authStore: AuthStore - @EnvironmentObject var audioPlayer: AudioPlayerService - @State private var summaryExpanded = false - @State private var showChapters = false - - init(slug: String) { - self.slug = slug - _vm = StateObject(wrappedValue: BookDetailViewModel(slug: slug)) - } - - var body: some View { - VStack(spacing: 0) { - OfflineBanner() - - ZStack(alignment: .top) { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - if vm.isLoading { - ProgressView().frame(maxWidth: .infinity).padding(.top, 120) - } else if let book = vm.book { - heroSection(book: book) - metaSection(book: book) - Divider().padding(.horizontal) - chaptersRow(book: book) - Divider().padding(.horizontal) - CommentsView(slug: slug) - } - } - } - .ignoresSafeArea(edges: .top) - } - } - .navigationBarTitleDisplayMode(.inline) - .appNavigationDestination() - .toolbar { bookmarkButton } - .task { await vm.load() } - .errorAlert($vm.error) - .sheet(isPresented: $showChapters) { - BookChaptersSheet( - slug: slug, - chapters: vm.chapters, - lastChapter: vm.lastChapter, - totalChapters: vm.book?.totalChapters ?? 0 - ) - } - } - - // MARK: - Hero - - @ViewBuilder - private func heroSection(book: Book) -> some View { - ZStack(alignment: .bottom) { - // Full-bleed blurred background - KFImage(URL(string: book.cover)) - .resizable() - .scaledToFill() - .frame(maxWidth: .infinity) - .frame(height: 320) - .blur(radius: 24) - .clipped() - .overlay( - LinearGradient( - colors: [.black.opacity(0.15), .black.opacity(0.68)], - startPoint: .top, - endPoint: .bottom - ) - ) - - VStack(spacing: 16) { - KFImage(URL(string: book.cover)) - .resizable() - .placeholder { - RoundedRectangle(cornerRadius: 12) - .fill(Color(.systemGray5)) - } - .scaledToFill() - .frame(width: 130, height: 188) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10) - .shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3) - - VStack(spacing: 6) { - Text(book.title) - .font(.title3.bold()) - .foregroundStyle(.white) - .multilineTextAlignment(.center) - .lineLimit(3) - .padding(.horizontal, 32) - - Text(book.author) - .font(.subheadline) - .foregroundStyle(.white.opacity(0.75)) - } - - if !book.genres.isEmpty { - HStack(spacing: 8) { - ForEach(book.genres.prefix(3), id: \.self) { genre in - TagChip(label: genre).colorScheme(.dark) - } - } - } - - if !book.status.isEmpty { - StatusBadge(status: book.status) - } - } - .padding(.horizontal) - .padding(.bottom, 28) - } - .frame(minHeight: 320) - } - - // MARK: - Meta section (stats + summary + CTAs) - - @ViewBuilder - private func metaSection(book: Book) -> some View { - VStack(alignment: .leading, spacing: 0) { - // Quick stats row - HStack(spacing: 0) { - MetaStat(value: "\(book.totalChapters)", label: "Chapters", icon: "doc.text") - Divider().frame(height: 36) - MetaStat( - value: book.status.capitalized.isEmpty ? "—" : book.status.capitalized, - label: "Status", icon: "flag" - ) - if book.ranking > 0 { - Divider().frame(height: 36) - MetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill") - } - } - .padding(.vertical, 16) - .frame(maxWidth: .infinity) - - Divider().padding(.horizontal) - - // Summary - VStack(alignment: .leading, spacing: 8) { - Text("About") - .font(.headline) - - Text(book.summary) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(summaryExpanded ? nil : 4) - .animation(.easeInOut(duration: 0.2), value: summaryExpanded) - - if book.summary.count > 200 { - Button(summaryExpanded ? "Less" : "More") { - withAnimation { summaryExpanded.toggle() } - } - .font(.caption.bold()) - .foregroundStyle(.amber) - } - } - .padding(.horizontal) - .padding(.vertical, 16) - - Divider().padding(.horizontal) - - // CTA buttons - HStack(spacing: 10) { - if let last = vm.lastChapter, last > 0 { - NavigationLink(value: NavDestination.chapter(slug, last)) { - Label("Continue Ch.\(last)", systemImage: "play.fill") - .frame(maxWidth: .infinity) - .fontWeight(.semibold) - } - .buttonStyle(.borderedProminent) - .tint(.amber) - - NavigationLink(value: NavDestination.chapter(slug, 1)) { - Label("From Ch.1", systemImage: "arrow.counterclockwise") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .tint(.secondary) - } else { - NavigationLink(value: NavDestination.chapter(slug, 1)) { - Label("Start Reading", systemImage: "book.fill") - .frame(maxWidth: .infinity) - .fontWeight(.semibold) - } - .buttonStyle(.borderedProminent) - .tint(.amber) - } - } - .padding(.horizontal) - .padding(.vertical, 16) - } - } - - // MARK: - Compact chapters row (tap → sheet) - - @ViewBuilder - private func chaptersRow(book: Book) -> some View { - Button { - showChapters = true - } label: { - HStack(spacing: 12) { - Image(systemName: "list.number") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.amber) - .frame(width: 28) - - VStack(alignment: .leading, spacing: 2) { - Text("Chapters") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - if !vm.chapters.isEmpty { - let last = vm.lastChapter - let total = vm.chapters.count - Text(last != nil && last! > 0 - ? "Reading Ch.\(last!) of \(total)" - : "\(total) chapter\(total == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.secondary) - } else if vm.isLoading { - Text("Loading…") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - // MARK: - Bookmark toolbar - - @ToolbarContentBuilder - private var bookmarkButton: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - Button { - Task { await vm.toggleSaved() } - } label: { - Image(systemName: vm.saved ? "bookmark.fill" : "bookmark") - .foregroundStyle(vm.saved ? .amber : .primary) - } - } - } -} - -// MARK: - Chapters list sheet -// Apple Books-style: chapters grouped into blocks of 100 with a right-edge jump bar. -// A .searchable bar filters by number or title; an "offline only" toggle shows downloaded chapters. -// Per-row download status (arc ring, labels, swipe actions) mirrors ChaptersListSheet in PlayerViews. - -struct BookChaptersSheet: View { - let slug: String - let chapters: [ChapterIndex] - let lastChapter: Int? - let totalChapters: Int - - @Environment(\.dismiss) private var dismiss - @EnvironmentObject var downloadService: AudioDownloadService - @EnvironmentObject var audioPlayer: AudioPlayerService - - @State private var searchText: String = "" - @State private var filterOfflineOnly = false - @State private var showingDownloadAll = false - /// The block label the jump bar is currently scrolling to (e.g. "1–100"). - @State private var activeBlock: String? = nil - - // MARK: Derived data - - private var downloadedCount: Int { - chapters.filter { ch in - downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice) - }.count - } - - private var downloadingCount: Int { - downloadService.downloads.filter { key, _ in - key.hasPrefix("\(slug)::") - }.count - } - - private var defaultVoice: String { - BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: audioPlayer.voice) - } - - private var filtered: [ChapterIndex] { - var result = chapters - - if filterOfflineOnly { - result = result.filter { ch in - downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice) - } - } - - if !searchText.isEmpty { - let q = searchText.lowercased() - result = result.filter { - "\($0.number)".contains(q) || - $0.title.lowercased().contains(q) || - "chapter \($0.number)".contains(q) - } - } - - return result - } - - /// Chapters grouped into blocks of 100 with range labels "1–100", "101–200", etc. - /// When searching or filtering the jump bar is hidden and a flat "Results" group is used. - private var groups: [(label: String, chapters: [ChapterIndex])] { - guard searchText.isEmpty && !filterOfflineOnly else { - return filtered.isEmpty ? [] : [("Results", filtered)] - } - guard !filtered.isEmpty else { return [] } - let blockSize = 100 - let minN = filtered.map(\.number).min() ?? 1 - let maxN = filtered.map(\.number).max() ?? 1 - let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 - var result: [(label: String, chapters: [ChapterIndex])] = [] - var blockStart = firstBlock - while blockStart <= maxN { - let blockEnd = blockStart + blockSize - 1 - let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } - if !slice.isEmpty { - result.append(("\(blockStart)–\(blockEnd)", slice)) - } - blockStart += blockSize - } - return result - } - - private var jumpLabels: [String] { groups.map(\.label) } - - // MARK: Body - - var body: some View { - NavigationStack { - ZStack(alignment: .trailing) { - // ── Main chapter list ────────────────────────────────────── - List { - // Offline downloads summary (shown when at least one chapter is downloaded) - if downloadedCount > 0 || downloadingCount > 0 { - Section { - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Offline Downloads") - .font(.headline) - Text("\(downloadedCount) of \(chapters.count) chapters") - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - - Button { - showingDownloadAll = true - } label: { - Label("Manage", systemImage: "arrow.down.circle") - .font(.subheadline.weight(.semibold)) - } - .buttonStyle(.bordered) - .tint(.blue) - } - - if downloadingCount > 0 { - HStack(spacing: 8) { - ProgressView() - .scaleEffect(0.8) - Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Toggle("Show offline only", isOn: $filterOfflineOnly) - .font(.subheadline) - .tint(.amber) - } - .padding(.vertical, 8) - } - } - - ForEach(groups, id: \.label) { group in - Section { - ForEach(group.chapters, id: \.number) { ch in - BookChapterRow( - chapter: ch, - slug: slug, - isCurrent: ch.number == lastChapter, - voice: defaultVoice - ) - .id(group.label) - } - } header: { - if searchText.isEmpty && !filterOfflineOnly { - Text(group.label) - .font(.caption.bold()) - .foregroundStyle(.secondary) - .id("header_\(group.label)") - } - } - } - - if chapters.isEmpty { - Section { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.vertical, 24) - .listRowBackground(Color.clear) - } - } - } - .listStyle(.plain) - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Chapter number or title" - ) - .scrollPosition(id: $activeBlock, anchor: .top) - .appNavigationDestination() - - // ── Right-edge jump bar ──────────────────────────────────── - if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 { - BookChaptersJumpBar( - labels: jumpLabels, - currentChapter: lastChapter ?? 0, - groups: groups - ) { label in - withAnimation { activeBlock = label } - } - .padding(.trailing, 4) - } - } - .navigationTitle("Chapters (\(filtered.count))") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - .fontWeight(.semibold) - } - } - // Sheet to manage bulk downloads for this book - .sheet(isPresented: $showingDownloadAll) { - DownloadManagementSheet( - chapters: chapters.map { ChapterIndexBrief(number: $0.number, title: $0.title) }, - slug: slug, - voice: Binding( - get: { defaultVoice }, - set: { _ in } // voice changes handled inside DownloadManagementSheet - ) - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - // Scroll to the current chapter's block on first appear - .onAppear { - if let block = groups.first(where: { g in - g.chapters.contains(where: { $0.number == (lastChapter ?? 0) }) - }) { - activeBlock = block.label - } - } - } - .presentationDetents([.large]) - .presentationDragIndicator(.visible) - } -} - -// MARK: - Individual chapter row with download status + NavigationLink - -private struct BookChapterRow: View { - let chapter: ChapterIndex - let slug: String - let isCurrent: Bool - let voice: String - - @EnvironmentObject var downloadService: AudioDownloadService - - private var isDownloaded: Bool { - downloadService.isDownloaded(slug: slug, chapter: chapter.number, voice: voice) - } - - private var downloadProgress: DownloadProgress? { - let key = downloadService.makeKey(slug: slug, chapter: chapter.number, voice: voice) - return downloadService.downloads[key] - } - - private var isDownloading: Bool { downloadProgress != nil } - - private var displayTitle: String { - let stripped = chapter.title.strippingTrailingDate() - if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { - return "Chapter \(chapter.number)" - } - return stripped - } - - var body: some View { - NavigationLink(value: NavDestination.chapter(slug, chapter.number)) { - HStack(spacing: 14) { - // Number badge with optional download-progress arc ring - ZStack { - Circle() - .fill(isCurrent ? Color.amber : Color(.systemGray5)) - .frame(width: 40, height: 40) - - Text("\(chapter.number)") - .font(.caption.bold().monospacedDigit()) - .foregroundStyle(isCurrent ? .white : .secondary) - .minimumScaleFactor(0.6) - .frame(width: 40, height: 40) - - // In-progress download arc - if isDownloading, let progress = downloadProgress { - Circle() - .trim(from: 0, to: progress.progress) - .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .frame(width: 44, height: 44) - .animation(.easeInOut(duration: 0.3), value: progress.progress) - } - } - - // Title + status subtitle - VStack(alignment: .leading, spacing: 3) { - Text(displayTitle) - .font(.subheadline.weight(isCurrent ? .semibold : .regular)) - .foregroundStyle(isCurrent ? .amber : .primary) - .lineLimit(1) - - HStack(spacing: 8) { - if isCurrent { - Label("Reading", systemImage: "bookmark.fill") - .font(.caption2) - .foregroundStyle(.amber) - } - - if isDownloading, let progress = downloadProgress { - Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle") - .font(.caption2) - .foregroundStyle(.blue) - } else if isDownloaded { - Label("Downloaded", systemImage: "checkmark.circle.fill") - .font(.caption2) - .foregroundStyle(.green) - } else if !chapter.dateLabel.isEmpty { - Text(chapter.dateLabel) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - } - - Spacer(minLength: 4) - } - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) - // Trailing swipe: Download / Cancel / Delete - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if isDownloaded { - Button(role: .destructive) { - Task { - try? downloadService.deleteDownload( - slug: slug, chapter: chapter.number, voice: voice - ) - } - } label: { - Label("Delete", systemImage: "trash") - } - } else if isDownloading { - Button(role: .destructive) { - downloadService.cancelDownload( - slug: slug, chapter: chapter.number, voice: voice - ) - } label: { - Label("Cancel", systemImage: "xmark") - } - } else { - Button { - Task { - try? await downloadService.download( - slug: slug, chapter: chapter.number, voice: voice - ) - } - } label: { - Label("Download", systemImage: "arrow.down.circle") - } - .tint(.blue) - } - } - } -} - -// MARK: - Right-edge jump bar for BookChaptersSheet -// Mirrors the JumpBar in PlayerViews.swift but operates on ChapterIndex groups. - -private struct BookChaptersJumpBar: View { - let labels: [String] - let currentChapter: Int - let groups: [(label: String, chapters: [ChapterIndex])] - let onSelect: (String) -> Void - - @State private var isDragging = false - - private func shortLabel(_ full: String) -> String { - full.components(separatedBy: "–").first ?? full - } - - private var currentBlock: String? { - groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label - } - - var body: some View { - VStack(spacing: 0) { - ForEach(labels, id: \.self) { label in - let isCurrent = label == currentBlock - Text(shortLabel(label)) - .font(.system(size: 10, weight: isCurrent ? .bold : .regular)) - .foregroundStyle(isCurrent ? Color.amber : Color.secondary) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - .onTapGesture { onSelect(label) } - } - } - .padding(.vertical, 6) - .background( - Capsule() - .fill(.ultraThinMaterial) - .shadow(color: .black.opacity(0.15), radius: 4) - ) - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .local) - .onChanged { value in - isDragging = true - let itemHeight: CGFloat = 28 - let index = Int(value.location.y / itemHeight) - let clamped = max(0, min(labels.count - 1, index)) - onSelect(labels[clamped]) - } - .onEnded { _ in isDragging = false } - ) - .animation(.easeInOut(duration: 0.15), value: isDragging) - } -} - -// MARK: - Supporting components - -private struct MetaStat: View { - let value: String - let label: String - let icon: String - - var body: some View { - VStack(spacing: 4) { - Image(systemName: icon) - .font(.caption) - .foregroundStyle(.amber) - Text(value) - .font(.subheadline.bold()) - .lineLimit(1) - .minimumScaleFactor(0.7) - Text(label) - .font(.caption2) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - } -} - -private struct StatusBadge: View { - let status: String - - private var color: Color { - switch status.lowercased() { - case "ongoing", "active": return .green - case "completed": return .blue - case "hiatus": return .orange - default: return .secondary - } - } - - var body: some View { - HStack(spacing: 4) { - Circle() - .fill(color) - .frame(width: 6, height: 6) - Text(status.capitalized) - .font(.caption.weight(.medium)) - .foregroundStyle(color) - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(color.opacity(0.12), in: Capsule()) - } -} diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift deleted file mode 100644 index e040e57..0000000 --- a/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift +++ /dev/null @@ -1,643 +0,0 @@ -import SwiftUI - -// MARK: - ViewModel - -@MainActor -class CommentsViewModel: ObservableObject { - let slug: String - - @Published var comments: [BookComment] = [] - @Published var myVotes: [String: String] = [:] // commentId → "up" | "down" - @Published var avatarUrls: [String: String] = [:] // userId → presigned URL - @Published var isLoading = true - @Published var error: String? - - @Published var newBody = "" - @Published var isPosting = false - @Published var postError: String? - - @Published var sort: CommentSortOrder = .top - - // Reply state - @Published var replyingToId: String? = nil - @Published var replyBody = "" - @Published var isPostingReply = false - @Published var replyError: String? - - private var votingIds: Set<String> = [] - private var deletingIds: Set<String> = [] - - init(slug: String) { - self.slug = slug - } - - func load() async { - isLoading = true - error = nil - do { - let response = try await APIClient.shared.fetchComments(slug: slug, sort: sort.rawValue) - comments = response.comments - myVotes = response.myVotes - avatarUrls = response.avatarUrls - } catch { - self.error = error.localizedDescription - } - isLoading = false - } - - func postComment() async { - let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, !isPosting else { return } - if text.count > 2000 { - postError = "Comment too long (max 2000 characters)." - return - } - isPosting = true - postError = nil - do { - var created = try await APIClient.shared.postComment(slug: slug, body: text) - created.replies = [] - comments.insert(created, at: 0) - newBody = "" - } catch let apiError as APIError { - switch apiError { - case .httpError(401, _): postError = "You must be logged in to comment." - default: postError = apiError.localizedDescription - } - } catch { - postError = error.localizedDescription - } - isPosting = false - } - - func postReply(parentId: String) async { - let text = replyBody.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, !isPostingReply else { return } - if text.count > 2000 { - replyError = "Reply too long (max 2000 characters)." - return - } - isPostingReply = true - replyError = nil - do { - let created = try await APIClient.shared.postComment(slug: slug, body: text, parentId: parentId) - if let idx = comments.firstIndex(where: { $0.id == parentId }) { - var parent = comments[idx] - var replies = parent.replies ?? [] - replies.append(created) - parent.replies = replies - comments[idx] = parent - } - replyBody = "" - replyingToId = nil - } catch let apiError as APIError { - switch apiError { - case .httpError(401, _): replyError = "You must be logged in to reply." - default: replyError = apiError.localizedDescription - } - } catch { - replyError = error.localizedDescription - } - isPostingReply = false - } - - func deleteComment(commentId: String, parentId: String? = nil) async { - guard !deletingIds.contains(commentId) else { return } - deletingIds.insert(commentId) - - // Optimistic removal — update the UI immediately before the network call - var removedComment: BookComment? - var removedAtIndex: Int? - if let parentId { - if let idx = comments.firstIndex(where: { $0.id == parentId }) { - var parent = comments[idx] - removedComment = parent.replies?.first(where: { $0.id == commentId }) - removedAtIndex = idx - parent.replies = (parent.replies ?? []).filter { $0.id != commentId } - comments[idx] = parent - } - } else { - removedAtIndex = comments.firstIndex(where: { $0.id == commentId }) - removedComment = removedAtIndex.map { comments[$0] } - comments.removeAll { $0.id == commentId } - } - - do { - try await APIClient.shared.deleteComment(commentId: commentId) - } catch { - // Revert the optimistic removal on failure - if let removed = removedComment { - if let parentId, let idx = removedAtIndex { - var parent = comments[idx] - var replies = parent.replies ?? [] - replies.append(removed) - replies.sort { $0.created < $1.created } - parent.replies = replies - comments[idx] = parent - } else if let idx = removedAtIndex { - comments.insert(removed, at: min(idx, comments.count)) - } - } - } - - deletingIds.remove(commentId) - } - - func vote(commentId: String, vote: String, parentId: String? = nil) async { - guard !votingIds.contains(commentId) else { return } - votingIds.insert(commentId) - defer { votingIds.remove(commentId) } - do { - let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote) - if let parentId { - if let idx = comments.firstIndex(where: { $0.id == parentId }) { - var parent = comments[idx] - if let rIdx = parent.replies?.firstIndex(where: { $0.id == commentId }) { - parent.replies![rIdx] = updated - } - comments[idx] = parent - } - } else { - if let idx = comments.firstIndex(where: { $0.id == commentId }) { - var c = updated - c.replies = comments[idx].replies - comments[idx] = c - } - } - let prev = myVotes[commentId] - if prev == vote { - myVotes.removeValue(forKey: commentId) - } else { - myVotes[commentId] = vote - } - } catch { - // Silently ignore vote errors - } - } - - func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) } - func isDeleting(_ commentId: String) -> Bool { deletingIds.contains(commentId) } - - func setSort(_ newSort: CommentSortOrder) { - guard newSort != sort else { return } - sort = newSort - Task { await load() } - } -} - -enum CommentSortOrder: String, CaseIterable { - case top = "top" - case new = "new" - - var label: String { - switch self { - case .top: return "Top" - case .new: return "New" - } - } -} - -// MARK: - CommentsView - -struct CommentsView: View { - @StateObject private var vm: CommentsViewModel - @EnvironmentObject private var authStore: AuthStore - - init(slug: String) { - _vm = StateObject(wrappedValue: CommentsViewModel(slug: slug)) - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Section header + sort picker - HStack { - Text("Comments") - .font(.headline) - let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) } - if !vm.isLoading && total > 0 { - Text("(\(total))") - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - // Sort picker - if !vm.isLoading && !vm.comments.isEmpty { - Picker("Sort", selection: Binding( - get: { vm.sort }, - set: { vm.setSort($0) } - )) { - ForEach(CommentSortOrder.allCases, id: \.self) { s in - Text(s.label).tag(s) - } - } - .pickerStyle(.segmented) - .frame(width: 120) - } - } - .padding(.horizontal) - .padding(.vertical, 14) - - Divider().padding(.horizontal) - - // Post form - postForm - .padding(.horizontal) - .padding(.vertical, 12) - - Divider().padding(.horizontal) - - // Comment list - if vm.isLoading { - loadingPlaceholder - } else if let err = vm.error { - Text(err) - .font(.subheadline) - .foregroundStyle(.red) - .padding() - } else if vm.comments.isEmpty { - Text("No comments yet. Be the first!") - .font(.subheadline) - .foregroundStyle(.secondary) - .padding() - } else { - ForEach(vm.comments) { comment in - commentThread(comment: comment) - Divider().padding(.leading, 16) - } - } - - Color.clear.frame(height: 16) - } - .task { await vm.load() } - } - - // MARK: - Comment thread (top-level + replies) - - @ViewBuilder - private func commentThread(comment: BookComment) -> some View { - VStack(alignment: .leading, spacing: 0) { - CommentRow( - comment: comment, - myVote: vm.myVotes[comment.id], - isVoting: vm.isVoting(comment.id), - isDeleting: vm.isDeleting(comment.id), - isOwner: authStore.user?.id == comment.userId, - isLoggedIn: authStore.isAuthenticated, - isReplyingTo: vm.replyingToId == comment.id, - avatarUrl: vm.avatarUrls[comment.userId], - onVote: { v in Task { await vm.vote(commentId: comment.id, vote: v) } }, - onDelete: { Task { await vm.deleteComment(commentId: comment.id) } }, - onReply: { - if vm.replyingToId == comment.id { - vm.replyingToId = nil - vm.replyBody = "" - vm.replyError = nil - } else { - vm.replyingToId = comment.id - vm.replyBody = "" - vm.replyError = nil - } - } - ) - - // Inline reply form - if vm.replyingToId == comment.id { - replyForm(parentId: comment.id) - .padding(.leading, 32) - .padding(.trailing, 16) - .padding(.bottom, 8) - } - - // Replies - if let replies = comment.replies, !replies.isEmpty { - VStack(alignment: .leading, spacing: 0) { - ForEach(replies) { reply in - CommentRow( - comment: reply, - myVote: vm.myVotes[reply.id], - isVoting: vm.isVoting(reply.id), - isDeleting: vm.isDeleting(reply.id), - isOwner: authStore.user?.id == reply.userId, - isLoggedIn: authStore.isAuthenticated, - isReplyingTo: false, - isReply: true, - avatarUrl: vm.avatarUrls[reply.userId], - onVote: { v in Task { await vm.vote(commentId: reply.id, vote: v, parentId: comment.id) } }, - onDelete: { Task { await vm.deleteComment(commentId: reply.id, parentId: comment.id) } }, - onReply: nil - ) - if reply.id != replies.last?.id { - Divider().padding(.leading, 48) - } - } - } - .padding(.leading, 24) - .overlay(alignment: .leading) { - Rectangle() - .fill(Color(.systemGray4)) - .frame(width: 2) - .padding(.leading, 16) - .padding(.vertical, 4) - } - } - } - } - - // MARK: - Reply form - - @ViewBuilder - private func replyForm(parentId: String) -> some View { - VStack(alignment: .leading, spacing: 6) { - ZStack(alignment: .topLeading) { - if vm.replyBody.isEmpty { - Text("Write a reply…") - .font(.caption) - .foregroundStyle(.tertiary) - .padding(.top, 6) - .padding(.leading, 4) - } - TextEditor(text: $vm.replyBody) - .font(.caption) - .frame(minHeight: 56, maxHeight: 120) - .scrollContentBackground(.hidden) - } - .padding(8) - .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8)) - - HStack { - let count = vm.replyBody.count - Text("\(count)/2000") - .font(.caption2) - .monospacedDigit() - .foregroundStyle(count > 2000 ? Color.red : Color.secondary) - - Spacer() - - if let err = vm.replyError { - Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1) - } - - Button("Cancel") { - vm.replyingToId = nil - vm.replyBody = "" - vm.replyError = nil - } - .font(.caption) - .foregroundStyle(.secondary) - - Button { - Task { await vm.postReply(parentId: parentId) } - } label: { - if vm.isPostingReply { - ProgressView().controlSize(.mini) - } else { - Text("Reply").fontWeight(.semibold).font(.caption) - } - } - .buttonStyle(.borderedProminent) - .tint(.amber) - .controlSize(.mini) - .disabled(vm.isPostingReply || vm.replyBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.replyBody.count > 2000) - } - } - } - - // MARK: - Post form - - @ViewBuilder - private var postForm: some View { - if authStore.isAuthenticated { - VStack(alignment: .leading, spacing: 8) { - ZStack(alignment: .topLeading) { - if vm.newBody.isEmpty { - Text("Write a comment…") - .font(.subheadline) - .foregroundStyle(.tertiary) - .padding(.top, 8) - .padding(.leading, 4) - } - TextEditor(text: $vm.newBody) - .font(.subheadline) - .frame(minHeight: 72, maxHeight: 160) - .scrollContentBackground(.hidden) - } - .padding(10) - .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10)) - - HStack { - let count = vm.newBody.count - Text("\(count)/2000") - .font(.caption2) - .monospacedDigit() - .foregroundStyle(count > 2000 ? Color.red : Color.secondary) - - Spacer() - - if let err = vm.postError { - Text(err) - .font(.caption2) - .foregroundStyle(.red) - .lineLimit(1) - } - - Button { - Task { await vm.postComment() } - } label: { - if vm.isPosting { - ProgressView().controlSize(.small) - } else { - Text("Post") - .fontWeight(.semibold) - } - } - .buttonStyle(.borderedProminent) - .tint(.amber) - .controlSize(.small) - .disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000) - } - } - } else { - Text("Log in to leave a comment.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - - // MARK: - Loading skeleton - - @ViewBuilder - private var loadingPlaceholder: some View { - VStack(spacing: 12) { - ForEach(0..<3, id: \.self) { _ in - VStack(alignment: .leading, spacing: 8) { - RoundedRectangle(cornerRadius: 4) - .fill(Color(.systemGray5)) - .frame(width: 100, height: 12) - RoundedRectangle(cornerRadius: 4) - .fill(Color(.systemGray6)) - .frame(maxWidth: .infinity) - .frame(height: 12) - RoundedRectangle(cornerRadius: 4) - .fill(Color(.systemGray6)) - .frame(width: 200, height: 12) - } - .padding(.horizontal) - .redacted(reason: .placeholder) - } - } - .padding(.vertical, 12) - } -} - -// MARK: - CommentRow - -private struct CommentRow: View { - let comment: BookComment - let myVote: String? - let isVoting: Bool - let isDeleting: Bool - let isOwner: Bool - let isLoggedIn: Bool - let isReplyingTo: Bool - var isReply: Bool = false - var avatarUrl: String? = nil - let onVote: (String) -> Void - let onDelete: () -> Void - let onReply: (() -> Void)? - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - // Avatar + Username + date - HStack(spacing: 8) { - avatarView - NavigationLink(value: NavDestination.userProfile(comment.username.isEmpty ? "" : comment.username)) { - Text(comment.username.isEmpty ? "Anonymous" : comment.username) - .font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium)) - .foregroundStyle(.primary) - } - .buttonStyle(.plain) - .disabled(comment.username.isEmpty) - Text("·") - .foregroundStyle(.tertiary) - Text(formattedDate(comment.created)) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - } - - // Body - Text(comment.body) - .font(isReply ? .caption : .subheadline) - .foregroundStyle(.primary) - .fixedSize(horizontal: false, vertical: true) - - // Actions - HStack(spacing: 14) { - // Upvote - Button { onVote("up") } label: { - HStack(spacing: 4) { - Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup") - .font(.caption) - Text("\(comment.upvotes)") - .font(.caption.monospacedDigit()) - } - .foregroundStyle(myVote == "up" ? Color.amber : .secondary) - } - .disabled(isVoting) - - // Downvote - Button { onVote("down") } label: { - HStack(spacing: 4) { - Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown") - .font(.caption) - Text("\(comment.downvotes)") - .font(.caption.monospacedDigit()) - } - .foregroundStyle(myVote == "down" ? .red : .secondary) - } - .disabled(isVoting) - - // Reply button (top-level only, logged in) - if let onReply, isLoggedIn { - Button { onReply() } label: { - HStack(spacing: 3) { - Image(systemName: "arrowshape.turn.up.left") - .font(.caption) - Text("Reply") - .font(.caption) - } - .foregroundStyle(isReplyingTo ? Color.amber : .secondary) - } - } - - Spacer() - - // Delete (owner only) - if isOwner { - Button(role: .destructive) { onDelete() } label: { - Image(systemName: "trash") - .font(.caption) - } - .disabled(isDeleting) - } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .opacity(isDeleting ? 0.5 : 1) - .animation(.easeInOut(duration: 0.15), value: isDeleting) - } - - private var avatarSize: CGFloat { isReply ? 20 : 24 } - - @ViewBuilder - private var avatarView: some View { - if let url = avatarUrl, let imageUrl = URL(string: url) { - AsyncImage(url: imageUrl) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFill() - default: - initialsView - } - } - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - } else { - initialsView - } - } - - private var initialsView: some View { - let name = comment.username.isEmpty ? "?" : comment.username - let letters = String(name.prefix(2)).uppercased() - return ZStack { - Circle() - .fill(Color(.systemGray4)) - .frame(width: avatarSize, height: avatarSize) - Text(letters) - .font(.system(size: avatarSize * 0.42, weight: .semibold)) - .foregroundStyle(.secondary) - } - } - - private func formattedDate(_ iso: String) -> String { - // PocketBase returns "2006-01-02 15:04:05.999Z" format - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: iso) { - let rel = RelativeDateTimeFormatter() - rel.unitsStyle = .abbreviated - return rel.localizedString(for: date, relativeTo: Date()) - } - // Fallback: try space-separated format - let df = DateFormatter() - df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" - if let date = df.date(from: iso) { - let rel = RelativeDateTimeFormatter() - rel.unitsStyle = .abbreviated - return rel.localizedString(for: date, relativeTo: Date()) - } - return String(iso.prefix(10)) - } -} diff --git a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift deleted file mode 100644 index 3f96a86..0000000 --- a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift +++ /dev/null @@ -1,567 +0,0 @@ -import SwiftUI - -// MARK: - Discover View (Browse) -// Serendipity-focused browsing with curated shelves. -// No search bar — use the dedicated Search tab for that. - -struct BrowseView: View { - @StateObject private var vm = DiscoverViewModel() - @State private var showGenreSheet = false - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - OfflineBanner() - - Group { - if vm.isLoading && vm.trending.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let errorMsg = vm.error, vm.trending.isEmpty { - VStack(spacing: 16) { - Image(systemName: "wifi.slash") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text(errorMsg) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding(.horizontal) - Button("Retry") { Task { await vm.load() } } - .buttonStyle(.borderedProminent) - .tint(.amber) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 32) { - // Trending shelf - if !vm.trending.isEmpty { - DiscoverShelf( - title: "Trending Now", - novels: vm.trending, - destination: .browseCategory( - sort: "popular", - genre: "all", - status: "all", - title: "Trending Now" - ) - ) - } - - // Top Rated shelf - if !vm.topRated.isEmpty { - DiscoverShelf( - title: "Top Rated", - novels: vm.topRated, - destination: .browseCategory( - sort: "rating", - genre: "all", - status: "all", - title: "Top Rated" - ) - ) - } - - // Recently Updated shelf - if !vm.recentlyUpdated.isEmpty { - DiscoverShelf( - title: "Recently Updated", - novels: vm.recentlyUpdated, - destination: .browseCategory( - sort: "updated", - genre: "all", - status: "all", - title: "Recently Updated" - ) - ) - } - - // New Releases shelf - if !vm.newReleases.isEmpty { - DiscoverShelf( - title: "New Releases", - novels: vm.newReleases, - destination: .browseCategory( - sort: "new", - genre: "all", - status: "all", - title: "New Releases" - ) - ) - } - - // Categories button — replaces individual genre shelves - CategoriesRow(onTap: { showGenreSheet = true }) - .padding(.horizontal) - - Color.clear.frame(height: 100) - } - .padding(.top, 8) - } - .refreshable { await vm.load() } - } - } - .navigationTitle("Discover") - .appNavigationDestination() - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 16) { - DownloadQueueButton() - AvatarToolbarButton() - } - } - } - .task { await vm.load() } - } - } - .sheet(isPresented: $showGenreSheet) { - GenrePickerSheet() - } - } -} - -// MARK: - Categories row (Apple Books–style single button) - -private struct CategoriesRow: View { - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - HStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(Color.amber.opacity(0.15)) - .frame(width: 44, height: 44) - Image(systemName: "square.grid.2x2") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(Color.amber) - } - - VStack(alignment: .leading, spacing: 2) { - Text("Browse by Genre") - .font(.body.weight(.semibold)) - .foregroundStyle(.primary) - Text("Action, Fantasy, Romance & more") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.tertiary) - } - .padding(14) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } - .buttonStyle(.plain) - } -} - -// MARK: - Genre picker sheet - -private struct GenrePickerSheet: View { - @Environment(\.dismiss) private var dismiss - - private let genres: [(label: String, genre: String, icon: String)] = [ - ("Action", "action", "bolt.fill"), - ("Fantasy", "fantasy", "wand.and.stars"), - ("Romance", "romance", "heart.fill"), - ("Sci-Fi", "sci-fi", "sparkles"), - ("Mystery", "mystery", "magnifyingglass"), - ("Horror", "horror", "moon.fill"), - ("Comedy", "comedy", "face.smiling"), - ("Adventure", "adventure", "map.fill"), - ("Martial Arts", "martial arts", "figure.martial.arts"), - ("Cultivation", "cultivation", "leaf.fill"), - ("Historical", "historical", "building.columns.fill"), - ("Slice of Life", "slice of life", "sun.max.fill"), - ] - - var body: some View { - NavigationStack { - ScrollView { - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ], - spacing: 12 - ) { - // "All" tile - NavigationLink(value: NavDestination.browseCategory( - sort: "popular", genre: "all", status: "all", title: "All Novels" - )) { - GenreTile(label: "All Novels", icon: "books.vertical.fill") - } - .buttonStyle(.plain) - .simultaneousGesture(TapGesture().onEnded { dismiss() }) - - ForEach(genres, id: \.genre) { item in - NavigationLink(value: NavDestination.browseCategory( - sort: "popular", - genre: item.genre, - status: "all", - title: item.label - )) { - GenreTile(label: item.label, icon: item.icon) - } - .buttonStyle(.plain) - .simultaneousGesture(TapGesture().onEnded { dismiss() }) - } - } - .padding(16) - .padding(.bottom, 20) - } - .navigationTitle("Genres") - .navigationBarTitleDisplayMode(.large) - .appNavigationDestination() - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - .fontWeight(.semibold) - .foregroundStyle(Color.amber) - } - } - } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .presentationCornerRadius(20) - } -} - -private struct GenreTile: View { - let label: String - let icon: String - - var body: some View { - HStack(spacing: 10) { - Image(systemName: icon) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(Color.amber) - .frame(width: 24) - Text(label) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.primary) - .lineLimit(1) - Spacer() - } - .padding(.horizontal, 14) - .padding(.vertical, 14) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } -} - -// MARK: - Discover Shelf (horizontal scrolling) - -private struct DiscoverShelf: View { - let title: String - let novels: [BrowseNovel] - let destination: NavDestination - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header with "See All" button - HStack(spacing: 10) { - // Amber accent bar — matches ShelfHeader style used on Home and UserProfile - RoundedRectangle(cornerRadius: 2) - .fill(Color.amber) - .frame(width: 3, height: 18) - Text(title) - .font(.title3.bold()) - Spacer() - NavigationLink(value: destination) { - HStack(spacing: 4) { - Text("See All") - .font(.subheadline) - Image(systemName: "chevron.right") - .font(.caption.bold()) - } - .foregroundStyle(.amber) - } - .buttonStyle(.plain) - } - .padding(.horizontal) - - // Horizontal scroll — leading padding aligns cards with header - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 12) { - ForEach(novels) { novel in - NavigationLink(value: NavDestination.book(novel.slug)) { - DiscoverShelfCard(novel: novel) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal) - .padding(.vertical, 4) // let shadows breathe - } - } - } -} - -// MARK: - Shelf card (card-style) - -private struct DiscoverShelfCard: View { - let novel: BrowseNovel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - ZStack(alignment: .topLeading) { - AsyncCoverImage(url: novel.cover) - .frame(width: 120, height: 173) // 2:3 ratio - .clipShape(RoundedRectangle(cornerRadius: 10)) - .bookCoverZoomSource(slug: novel.slug) - - if !novel.rank.isEmpty { - Text(novel.rank) - .font(.caption2.bold()) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) - .padding(6) - } - } - - VStack(alignment: .leading, spacing: 3) { - Text(novel.title) - .font(.caption.bold()) - .lineLimit(2) - .frame(width: 120, alignment: .leading) - .multilineTextAlignment(.leading) - - if !novel.chapters.isEmpty { - Text(novel.chapters) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(width: 120, alignment: .leading) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 8) - } - .frame(width: 136) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) - } -} - -// MARK: - Browse Category View (full grid for "See All") - -struct BrowseCategoryView: View { - let sort: String - let genre: String - let status: String - let title: String - - @StateObject private var vm: BrowseViewModel - @State private var showFilters = false - - init(sort: String, genre: String, status: String, title: String) { - self.sort = sort - self.genre = genre - self.status = status - self.title = title - - let viewModel = BrowseViewModel() - viewModel.sort = sort - viewModel.genre = genre - viewModel.status = status - _vm = StateObject(wrappedValue: viewModel) - } - - var body: some View { - Group { - if vm.isLoading && vm.novels.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let errorMsg = vm.error, vm.novels.isEmpty { - VStack(spacing: 16) { - Image(systemName: "wifi.slash") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text(errorMsg) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding(.horizontal) - Button("Retry") { Task { await vm.loadFirstPage() } } - .buttonStyle(.borderedProminent) - .tint(.amber) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 14), - GridItem(.flexible(), spacing: 14) - ], - spacing: 14 - ) { - ForEach(vm.novels) { novel in - NavigationLink(value: NavDestination.book(novel.slug)) { - BrowseCategoryCard(novel: novel) - } - .buttonStyle(.plain) - .onAppear { - // Infinite scroll - if novel.id == vm.novels.last?.id { - Task { await vm.loadNextPage() } - } - } - } - } - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 100) - - if vm.isLoading && !vm.novels.isEmpty { - ProgressView() - .padding() - } - } - .refreshable { await vm.loadFirstPage() } - } - } - .navigationTitle(title) - .navigationBarTitleDisplayMode(.large) - .appNavigationDestination() - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showFilters = true - } label: { - Image(systemName: "slider.horizontal.3") - .foregroundStyle(.amber) - } - } - } - .sheet(isPresented: $showFilters) { - BrowseFiltersView(vm: vm) - } - .task { - if vm.novels.isEmpty { - await vm.loadFirstPage() - } - } - } -} - -private struct BrowseCategoryCard: View { - let novel: BrowseNovel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - ZStack(alignment: .topLeading) { - AsyncCoverImage(url: novel.cover) - .frame(maxWidth: .infinity) - .aspectRatio(2/3, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .bookCoverZoomSource(slug: novel.slug) - - if !novel.rank.isEmpty { - Text(novel.rank) - .font(.caption2.bold()) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) - .padding(6) - } - } - - VStack(alignment: .leading, spacing: 3) { - Text(novel.title) - .font(.subheadline.bold()) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - - if !novel.author.isEmpty { - Text(novel.author) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - if !novel.chapters.isEmpty { - Text(novel.chapters) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 10) - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) - } -} - -// MARK: - Filters sheet (kept for future "See All" views) - -struct BrowseFiltersView: View { - @ObservedObject var vm: BrowseViewModel - @Environment(\.dismiss) private var dismiss - - let sortOptions = ["popular", "new", "updated", "rating", "rank"] - let genreOptions = ["all", "action", "fantasy", "romance", "sci-fi", "mystery", - "horror", "comedy", "drama", "adventure", "martial arts", - "cultivation", "magic", "supernatural", "historical", "slice of life"] - let statusOptions = ["all", "ongoing", "completed"] - - var body: some View { - NavigationStack { - Form { - Section("Sort") { - ForEach(sortOptions, id: \.self) { opt in - HStack { - Text(opt.capitalized) - Spacer() - if vm.sort == opt { Image(systemName: "checkmark").foregroundStyle(.amber) } - } - .contentShape(Rectangle()) - .onTapGesture { vm.sort = opt; dismiss() } - } - } - Section("Genre") { - ForEach(genreOptions, id: \.self) { opt in - HStack { - Text(opt == "all" ? "All Genres" : opt.capitalized) - Spacer() - if vm.genre == opt { Image(systemName: "checkmark").foregroundStyle(.amber) } - } - .contentShape(Rectangle()) - .onTapGesture { vm.genre = opt; dismiss() } - } - } - Section("Status") { - ForEach(statusOptions, id: \.self) { opt in - HStack { - Text(opt.capitalized) - Spacer() - if vm.status == opt { Image(systemName: "checkmark").foregroundStyle(.amber) } - } - .contentShape(Rectangle()) - .onTapGesture { vm.status = opt; dismiss() } - } - } - } - .navigationTitle("Filters") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - } - } - } - .presentationDetents([.medium, .large]) - } -} diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift deleted file mode 100644 index ed18f9d..0000000 --- a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift +++ /dev/null @@ -1,1240 +0,0 @@ -import SwiftUI -import WebKit -import UIKit - -// MARK: - Chapter Reader (Apple Books–style, modern) - -struct ChapterReaderView: View { - let slug: String - let chapterNumber: Int - - @State private var currentChapter: Int - @StateObject private var vm: ChapterReaderViewModel - @StateObject private var readerSettings = ReaderSettingsStore() - @EnvironmentObject var audioPlayer: AudioPlayerService - @EnvironmentObject var authStore: AuthStore - - @State private var chromeVisible = true - @State private var showSettingsPanel = false - @State private var showToCSheet = false - - init(slug: String, chapterNumber: Int) { - self.slug = slug - self.chapterNumber = chapterNumber - _currentChapter = State(initialValue: chapterNumber) - _vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber)) - } - - var body: some View { - ZStack { - // Full-bleed background - readerSettings.settings.theme.backgroundColor - .ignoresSafeArea() - - if vm.isLoading { - ProgressView() - .tint(readerSettings.settings.theme.textColor) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let content = vm.content { - if readerSettings.settings.scrollMode { - ScrollReaderContent( - content: content, - readerSettings: readerSettings, - chromeVisible: $chromeVisible, - onNavigateChapter: navigateToChapter - ) - } else { - PaginatedReaderContent( - content: content, - readerSettings: readerSettings, - chromeVisible: $chromeVisible, - onNavigateChapter: navigateToChapter - ) - } - } else if let errMsg = vm.error { - errorView(errMsg) - } - - // Overlaid chrome (top + bottom) — must NOT ignore safe area so buttons - // stay above the home indicator and below the status bar. - if chromeVisible { - VStack(spacing: 0) { - topChrome - Spacer() - if let content = vm.content { - bottomChrome(content: content) - } - } - .transition(.opacity.animation(.easeInOut(duration: 0.2))) - .ignoresSafeArea(edges: .top) // top chrome extends behind status bar only - } - } - .ignoresSafeArea(edges: .all) - .navigationBarHidden(true) - .toolbar(.hidden, for: .tabBar) - .preferredColorScheme(readerSettings.settings.theme.colorScheme) - .hideMiniPlayer() - .task(id: currentChapter) { await vm.load() } - .sheet(isPresented: $showSettingsPanel) { - ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel) - .presentationDetents([.height(460)]) - .presentationDragIndicator(.visible) - .presentationCornerRadius(24) - .presentationBackground(.regularMaterial) - } - .sheet(isPresented: $showToCSheet) { - if let content = vm.content { - ChaptersListSheet( - chapters: content.chapters, - currentChapter: currentChapter, - onChapterSelect: { selected in - showToCSheet = false - navigateToChapter(selected) - } - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - } - .onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in - guard let next = note.userInfo?["next"] as? Int, - let autoNext = note.userInfo?["autoNext"] as? Bool, - autoNext, currentChapter == audioPlayer.chapter else { return } - navigateToChapter(next) - } - .onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in - guard let next = note.userInfo?["next"] as? Int, - currentChapter == audioPlayer.chapter else { return } - navigateToChapter(next) - } - .onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in - guard let prev = note.userInfo?["prev"] as? Int, - currentChapter == audioPlayer.chapter else { return } - navigateToChapter(prev) - } - } - - // MARK: - Top chrome - - @Environment(\.dismiss) private var dismiss - - private var topChrome: some View { - ZStack(alignment: .bottom) { - Rectangle() - .fill(.ultraThinMaterial) - .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) - .ignoresSafeArea(edges: .top) - - VStack(spacing: 0) { - HStack(spacing: 0) { - // Back - Button { dismiss() } label: { - Image(systemName: "chevron.left") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(readerSettings.settings.theme.textColor) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - - Spacer() - - // Single-line chapter title - if let content = vm.content { - Text(content.chapter.title.strippingTrailingDate()) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) - .lineLimit(1) - .frame(maxWidth: 200) - } - - Spacer() - - // ToC + Aa - HStack(spacing: 0) { - Button { showToCSheet = true } label: { - Image(systemName: "list.bullet") - .font(.system(size: 16, weight: .regular)) - .foregroundStyle(readerSettings.settings.theme.textColor) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - Button { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - showSettingsPanel.toggle() - } - } label: { - Text("Aa") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(readerSettings.settings.theme.textColor) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - } - } - .padding(.horizontal, 4) - .frame(height: 44) - - // Progress bar - if let content = vm.content { - ChapterProgressBar( - currentChapter: content.chapter.number, - totalChapters: content.chapters.last?.number ?? content.chapter.number, - color: accentColor - ) - } - } - } - .fixedSize(horizontal: false, vertical: true) - } - - // MARK: - Bottom chrome - - private func bottomChrome(content: ChapterResponse) -> some View { - HStack(alignment: .center, spacing: 12) { - - // ── Prev chapter ──────────────────────────────────────────────── - if let prev = content.prev { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - navigateToChapter(prev) - } label: { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 12, weight: .bold)) - Text("Ch.\(prev)") - .font(.caption.weight(.semibold)) - } - .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) - .frame(minWidth: 64) - .padding(.vertical, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } else { - Color.clear.frame(width: 64, height: 40) - } - - Spacer(minLength: 0) - - // ── Download ──────────────────────────────────────────────────── - DownloadAudioButton( - slug: slug, - chapter: currentChapter, - voice: audioPlayer.voice, - theme: readerSettings.settings.theme - ) - - // ── Listen / Pause pill ───────────────────────────────────────── - ListenButton( - audioPlayer: audioPlayer, - vm: vm, - authStore: authStore, - theme: readerSettings.settings.theme - ) - - Spacer(minLength: 0) - - // ── Next chapter ──────────────────────────────────────────────── - if let next = content.next { - Button { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - navigateToChapter(next) - } label: { - HStack(spacing: 4) { - Text("Ch.\(next)") - .font(.caption.weight(.semibold)) - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .bold)) - } - .foregroundStyle(.white) - .frame(minWidth: 64) - .padding(.vertical, 10) - .background(Capsule().fill(accentColor)) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - } else { - Color.clear.frame(width: 64, height: 40) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background( - Rectangle() - .fill(.ultraThinMaterial) - .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) - .ignoresSafeArea(edges: .bottom) - ) - } - - // MARK: - Helpers - - private var accentColor: Color { - readerSettings.settings.theme == .sepia - ? Color(red: 0.65, green: 0.45, blue: 0.15) - : .amber - } - - private func errorView(_ msg: String) -> some View { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundStyle(.orange) - Text(msg) - .multilineTextAlignment(.center) - .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) - .padding(.horizontal) - Button("Retry") { Task { await vm.load() } } - .buttonStyle(.borderedProminent) - .tint(.amber) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func navigateToChapter(_ chapter: Int) { - vm.switchChapter(to: chapter) - currentChapter = chapter - } -} - -// MARK: - Paginated reader content - -/// Splits chapter HTML into pages and renders them in a horizontal TabView (swipe to turn pages). -/// Edge-swipe on title page (left→right) navigates to previous chapter; -/// edge-swipe on end page (right→left) navigates to next chapter. -private struct PaginatedReaderContent: View { - let content: ChapterResponse - let readerSettings: ReaderSettingsStore - @Binding var chromeVisible: Bool - let onNavigateChapter: (Int) -> Void - - @State private var pages: [AttributedString] = [] - @State private var currentPage: Int = 0 - @State private var geometrySize: CGSize = .zero - @State private var lastPage: Int = 0 - - // Height reserved for top and bottom chrome (approximation — avoids layout passes) - private let topReserve: CGFloat = 80 // nav bar + progress bar + safe area - private let bottomReserve: CGFloat = 64 // single unified toolbar + safe area - - var body: some View { - GeometryReader { geo in - let size = geo.size - TabView(selection: $currentPage) { - ChapterTitlePage( - content: content, - readerSettings: readerSettings - ) - .tag(-1) - .onTapGesture { toggleChrome() } - - ForEach(Array(pages.enumerated()), id: \.offset) { idx, page in - ReaderPage( - text: page, - readerSettings: readerSettings, - pageNumber: idx + 1, - totalPages: pages.count - ) - .tag(idx) - .onTapGesture { toggleChrome() } - } - - ChapterEndPage( - content: content, - readerSettings: readerSettings, - onNavigateChapter: onNavigateChapter - ) - .tag(pages.count) - .onTapGesture { toggleChrome() } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .onChange(of: currentPage) { _, newPage in lastPage = newPage } - .onAppear { - if geometrySize != size { - geometrySize = size - repaginate(size: size) - } - } - .onChange(of: size) { _, newSize in - geometrySize = newSize - repaginate(size: newSize) - } - .onChange(of: readerSettings.settings) { _, _ in - repaginate(size: geometrySize) - } - .onChange(of: content.chapter.number) { _, _ in - currentPage = -1 - repaginate(size: geometrySize) - } - } - .ignoresSafeArea() - .onAppear { currentPage = -1 } - .simultaneousGesture( - DragGesture(minimumDistance: 40, coordinateSpace: .global) - .onEnded { value in - let isHorizontal = abs(value.translation.width) > abs(value.translation.height) * 1.5 - guard isHorizontal else { return } - let swipedRight = value.translation.width > 0 - let swipedLeft = value.translation.width < 0 - if swipedRight && currentPage == -1, let prev = content.prev { - onNavigateChapter(prev) - } else if swipedLeft && currentPage == pages.count, let next = content.next { - onNavigateChapter(next) - } - } - ) - } - - private func toggleChrome() { - withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } - } - - private func repaginate(size: CGSize) { - guard size.width > 0, size.height > 0 else { return } - let settings = readerSettings.settings - let hPad: CGFloat = 28 - let textWidth = size.width - hPad * 2 - let textHeight = size.height - topReserve - bottomReserve - - let attributed = HTMLParser.toAttributedString( - html: content.html, - fontSize: settings.fontSize, - lineSpacing: settings.lineSpacing, - fontName: settings.font.fontName, - textColor: settings.theme.textColor - ) - pages = TextPaginator.paginate( - attributed: attributed, - width: textWidth, - height: textHeight, - fontSize: settings.fontSize - ) - if currentPage > pages.count - 1 { - currentPage = max(0, pages.count - 1) - } - } -} - -// MARK: - Scroll mode reader content - -private struct ScrollReaderContent: View { - let content: ChapterResponse - let readerSettings: ReaderSettingsStore - @Binding var chromeVisible: Bool - let onNavigateChapter: (Int) -> Void - - var body: some View { - let settings = readerSettings.settings - let hPad: CGFloat = 24 - let accentColor: Color = settings.theme == .sepia - ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber - - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - // Chapter header - VStack(alignment: .leading, spacing: 10) { - Text(content.book.title) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(settings.theme.textColor.opacity(0.45)) - .textCase(.uppercase) - .tracking(1.2) - Rectangle() - .fill(accentColor.opacity(0.6)) - .frame(width: 36, height: 2) - Text(content.chapter.title.strippingTrailingDate()) - .font(.system(size: 22, weight: .bold, design: .serif)) - .foregroundStyle(settings.theme.textColor) - if !content.chapter.dateLabel.isEmpty { - Text(content.chapter.dateLabel) - .font(.caption) - .foregroundStyle(settings.theme.textColor.opacity(0.4)) - } - } - .padding(.horizontal, hPad) - .padding(.top, 20) - .padding(.bottom, 20) - - // Body - let attributed = HTMLParser.toAttributedString( - html: content.html, - fontSize: settings.fontSize, - lineSpacing: settings.lineSpacing, - fontName: settings.font.fontName, - textColor: settings.theme.textColor - ) - Text(attributed) - .padding(.horizontal, hPad) - - // Next chapter footer - VStack(spacing: 16) { - Divider().padding(.horizontal, hPad) - if let next = content.next { - Button { onNavigateChapter(next) } label: { - HStack { - Text("Next Chapter") - .fontWeight(.semibold) - Image(systemName: "arrow.right") - } - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 50) - .background(Capsule().fill(accentColor)) - } - .buttonStyle(.plain) - .padding(.horizontal, hPad) - } - } - .padding(.vertical, 24) - .padding(.bottom, 80) - } - } - // Offset content below the top chrome without padding (safeAreaInset) - .safeAreaInset(edge: .top) { Color.clear.frame(height: 52) } - .background(settings.theme.backgroundColor) - .ignoresSafeArea() - .onTapGesture { - withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } - } - } -} - -// MARK: - Individual reader page - -private struct ReaderPage: View { - let text: AttributedString - let readerSettings: ReaderSettingsStore - let pageNumber: Int - let totalPages: Int - - var body: some View { - let settings = readerSettings.settings - let hPad: CGFloat = 28 - let topPad: CGFloat = 80 // visual breathing room below top chrome - let bottomPad: CGFloat = 56 // visual breathing room above bottom chrome - - GeometryReader { geo in - ZStack(alignment: .bottom) { - Text(text) - .frame(width: geo.size.width - hPad * 2, alignment: .topLeading) - .frame(maxHeight: .infinity, alignment: .top) - .padding(.horizontal, hPad) - .padding(.top, topPad) - .padding(.bottom, bottomPad) - .frame(maxWidth: .infinity) - - // Page indicator: "3 of 47" centered at bottom - Text("\(pageNumber) of \(totalPages)") - .font(.system(size: 11, weight: .regular).monospacedDigit()) - .foregroundStyle(settings.theme.textColor.opacity(0.3)) - .padding(.bottom, bottomPad - 24) - .frame(maxWidth: .infinity, alignment: .center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(settings.theme.backgroundColor) - } - } -} - -// MARK: - Chapter title page - -private struct ChapterTitlePage: View { - let content: ChapterResponse - let readerSettings: ReaderSettingsStore - - private var totalChapters: Int { - content.chapters.last?.number ?? content.chapter.number - } - - private var progressPercent: Int { - guard totalChapters > 1 else { return 100 } - return Int((Double(content.chapter.number) / Double(totalChapters)) * 100) - } - - private var accentColor: Color { - readerSettings.settings.theme == .sepia - ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber - } - - var body: some View { - let settings = readerSettings.settings - GeometryReader { geo in - VStack(alignment: .leading, spacing: 0) { - Spacer() - - VStack(alignment: .leading, spacing: 14) { - // Book name pill - Text(content.book.title) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(settings.theme.textColor.opacity(0.45)) - .textCase(.uppercase) - .tracking(1.4) - .lineLimit(2) - - // Accent rule - Rectangle() - .fill(accentColor) - .frame(width: 36, height: 2) - .clipShape(Capsule()) - - // Chapter title — large serif - Text(content.chapter.title.strippingTrailingDate()) - .font(.system(size: min(32, geo.size.width / 10.5), weight: .bold, design: .serif)) - .foregroundStyle(settings.theme.textColor) - .fixedSize(horizontal: false, vertical: true) - .lineSpacing(4) - - // Meta row - HStack(spacing: 8) { - if !content.chapter.dateLabel.isEmpty { - Text(content.chapter.dateLabel) - .font(.caption) - .foregroundStyle(settings.theme.textColor.opacity(0.4)) - } - if totalChapters > 1 { - if !content.chapter.dateLabel.isEmpty { - Circle() - .fill(settings.theme.textColor.opacity(0.25)) - .frame(width: 3, height: 3) - } - Text("\(progressPercent)% through") - .font(.caption.weight(.medium)) - .foregroundStyle(accentColor.opacity(0.85)) - } - } - } - .padding(.horizontal, 36) - - Spacer() - Spacer() - - // Swipe hint — uses phaseAnimator for continuous subtle motion - HStack(spacing: 6) { - Image(systemName: "arrow.right") - .font(.caption2.weight(.semibold)) - Text("Swipe to read") - .font(.caption2) - } - .foregroundStyle(settings.theme.textColor.opacity(0.5)) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 96) - .phaseAnimator([false, true]) { content, phase in - content - .offset(x: phase ? 4 : -2) - .opacity(phase ? 0.55 : 0.15) - } animation: { phase in - .easeInOut(duration: 0.9) - } - .onAppear {} - } - .frame(maxWidth: .infinity) - .background(settings.theme.backgroundColor) - } - } -} - -// MARK: - Chapter end page - -private struct ChapterEndPage: View { - let content: ChapterResponse - let readerSettings: ReaderSettingsStore - let onNavigateChapter: (Int) -> Void - - @State private var appeared = false - - private var accentColor: Color { - readerSettings.settings.theme == .sepia - ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber - } - - var body: some View { - let settings = readerSettings.settings - VStack(spacing: 32) { - Spacer() - - VStack(spacing: 20) { - // Layered ring + checkmark - ZStack { - Circle() - .fill(accentColor.opacity(0.07)) - .frame(width: 96, height: 96) - Circle() - .fill(accentColor.opacity(0.14)) - .frame(width: 72, height: 72) - Image(systemName: "checkmark") - .font(.system(size: 28, weight: .semibold)) - .foregroundStyle(accentColor) - .symbolEffect(.bounce, value: appeared) - } - .scaleEffect(appeared ? 1 : 0.7) - .opacity(appeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.65).delay(0.05), value: appeared) - - VStack(spacing: 6) { - Text("Chapter \(content.chapter.number)") - .font(.caption.weight(.semibold)) - .foregroundStyle(accentColor) - .textCase(.uppercase) - .tracking(1.2) - - Text("Complete") - .font(.title2.bold()) - .foregroundStyle(settings.theme.textColor) - - if content.next == nil { - Text("You've reached the latest chapter") - .font(.subheadline) - .foregroundStyle(settings.theme.textColor.opacity(0.4)) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 10) - .animation(.easeOut(duration: 0.35).delay(0.15), value: appeared) - } - - if let next = content.next { - Button { onNavigateChapter(next) } label: { - HStack(spacing: 8) { - Text("Chapter \(next)") - .fontWeight(.semibold) - Image(systemName: "arrow.right") - .font(.system(size: 14, weight: .semibold)) - } - .foregroundStyle(.white) - .frame(height: 52) - .frame(maxWidth: 240) - .background(Capsule().fill(accentColor)) - } - .buttonStyle(.plain) - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 12) - .animation(.easeOut(duration: 0.35).delay(0.25), value: appeared) - } - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(settings.theme.backgroundColor) - .onAppear { appeared = true } - .onDisappear { appeared = false } - } -} - -// MARK: - Chapter progress bar - -private struct ChapterProgressBar: View { - let currentChapter: Int - let totalChapters: Int - let color: Color - - private var progress: Double { - guard totalChapters > 1 else { return 1.0 } - return Double(currentChapter) / Double(totalChapters) - } - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Rectangle().fill(color.opacity(0.10)) - Rectangle() - .fill( - LinearGradient( - colors: [color.opacity(0.7), color], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: geo.size.width * progress) - .animation(.spring(response: 0.5, dampingFraction: 0.85), value: progress) - } - } - .frame(height: 3) - } -} - -// MARK: - Listen button (bottom chrome) - -private struct ListenButton: View { - @ObservedObject var audioPlayer: AudioPlayerService - @ObservedObject var vm: ChapterReaderViewModel - @ObservedObject var authStore: AuthStore - let theme: ReaderTheme - - private var isActive: Bool { - audioPlayer.isActive && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter - } - - private var isGenerating: Bool { - audioPlayer.status == .generating && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter - } - - private var accentColor: Color { - theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber - } - - var body: some View { - Button { - vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings) - } label: { - HStack(spacing: 7) { - if isGenerating { - ProgressView() - .scaleEffect(0.75) - .tint(isActive ? .white : accentColor) - } else { - Image(systemName: isActive ? "waveform" : "headphones") - .font(.system(size: 15, weight: .semibold)) - .symbolEffect(.variableColor.cumulative, isActive: isActive) - } - Text(isGenerating ? "Generating…" : (isActive ? "Listening" : "Listen")) - .font(.subheadline.weight(.semibold)) - } - .foregroundStyle(isActive ? .white : accentColor) - .padding(.horizontal, 18) - .padding(.vertical, 10) - .background( - Capsule() - .fill(isActive ? accentColor : accentColor.opacity(0.13)) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isActive) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isGenerating) - } -} - -// MARK: - Reading settings panel - -struct ReaderSettingsPanel: View { - @ObservedObject var store: ReaderSettingsStore - @Binding var isPresented: Bool - - var body: some View { - VStack(spacing: 0) { - // Handle - Capsule() - .fill(Color(.systemGray4)) - .frame(width: 36, height: 5) - .padding(.top, 10) - .padding(.bottom, 18) - - ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 22) { - - // ── Font size ────────────────────────────────────────── - VStack(alignment: .leading, spacing: 10) { - SectionLabel("Font Size") - HStack(spacing: 0) { - Button { adjustFontSize(-1) } label: { - Text("A") - .font(.system(size: 13, weight: .regular)) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(.primary) - Slider( - value: Binding( - get: { store.settings.fontSize }, - set: { v in - var s = store.settings; s.fontSize = v; store.update(s) - } - ), - in: 12...26, step: 1 - ) - .tint(.amber) - .padding(.horizontal, 8) - Button { adjustFontSize(1) } label: { - Text("A") - .font(.system(size: 21, weight: .semibold)) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(.primary) - } - } - - settingsDivider - - // ── Font family ──────────────────────────────────────── - VStack(alignment: .leading, spacing: 10) { - SectionLabel("Font") - HStack(spacing: 8) { - ForEach(ReaderFont.allCases, id: \.self) { font in - FontChip(font: font, isSelected: store.settings.font == font) { - var s = store.settings; s.font = font; store.update(s) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - } - } - } - - settingsDivider - - // ── Theme ────────────────────────────────────────────── - VStack(alignment: .leading, spacing: 10) { - SectionLabel("Theme") - HStack(spacing: 8) { - ForEach(ReaderTheme.allCases, id: \.self) { theme in - ThemeChip(theme: theme, isSelected: store.settings.theme == theme) { - var s = store.settings; s.theme = theme; store.update(s) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - } - } - } - - settingsDivider - - // ── Line spacing ─────────────────────────────────────── - VStack(alignment: .leading, spacing: 10) { - SectionLabel("Line Spacing") - HStack(spacing: 8) { - Image(systemName: "text.alignleft") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - .frame(width: 28) - Slider( - value: Binding( - get: { store.settings.lineSpacing }, - set: { v in var s = store.settings; s.lineSpacing = v; store.update(s) } - ), - in: 1.2...2.4, step: 0.1 - ) - .tint(.amber) - Image(systemName: "text.alignleft") - .font(.system(size: 20)) - .foregroundStyle(.secondary) - .frame(width: 28) - } - } - - settingsDivider - - // ── Scroll vs Pages ──────────────────────────────────── - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(store.settings.scrollMode ? "Scroll" : "Pages") - .font(.subheadline.weight(.medium)) - Text(store.settings.scrollMode - ? "Continuous vertical scroll" - : "Swipe horizontally between pages") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Toggle("", isOn: Binding( - get: { store.settings.scrollMode }, - set: { v in - var s = store.settings; s.scrollMode = v; store.update(s) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - )) - .tint(.amber) - .labelsHidden() - } - - Color.clear.frame(height: 8) - } - .padding(.horizontal, 20) - } - } - } - - private var settingsDivider: some View { - Divider().padding(.horizontal, 4) - } - - private func adjustFontSize(_ delta: CGFloat) { - var s = store.settings - s.fontSize = max(12, min(26, s.fontSize + delta)) - store.update(s) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } -} - -private struct SectionLabel: View { - let title: String - init(_ title: String) { self.title = title } - - var body: some View { - Text(title) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.8) - } -} - -private struct FontChip: View { - let font: ReaderFont - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(font.rawValue) - .font(font.fontName.map { Font.custom($0, size: 15) } ?? .system(size: 15)) - .frame(maxWidth: .infinity) - .frame(height: 46) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5) - ) - ) - .foregroundStyle(isSelected ? .amber : .primary) - .scaleEffect(isSelected ? 1.03 : 1.0) - } - .buttonStyle(.plain) - .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) - } -} - -private struct ThemeChip: View { - let theme: ReaderTheme - let isSelected: Bool - let action: () -> Void - - private var label: String { - switch theme { - case .white: return "White" - case .sepia: return "Sepia" - case .night: return "Night" - } - } - - var body: some View { - Button(action: action) { - Text(label) - .font(.subheadline.weight(isSelected ? .semibold : .regular)) - .frame(maxWidth: .infinity) - .frame(height: 46) - .background(theme.backgroundColor) - .foregroundStyle(theme.textColor) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Color.amber : Color(.systemGray4), - lineWidth: isSelected ? 2 : 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .scaleEffect(isSelected ? 1.03 : 1.0) - } - .buttonStyle(.plain) - .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) - } -} - -// MARK: - ReaderSettingsStore - -final class ReaderSettingsStore: ObservableObject { - @Published private(set) var settings: ReaderSettings - - init() { settings = ReaderSettings.load() } - - func update(_ new: ReaderSettings) { - settings = new - new.save() - } -} - -// MARK: - HTML → AttributedString parser - -enum HTMLParser { - /// Strips the duplicated chapter-header block novelfire embeds at the top of the HTML body. - static func stripLeadingChapterHeader(from html: String) -> String { - var result = html - for _ in 0..<3 { - let pattern = #"^(\s*<p[^>]*>)(.*?)(</p>)"# - guard let regex = try? NSRegularExpression( - pattern: pattern, - options: [.dotMatchesLineSeparators, .caseInsensitive] - ) else { break } - - guard let match = regex.firstMatch( - in: result, - range: NSRange(result.startIndex..., in: result) - ) else { break } - - let innerRange = match.range(at: 2) - guard innerRange.location != NSNotFound, - let swiftRange = Range(innerRange, in: result) else { break } - - let inner = String(result[swiftRange]) - let plain = inner - .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - - let isHeaderLine = plain.range( - of: #"^\d*\s*[Cc]hapter\s+\d+"#, - options: .regularExpression - ) != nil - guard isHeaderLine else { break } - - let fullMatchRange = match.range(at: 0) - guard let swiftFullRange = Range(fullMatchRange, in: result) else { break } - result.removeSubrange(swiftFullRange) - } - return result - } - - static func toAttributedString( - html: String, - fontSize: CGFloat, - lineSpacing: CGFloat, - fontName: String?, - textColor: Color - ) -> AttributedString { - let uiFont: UIFont - if let name = fontName, let custom = UIFont(name: name, size: fontSize) { - uiFont = custom - } else { - uiFont = UIFont.systemFont(ofSize: fontSize) - } - - let uiColor = UIColor(textColor) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = (lineSpacing - 1.0) * fontSize - paragraphStyle.paragraphSpacing = fontSize * 0.7 - - let cleanedHtml = stripLeadingChapterHeader(from: html) - let htmlData = Data(cleanedHtml.utf8) - let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ - .documentType: NSAttributedString.DocumentType.html, - .characterEncoding: String.Encoding.utf8.rawValue - ] - - let nsAttr: NSMutableAttributedString - if let parsed = try? NSMutableAttributedString(data: htmlData, options: options, documentAttributes: nil) { - nsAttr = parsed - } else { - let plain = cleanedHtml.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) - nsAttr = NSMutableAttributedString(string: plain) - } - - let fullRange = NSRange(location: 0, length: nsAttr.length) - nsAttr.addAttribute(.font, value: uiFont, range: fullRange) - nsAttr.addAttribute(.foregroundColor, value: uiColor, range: fullRange) - nsAttr.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) - - return (try? AttributedString(nsAttr, including: \.uiKit)) ?? AttributedString(nsAttr.string) - } -} - -// MARK: - Text paginator - -enum TextPaginator { - static func paginate( - attributed: AttributedString, - width: CGFloat, - height: CGFloat, - fontSize: CGFloat - ) -> [AttributedString] { - guard width > 0, height > 0 else { return [attributed] } - - let nsAttr = NSAttributedString(attributed) - guard nsAttr.length > 0 else { return [] } - - let framesetter = CTFramesetterCreateWithAttributedString(nsAttr) - let path = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: height), transform: nil) - - var pages: [AttributedString] = [] - var startIndex = 0 - let totalLength = nsAttr.length - var emergencyBreak = 0 - - while startIndex < totalLength { - emergencyBreak += 1 - if emergencyBreak > 2000 { break } - - let range = CFRange(location: startIndex, length: totalLength - startIndex) - let frame = CTFramesetterCreateFrame(framesetter, range, path, nil) - let visibleRange = CTFrameGetVisibleStringRange(frame) - - let pageLength = visibleRange.length > 0 ? visibleRange.length : max(1, totalLength - startIndex) - let endIndex = min(startIndex + pageLength, totalLength) - - let pageRange = NSRange(location: startIndex, length: endIndex - startIndex) - let pageAttr = nsAttr.attributedSubstring(from: pageRange) - if let pageAS = try? AttributedString(pageAttr, including: \.uiKit) { - pages.append(pageAS) - } - - if visibleRange.length <= 0 { break } - startIndex = endIndex - } - - return pages.isEmpty ? [attributed] : pages - } -} - -// MARK: - Reverse label style (kept for compatibility) - -struct ReverseLabelStyle: LabelStyle { - func makeBody(configuration: Configuration) -> some View { - HStack { - configuration.title - configuration.icon - } - } -} - -// MARK: - HTMLContentView (kept for potential fallback use) - -struct HTMLContentView: UIViewRepresentable { - let html: String - @Binding var height: CGFloat - - func makeCoordinator() -> Coordinator { Coordinator(self) } - - func makeUIView(context: Context) -> WKWebView { - let wv = WKWebView() - wv.scrollView.isScrollEnabled = false - wv.isOpaque = false - wv.backgroundColor = .clear - wv.scrollView.backgroundColor = .clear - wv.navigationDelegate = context.coordinator - return wv - } - - func updateUIView(_ uiView: WKWebView, context: Context) { - let isDark = UITraitCollection.current.userInterfaceStyle == .dark - let textColor = isDark ? "#e5e5e5" : "#1a1a1a" - let css = """ - body { - font-family: -apple-system, Georgia, serif; - font-size: 17px; - line-height: 1.7; - color: \(textColor); - background: transparent; - margin: 0; padding: 0; - word-break: break-word; - } - p { margin: 0 0 1em 0; } - """ - let wrapped = "<html><head><style>\(css)</style><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>\(html)</body></html>" - uiView.loadHTMLString(wrapped, baseURL: nil) - } - - class Coordinator: NSObject, WKNavigationDelegate { - var parent: HTMLContentView - init(_ parent: HTMLContentView) { self.parent = parent } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - webView.evaluateJavaScript("document.body.scrollHeight") { result, _ in - DispatchQueue.main.async { - if let h = result as? CGFloat, h > 0 { - self.parent.height = h - } else if let h = result as? Double, h > 0 { - self.parent.height = CGFloat(h) - } - } - } - } - } -} diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/DownloadAudioButton.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/DownloadAudioButton.swift deleted file mode 100644 index 9a609fd..0000000 --- a/ios/LibNovel/LibNovel/Views/ChapterReader/DownloadAudioButton.swift +++ /dev/null @@ -1,156 +0,0 @@ -import SwiftUI - -// MARK: - Download Audio Button -// Shows download status and allows users to download/delete offline audio. -// Uses symbolEffect + spring animations for a modern, tactile feel. - -struct DownloadAudioButton: View { - let slug: String - let chapter: Int - let voice: String - let theme: ReaderTheme - - @StateObject private var downloadService = AudioDownloadService.shared - @State private var showDownloadMenu = false - @State private var bounceDownload = false - - private var downloadKey: String { - AudioDownloadService.shared.makeKey(slug: slug, chapter: chapter, voice: voice) - } - - private var isDownloaded: Bool { - downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice) - } - - private var downloadProgress: DownloadProgress? { - downloadService.downloads[downloadKey] - } - - private var accentColor: Color { - theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber - } - - var body: some View { - Button { - showDownloadMenu = true - } label: { - ZStack { - // Background pill - Circle() - .fill(backgroundFillColor) - .frame(width: 44, height: 44) - - stateIcon - } - } - .buttonStyle(.plain) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isDownloaded) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: downloadProgress?.status.isDownloading) - .confirmationDialog("Audio Download", isPresented: $showDownloadMenu) { - if isDownloaded { - Button("Delete Download", role: .destructive) { - Task { - try? await downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice) - } - } - } else if let progress = downloadProgress, case .downloading = progress.status { - Button("Cancel Download", role: .destructive) { - downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice) - } - } else { - Button("Download for Offline") { - Task { - try? await downloadService.download(slug: slug, chapter: chapter, voice: voice) - } - withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { bounceDownload.toggle() } - } - } - Button("Cancel", role: .cancel) {} - } message: { - if isDownloaded { - Text("This chapter's audio is downloaded for offline listening.") - } else if let progress = downloadProgress, case .downloading = progress.status { - Text("Downloading… \(Int(progress.progress * 100))%") - } else { - Text("Download this chapter's audio to listen offline without internet connection.") - } - } - } - - // MARK: - Background - - private var backgroundFillColor: Color { - if isDownloaded { - return Color.green.opacity(0.15) - } else if let progress = downloadProgress, case .downloading = progress.status { - return accentColor.opacity(0.1) - } else if let progress = downloadProgress, case .failed = progress.status { - return Color.red.opacity(0.12) - } else { - return theme.textColor.opacity(0.07) - } - } - - // MARK: - Icon - - @ViewBuilder - private var stateIcon: some View { - if isDownloaded { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 22)) - .foregroundStyle(.green) - .symbolEffect(.bounce, value: isDownloaded) - .transition(.scale.combined(with: .opacity)) - - } else if let progress = downloadProgress { - switch progress.status { - case .downloading: - ZStack { - // Track ring - Circle() - .stroke(accentColor.opacity(0.18), lineWidth: 2.5) - // Progress arc - Circle() - .trim(from: 0, to: progress.progress) - .stroke( - accentColor, - style: StrokeStyle(lineWidth: 2.5, lineCap: .round) - ) - .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.2), value: progress.progress) - // Down arrow - Image(systemName: "arrow.down") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(accentColor) - } - .frame(width: 26, height: 26) - .transition(.scale.combined(with: .opacity)) - - case .failed: - Image(systemName: "exclamationmark.circle.fill") - .font(.system(size: 22)) - .foregroundStyle(.red) - .symbolEffect(.pulse) - .transition(.scale.combined(with: .opacity)) - - case .completed: - EmptyView() - } - - } else { - // Idle — not yet downloaded - Image(systemName: "arrow.down.circle") - .font(.system(size: 22)) - .foregroundStyle(theme.textColor.opacity(0.55)) - .symbolEffect(.bounce, value: bounceDownload) - .transition(.scale.combined(with: .opacity)) - } - } -} - -private extension DownloadStatus { - var isDownloading: Bool { - if case .downloading = self { return true } - return false - } -} diff --git a/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift deleted file mode 100644 index 264a0f1..0000000 --- a/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift +++ /dev/null @@ -1,164 +0,0 @@ -import SwiftUI -import Kingfisher - -// MARK: - Empty state placeholder used across all screens - -struct EmptyStateView: View { - let icon: String - let title: String - let message: String - - var body: some View { - VStack(spacing: 14) { - Image(systemName: icon) - .font(.system(size: 48)) - .foregroundStyle(.tertiary) - Text(title) - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - } - } -} - -// MARK: - Cover image card reused across screens - -struct BookCard: View { - let book: Book - var body: some View { - VStack(alignment: .leading, spacing: 6) { - AsyncCoverImage(url: book.cover) - .frame(height: 200) - .clipShape(RoundedRectangle(cornerRadius: 10)) - Text(book.title) - .font(.caption.bold()) - .lineLimit(2) - Text(book.author) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } -} - -// MARK: - Async cover image with disk/memory caching via Kingfisher - -struct AsyncCoverImage: View { - let url: String - /// When true the placeholder is a plain colour fill — used for blurred hero backgrounds - /// so the rounded-rect loading indicator doesn't bleed through. - var isBackground: Bool = false - - var body: some View { - KFImage(URL(string: url)) - .resizable() - .placeholder { - if isBackground { - Color(.systemGray6) - } else { - RoundedRectangle(cornerRadius: 10) - .fill(Color(.systemGray5)) - .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) - } - } - .scaledToFill() - } -} - -// MARK: - Tag chip - -struct TagChip: View { - let label: String - var body: some View { - Text(label) - .font(.caption2.bold()) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color(.systemGray5), in: Capsule()) - } -} - -// MARK: - Unified chip button (filter/sort chips across all screens) -// -// .filled → amber background when selected (genre filter chips in Library) -// .outlined → amber border + tint when selected, grey background (sort chips, browse filter chips) - -enum ChipButtonStyle { case filled, outlined } - -struct ChipButton: View { - let label: String - let isSelected: Bool - var style: ChipButtonStyle = .filled - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(label) - .font(chipFont) - .padding(.horizontal, chipHPad) - .padding(.vertical, 6) - .background(background) - .foregroundStyle(foregroundColor) - .overlay(border) - } - .buttonStyle(.plain) - } - - private var chipFont: Font { - switch style { - case .filled: return .caption.weight(isSelected ? .semibold : .regular) - case .outlined: return .subheadline.weight(isSelected ? .semibold : .regular) - } - } - - private var chipHPad: CGFloat { style == .outlined ? 14 : 12 } - - @ViewBuilder - private var background: some View { - switch style { - case .filled: - Capsule().fill(isSelected ? Color.amber : Color(.systemGray5)) - case .outlined: - Capsule() - .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) - .overlay(Capsule().stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5)) - } - } - - private var foregroundColor: Color { - switch style { - case .filled: return isSelected ? .white : .primary - case .outlined: return isSelected ? .amber : .primary - } - } - - @ViewBuilder - private var border: some View { - // outlined style already has its border baked into `background` - EmptyView() - } -} - -// MARK: - Shelf header (amber accent bar + title) -// Used by HomeView, UserProfileView, BrowseView's DiscoverShelf, and any future shelf screen. -// Call sites that need trailing content (e.g. a "See All" NavigationLink) wrap this in an HStack. - -struct ShelfHeader: View { - let title: String - - var body: some View { - HStack(spacing: 10) { - // 3-pt amber accent bar — the brand visual anchor for all shelf titles - RoundedRectangle(cornerRadius: 2) - .fill(Color.amber) - .frame(width: 3, height: 18) - Text(title) - .font(.title3.bold()) - } - .padding(.horizontal) - .padding(.bottom, 10) - } -} diff --git a/ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift b/ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift deleted file mode 100644 index b440baa..0000000 --- a/ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI - -// MARK: - Offline Banner -// Subtle banner shown at top of screen when network is unavailable - -struct OfflineBanner: View { - @EnvironmentObject var networkMonitor: NetworkMonitor - - var body: some View { - if !networkMonitor.isConnected { - HStack(spacing: 8) { - Image(systemName: "wifi.slash") - .font(.caption) - Text("You're offline") - .font(.subheadline.weight(.medium)) - Spacer() - Text("Showing cached content") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color.orange.opacity(0.15)) - .overlay(alignment: .bottom) { - Rectangle() - .fill(Color.orange.opacity(0.3)) - .frame(height: 1) - } - .transition(.move(edge: .top).combined(with: .opacity)) - } - } -} diff --git a/ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift b/ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift deleted file mode 100644 index cbb3c4a..0000000 --- a/ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift +++ /dev/null @@ -1,340 +0,0 @@ -import SwiftUI - -// MARK: - Download Queue Toolbar Button -// Compact toolbar button that shows active download status and opens queue management sheet. -// Shows: -// - Download icon with badge count when downloads are active -// - Progress ring around icon -// - Taps opens DownloadQueueSheet for management - -struct DownloadQueueButton: View { - @StateObject private var downloadService = AudioDownloadService.shared - @State private var showQueue = false - - private var activeDownloads: [DownloadProgress] { - downloadService.downloads.values.filter { $0.status == .downloading } - } - - private var hasActiveDownloads: Bool { - !activeDownloads.isEmpty - } - - private var averageProgress: Double { - guard !activeDownloads.isEmpty else { return 0 } - let total = activeDownloads.reduce(0.0) { $0 + $1.progress } - return total / Double(activeDownloads.count) - } - - var body: some View { - Button { - showQueue = true - } label: { - ZStack { - // Progress ring (only shown when downloading) - if hasActiveDownloads { - Circle() - .stroke(Color.amber.opacity(0.3), lineWidth: 2) - .frame(width: 30, height: 30) - - Circle() - .trim(from: 0, to: averageProgress) - .stroke(Color.amber, style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .frame(width: 30, height: 30) - .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.3), value: averageProgress) - } - - // Download icon - Image(systemName: hasActiveDownloads ? "arrow.down.circle.fill" : "arrow.down.circle") - .font(.system(size: 22)) - .foregroundStyle(hasActiveDownloads ? .amber : .secondary) - .symbolRenderingMode(.hierarchical) - - // Badge count (top-right corner) - if activeDownloads.count > 0 { - VStack { - HStack { - Spacer() - Text("\(activeDownloads.count)") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(.white) - .padding(3) - .frame(minWidth: 16) - .background(Circle().fill(Color.red)) - .offset(x: 6, y: -6) - } - Spacer() - } - .frame(width: 30, height: 30) - } - } - } - .opacity(hasActiveDownloads || downloadService.downloadedChapters.count > 0 ? 1 : 0.6) - .sheet(isPresented: $showQueue) { - DownloadQueueSheet() - } - } -} - -// MARK: - Download Queue Management Sheet -// Bottom sheet showing active downloads and quick management options - -struct DownloadQueueSheet: View { - @StateObject private var downloadService = AudioDownloadService.shared - @Environment(\.dismiss) private var dismiss - - private var activeDownloads: [(key: String, value: DownloadProgress)] { - downloadService.downloads - .filter { $0.value.status == .downloading } - .sorted { $0.key < $1.key } - } - - private var failedDownloads: [(key: String, value: DownloadProgress)] { - downloadService.downloads.compactMap { key, value in - if case .failed = value.status { - return (key, value) - } - return nil - } - .sorted { $0.key < $1.key } - } - - private var totalDownloaded: Int { - downloadService.downloadedChapters.count - } - - var body: some View { - NavigationStack { - Group { - if activeDownloads.isEmpty && failedDownloads.isEmpty && totalDownloaded == 0 { - emptyState - } else { - List { - // Active downloads section - if !activeDownloads.isEmpty { - Section { - ForEach(activeDownloads, id: \.key) { key, progress in - ActiveDownloadRow(progress: progress) - } - } header: { - HStack { - Text("Downloading") - Spacer() - Text("\(activeDownloads.count)") - .foregroundStyle(.secondary) - } - } - } - - // Failed downloads section - if !failedDownloads.isEmpty { - Section("Failed") { - ForEach(failedDownloads, id: \.key) { key, progress in - FailedDownloadRow(progress: progress, key: key) - } - } - } - - // Quick stats section - Section { - NavigationLink { - DownloadsView() - } label: { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Downloaded Chapters") - Spacer() - Text("\(totalDownloaded)") - .foregroundStyle(.secondary) - } - } - - HStack { - Image(systemName: "internaldrive") - .foregroundStyle(.amber) - Text("Storage Used") - Spacer() - Text(storageUsedFormatted) - .foregroundStyle(.secondary) - } - } - - // Cancel all option (only show if there are active downloads) - if !activeDownloads.isEmpty { - Section { - Button(role: .destructive) { - activeDownloads.forEach { key, progress in - downloadService.cancelDownload( - slug: progress.slug, - chapter: progress.chapter, - voice: progress.voice - ) - } - } label: { - HStack { - Spacer() - Text("Cancel All Downloads") - .font(.subheadline.bold()) - Spacer() - } - } - } - } - } - } - } - .navigationTitle("Download Queue") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { - dismiss() - } - .foregroundStyle(.amber) - } - } - } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - - // MARK: - Empty State - - @ViewBuilder - private var emptyState: some View { - VStack(spacing: 16) { - Image(systemName: "arrow.down.circle") - .font(.system(size: 56)) - .foregroundStyle(.secondary.opacity(0.5)) - Text("No Active Downloads") - .font(.title2.bold()) - .foregroundStyle(.primary) - Text("Audio chapters you download will appear here") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - // MARK: - Helpers - - private var storageUsedFormatted: String { - let bytes = downloadService.getTotalStorageUsed() - return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) - } -} - -// MARK: - Active Download Row - -private struct ActiveDownloadRow: View { - let progress: DownloadProgress - @StateObject private var downloadService = AudioDownloadService.shared - - var body: some View { - HStack(spacing: 12) { - // Book/Chapter info - VStack(alignment: .leading, spacing: 4) { - Text(formatSlug(progress.slug)) - .font(.subheadline.bold()) - .lineLimit(1) - Text("Chapter \(progress.chapter)") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - // Progress indicator - VStack(alignment: .trailing, spacing: 4) { - Text("\(Int(progress.progress * 100))%") - .font(.caption.bold()) - .foregroundStyle(.amber) - .monospacedDigit() - - ProgressView(value: progress.progress) - .frame(width: 60) - .tint(.amber) - } - - // Cancel button - Button { - downloadService.cancelDownload( - slug: progress.slug, - chapter: progress.chapter, - voice: progress.voice - ) - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 20)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - .padding(.vertical, 4) - } - - private func formatSlug(_ slug: String) -> String { - // Convert slug to readable title (e.g., "my-book-title" -> "My Book Title") - slug.split(separator: "-") - .map { $0.capitalized } - .joined(separator: " ") - } -} - -// MARK: - Failed Download Row - -private struct FailedDownloadRow: View { - let progress: DownloadProgress - let key: String - @StateObject private var downloadService = AudioDownloadService.shared - - var body: some View { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - - VStack(alignment: .leading, spacing: 4) { - Text(formatSlug(progress.slug)) - .font(.subheadline.bold()) - .lineLimit(1) - Text("Chapter \(progress.chapter)") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - // Retry button - Button { - Task { - // Remove failed status - downloadService.downloads.removeValue(forKey: key) - // Retry download - try? await downloadService.download( - slug: progress.slug, - chapter: progress.chapter, - voice: progress.voice - ) - } - } label: { - Text("Retry") - .font(.caption.bold()) - .foregroundStyle(.amber) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.amber.opacity(0.15), in: Capsule()) - } - .buttonStyle(.plain) - } - .padding(.vertical, 4) - } - - private func formatSlug(_ slug: String) -> String { - slug.split(separator: "-") - .map { $0.capitalized } - .joined(separator: " ") - } -} diff --git a/ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift b/ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift deleted file mode 100644 index 219c447..0000000 --- a/ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift +++ /dev/null @@ -1,216 +0,0 @@ -import SwiftUI - -// MARK: - Downloads Management View -// Shows all downloaded audio chapters and allows deletion - -struct DownloadsView: View { - @StateObject private var downloadService = AudioDownloadService.shared - @Environment(\.dismiss) private var dismiss - - private var sortedDownloads: [(key: String, value: DownloadProgress)] { - downloadService.downloads.sorted { $0.key < $1.key } - } - - private var totalStorageFormatted: String { - let bytes = downloadService.getTotalStorageUsed() - return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) - } - - var body: some View { - NavigationStack { - Group { - if downloadService.downloadedChapters.isEmpty && downloadService.downloads.isEmpty { - // Empty state - VStack(spacing: 16) { - Image(systemName: "arrow.down.circle") - .font(.system(size: 56)) - .foregroundStyle(.secondary.opacity(0.5)) - Text("No Downloads") - .font(.title2.bold()) - .foregroundStyle(.primary) - Text("Downloaded audio chapters will appear here for offline listening") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List { - // Storage info section - Section { - HStack { - Image(systemName: "internaldrive") - .foregroundStyle(.amber) - Text("Total Storage Used") - Spacer() - Text(totalStorageFormatted) - .foregroundStyle(.secondary) - } - } - - // Active downloads - if !downloadService.downloads.isEmpty { - Section("Active Downloads") { - ForEach(sortedDownloads, id: \.key) { key, progress in - DownloadRow(progress: progress, key: key) - } - } - } - - // Downloaded chapters - if !downloadService.downloadedChapters.isEmpty { - Section("Downloaded (\(downloadService.downloadedChapters.count))") { - ForEach(Array(downloadService.downloadedChapters.sorted()), id: \.self) { key in - DownloadedChapterRow(key: key) - } - } - } - - // Delete all button - if !downloadService.downloadedChapters.isEmpty { - Section { - Button(role: .destructive) { - try? downloadService.deleteAllDownloads() - } label: { - HStack { - Spacer() - Text("Delete All Downloads") - .font(.subheadline.bold()) - Spacer() - } - } - } - } - } - } - } - .navigationTitle("Downloads") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - .foregroundStyle(.amber) - } - } - } - } -} - -// MARK: - Download Row (in progress) - -private struct DownloadRow: View { - let progress: DownloadProgress - let key: String - @StateObject private var downloadService = AudioDownloadService.shared - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Chapter \(progress.chapter)") - .font(.subheadline.bold()) - Text(progress.slug) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - if progress.status == .downloading { - VStack(alignment: .trailing, spacing: 4) { - Text("\(Int(progress.progress * 100))%") - .font(.caption) - .foregroundStyle(.secondary) - ProgressView(value: progress.progress) - .frame(width: 60) - } - - Button { - downloadService.cancelDownload(slug: progress.slug, chapter: progress.chapter, voice: progress.voice) - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } else if case .failed(let error) = progress.status { - VStack(alignment: .trailing) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - Text("Failed") - .font(.caption2) - .foregroundStyle(.red) - } - } - } - } -} - -// MARK: - Downloaded Chapter Row - -private struct DownloadedChapterRow: View { - let key: String - @StateObject private var downloadService = AudioDownloadService.shared - - private var components: (slug: String, chapter: String, voice: String) { - let parts = key.split(separator: "-") - if parts.count >= 3 { - return (String(parts[0]), String(parts[1]), parts[2...].joined(separator: "-")) - } - return ("", "", "") - } - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Chapter \(components.chapter)") - .font(.subheadline.bold()) - HStack(spacing: 4) { - Text(components.slug) - .font(.caption) - .foregroundStyle(.secondary) - Text("•") - .font(.caption) - .foregroundStyle(.secondary) - Text(formatVoice(components.voice)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - let parts = components - if let chapter = Int(parts.chapter) { - try? downloadService.deleteDownload(slug: parts.slug, chapter: chapter, voice: parts.voice) - } - } label: { - Label("Delete", systemImage: "trash") - } - } - } - - private func formatVoice(_ voice: String) -> String { - // Format voice name (e.g., "af_bella" -> "Bella (US F)") - let parts = voice.split(separator: "_") - guard parts.count == 2 else { return voice } - - let prefix = String(parts[0]) - let name = String(parts[1]).capitalized - - let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : "" - let accent = prefix.hasPrefix("af") ? "US" : prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK" : "" - - if !gender.isEmpty && !accent.isEmpty { - return "\(name) (\(accent) \(gender))" - } else if !gender.isEmpty { - return "\(name) (\(gender))" - } else { - return name - } - } -} diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift deleted file mode 100644 index 35fdaff..0000000 --- a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift +++ /dev/null @@ -1,451 +0,0 @@ -import SwiftUI - -struct HomeView: View { - @StateObject private var vm = HomeViewModel() - @EnvironmentObject var authStore: AuthStore - @StateObject private var downloadService = AudioDownloadService.shared - - private var offlineBooks: [Book] { - let offlineSlugs = downloadService.getOfflineBookSlugs() - // Filter continue reading items that have offline downloads - return vm.continueReading - .filter { offlineSlugs.contains($0.book.slug) } - .map { $0.book } - } - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - OfflineBanner() - - ScrollView { - VStack(alignment: .leading, spacing: 0) { - - // Continue reading — all in-progress books as a horizontal shelf (Apple Books style) - if !vm.continueReading.isEmpty { - ShelfHeader(title: "Continue Reading") - .padding(.top, 8) - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 16) { - ForEach(vm.continueReading) { item in - NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) { - ContinueReadingCard(item: item) - } - .buttonStyle(.plain) - .contextMenu { - ContinueReadingContextMenu( - item: item, - onMarkFinished: { - Task { await markAsFinished(item.book) } - }, - onRemove: { - Task { await removeFromLibrary(item.book.slug) } - } - ) - } - } - } - .padding(.horizontal) - .padding(.bottom, 4) - } - .padding(.bottom, 28) - } - - // Offline books — books with downloaded chapters - if !offlineBooks.isEmpty { - HStack { - ShelfHeader(title: "Downloaded for Offline") - Spacer() - Image(systemName: "wifi.slash") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.trailing, 16) - } - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 14) { - ForEach(offlineBooks) { book in - NavigationLink(value: NavDestination.book(book.slug)) { - VStack(alignment: .leading, spacing: 8) { - ShelfBookCard(book: book) - HStack(spacing: 4) { - Image(systemName: "arrow.down.circle.fill") - .font(.caption2) - .foregroundStyle(.green) - Text("\(downloadService.getDownloadedChapterCount(for: book.slug)) chapters") - .font(.caption2) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 4) - } - } - .buttonStyle(.plain) - .contextMenu { - ShareLink(item: shareURL(for: book)) { - Label("Share", systemImage: "square.and.arrow.up") - } - } - } - } - .padding(.horizontal) - .padding(.bottom, 4) - } - .padding(.bottom, 28) - } - - // Stats strip - if let stats = vm.stats { - StatsStrip(stats: stats) - .padding(.horizontal) - .padding(.bottom, 28) - } - - // Recently updated shelf - if !vm.recentlyUpdated.isEmpty { - ShelfHeader(title: "Recently Updated") - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 14) { - ForEach(vm.recentlyUpdated) { book in - NavigationLink(value: NavDestination.book(book.slug)) { - ShelfBookCard(book: book) - } - .buttonStyle(.plain) - .contextMenu { - ShareLink(item: shareURL(for: book)) { - Label("Share", systemImage: "square.and.arrow.up") - } - } - } - } - .padding(.horizontal) - .padding(.bottom, 4) - } - .padding(.bottom, 28) - } - - // Subscription feed shelf - if !vm.subscriptionFeed.isEmpty { - ShelfHeader(title: "From People You Follow") - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 14) { - ForEach(vm.subscriptionFeed) { item in - NavigationLink(value: NavDestination.book(item.book.slug)) { - SubscriptionFeedCard(item: item) - } - .buttonStyle(.plain) - .contextMenu { - ShareLink(item: shareURL(for: item.book)) { - Label("Share", systemImage: "square.and.arrow.up") - } - } - } - } - .padding(.horizontal) - .padding(.bottom, 4) - } - .padding(.bottom, 28) - } - - // Empty state - if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty && !vm.isLoading { - EmptyStateView( - icon: "books.vertical", - title: "Your library is empty", - message: "Head to Discover to find novels to read." - ) - .frame(maxWidth: .infinity) - .padding(.top, 60) - } - - if vm.isLoading { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.top, 60) - } - - Color.clear.frame(height: 20) - } - } - .navigationTitle("Reading Now") - .appNavigationDestination() - .refreshable { await vm.load() } - .task { await vm.load() } - .errorAlert($vm.error) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 8) { - DownloadQueueButton() - Divider() - .frame(height: 18) - AvatarToolbarButton() - } - } - } - } - } - } - - private func markAsFinished(_ book: Book) async { - do { - try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters) - await vm.load() // Refresh home - } catch { - vm.error = error.localizedDescription - } - } - - private func removeFromLibrary(_ slug: String) async { - do { - try await APIClient.shared.deleteProgress(slug: slug) - await vm.load() // Refresh home - } catch { - vm.error = error.localizedDescription - } - } - - private func shareURL(for book: Book) -> URL { - let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String - ?? "https://v2.libnovel.kalekber.cc" - return URL(string: "\(baseURL)/books/\(book.slug)")! - } -} - -// MARK: - Horizontal shelf: continue reading card (Apple Books style) - -private struct ContinueReadingCard: View { - let item: ContinueReadingItem - - private var progressFraction: Double { - guard item.book.totalChapters > 0 else { return 0 } - return min(1.0, Double(item.chapter) / Double(item.book.totalChapters)) - } - - private var progressText: String { - let percentage = progressFraction * 100 - - // For books with many chapters, show decimal precision when less than 10% - if percentage < 10 && percentage > 0 { - return String(format: "%.1f%% complete", percentage) - } - - // Otherwise, round to nearest integer (min 1% if any progress exists) - let rounded = max(1, Int(round(percentage))) - return "\(rounded)% complete" - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - // Cover - ZStack(alignment: .bottom) { - AsyncCoverImage(url: item.book.cover) - .frame(width: 130, height: 188) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .shadow(color: .black.opacity(0.22), radius: 8, y: 4) - .bookCoverZoomSource(slug: item.book.slug) - - // Gradient scrim so badge is always readable - LinearGradient( - colors: [Color.black.opacity(0), Color.black.opacity(0.55)], - startPoint: .center, - endPoint: .bottom - ) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .frame(height: 60) - - // "Continue" pill badge — centered at bottom over the scrim - HStack(spacing: 4) { - Image(systemName: "play.fill") - .font(.system(size: 8, weight: .bold)) - Text("Ch.\(item.chapter)") - .font(.system(size: 10, weight: .bold)) - } - .foregroundStyle(.white) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Capsule().fill(Color.amber)) - .padding(.bottom, 10) - } - - // Title - Text(item.book.title) - .font(.caption.bold()) - .lineLimit(2) - .frame(width: 130, alignment: .leading) - .foregroundStyle(.primary) - - // Progress bar — show at least a 4pt sliver so early chapters aren't invisible - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(Color.secondary.opacity(0.2)) - Capsule() - .fill(Color.amber.opacity(0.9)) - .frame(width: max(4, geo.size.width * progressFraction)) - } - } - .frame(width: 130, height: 3) - - // Progress label with smart rounding - Text(progressText) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(width: 130) - } -} - -// MARK: - Horizontal shelf: recently updated book card - -private struct ShelfBookCard: View { - let book: Book - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - ZStack(alignment: .topTrailing) { - AsyncCoverImage(url: book.cover) - .frame(width: 110, height: 158) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(color: .black.opacity(0.12), radius: 4, y: 2) - .bookCoverZoomSource(slug: book.slug) - - // Chapter count badge - Text("\(book.totalChapters) ch") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Capsule().fill(Color.black.opacity(0.55))) - .padding(6) - } - - Text(book.title) - .font(.caption.bold()) - .lineLimit(2) - .frame(width: 110, alignment: .leading) - - Text(book.author) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(width: 110, alignment: .leading) - } - } -} - -// MARK: - Horizontal shelf: subscription feed card - -private struct SubscriptionFeedCard: View { - let item: SubscriptionFeedItem - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - AsyncCoverImage(url: item.book.cover) - .frame(width: 110, height: 158) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(color: .black.opacity(0.12), radius: 4, y: 2) - .bookCoverZoomSource(slug: item.book.slug) - - Text(item.book.title) - .font(.caption.bold()) - .lineLimit(2) - .frame(width: 110, alignment: .leading) - - // Tappable "via @username" attribution - NavigationLink(value: NavDestination.userProfile(item.readerUsername)) { - Text("via @\(item.readerUsername)") - .font(.caption2) - .foregroundStyle(Color.amber) - .lineLimit(1) - .frame(width: 110, alignment: .leading) - } - .buttonStyle(.plain) - } - } -} - -// MARK: - Stats strip (compact inline) - -private struct StatsStrip: View { - let stats: HomeStats - - var body: some View { - HStack(spacing: 0) { - StatPill(icon: "books.vertical.fill", value: "\(stats.totalBooks)", label: "Books") - Divider().frame(height: 28) - StatPill(icon: "text.alignleft", value: "\(stats.totalChapters)", label: "Chapters") - Divider().frame(height: 28) - StatPill(icon: "bookmark.fill", value: "\(stats.booksInProgress)", label: "In Progress") - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) - } -} - -private struct StatPill: View { - let icon: String - let value: String - let label: String - - var body: some View { - VStack(spacing: 5) { - Image(systemName: icon) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(Color.amber) - Text(value) - .font(.subheadline.bold().monospacedDigit()) - .foregroundStyle(.primary) - Text(label) - .font(.caption2) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - } -} - -// MARK: - Context menus - -private struct ContinueReadingContextMenu: View { - let item: ContinueReadingItem - let onMarkFinished: () -> Void - let onRemove: () -> Void - - private var isFinished: Bool { - guard item.book.totalChapters > 0 else { return false } - return item.chapter >= item.book.totalChapters - } - - var body: some View { - Group { - // Share book - ShareLink(item: shareURL) { - Label("Share", systemImage: "square.and.arrow.up") - } - - Divider() - - // Mark as finished (only show if not already finished) - if !isFinished { - Button { - onMarkFinished() - } label: { - Label("Mark as Finished", systemImage: "checkmark.circle") - } - } - - Divider() - - // Remove from library (destructive) - Button(role: .destructive) { - onRemove() - } label: { - Label("Remove from Library", systemImage: "trash") - } - } - } - - private var shareURL: URL { - let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String - ?? "https://v2.libnovel.kalekber.cc" - return URL(string: "\(baseURL)/books/\(item.book.slug)")! - } -} diff --git a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift deleted file mode 100644 index a5921d2..0000000 --- a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift +++ /dev/null @@ -1,391 +0,0 @@ -import SwiftUI -import Kingfisher - -struct LibraryView: View { - @StateObject private var vm = LibraryViewModel() - @State private var sortOrder: SortOrder = .recentlyRead - @State private var readingFilter: ReadingFilter = .all - @State private var selectedGenre: String = "all" - - enum SortOrder: String, CaseIterable { - case recentlyRead = "Recent" - case title = "Title" - case author = "Author" - case progress = "Progress" - } - - enum ReadingFilter: String, CaseIterable { - case all = "All" - case inProgress = "In Progress" - case completed = "Completed" - } - - // All distinct genres across the library, sorted alphabetically. - private var availableGenres: [String] { - let all = vm.items.flatMap { $0.book.genres } - let unique = Array(Set(all)).sorted() - return unique - } - - private var filtered: [LibraryItem] { - var result = vm.items - - // 1. Reading filter - switch readingFilter { - case .all: - break - case .inProgress: - result = result.filter { !isCompleted($0) } - case .completed: - result = result.filter { isCompleted($0) } - } - - // 2. Genre filter - if selectedGenre != "all" { - result = result.filter { $0.book.genres.contains(selectedGenre) } - } - - // 3. Sort - switch sortOrder { - case .recentlyRead: - break // server returns by recency - case .title: - result = result.sorted { $0.book.title < $1.book.title } - case .author: - result = result.sorted { $0.book.author < $1.book.author } - case .progress: - result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) } - } - - return result - } - - private func isCompleted(_ item: LibraryItem) -> Bool { - // Treat as completed if book status is "completed" OR - // the user has read up to (or past) the total chapter count. - if item.book.status.lowercased() == "completed", - let ch = item.lastChapter, - item.book.totalChapters > 0, - ch >= item.book.totalChapters { - return true - } - return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0 - } - - private func markAsFinished(_ book: Book) async { - do { - try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters) - await vm.load() // Refresh library - } catch { - vm.error = error.localizedDescription - } - } - - private func removeFromLibrary(_ slug: String) async { - do { - try await APIClient.shared.deleteProgress(slug: slug) - await vm.load() // Refresh library - } catch { - vm.error = error.localizedDescription - } - } - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - OfflineBanner() - - Group { - if vm.isLoading && vm.items.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if vm.items.isEmpty { - EmptyStateView( - icon: "bookmark", - title: "No saved books", - message: "Books you save or start reading will appear here." - ) - } else { - ScrollView { - VStack(spacing: 0) { - // Reading filter (All / In Progress / Completed) - Picker("", selection: $readingFilter) { - ForEach(ReadingFilter.allCases, id: \.self) { f in - Text(f.rawValue).tag(f) - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - .padding(.top, 16) - - // Genre filter chips (only shown when genres are available) - if !availableGenres.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - // "All" chip - ChipButton( - label: "All", - isSelected: selectedGenre == "all", - style: .filled - ) { - withAnimation { selectedGenre = "all" } - } - ForEach(availableGenres, id: \.self) { genre in - ChipButton( - label: genre.capitalized, - isSelected: selectedGenre == genre, - style: .filled - ) { - withAnimation { - selectedGenre = selectedGenre == genre ? "all" : genre - } - } - } - } - .padding(.horizontal) - } - .padding(.top, 10) - } - - // Sort chips - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(SortOrder.allCases, id: \.self) { order in - ChipButton( - label: order.rawValue, - isSelected: sortOrder == order, - style: .outlined - ) { - withAnimation { sortOrder = order } - } - } - } - .padding(.horizontal) - } - .padding(.vertical, 10) - - // Book count - Text("\(filtered.count) book\(filtered.count == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .padding(.bottom, 4) - - if filtered.isEmpty { - VStack(spacing: 12) { - Image(systemName: readingFilter == .completed ? "checkmark.circle" : "book") - .font(.system(size: 40)) - .foregroundStyle(.secondary) - Text(emptyMessage) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.top, 60) - } else { - // 2-column grid (matches Discover) - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 14), - GridItem(.flexible(), spacing: 14) - ], - spacing: 14 - ) { - ForEach(filtered) { item in - NavigationLink(value: NavDestination.book(item.book.slug)) { - LibraryBookCard(item: item) - } - .buttonStyle(.plain) - .contextMenu { - BookContextMenu( - book: item.book, - isFinished: isCompleted(item), - onMarkFinished: { - Task { - await markAsFinished(item.book) - } - }, - onRemove: { - Task { - await removeFromLibrary(item.book.slug) - } - } - ) - } - } - } - .padding(.horizontal) - .padding(.top, 8) - .padding(.bottom, 100) - } - } - } - } - } - .navigationTitle("Library") - .appNavigationDestination() - .refreshable { await vm.load() } - .task { await vm.load() } - .errorAlert($vm.error) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 16) { - DownloadQueueButton() - AvatarToolbarButton() - } - } - } - } - } - - var emptyMessage: String { - switch readingFilter { - case .all: - return selectedGenre == "all" ? "No books in your library." : "No \(selectedGenre.capitalized) books in your library." - case .inProgress: - return "No books in progress." - case .completed: - return "No completed books yet." - } - } - } - - // MARK: - Library book card (3-column) - - private struct LibraryBookCard: View { - let item: LibraryItem - - private var progressFraction: Double { - guard let ch = item.lastChapter, item.book.totalChapters > 0 else { return 0 } - return Double(ch) / Double(item.book.totalChapters) - } - - private var isCompleted: Bool { - progressFraction >= 1.0 - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - ZStack(alignment: .topTrailing) { - // Cover image - KFImage(URL(string: item.book.cover)) - .resizable() - .placeholder { - RoundedRectangle(cornerRadius: 10) - .fill(Color(.systemGray5)) - .overlay( - Image(systemName: "book.closed") - .foregroundStyle(.secondary) - ) - } - .scaledToFill() - .frame(maxWidth: .infinity) - .aspectRatio(2/3, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .bookCoverZoomSource(slug: item.book.slug) - - // Progress arc or completed checkmark in top-right corner - if isCompleted { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - .background(Circle().fill(Color.amber).padding(1)) - .padding(6) - } else if progressFraction > 0 { - ProgressArc(fraction: progressFraction) - .frame(width: 28, height: 28) - .padding(5) - } - } - - // Title + chapter badge - VStack(alignment: .leading, spacing: 3) { - Text(item.book.title) - .font(.subheadline.bold()) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - .multilineTextAlignment(.leading) - - if let ch = item.lastChapter { - Text(isCompleted ? "Finished" : "Ch.\(ch)") - .font(.caption) - .foregroundStyle(isCompleted ? Color.amber : .secondary) - .lineLimit(1) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 10) - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) - } - } - - // MARK: - Circular progress arc overlay - - private struct ProgressArc: View { - let fraction: Double // 0...1 - - var body: some View { - ZStack { - Circle() - .fill(.ultraThinMaterial) - - Circle() - .trim(from: 0, to: fraction) - .stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.5), value: fraction) - } - } - } - - // MARK: - Book context menu - - private struct BookContextMenu: View { - let book: Book - let isFinished: Bool - let onMarkFinished: () -> Void - let onRemove: () -> Void - - var body: some View { - Group { - // Share book - ShareLink(item: shareURL) { - Label("Share", systemImage: "square.and.arrow.up") - } - - Divider() - - // Mark as finished (only show if not already finished) - if !isFinished { - Button { - onMarkFinished() - } label: { - Label("Mark as Finished", systemImage: "checkmark.circle") - } - } - - Divider() - - // Remove from library (destructive) - Button(role: .destructive) { - onRemove() - } label: { - Label("Remove from Library", systemImage: "trash") - } - } - } - - private var shareURL: URL { - // Share the book detail page URL - let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String - ?? "https://v2.libnovel.kalekber.cc" - return URL(string: "\(baseURL)/books/\(book.slug)")! - } - } -} diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift deleted file mode 100644 index 06ba600..0000000 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ /dev/null @@ -1,2060 +0,0 @@ -import SwiftUI -import Kingfisher // used directly for blurred background in FullPlayerView -import AVKit // for AVRoutePickerView (AirPlay) - -// MARK: - Mini player bar (Spotify-style, fixed above tab bar) -// Replaces the old FloatingPlayerButton + CompactPlayerControls. -// Swipe up → full player. Swipe down → stop. Tap cover/track info → full player. -// All transport buttons are plain Buttons with no competing gesture recognisers. - -struct MiniPlayerBar: View { - @Binding var showFullPlayer: Bool - @EnvironmentObject var audioPlayer: AudioPlayerService - @EnvironmentObject var downloadService: AudioDownloadService - - /// Live vertical drag offset while the user swipes up/down. - @State private var dragOffset: CGFloat = 0 - - private var isCurrentChapterDownloaded: Bool { - downloadService.isDownloaded( - slug: audioPlayer.slug, - chapter: audioPlayer.chapter, - voice: audioPlayer.voice - ) - } - - var body: some View { - VStack(spacing: 0) { - // ── Thin amber progress strip at the very top ──────────────── - MiniBarProgress(progress: audioPlayer.progress) - - // ── Main content row ───────────────────────────────────────── - HStack(spacing: 12) { - // Cover art — tap opens full player - Button { showFullPlayer = true } label: { - AsyncCoverImage(url: audioPlayer.coverURL) - .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 9)) - .shadow(color: .black.opacity(0.18), radius: 6, y: 2) - } - .buttonStyle(.plain) - - // Track info — tap opens full player - Button { showFullPlayer = true } label: { - VStack(alignment: .leading, spacing: 2) { - Text(chapterLabel) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - HStack(spacing: 4) { - Text(audioPlayer.bookTitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - if isCurrentChapterDownloaded { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 9)) - .foregroundStyle(.green) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - - // ── Transport controls ─────────────────────────────────── - - // Previous chapter - Button { - if let prev = audioPlayer.prevChapter { - NotificationCenter.default.post( - name: .skipToPrevChapter, - object: nil, - userInfo: ["prev": prev] - ) - } - } label: { - Image(systemName: "backward.end.fill") - .font(.system(size: 19, weight: .semibold)) - .foregroundStyle(.primary) - .frame(width: 36, height: 36) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(audioPlayer.prevChapter == nil) - .opacity(audioPlayer.prevChapter == nil ? 0.3 : 1) - - // Play / pause (isolated observer — only re-renders this button) - MiniBarPlayPause(progress: audioPlayer.progress) { - audioPlayer.togglePlayPause() - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - .disabled(audioPlayer.status == .generating) - - // Next chapter - Button { - if let next = audioPlayer.nextChapter { - NotificationCenter.default.post( - name: .skipToNextChapter, - object: nil, - userInfo: ["next": next] - ) - } - } label: { - Image(systemName: "forward.end.fill") - .font(.system(size: 19, weight: .semibold)) - .foregroundStyle(.primary) - .frame(width: 36, height: 36) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(audioPlayer.nextChapter == nil) - .opacity(audioPlayer.nextChapter == nil ? 0.3 : 1) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - } - .background(.regularMaterial) - .offset(y: dragOffset) - .opacity(dragOffset > 0 ? max(0.3, 1 - dragOffset / 200) : 1) - .gesture( - DragGesture(minimumDistance: 8, coordinateSpace: .local) - .onChanged { value in - let dy = value.translation.height - dragOffset = dy < 0 ? dy * 0.25 : dy * 0.7 - } - .onEnded { value in - let dy = value.translation.height - let velocity = value.predictedEndTranslation.height - value.translation.height - if dy < -30 || velocity < -150 { - withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { dragOffset = 0 } - showFullPlayer = true - } else if dy > 60 || velocity > 200 { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { dragOffset = 200 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { audioPlayer.stop() } - } else { - withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { dragOffset = 0 } - } - } - ) - } - - private var chapterLabel: String { - let raw = audioPlayer.chapterTitle.isEmpty - ? "Chapter \(audioPlayer.chapter)" - : audioPlayer.chapterTitle - return raw.strippingTrailingDate() - } -} - -// MARK: - Isolated progress strip (observes PlaybackProgress directly) - -private struct MiniBarProgress: View { - @ObservedObject var progress: PlaybackProgress - - var body: some View { - GeometryReader { geo in - let fraction = progress.duration > 0 - ? CGFloat(progress.currentTime / progress.duration) - : 0 - Rectangle() - .fill(Color.amber) - .frame(width: geo.size.width * max(0, min(1, fraction)), height: 2) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(height: 2) - } -} - -// MARK: - Isolated play/pause for mini bar (observes PlaybackProgress directly) - -private struct MiniBarPlayPause: View { - @ObservedObject var progress: PlaybackProgress - let onToggle: () -> Void - - var body: some View { - Button(action: onToggle) { - Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 21, weight: .semibold)) - .foregroundStyle(.primary) - .frame(width: 36, height: 36) - .contentShape(Rectangle()) - .contentTransition(.symbolEffect(.replace.downUp)) - } - .buttonStyle(.plain) - } -} - -// CompactPlayerControls removed — replaced by MiniPlayerBar above. - -// MARK: - Full player sheet - -struct FullPlayerView: View { - @EnvironmentObject var audioPlayer: AudioPlayerService - @EnvironmentObject var downloadService: AudioDownloadService - @EnvironmentObject var authStore: AuthStore - /// Called when the view wants to close itself (Done button or drag-to-dismiss). - var onDismiss: () -> Void = {} - - @State private var showingChaptersList = false - @State private var showingSleepTimer = false - @State private var showingVoiceSelector = false - @StateObject private var voiceVM = VoiceSelectionViewModel() - @State private var coverAppeared = false - - private var isCurrentChapterDownloaded: Bool { - downloadService.isDownloaded( - slug: audioPlayer.slug, - chapter: audioPlayer.chapter, - voice: audioPlayer.voice - ) - } - - private var currentDownloadProgress: DownloadProgress? { - let key = downloadService.makeKey( - slug: audioPlayer.slug, - chapter: audioPlayer.chapter, - voice: audioPlayer.voice - ) - return downloadService.downloads[key] - } - - var body: some View { - GeometryReader { geo in - ZStack { - // ── Background: blurred cover art ────────────────────────── - KFImage(URL(string: audioPlayer.coverURL)) - .resizable() - .scaledToFill() - .frame(width: geo.size.width, height: geo.size.height) - .clipped() - .blur(radius: 55, opaque: true) - .overlay(Color.black.opacity(0.55)) - .ignoresSafeArea() - .id(audioPlayer.coverURL) // re-render background on track change - - // ── Content ──────────────────────────────────────────────── - VStack(spacing: 0) { - // Drag handle - Capsule() - .fill(Color.white.opacity(0.25)) - .frame(width: 36, height: 4) - .padding(.top, 14) - - // ── Cover art ────────────────────────────────────────── - let coverSize = min(geo.size.width - 56, geo.size.height * 0.42) - ZStack { - KFImage(URL(string: audioPlayer.coverURL)) - .resizable() - .placeholder { - RoundedRectangle(cornerRadius: 22) - .fill(.white.opacity(0.08)) - .overlay( - Image(systemName: "book.closed") - .font(.system(size: 56)) - .foregroundStyle(.white.opacity(0.25)) - ) - } - .scaledToFill() - .frame(width: coverSize, height: coverSize) - .clipShape(RoundedRectangle(cornerRadius: 22)) - .shadow(color: .black.opacity(0.55), radius: 36, y: 18) - .overlay( - RoundedRectangle(cornerRadius: 22) - .fill(Color.black.opacity(audioPlayer.status == .generating ? 0.5 : 0)) - .animation(.easeInOut(duration: 0.3), value: audioPlayer.status == .generating) - ) - // Subtle scale pulse while playing - .scaleEffect(audioPlayer.progress.isPlaying && !coverAppeared ? 1.0 : (audioPlayer.progress.isPlaying ? 1.02 : 0.97)) - .animation(.spring(response: 0.45, dampingFraction: 0.7), value: audioPlayer.progress.isPlaying) - - // Generating overlay - if audioPlayer.status == .generating { - VStack(spacing: 10) { - ProgressView() - .tint(.white) - .scaleEffect(1.4) - Text("Generating audio…") - .font(.caption.weight(.medium)) - .foregroundStyle(.white.opacity(0.8)) - } - .transition(.opacity) - } - - // Voice watermark — bottom-left corner - VStack { - Spacer() - HStack { - Text(voiceName) - .font(.custom("Snell Roundhand", size: 17)) - .foregroundStyle(.white.opacity(0.5)) - .shadow(color: .black.opacity(0.5), radius: 2) - .padding(12) - Spacer() - } - } - .frame(width: coverSize, height: coverSize) - } - .frame(width: coverSize, height: coverSize) - .padding(.top, 18) - .onAppear { - withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1)) { - coverAppeared = true - } - } - .onChange(of: audioPlayer.slug) { _, _ in - coverAppeared = false - withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.05)) { - coverAppeared = true - } - } - - // ── Title block ──────────────────────────────────────── - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text((audioPlayer.chapterTitle.isEmpty - ? "Chapter \(audioPlayer.chapter)" - : audioPlayer.chapterTitle).strippingTrailingDate()) - .font(.title3.weight(.bold)) - .foregroundStyle(.white) - .lineLimit(2) - Text(audioPlayer.bookTitle) - .font(.subheadline) - .foregroundStyle(.white.opacity(0.55)) - .lineLimit(1) - - HStack(spacing: 8) { - if !audioPlayer.chapters.isEmpty { - Text(chapterPositionText) - .font(.caption2.monospacedDigit()) - .foregroundStyle(.white.opacity(0.3)) - } - - // Download badge - if let progress = currentDownloadProgress { - Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle") - .font(.caption2) - .foregroundStyle(.blue) - } else if isCurrentChapterDownloaded { - Label("Offline", systemImage: "checkmark.circle.fill") - .font(.caption2) - .foregroundStyle(.green) - } - } - .padding(.top, 1) - } - .frame(maxWidth: .infinity, alignment: .leading) - - // Quick download - if !isCurrentChapterDownloaded && currentDownloadProgress == nil { - Button { - Task { - try? await downloadService.download( - slug: audioPlayer.slug, - chapter: audioPlayer.chapter, - voice: audioPlayer.voice - ) - } - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } label: { - Image(systemName: "arrow.down.circle") - .font(.system(size: 24)) - .foregroundStyle(.white.opacity(0.65)) - } - .buttonStyle(.plain) - } - - // Auto-next toggle - Button { - audioPlayer.autoNext.toggle() - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } label: { - Image(systemName: audioPlayer.autoNext ? "infinity.circle.fill" : "infinity.circle") - .font(.system(size: 28)) - .foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.4)) - .contentTransition(.symbolEffect(.replace)) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 28) - .padding(.top, 22) - - // ── Seek bar ─────────────────────────────────────────── - PlayerProgressSection( - progress: audioPlayer.progress, - onSeek: { audioPlayer.seek(to: $0) } - ) - .padding(.top, 18) - .opacity(audioPlayer.status == .generating ? 0.3 : 1) - .allowsHitTesting(audioPlayer.status != .generating) - - // ── Transport controls ───────────────────────────────── - HStack(spacing: 0) { - PlayerSecondaryButton( - systemName: "gobackward.15", - size: 24, - disabled: audioPlayer.status == .generating - ) { - audioPlayer.skip(by: -15) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - - PlayerChapterSkipButton( - systemName: "backward.end.fill", - size: 30, - disabled: audioPlayer.prevChapter == nil - ) { - if let prev = audioPlayer.prevChapter { - NotificationCenter.default.post( - name: .skipToPrevChapter, object: nil, - userInfo: ["prev": prev] - ) - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } - } - - PlayerPlayPauseButton( - progress: audioPlayer.progress, - isGenerating: audioPlayer.status == .generating, - onToggle: { - audioPlayer.togglePlayPause() - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } - ) - - PlayerChapterSkipButton( - systemName: "forward.end.fill", - size: 30, - disabled: audioPlayer.nextChapter == nil, - prefetching: audioPlayer.nextPrefetchStatus == .prefetching - ) { - if let next = audioPlayer.nextChapter { - NotificationCenter.default.post( - name: .skipToNextChapter, object: nil, - userInfo: ["next": next] - ) - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } - } - - PlayerSecondaryButton( - systemName: "goforward.15", - size: 24, - disabled: audioPlayer.status == .generating - ) { - audioPlayer.skip(by: 15) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 8) - - // ── Bottom toolbar ───────────────────────────────────── - HStack(spacing: 0) { - // AirPlay - AirPlayButton() - .frame(width: 24, height: 24) - .frame(maxWidth: .infinity) - - // Speed - Menu { - ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in - Button { - audioPlayer.setSpeed(s) - } label: { - if s == audioPlayer.speed { - Label("\(s, specifier: "%.2g")×", systemImage: "checkmark") - } else { - Text("\(s, specifier: "%.2g")×") - } - } - } - } label: { - Text("\(audioPlayer.speed, specifier: "%.2g")×") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white.opacity(0.65)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - Capsule().fill(.white.opacity(0.12)) - ) - .frame(maxWidth: .infinity) - .frame(height: 44) - } - .buttonStyle(.plain) - - // Voice Selector - Button { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - showingVoiceSelector.toggle() - } - if !showingVoiceSelector { - voiceVM.stopSample() - } - } label: { - Image(systemName: showingVoiceSelector ? "mic.fill" : "mic") - .font(.system(size: 20)) - .foregroundStyle(showingVoiceSelector ? Color.amber : .white.opacity(0.65)) - .frame(maxWidth: .infinity) - .frame(height: 44) - .contentTransition(.symbolEffect(.replace)) - } - .buttonStyle(.plain) - - // Collapse - Button { onDismiss() } label: { - Image(systemName: "chevron.down") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white.opacity(0.65)) - .frame(maxWidth: .infinity) - .frame(height: 44) - } - .buttonStyle(.plain) - - // Chapters list - Button { showingChaptersList = true } label: { - Image(systemName: "list.bullet") - .font(.system(size: 20)) - .foregroundStyle(.white.opacity(0.65)) - .frame(maxWidth: .infinity) - .frame(height: 44) - } - .buttonStyle(.plain) - - // Sleep timer - Button { showingSleepTimer = true } label: { - VStack(spacing: 1) { - Image(systemName: sleepTimerIcon) - .font(.system(size: 20)) - .foregroundStyle(audioPlayer.sleepTimer != nil ? Color.amber : .white.opacity(0.65)) - .contentTransition(.symbolEffect(.replace)) - if !audioPlayer.sleepTimerRemainingText.isEmpty { - Text(audioPlayer.sleepTimerRemainingText) - .font(.system(size: 9, weight: .semibold).monospacedDigit()) - .foregroundStyle(Color.amber) - .lineLimit(1) - } - } - .frame(maxWidth: .infinity) - .frame(height: 44) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 12) - .padding(.bottom, showingVoiceSelector ? 0 : 8) - - // ── Voice selection panel (expandable) ──────────────── - if showingVoiceSelector { - VoiceSelectorPanel( - voices: voiceVM.voices, - selectedVoice: audioPlayer.voice, - playingVoice: voiceVM.playingVoice, - voiceVM: voiceVM, - onSelectVoice: { voice in - voiceVM.stopSample() - audioPlayer.voice = voice - BookVoicePreferences.shared.setVoice(voice, for: audioPlayer.slug) - Task { - var settings = authStore.settings - settings.voice = voice - await authStore.saveSettings(settings) - } - } - ) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .task { - if voiceVM.voices.isEmpty { - await voiceVM.loadVoices() - } - } - } - } - .ignoresSafeArea(edges: .bottom) - } - } - .ignoresSafeArea() - .sheet(isPresented: $showingChaptersList) { - ChaptersListSheet( - chapters: audioPlayer.chapters, - currentChapter: audioPlayer.chapter, - onChapterSelect: { selectedChapter in - showingChaptersList = false - guard selectedChapter != audioPlayer.chapter else { return } - - let chapterTitle = audioPlayer.chapters - .first(where: { $0.number == selectedChapter })?.title ?? "" - let nextChapter = audioPlayer.chapters - .filter({ $0.number > selectedChapter }) - .min(by: { $0.number < $1.number })?.number - let prevChapter: Int? = selectedChapter > 1 ? selectedChapter - 1 : nil - - audioPlayer.load( - slug: audioPlayer.slug, - chapter: selectedChapter, - chapterTitle: chapterTitle, - bookTitle: audioPlayer.bookTitle, - coverURL: audioPlayer.coverURL, - voice: audioPlayer.voice, - speed: audioPlayer.speed, - chapters: audioPlayer.chapters, - nextChapter: nextChapter, - prevChapter: prevChapter - ) - } - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - .sheet(isPresented: $showingSleepTimer) { - SleepTimerSheet(audioPlayer: audioPlayer) - .presentationDetents([.height(500)]) - .presentationDragIndicator(.visible) - } - } - - // MARK: - Helpers - - private var chapterPositionText: String { - let total = audioPlayer.chapters.count - guard total > 0 else { return "" } - let sorted = audioPlayer.chapters.sorted(by: { $0.number < $1.number }) - let idx = (sorted.firstIndex(where: { $0.number == audioPlayer.chapter }) ?? 0) + 1 - return "Chapter \(idx) of \(total)" - } - - private var voiceName: String { - let components = audioPlayer.voice.split(separator: "_") - if components.count > 1 { return String(components[1]).capitalized } - return audioPlayer.voice.capitalized - } - - private var sleepTimerIcon: String { - audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz" - } -} - -// MARK: - Small secondary transport button (±15 s skips) - -private struct PlayerSecondaryButton: View { - let systemName: String - let size: CGFloat - let disabled: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: systemName) - .font(.system(size: size, weight: .regular)) - .foregroundStyle(.white.opacity(disabled ? 0.3 : 0.85)) - .frame(maxWidth: .infinity) - .frame(height: 64) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(disabled) - } -} - -// MARK: - Medium chapter-skip button (prev / next chapter) - -private struct PlayerChapterSkipButton: View { - let systemName: String - let size: CGFloat - let disabled: Bool - var prefetching: Bool = false - let action: () -> Void - - var body: some View { - Button(action: action) { - ZStack { - Image(systemName: systemName) - .font(.system(size: size, weight: .regular)) - .foregroundStyle(.white.opacity(disabled ? 0.3 : 0.9)) - - if prefetching { - VStack { - Spacer() - HStack { - Spacer() - ProgressView() - .scaleEffect(0.55) - .tint(.amber) - .padding(3) - .background(Circle().fill(.black.opacity(0.6))) - } - } - } - } - .frame(maxWidth: .infinity) - .frame(height: 64) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(disabled) - .opacity(disabled ? 0.4 : 1.0) - } -} - -// MARK: - AirPlay Button - -struct AirPlayButton: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> UIViewController { - let vc = UIViewController() - vc.view.backgroundColor = .clear - - let picker = AVRoutePickerView() - picker.tintColor = UIColor.white.withAlphaComponent(0.7) - picker.activeTintColor = UIColor(named: "AccentColor") ?? UIColor.systemOrange - picker.prioritizesVideoDevices = false - picker.translatesAutoresizingMaskIntoConstraints = false - - vc.view.addSubview(picker) - NSLayoutConstraint.activate([ - picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor), - picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor), - picker.topAnchor.constraint(equalTo: vc.view.topAnchor), - picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor), - ]) - return vc - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} -} - -// MARK: - Sleep Timer Sheet - -struct SleepTimerSheet: View { - @ObservedObject var audioPlayer: AudioPlayerService - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 20) { - // ── Off card ────────────────────────────────────────── - TimerCard { - TimerOptionRow( - label: "Off", - systemImage: "moon.zzz", - isSelected: audioPlayer.sleepTimer == nil - ) { - audioPlayer.setSleepTimer(nil) - dismiss() - } - } - - // ── Chapter-based ───────────────────────────────────── - VStack(spacing: 0) { - SectionLabel("Chapter-based") - TimerCard { - ForEach([1, 2, 3, 4], id: \.self) { count in - let isSelected: Bool = { - if case .chapters(let c) = audioPlayer.sleepTimer { return c == count } - return false - }() - TimerOptionRow( - label: "\(count) \(count == 1 ? "chapter" : "chapters")", - systemImage: "book", - isSelected: isSelected - ) { - audioPlayer.setSleepTimer(.chapters(count)) - dismiss() - } - if count < 4 { Divider().padding(.leading, 56) } - } - } - } - - // ── Time-based ──────────────────────────────────────── - VStack(spacing: 0) { - SectionLabel("Time-based") - TimerCard { - ForEach([20, 40, 60, 120], id: \.self) { minutes in - let isSelected: Bool = { - if case .minutes(let m) = audioPlayer.sleepTimer { return m == minutes } - return false - }() - TimerOptionRow( - label: formatTimerOption(minutes), - systemImage: "clock", - isSelected: isSelected - ) { - audioPlayer.setSleepTimer(.minutes(minutes)) - dismiss() - } - if minutes != 120 { Divider().padding(.leading, 56) } - } - } - } - } - .padding(20) - } - .background(Color(.systemGroupedBackground)) - .navigationTitle("Sleep Timer") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - .fontWeight(.semibold) - } - } - } - } - - private func formatTimerOption(_ minutes: Int) -> String { - if minutes < 60 { return "\(minutes) mins" } - let hours = minutes / 60 - return "\(hours) \(hours == 1 ? "hour" : "hours")" - } -} - -// MARK: - Sleep timer helper views - -private struct TimerCard<Content: View>: View { - @ViewBuilder let content: Content - - var body: some View { - VStack(spacing: 0) { - content - } - .background(Color(.secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } -} - -private struct SectionLabel: View { - let text: String - init(_ text: String) { self.text = text } - - var body: some View { - Text(text.uppercased()) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 4) - .padding(.bottom, 8) - } -} - -private struct TimerOptionRow: View { - let label: String - let systemImage: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - action() - }) { - HStack(spacing: 14) { - Image(systemName: systemImage) - .font(.system(size: 16)) - .foregroundStyle(isSelected ? Color.amber : .secondary) - .frame(width: 28) - - Text(label) - .font(.body) - .foregroundStyle(.primary) - - Spacer() - - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.amber) - .transition(.scale.combined(with: .opacity)) - } - } - .padding(.horizontal, 18) - .padding(.vertical, 14) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) - } -} - -// MARK: - Chapters List Sheet -// Apple Books-style: chapters grouped into blocks of 100 with a sticky jump -// bar along the right edge. A search bar filters by number or title. - -struct ChaptersListSheet: View { - let chapters: [ChapterIndexBrief] - let currentChapter: Int - let onChapterSelect: (Int) -> Void - - @Environment(\.dismiss) private var dismiss - @EnvironmentObject var audioPlayer: AudioPlayerService - @EnvironmentObject var downloadService: AudioDownloadService - - @State private var searchText: String = "" - @State private var filterOfflineOnly = false - @State private var showingDownloadAll = false - /// The block label the jump bar is currently scrolling to (e.g. "1–100"). - @State private var activeBlock: String? = nil - - // MARK: Derived data - - /// Count of downloaded chapters for this book - private var downloadedCount: Int { - chapters.filter { ch in - downloadService.isDownloaded( - slug: audioPlayer.slug, - chapter: ch.number, - voice: audioPlayer.voice - ) - }.count - } - - /// Count of downloading chapters - private var downloadingCount: Int { - downloadService.downloads.filter { key, _ in - key.hasPrefix("\(audioPlayer.slug)::") - }.count - } - - /// Chapters matching the current search query (or all chapters if empty). - private var filtered: [ChapterIndexBrief] { - var result = chapters - - // Apply offline filter - if filterOfflineOnly { - result = result.filter { ch in - downloadService.isDownloaded( - slug: audioPlayer.slug, - chapter: ch.number, - voice: audioPlayer.voice - ) - } - } - - // Apply search filter - if !searchText.isEmpty { - let q = searchText.lowercased() - result = result.filter { - "\($0.number)".contains(q) || $0.title.lowercased().contains(q) - } - } - - return result - } - - /// Chapters grouped into blocks of 100: ["1–100": [...], "101–200": [...], …] - /// When the user is searching or filtering we use a single "Results" group so the jump - /// bar hides and the flat list is shown directly. - private var groups: [(label: String, chapters: [ChapterIndexBrief])] { - guard searchText.isEmpty && !filterOfflineOnly else { - return filtered.isEmpty ? [] : [("Results", filtered)] - } - guard !filtered.isEmpty else { return [] } - let blockSize = 100 - let minN = filtered.map(\.number).min() ?? 1 - let maxN = filtered.map(\.number).max() ?? 1 - // Round down to the nearest block boundary for the first block start. - let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 - var result: [(label: String, chapters: [ChapterIndexBrief])] = [] - var blockStart = firstBlock - while blockStart <= maxN { - let blockEnd = blockStart + blockSize - 1 - let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } - if !slice.isEmpty { - result.append(("\(blockStart)–\(blockEnd)", slice)) - } - blockStart += blockSize - } - return result - } - - /// Jump-bar labels (shown only when not searching/filtering). - private var jumpLabels: [String] { groups.map(\.label) } - - // MARK: Body - - var body: some View { - NavigationStack { - ZStack(alignment: .trailing) { - // ── Main chapter list ────────────────────────────────────── - List { - // Download summary section - if downloadedCount > 0 || downloadingCount > 0 { - Section { - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Offline Downloads") - .font(.headline) - Text("\(downloadedCount) of \(chapters.count) chapters") - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - - Button { - showingDownloadAll = true - } label: { - Label("Manage", systemImage: "arrow.down.circle") - .font(.subheadline.weight(.semibold)) - } - .buttonStyle(.bordered) - .tint(.blue) - } - - if downloadingCount > 0 { - HStack(spacing: 8) { - ProgressView() - .scaleEffect(0.8) - Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - // Quick filter toggle - Toggle("Show offline only", isOn: $filterOfflineOnly) - .font(.subheadline) - .tint(.amber) - } - .padding(.vertical, 8) - } - } - - ForEach(groups, id: \.label) { group in - // Section header — shows block range (e.g. "1–100") - Section { - ForEach(group.chapters, id: \.number) { ch in - ChapterRow( - chapter: ch, - isCurrent: ch.number == currentChapter, - onSelect: { onChapterSelect(ch.number) } - ) - .id(group.label) // anchor for jump-bar scrollTo - } - } header: { - if searchText.isEmpty && !filterOfflineOnly { - Text(group.label) - .font(.caption.bold()) - .foregroundStyle(.secondary) - .id("header_\(group.label)") - } - } - } - } - .listStyle(.plain) - .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Chapter number or title") - .scrollPosition(id: $activeBlock, anchor: .top) - - // ── Right-edge jump bar (hidden while searching/filtering) ─────────── - if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 { - JumpBar(labels: jumpLabels, currentChapter: currentChapter, groups: groups) { label in - withAnimation { activeBlock = label } - } - .padding(.trailing, 4) - } - } - .navigationTitle("Chapters (\(filtered.count))") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - .fontWeight(.semibold) - } - } - .sheet(isPresented: $showingDownloadAll) { - DownloadManagementSheet( - chapters: chapters, - slug: audioPlayer.slug, - voice: Binding( - get: { audioPlayer.voice }, - set: { audioPlayer.voice = $0 } - ) - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - // Scroll to the currently playing chapter's block on first appear. - .onAppear { - if let block = groups.first(where: { g in - g.chapters.contains(where: { $0.number == currentChapter }) - }) { - activeBlock = block.label - } - } - } - } -} - -// MARK: - Individual chapter row with download status - -private struct ChapterRow: View { - let chapter: ChapterIndexBrief - let isCurrent: Bool - let onSelect: () -> Void - - @EnvironmentObject var audioPlayer: AudioPlayerService - @EnvironmentObject var downloadService: AudioDownloadService - - private var isDownloaded: Bool { - downloadService.isDownloaded( - slug: audioPlayer.slug, - chapter: chapter.number, - voice: audioPlayer.voice - ) - } - - private var downloadProgress: DownloadProgress? { - let key = downloadService.makeKey(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) - return downloadService.downloads[key] - } - - private var isDownloading: Bool { - downloadProgress != nil - } - - var body: some View { - Button(action: onSelect) { - HStack(spacing: 14) { - // Number badge with download indicator - ZStack { - Text("\(chapter.number)") - .font(.caption.bold()) - .foregroundStyle(isCurrent ? .white : .secondary) - .frame(width: 40, height: 40) - .background( - Circle().fill(isCurrent ? Color.amber : Color(.systemGray5)) - ) - - // Download progress ring - if isDownloading, let progress = downloadProgress { - Circle() - .trim(from: 0, to: progress.progress) - .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .frame(width: 44, height: 44) - .animation(.easeInOut(duration: 0.3), value: progress.progress) - } - } - - // Title + status subtitle - VStack(alignment: .leading, spacing: 3) { - Text(chapter.title.strippingTrailingDate()) - .font(.subheadline.weight(isCurrent ? .semibold : .regular)) - .foregroundStyle(.primary) - .lineLimit(2) - - HStack(spacing: 8) { - if isCurrent { - Label("Now Playing", systemImage: "waveform") - .font(.caption2) - .foregroundStyle(.amber) - .symbolEffect(.variableColor.cumulative, isActive: isCurrent) - } - - if isDownloading, let progress = downloadProgress { - Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle") - .font(.caption2) - .foregroundStyle(.blue) - } else if isDownloaded { - Label("Downloaded", systemImage: "checkmark.circle.fill") - .font(.caption2) - .foregroundStyle(.green) - } - } - } - - Spacer() - - // Right side indicator - if isCurrent { - Image(systemName: "waveform") - .font(.caption.bold()) - .foregroundStyle(.amber) - .symbolEffect(.variableColor.cumulative, isActive: isCurrent) - } else if isDownloaded { - Image(systemName: "arrow.down.circle.fill") - .font(.body) - .foregroundStyle(.green) - } else if isDownloading { - ProgressView() - .scaleEffect(0.8) - } - } - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - // Download/Delete action - if isDownloaded { - Button(role: .destructive) { - Task { - try? downloadService.deleteDownload( - slug: audioPlayer.slug, - chapter: chapter.number, - voice: audioPlayer.voice - ) - } - } label: { - Label("Delete", systemImage: "trash") - } - } else if isDownloading { - Button(role: .destructive) { - downloadService.cancelDownload( - slug: audioPlayer.slug, - chapter: chapter.number, - voice: audioPlayer.voice - ) - } label: { - Label("Cancel", systemImage: "xmark") - } - } else { - Button { - Task { - try? await downloadService.download( - slug: audioPlayer.slug, - chapter: chapter.number, - voice: audioPlayer.voice - ) - } - } label: { - Label("Download", systemImage: "arrow.down.circle") - } - .tint(.blue) - } - } - } -} - -// MARK: - Right-edge jump bar -// A thin vertical strip on the right side of the sheet with block labels. -// Tapping or dragging a label jumps the list to that block instantly — -// exactly like the Contacts A–Z bar or Apple Books chapter scrubber. - -private struct JumpBar: View { - let labels: [String] - let currentChapter: Int - let groups: [(label: String, chapters: [ChapterIndexBrief])] - let onSelect: (String) -> Void - - @State private var isDragging = false - - /// Short display label for each block: "1–100" → "1" etc. - private func shortLabel(_ full: String) -> String { - full.components(separatedBy: "–").first ?? full - } - - /// Which block contains the currently playing chapter. - private var currentBlock: String? { - groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label - } - - var body: some View { - VStack(spacing: 0) { - ForEach(labels, id: \.self) { label in - let isCurrent = label == currentBlock - Text(shortLabel(label)) - .font(.system(size: 10, weight: isCurrent ? .bold : .regular)) - .foregroundStyle(isCurrent ? Color.amber : Color.secondary) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - .onTapGesture { onSelect(label) } - } - } - .padding(.vertical, 6) - .background( - Capsule() - .fill(.ultraThinMaterial) - .shadow(color: .black.opacity(0.15), radius: 4) - ) - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .local) - .onChanged { value in - isDragging = true - let itemHeight: CGFloat = 28 - let index = Int(value.location.y / itemHeight) - let clamped = max(0, min(labels.count - 1, index)) - onSelect(labels[clamped]) - } - .onEnded { _ in isDragging = false } - ) - .animation(.easeInOut(duration: 0.15), value: isDragging) - } -} - -// MARK: - Custom seek slider -// A thicker, rounded-thumb slider that matches the amber design language. - -struct PlayerSlider: View { - @Binding var value: Double - let range: ClosedRange<Double> - - @State private var isDragging = false - @State private var didFireHaptic = false - - var body: some View { - GeometryReader { geo in - let width = geo.size.width - let fraction = (value - range.lowerBound) / (range.upperBound - range.lowerBound) - let clampedFraction = max(0, min(1, fraction)) - let filled = width * clampedFraction - let thumbSize: CGFloat = isDragging ? 26 : 20 - let trackHeight: CGFloat = isDragging ? 5 : 4 - - ZStack(alignment: .leading) { - // Track background - Capsule() - .fill(Color.white.opacity(0.2)) - .frame(height: trackHeight) - - // Filled portion — amber gradient - Capsule() - .fill( - LinearGradient( - colors: [Color.amber.opacity(0.9), Color.amber], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: max(filled, thumbSize / 2), height: trackHeight) - - // Thumb - Circle() - .fill(Color.white) - .frame(width: thumbSize, height: thumbSize) - .shadow(color: .black.opacity(0.3), radius: isDragging ? 6 : 3, y: isDragging ? 2 : 1) - .offset(x: max(0, filled - thumbSize / 2)) - .animation(.spring(response: 0.2, dampingFraction: 0.65), value: isDragging) - } - .frame(height: 36) // generous touch target - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { drag in - if !isDragging { - isDragging = true - if !didFireHaptic { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - didFireHaptic = true - } - } - let raw = drag.location.x / width - let clamped = max(0, min(1, raw)) - value = range.lowerBound + clamped * (range.upperBound - range.lowerBound) - } - .onEnded { _ in - isDragging = false - didFireHaptic = false - } - ) - } - .frame(height: 36) - } -} - -// MARK: - Isolated mini-player progress bar background - -private struct MiniPlayerProgressBar: View { - @ObservedObject var progress: PlaybackProgress - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 40) - .fill(Color.white.opacity(0.2)) - RoundedRectangle(cornerRadius: 6) - .fill(Color.amber.opacity(0.3)) - .frame(width: max(0, geo.size.width * fraction)) - } - .clipShape(RoundedRectangle(cornerRadius: 40)) - } - } - - private var fraction: CGFloat { - guard progress.duration > 0 else { return 0 } - return CGFloat(progress.currentTime / progress.duration) - } -} - -// MARK: - Isolated progress section (seek bar + timestamps) -// Observes PlaybackProgress directly so the 0.5-second time ticks only -// invalidate this small view — not the menus or controls around it. - -private struct PlayerProgressSection: View { - @ObservedObject var progress: PlaybackProgress - let onSeek: (Double) -> Void - - var body: some View { - VStack(spacing: 4) { - PlayerSlider( - value: Binding( - get: { progress.currentTime }, - set: { onSeek($0) } - ), - range: 0...max(progress.duration, 1) - ) - HStack { - Text(formatTime(progress.currentTime)) - Spacer() - Text("-" + formatTime(progress.duration - progress.currentTime)) - } - .font(.caption.monospacedDigit()) - .foregroundStyle(.white.opacity(0.5)) - } - .padding(.horizontal, 28) - } - - private func formatTime(_ seconds: Double) -> String { - guard seconds.isFinite, seconds >= 0 else { return "0:00" } - let s = Int(seconds) - return "\(s / 60):\(String(format: "%02d", s % 60))" - } -} - -// MARK: - Isolated play/pause button -// Observes PlaybackProgress so isPlaying changes only re-render this button. - -private struct PlayerPlayPauseButton: View { - @ObservedObject var progress: PlaybackProgress - let isGenerating: Bool - let onToggle: () -> Void - - @State private var isPressed = false - - var body: some View { - Button { - onToggle() - } label: { - ZStack { - // Outer glow ring (visible while playing) - Circle() - .fill(Color.amber.opacity(progress.isPlaying ? 0.18 : 0)) - .frame(width: 80, height: 80) - .animation(.easeInOut(duration: 0.35), value: progress.isPlaying) - - // Main fill circle - Circle() - .fill( - LinearGradient( - colors: [Color.amber.opacity(0.9), Color.amber.opacity(0.65)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 64, height: 64) - .shadow(color: Color.amber.opacity(0.45), radius: 12, y: 4) - .scaleEffect(isPressed ? 0.92 : 1.0) - .animation(.spring(response: 0.2, dampingFraction: 0.6), value: isPressed) - - if isGenerating { - ProgressView() - .tint(.white) - .scaleEffect(1.2) - } else { - Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 28, weight: .bold)) - .foregroundStyle(.white) - .offset(x: progress.isPlaying ? 0 : 2) - .contentTransition(.symbolEffect(.replace.downUp)) - } - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.plain) - .disabled(isGenerating) - ._onButtonGesture(pressing: { isPressed = $0 }, perform: {}) - } -} - -// MARK: - Isolated mini-player play/pause button - -private struct MiniPlayerPlayPauseButton: View { - @ObservedObject var progress: PlaybackProgress - let onToggle: () -> Void - - var body: some View { - Button { onToggle() } label: { - Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -// MARK: - Download Management Sheet - -struct DownloadManagementSheet: View { - let chapters: [ChapterIndexBrief] - let slug: String - @Binding var voice: String - - @Environment(\.dismiss) private var dismiss - @EnvironmentObject var downloadService: AudioDownloadService - @EnvironmentObject var authStore: AuthStore - @State private var showingDeleteAll = false - @State private var isDownloadingAll = false - @State private var showingVoiceSelector = false - @State private var showingRangeSelector = false - @StateObject private var voiceVM = VoiceSelectionViewModel() - - // Range selection state - @State private var rangeStart: Int = 1 - @State private var rangeEnd: Int = 1 - - private var downloadedChapters: [ChapterIndexBrief] { - chapters.filter { ch in - downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: voice) - } - } - - private var notDownloadedChapters: [ChapterIndexBrief] { - chapters.filter { ch in - !downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: voice) - } - } - - var body: some View { - NavigationStack { - List { - // Voice info section - Section { - Button { - showingVoiceSelector = true - } label: { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Download Voice") - .font(.subheadline) - .foregroundStyle(.secondary) - HStack(spacing: 6) { - Text(voiceLabel(voice)) - .font(.body.weight(.semibold)) - .foregroundStyle(.primary) - if BookVoicePreferences.shared.hasOverride(for: slug) { - Text("(Custom)") - .font(.caption) - .foregroundStyle(.blue) - } - } - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 4) - } - .buttonStyle(.plain) - } footer: { - Text("Tap to change voice. All downloads will use the selected voice for this book.") - .font(.caption) - } - - Section { - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("\(downloadedChapters.count) Downloaded") - .font(.title2.bold()) - Text("\(notDownloadedChapters.count) remaining") - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - // Circular completion indicator - ZStack { - Circle() - .stroke(Color(.systemGray5), lineWidth: 4) - Circle() - .trim(from: 0, to: chapters.isEmpty ? 0 : CGFloat(downloadedChapters.count) / CGFloat(chapters.count)) - .stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.4), value: downloadedChapters.count) - Text("\(chapters.isEmpty ? 0 : Int(Double(downloadedChapters.count) / Double(chapters.count) * 100))%") - .font(.caption2.bold()) - .foregroundStyle(.secondary) - } - .frame(width: 44, height: 44) - } - - HStack(spacing: 10) { - if notDownloadedChapters.count > 0 { - Button { - showingRangeSelector = true - } label: { - Label("Range", systemImage: "list.number") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .tint(.blue) - - Button { - downloadAllRemaining() - } label: { - HStack(spacing: 6) { - if isDownloadingAll { - ProgressView().scaleEffect(0.75) - } else { - Image(systemName: "arrow.down.circle.fill") - } - Text("All (\(notDownloadedChapters.count))") - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(.blue) - .disabled(isDownloadingAll) - } - - if downloadedChapters.count > 0 { - Button { - showingDeleteAll = true - } label: { - Label("Delete All", systemImage: "trash") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .tint(.red) - } - } - } - .padding(.vertical, 8) - } - - if downloadedChapters.count > 0 { - Section { - ForEach(downloadedChapters, id: \.number) { chapter in - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Chapter \(chapter.number)") - .font(.subheadline.weight(.semibold)) - Text(chapter.title.strippingTrailingDate()) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } - } - .onDelete { indexSet in - deleteChapters(at: indexSet, from: downloadedChapters) - } - } header: { - Text("Downloaded (\(downloadedChapters.count))") - } - } - } - .navigationTitle("Manage Downloads") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - .fontWeight(.semibold) - } - } - .confirmationDialog( - "Delete all downloads?", - isPresented: $showingDeleteAll, - titleVisibility: .visible - ) { - Button("Delete All Downloads", role: .destructive) { - deleteAllDownloads() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This will delete \(downloadedChapters.count) downloaded chapters. You can re-download them later.") - } - .sheet(isPresented: $showingVoiceSelector) { - VoiceSelectorSheet( - selectedVoice: voice, - slug: slug, - voiceVM: voiceVM, - onSelectVoice: { newVoice in - voice = newVoice - // Save per-book voice override - BookVoicePreferences.shared.setVoice(newVoice, for: slug) - showingVoiceSelector = false - } - ) - } - .sheet(isPresented: $showingRangeSelector) { - RangeDownloadSheet( - chapters: notDownloadedChapters, - slug: slug, - voice: voice, - onDownload: { start, end in - downloadRange(from: start, to: end) - showingRangeSelector = false - } - ) - .presentationDetents([.medium]) - } - } - } - - private func downloadRange(from start: Int, to end: Int) { - isDownloadingAll = true - Task { - let chaptersToDownload = notDownloadedChapters.filter { ch in - ch.number >= start && ch.number <= end - } - for chapter in chaptersToDownload { - try? await downloadService.download(slug: slug, chapter: chapter.number, voice: voice) - try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s - } - await MainActor.run { - isDownloadingAll = false - } - } - } - - private func downloadAllRemaining() { - isDownloadingAll = true - Task { - for chapter in notDownloadedChapters { - try? await downloadService.download(slug: slug, chapter: chapter.number, voice: voice) - // Small delay to avoid overwhelming the API - try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s - } - await MainActor.run { - isDownloadingAll = false - } - } - } - - private func deleteChapters(at indexSet: IndexSet, from chapters: [ChapterIndexBrief]) { - for index in indexSet { - let chapter = chapters[index] - try? downloadService.deleteDownload(slug: slug, chapter: chapter.number, voice: voice) - } - } - - private func deleteAllDownloads() { - for chapter in downloadedChapters { - try? downloadService.deleteDownload(slug: slug, chapter: chapter.number, voice: voice) - } - } - - // Voice label formatting (matches VoiceSelectionViewModel) - private func voiceLabel(_ voice: String) -> String { - let parts = voice.split(separator: "_") - guard parts.count >= 2 else { return voice } - - let prefix = String(parts[0]) - let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") - - // Parse prefix for language/gender info - var info = "" - switch prefix { - case "af": info = "US F" - case "am": info = "US M" - case "bf": info = "UK F" - case "bm": info = "UK M" - default: info = prefix.uppercased() - } - - return "\(name) (\(info))" - } -} - -// MARK: - Voice Selector Panel (inline, expandable) - -private struct VoiceSelectorPanel: View { - let voices: [String] - let selectedVoice: String - let playingVoice: String? - let voiceVM: VoiceSelectionViewModel - let onSelectVoice: (String) -> Void - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Text("Choose Voice") - .font(.caption.weight(.semibold)) - .foregroundStyle(.white.opacity(0.45)) - .textCase(.uppercase) - .tracking(0.8) - Spacer() - } - .padding(.horizontal, 18) - .padding(.top, 10) - .padding(.bottom, 6) - - // Voice list (scrollable) - ScrollView { - VStack(spacing: 0) { - ForEach(voices, id: \.self) { voice in - VoiceOptionRow( - voice: voice, - isSelected: selectedVoice == voice, - isPlaying: playingVoice == voice, - voiceLabel: voiceVM.voiceLabel(voice), - voiceId: voiceVM.voiceId(voice), - onSelect: { onSelectVoice(voice) }, - onPlaySample: { - Task { await voiceVM.playSample(voice) } - } - ) - - if voice != voices.last { - Divider() - .overlay(Color.white.opacity(0.08)) - .padding(.leading, 52) - } - } - } - } - .frame(maxHeight: 220) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) - .padding(.horizontal, 16) - - // Footer note - Text("New voice applies on next chapter") - .font(.caption2) - .foregroundStyle(.white.opacity(0.35)) - .padding(.top, 7) - .padding(.bottom, 10) - } - .background(.ultraThinMaterial) - } -} - -// MARK: - Voice Option Row (for inline panel) - -private struct VoiceOptionRow: View { - let voice: String - let isSelected: Bool - let isPlaying: Bool - let voiceLabel: String - let voiceId: String - let onSelect: () -> Void - let onPlaySample: () -> Void - - var body: some View { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - onSelect() - } label: { - HStack(spacing: 12) { - // Selection indicator with spring bounce - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.system(size: 18)) - .foregroundStyle(isSelected ? Color.amber : .white.opacity(0.25)) - .scaleEffect(isSelected ? 1.1 : 1.0) - .animation(.spring(response: 0.3, dampingFraction: 0.55), value: isSelected) - .frame(width: 24) - - // Voice info - VStack(alignment: .leading, spacing: 2) { - Text(voiceLabel) - .font(.subheadline) - .foregroundStyle(isSelected ? Color.amber : .white) - .fontWeight(isSelected ? .semibold : .regular) - .animation(.easeInOut(duration: 0.2), value: isSelected) - - Text(voiceId) - .font(.caption2) - .fontDesign(.monospaced) - .foregroundStyle(.white.opacity(0.4)) - } - - Spacer() - - // Play/Stop sample button - Button { - onPlaySample() - } label: { - Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") - .font(.system(size: 24)) - .foregroundStyle(isPlaying ? Color.red : Color.amber.opacity(0.8)) - .contentTransition(.symbolEffect(.replace.downUp)) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .background(isSelected ? Color.amber.opacity(0.08) : Color.clear) - .animation(.easeInOut(duration: 0.2), value: isSelected) - } -} - -// MARK: - Voice Selector Sheet (for download management) - -private struct VoiceSelectorSheet: View { - let selectedVoice: String - let slug: String - let voiceVM: VoiceSelectionViewModel - let onSelectVoice: (String) -> Void - - @Environment(\.dismiss) private var dismiss - @EnvironmentObject var authStore: AuthStore - - var body: some View { - NavigationStack { - List { - Section { - ForEach(voiceVM.voices, id: \.self) { voice in - Button { - onSelectVoice(voice) - } label: { - HStack(spacing: 12) { - // Checkmark for selected voice - Image(systemName: "checkmark") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.blue) - .opacity(voice == selectedVoice ? 1 : 0) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(voiceVM.voiceLabel(voice)) - .font(.body) - .foregroundStyle(.primary) - Text(voice) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } - - Spacer() - - // Play/stop button - Button { - Task { - await voiceVM.playSample(voice) - } - } label: { - Image(systemName: voiceVM.playingVoice == voice ? "stop.circle.fill" : "play.circle") - .font(.system(size: 24)) - .foregroundStyle(voiceVM.playingVoice == voice ? .red : .blue) - } - .buttonStyle(.plain) - } - .padding(.vertical, 4) - } - .buttonStyle(.plain) - } - } header: { - Text("Select Voice") - } footer: { - if BookVoicePreferences.shared.hasOverride(for: slug) { - Button("Reset to Global Voice") { - BookVoicePreferences.shared.removeVoice(for: slug) - onSelectVoice(authStore.settings.voice) - } - .font(.subheadline) - } - } - } - .navigationTitle("Download Voice") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { - voiceVM.stopSample() - dismiss() - } - .fontWeight(.semibold) - } - } - .task { - if voiceVM.voices.isEmpty { - await voiceVM.loadVoices() - } - } - } - } -} - -// MARK: - Range Download Sheet - -private struct RangeDownloadSheet: View { - let chapters: [ChapterIndexBrief] - let slug: String - let voice: String - let onDownload: (Int, Int) -> Void - - @Environment(\.dismiss) private var dismiss - @State private var startChapter: Int - @State private var endChapter: Int - - init(chapters: [ChapterIndexBrief], slug: String, voice: String, onDownload: @escaping (Int, Int) -> Void) { - self.chapters = chapters - self.slug = slug - self.voice = voice - self.onDownload = onDownload - - let firstChapter = chapters.first?.number ?? 1 - let lastChapter = chapters.last?.number ?? 1 - _startChapter = State(initialValue: firstChapter) - _endChapter = State(initialValue: min(firstChapter + 9, lastChapter)) - } - - private var chapterRange: [Int] { - guard let first = chapters.first?.number, - let last = chapters.last?.number else { return [] } - return Array(first...last) - } - - private var selectedCount: Int { - guard startChapter <= endChapter else { return 0 } - return endChapter - startChapter + 1 - } - - var body: some View { - NavigationStack { - Form { - Section { - Picker("Start Chapter", selection: $startChapter) { - ForEach(chapterRange, id: \.self) { num in - Text("Chapter \(num)").tag(num) - } - } - - Picker("End Chapter", selection: $endChapter) { - ForEach(chapterRange.filter { $0 >= startChapter }, id: \.self) { num in - Text("Chapter \(num)").tag(num) - } - } - } header: { - Text("Select Range") - } footer: { - Text("\(selectedCount) chapters will be downloaded") - } - - Section { - Button { - onDownload(startChapter, endChapter) - dismiss() - } label: { - HStack { - Spacer() - Image(systemName: "arrow.down.circle.fill") - Text("Download \(selectedCount) Chapters") - Spacer() - } - } - .disabled(selectedCount == 0) - } - } - .navigationTitle("Download Range") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Cancel") { dismiss() } - } - } - } - } -} diff --git a/ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift b/ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift deleted file mode 100644 index 79aff03..0000000 --- a/ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift +++ /dev/null @@ -1,341 +0,0 @@ -import SwiftUI -import PhotosUI -import Kingfisher - -// MARK: - AvatarNavButton -// Drop this into any NavigationStack toolbar to get an avatar button that opens the account sheet. -// -// Usage: -// .toolbar { AvatarToolbarButton() } - -struct AvatarToolbarButton: View { - @EnvironmentObject private var authStore: AuthStore - @State private var showAccount = false - - var body: some View { - Button { - showAccount = true - } label: { - AvatarThumb(urlString: authStore.user?.avatarURL, size: 30) - } - .sheet(isPresented: $showAccount) { - AccountMenuSheet() - } - } -} - -// MARK: - AvatarThumb -// Reusable small circular avatar (used by both toolbar button and the sheet header). - -struct AvatarThumb: View { - let urlString: String? - let size: CGFloat - - var body: some View { - Group { - if let str = urlString, let url = URL(string: str) { - KFImage(url) - .placeholder { placeholderCircle } - .resizable() - .scaledToFill() - } else { - placeholderCircle - } - } - .frame(width: size, height: size) - .clipShape(Circle()) - .overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5)) - } - - private var placeholderCircle: some View { - Circle() - .fill(Color(.systemGray4)) - .overlay( - Image(systemName: "person.fill") - .font(.system(size: size * 0.5)) - .foregroundStyle(Color.amber) - ) - } -} - -// MARK: - AccountMenuSheet - -struct AccountMenuSheet: View { - @EnvironmentObject private var authStore: AuthStore - @StateObject private var vm = ProfileViewModel() - @Environment(\.dismiss) private var dismiss - - @State private var showChangePassword = false - - // Avatar upload - @State private var photoPickerItem: PhotosPickerItem? - @State private var pendingCropImage: UIImage? - @State private var avatarURL: String? = nil - @State private var avatarUploading = false - @State private var avatarError: String? - - var body: some View { - NavigationStack { - List { - // ── User header ──────────────────────────────────────────── - Section { - HStack(spacing: 16) { - avatarPicker - VStack(alignment: .leading, spacing: 3) { - Text(authStore.user?.username ?? "") - .font(.headline) - Text(authStore.user?.role.capitalized ?? "") - .font(.caption) - .foregroundStyle(.secondary) - if let err = avatarError { - Text(err) - .font(.caption2) - .foregroundStyle(.red) - } - } - } - .padding(.vertical, 6) - } - - // ── Reading settings ─────────────────────────────────────── - Section("Reading Settings") { - voicePicker - speedSlider - Toggle("Auto-advance chapter", isOn: Binding( - get: { authStore.settings.autoNext }, - set: { newVal in - Task { - var s = authStore.settings - s.autoNext = newVal - await authStore.saveSettings(s) - } - } - )) - .tint(.amber) - } - - // ── Sessions ─────────────────────────────────────────────── - Section("Active Sessions") { - if vm.sessionsLoading { - ProgressView() - } else { - ForEach(vm.sessions) { session in - SessionRow(session: session) { - Task { await vm.revokeSession(id: session.id) } - } - } - } - } - - // ── Account ──────────────────────────────────────────────── - Section("Account") { - Button("Change Password") { showChangePassword = true } - Button("Sign Out", role: .destructive) { - dismiss() - Task { await authStore.logout() } - } - } - } - .navigationTitle("Account") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - .fontWeight(.semibold) - } - } - .task { await vm.loadSessions() } - .sheet(isPresented: $showChangePassword) { - ChangePasswordView() - } - .sheet(item: Binding( - get: { pendingCropImage.map { CropImageItem(image: $0) } }, - set: { if $0 == nil { pendingCropImage = nil } } - )) { item in - AvatarCropView(image: item.image) { croppedData in - pendingCropImage = nil - Task { await uploadCroppedData(croppedData) } - } onCancel: { - pendingCropImage = nil - } - } - .errorAlert($vm.error) - } - .presentationDetents([.large]) - .presentationDragIndicator(.visible) - } - - // MARK: - Avatar upload - - private func loadImageForCrop(_ item: PhotosPickerItem) async { - guard let data = try? await item.loadTransferable(type: Data.self), - let image = UIImage(data: data) else { - avatarError = "Could not read image" - return - } - pendingCropImage = image - } - - private func uploadCroppedData(_ data: Data) async { - avatarUploading = true - avatarError = nil - defer { avatarUploading = false } - do { - let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg") - avatarURL = url - await authStore.validateToken() - } catch { - avatarError = "Upload failed: \(error.localizedDescription)" - } - } - - // MARK: - Avatar picker - - @ViewBuilder - private var avatarPicker: some View { - PhotosPicker(selection: $photoPickerItem, - matching: .images, - photoLibrary: .shared()) { - ZStack { - Circle() - .fill(Color(.systemGray5)) - .frame(width: 72, height: 72) - - if avatarUploading { - ProgressView() - .frame(width: 72, height: 72) - } else if let urlStr = avatarURL ?? authStore.user?.avatarURL, - let url = URL(string: urlStr) { - KFImage(url) - .placeholder { - Image(systemName: "person.circle.fill") - .font(.system(size: 52)) - .foregroundStyle(.amber) - } - .resizable() - .scaledToFill() - .frame(width: 72, height: 72) - .clipShape(Circle()) - } else { - Image(systemName: "person.circle.fill") - .font(.system(size: 52)) - .foregroundStyle(.amber) - .frame(width: 72, height: 72) - } - - // Camera badge - if !avatarUploading { - VStack { - Spacer() - HStack { - Spacer() - ZStack { - Circle().fill(Color.amber).frame(width: 22, height: 22) - Image(systemName: "camera.fill") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.black) - } - .offset(x: 2, y: 2) - } - } - .frame(width: 72, height: 72) - } - } - } - .buttonStyle(.plain) - .onChange(of: photoPickerItem) { _, item in - guard let item else { return } - Task { await loadImageForCrop(item) } - } - } - - // MARK: - Voice picker - - @ViewBuilder - private var voicePicker: some View { - Picker("TTS Voice", selection: Binding( - get: { authStore.settings.voice }, - set: { newVoice in - Task { - var s = authStore.settings - s.voice = newVoice - await authStore.saveSettings(s) - } - } - )) { - if vm.voices.isEmpty { - Text("Default").tag("af_bella") - } else { - ForEach(vm.voices, id: \.self) { v in - Text(v).tag(v) - } - } - } - .task { await vm.loadVoices() } - } - - // MARK: - Speed slider - - @ViewBuilder - private var speedSlider: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Playback Speed") - Spacer() - Text("\(authStore.settings.speed, specifier: "%.1f")×") - .foregroundStyle(.secondary) - } - Slider( - value: Binding( - get: { authStore.settings.speed }, - set: { newSpeed in - Task { - var s = authStore.settings - s.speed = newSpeed - await authStore.saveSettings(s) - } - } - ), - in: 0.5...2.0, step: 0.25 - ) - .tint(.amber) - } - } -} - -// MARK: - Session row (local copy — mirrors ProfileView.SessionRow) - -private struct SessionRow: View { - let session: UserSession - let onRevoke: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "iphone") - Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent) - .font(.subheadline) - .lineLimit(1) - Spacer() - if session.isCurrent { - Text("This device") - .font(.caption2.bold()) - .foregroundStyle(.amber) - } else { - Button("Revoke", role: .destructive, action: onRevoke) - .font(.caption) - } - } - Text("Last seen: \(session.lastSeen.prefix(10))") - .font(.caption2) - .foregroundStyle(.secondary) - } - } -} - -// MARK: - CropImageItem (Identifiable wrapper for the sheet) - -private struct CropImageItem: Identifiable { - let id = UUID() - let image: UIImage -} diff --git a/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift b/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift deleted file mode 100644 index 74dbd67..0000000 --- a/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift +++ /dev/null @@ -1,270 +0,0 @@ -import SwiftUI - -// MARK: - AvatarCropView -// A sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region. -// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in … }) } - -struct AvatarCropView: View { - let image: UIImage - let onConfirm: (Data) -> Void - let onCancel: () -> Void - - // Crop circle diameter (points) - private let cropSize: CGFloat = 280 - - // Pan/zoom state — all in screen points, relative to the image's natural fill-fitted frame - @State private var scale: CGFloat = 1.0 - @State private var lastScale: CGFloat = 1.0 - @State private var offset: CGSize = .zero - @State private var lastOffset: CGSize = .zero - - // Container size captured from GeometryReader - @State private var containerSize: CGSize = .zero - - var body: some View { - NavigationStack { - GeometryReader { geo in - ZStack { - Color.black.ignoresSafeArea() - - // Draggable / pinchable image - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: geo.size.width, height: geo.size.height) - .scaleEffect(scale, anchor: .center) - .offset(offset) - .gesture( - SimultaneousGesture( - MagnificationGesture() - .onChanged { value in - let proposed = lastScale * value - scale = max(minScale(in: geo.size), proposed) - } - .onEnded { _ in - lastScale = scale - clampOffset(in: geo.size) - lastOffset = offset - }, - DragGesture() - .onChanged { value in - let proposed = CGSize( - width: lastOffset.width + value.translation.width, - height: lastOffset.height + value.translation.height - ) - offset = clampedOffset(proposed, in: geo.size) - } - .onEnded { _ in - lastOffset = offset - } - ) - ) - .clipped() - - // Dim overlay with transparent crop circle cut out - CropOverlay(cropSize: cropSize, containerSize: geo.size) - .allowsHitTesting(false) - } - .onAppear { - containerSize = geo.size - fitImageInitially(in: geo.size) - } - } - .navigationTitle("Crop Photo") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel", action: onCancel) - .foregroundStyle(.white) - } - ToolbarItem(placement: .topBarTrailing) { - Button("Use Photo") { - confirmCrop() - } - .fontWeight(.semibold) - .foregroundStyle(.amber) - } - } - .toolbarColorScheme(.dark, for: .navigationBar) - } - } - - // MARK: - Initial fit - - private func fitImageInitially(in size: CGSize) { - // The image is displayed with .scaledToFill() in the container (size). - // That means one dimension equals the container and the other overflows. - // We want the image to be just large enough that the crop circle is fully - // covered — i.e. the fill-fitted image's shorter displayed dimension >= cropSize. - // - // .scaledToFill fills the container, so the image already covers the container. - // The minimum scale that covers the crop square is therefore 1.0 (image already - // fills container which is >= cropSize on both axes). - // We keep scale = 1.0 and centre the offset. - scale = 1.0 - lastScale = 1.0 - offset = .zero - lastOffset = .zero - } - - // MARK: - Clamping helpers - - /// Minimum scale: the image (at .scaledToFill in container) must cover the crop square. - /// At scale=1 the image already fills the container; cropSize <= container dimension, - /// so 1.0 is always sufficient. We cap at 1.0 to prevent zooming out below fill. - private func minScale(in containerSize: CGSize) -> CGFloat { - return 1.0 - } - - /// The displayed (fill-fitted) image size in the container at the given user scale. - private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize { - let imgAspect = image.size.width / image.size.height - let containerAspect = containerSize.width / containerSize.height - - // .scaledToFill base size before user scale - let baseWidth: CGFloat - let baseHeight: CGFloat - if imgAspect > containerAspect { - // image is wider — height fills container - baseHeight = containerSize.height - baseWidth = baseHeight * imgAspect - } else { - // image is taller — width fills container - baseWidth = containerSize.width - baseHeight = baseWidth / imgAspect - } - return CGSize(width: baseWidth * userScale, height: baseHeight * userScale) - } - - /// Maximum offset so the crop square is always covered by the image. - private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize { - let displayed = displayedImageSize(in: containerSize, userScale: scale) - // Half of how much the image overflows the container on each axis - let maxX = max(0, (displayed.width - cropSize) / 2) - let maxY = max(0, (displayed.height - cropSize) / 2) - return CGSize( - width: min(maxX, max(-maxX, proposed.width)), - height: min(maxY, max(-maxY, proposed.height)) - ) - } - - private func clampOffset(in containerSize: CGSize) { - offset = clampedOffset(offset, in: containerSize) - } - - // MARK: - Crop - - private func confirmCrop() { - let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844) - let outputSize = CGSize(width: 400, height: 400) - - // --- Step 1: compute the fill-fitted base display size --- - let imgAspect = image.size.width / image.size.height - let containerAspect = size.width / size.height - - let baseDisplayW: CGFloat - let baseDisplayH: CGFloat - if imgAspect > containerAspect { - baseDisplayH = size.height - baseDisplayW = baseDisplayH * imgAspect - } else { - baseDisplayW = size.width - baseDisplayH = baseDisplayW / imgAspect - } - - // Displayed image size after user zoom - let displayW = baseDisplayW * scale - let displayH = baseDisplayH * scale - - // --- Step 2: the crop square centre is the container centre --- - // The image centre (after offset) in container coords: - let imageCentreX = size.width / 2 + offset.width - let imageCentreY = size.height / 2 + offset.height - - // Top-left of the crop square in container coords: - let cropOriginX = (size.width - cropSize) / 2 - let cropOriginY = (size.height - cropSize) / 2 - - // Top-left of the crop square relative to the image's top-left in display space: - let imageOriginX = imageCentreX - displayW / 2 - let imageOriginY = imageCentreY - displayH / 2 - - let cropInImageX = cropOriginX - imageOriginX // pixels in display space - let cropInImageY = cropOriginY - imageOriginY - - // --- Step 3: convert display-space coords to image pixel coords --- - let displayToPixelX = image.size.width / displayW - let displayToPixelY = image.size.height / displayH - - let pixelX = cropInImageX * displayToPixelX - let pixelY = cropInImageY * displayToPixelY - let pixelW = cropSize * displayToPixelX - let pixelH = cropSize * displayToPixelY - - let cropRect = CGRect(x: pixelX, y: pixelY, width: pixelW, height: pixelH) - .intersection(CGRect(origin: .zero, size: image.size)) - - guard cropRect.width > 0, cropRect.height > 0 else { - // Fallback: use entire image - if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) } - return - } - - // --- Step 4: render cropped region into 400×400 --- - let renderer = UIGraphicsImageRenderer(size: outputSize) - let cropped = renderer.image { _ in - // Draw only the cropRect portion of the image scaled to fill outputSize - let destRect = CGRect(origin: .zero, size: outputSize) - // UIImage.draw(in:) draws the full image; we use CGImage cropping instead - if let cgImg = image.cgImage?.cropping(to: cropRect) { - let croppedUI = UIImage(cgImage: cgImg, scale: image.scale, orientation: image.imageOrientation) - croppedUI.draw(in: destRect) - } else { - image.draw(in: destRect) - } - } - - if let jpeg = cropped.jpegData(compressionQuality: 0.9) { - onConfirm(jpeg) - } - } -} - -// MARK: - Crop overlay - -private struct CropOverlay: View { - let cropSize: CGFloat - let containerSize: CGSize - - var body: some View { - Canvas { context, size in - // Fill entire canvas with semi-transparent black - context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55))) - // Cut out the crop circle in the centre - let origin = CGPoint( - x: (size.width - cropSize) / 2, - y: (size.height - cropSize) / 2 - ) - let cropRect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize)) - context.blendMode = .destinationOut - context.fill(Path(ellipseIn: cropRect), with: .color(.white)) - } - .compositingGroup() - .overlay { - // Amber circle border around the crop region - let origin = CGPoint( - x: (containerSize.width - cropSize) / 2, - y: (containerSize.height - cropSize) / 2 - ) - Circle() - .stroke(Color.amber.opacity(0.8), lineWidth: 2) - .frame(width: cropSize, height: cropSize) - .position( - x: origin.x + cropSize / 2, - y: origin.y + cropSize / 2 - ) - } - .frame(width: containerSize.width, height: containerSize.height) - .allowsHitTesting(false) - } -} diff --git a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift deleted file mode 100644 index b25508c..0000000 --- a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift +++ /dev/null @@ -1,362 +0,0 @@ -import SwiftUI -import PhotosUI -import Kingfisher - -struct ProfileView: View { - @EnvironmentObject var authStore: AuthStore - @StateObject private var vm = ProfileViewModel() - @State private var showChangePassword = false - @State private var showVoiceSelection = false - @State private var showDownloads = false - - // Avatar upload state - @State private var photoPickerItem: PhotosPickerItem? - @State private var pendingCropImage: UIImage? // image waiting to be cropped - @State private var avatarURL: String? = nil - @State private var avatarUploading = false - @State private var avatarError: String? - - var body: some View { - NavigationStack { - List { - // ── User header ──────────────────────────────────────────── - Section { - HStack(spacing: 16) { - avatarPicker - VStack(alignment: .leading, spacing: 3) { - Text(authStore.user?.username ?? "") - .font(.headline) - Text(authStore.user?.role.capitalized ?? "") - .font(.caption) - .foregroundStyle(.secondary) - if let err = avatarError { - Text(err) - .font(.caption2) - .foregroundStyle(.red) - } - } - } - .padding(.vertical, 6) - } - - // ── Reading settings ─────────────────────────────────────── - Section("Reading Settings") { - voicePicker - speedSlider - Toggle("Auto-advance chapter", isOn: Binding( - get: { authStore.settings.autoNext }, - set: { newVal in - Task { - var s = authStore.settings - s.autoNext = newVal - await authStore.saveSettings(s) - } - } - )) - .tint(.amber) - - Button { - showDownloads = true - } label: { - HStack { - Text("Downloads") - .foregroundStyle(.primary) - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - } - - // ── Sessions ─────────────────────────────────────────────── - Section("Active Sessions") { - if vm.sessionsLoading { - ProgressView() - } else { - ForEach(vm.sessions) { session in - SessionRow(session: session) { - Task { await vm.revokeSession(id: session.id) } - } - } - } - } - - // ── Account ──────────────────────────────────────────────── - Section("Account") { - Button("Change Password") { showChangePassword = true } - Button("Sign Out", role: .destructive) { - Task { await authStore.logout() } - } - } - } - .navigationTitle("Profile") - .task { - await vm.loadSessions() - } - - .sheet(isPresented: $showChangePassword) { - ChangePasswordView() - } - .sheet(isPresented: $showVoiceSelection) { - VoiceSelectionView(currentVoice: authStore.settings.voice) - } - .sheet(isPresented: $showDownloads) { - DownloadsView() - } - .sheet(item: Binding( - get: { pendingCropImage.map { CropImageItem(image: $0) } }, - set: { if $0 == nil { pendingCropImage = nil } } - )) { item in - AvatarCropView(image: item.image) { croppedData in - pendingCropImage = nil - Task { await uploadCroppedData(croppedData) } - } onCancel: { - pendingCropImage = nil - } - } - .errorAlert($vm.error) - } - } - - // MARK: - Avatar upload - - /// Step 1: Load the raw image from the picker and show the crop sheet. - private func loadImageForCrop(_ item: PhotosPickerItem) async { - guard let data = try? await item.loadTransferable(type: Data.self), - let image = UIImage(data: data) else { - avatarError = "Could not read image" - return - } - pendingCropImage = image - } - - /// Step 2: Called by AvatarCropView once the user confirms. Upload the cropped JPEG. - private func uploadCroppedData(_ data: Data) async { - avatarUploading = true - avatarError = nil - defer { avatarUploading = false } - do { - let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg") - avatarURL = url - // Refresh user record so the new avatar persists across sessions - await authStore.validateToken() - } catch { - avatarError = "Upload failed: \(error.localizedDescription)" - } - } - - // MARK: - Avatar picker - - @ViewBuilder - private var avatarPicker: some View { - PhotosPicker(selection: $photoPickerItem, - matching: .images, - photoLibrary: .shared()) { - ZStack { - Circle() - .fill(Color(.systemGray5)) - .frame(width: 72, height: 72) - - if avatarUploading { - ProgressView() - .frame(width: 72, height: 72) - } else if let urlStr = avatarURL ?? authStore.user?.avatarURL, - let url = URL(string: urlStr) { - KFImage(url) - .placeholder { - Image(systemName: "person.circle.fill") - .font(.system(size: 52)) - .foregroundStyle(.amber) - } - .resizable() - .scaledToFill() - .frame(width: 72, height: 72) - .clipShape(Circle()) - } else { - Image(systemName: "person.circle.fill") - .font(.system(size: 52)) - .foregroundStyle(.amber) - .frame(width: 72, height: 72) - } - - // Camera overlay badge - if !avatarUploading { - VStack { - Spacer() - HStack { - Spacer() - ZStack { - Circle() - .fill(Color.amber) - .frame(width: 22, height: 22) - Image(systemName: "camera.fill") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.black) - } - .offset(x: 2, y: 2) - } - } - .frame(width: 72, height: 72) - } - } - } - .buttonStyle(.plain) - .onChange(of: photoPickerItem) { _, item in - guard let item else { return } - Task { await loadImageForCrop(item) } - } - } - - // MARK: - Voice picker - - @ViewBuilder - private var voicePicker: some View { - Button { - showVoiceSelection = true - } label: { - HStack { - Text("TTS Voice") - .foregroundStyle(.primary) - Spacer() - Text(formatVoiceLabel(authStore.settings.voice)) - .foregroundStyle(.secondary) - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - } - - private func formatVoiceLabel(_ voice: String) -> String { - let parts = voice.split(separator: "_") - guard parts.count >= 2 else { return voice } - let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") - return name - } - - // MARK: - Speed slider - - @ViewBuilder - private var speedSlider: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Playback Speed") - Spacer() - Text("\(authStore.settings.speed, specifier: "%.1f")×") - .foregroundStyle(.secondary) - } - Slider( - value: Binding( - get: { authStore.settings.speed }, - set: { newSpeed in - Task { - var s = authStore.settings - s.speed = newSpeed - await authStore.saveSettings(s) - } - } - ), - in: 0.5...2.0, step: 0.25 - ) - .tint(.amber) - } - } -} - -// MARK: - Session row - -private struct SessionRow: View { - let session: UserSession - let onRevoke: () -> Void - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "iphone") - Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent) - .font(.subheadline) - .lineLimit(1) - Spacer() - if session.isCurrent { - Text("This device") - .font(.caption2.bold()) - .foregroundStyle(.amber) - } else { - Button("Revoke", role: .destructive, action: onRevoke) - .font(.caption) - } - } - Text("Last seen: \(session.lastSeen.prefix(10))") - .font(.caption2) - .foregroundStyle(.secondary) - } - } -} - -// MARK: - Change password sheet - -struct ChangePasswordView: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject var authStore: AuthStore - @State private var current = "" - @State private var newPwd = "" - @State private var confirm = "" - @State private var isLoading = false - @State private var error: String? - @State private var success = false - - var body: some View { - NavigationStack { - Form { - Section { - SecureField("Current password", text: $current) - SecureField("New password", text: $newPwd) - SecureField("Confirm new password", text: $confirm) - } - if let error { - Text(error).foregroundStyle(.red).font(.caption) - } - if success { - Text("Password changed successfully").foregroundStyle(.green).font(.caption) - } - } - .navigationTitle("Change Password") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } - ToolbarItem(placement: .topBarTrailing) { - Button("Save") { save() } - .disabled(isLoading || newPwd.count < 4 || newPwd != confirm) - } - } - } - } - - private func save() { - guard newPwd == confirm else { error = "Passwords do not match"; return } - isLoading = true - error = nil - Task { - do { - struct Body: Encodable { let currentPassword, newPassword: String } - let _: EmptyResponse = try await APIClient.shared.fetch( - "/api/auth/change-password", method: "POST", - body: Body(currentPassword: current, newPassword: newPwd) - ) - success = true - try? await Task.sleep(nanoseconds: 1_200_000_000) - dismiss() - } catch { - self.error = error.localizedDescription - } - isLoading = false - } - } -} - -// MARK: - Crop image item (Identifiable wrapper for .sheet(item:)) - -private struct CropImageItem: Identifiable { - let id = UUID() - let image: UIImage -} diff --git a/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift deleted file mode 100644 index f365079..0000000 --- a/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift +++ /dev/null @@ -1,197 +0,0 @@ -import SwiftUI - -struct UserProfileView: View { - let username: String - - @StateObject private var vm: UserProfileViewModel - @EnvironmentObject private var authStore: AuthStore - - init(username: String) { - self.username = username - _vm = StateObject(wrappedValue: UserProfileViewModel(username: username)) - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - if vm.isLoading && vm.profile == nil { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.top, 60) - } else if let profile = vm.profile { - profileHeader(profile) - .padding(.bottom, 28) - - if !vm.currentlyReading.isEmpty { - ShelfHeader(title: "Currently Reading") - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 14) { - ForEach(vm.currentlyReading) { item in - NavigationLink(value: NavDestination.book(item.book.slug)) { - ProfileBookCard(item: item) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal) - .padding(.bottom, 4) - } - .padding(.bottom, 28) - } - - if !vm.library.isEmpty { - ShelfHeader(title: "Library") - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 14) { - ForEach(vm.library) { item in - NavigationLink(value: NavDestination.book(item.book.slug)) { - ProfileBookCard(item: item) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal) - .padding(.bottom, 4) - } - .padding(.bottom, 28) - } - - if vm.currentlyReading.isEmpty && vm.library.isEmpty && !vm.isLoading { - EmptyStateView( - icon: "books.vertical", - title: "No books yet", - message: "\(username) hasn't read anything yet." - ) - .frame(maxWidth: .infinity) - .padding(.top, 20) - } - } else if let err = vm.error { - EmptyStateView(icon: "exclamationmark.triangle", title: "Error", message: err) - .frame(maxWidth: .infinity) - .padding(.top, 60) - } - - Color.clear.frame(height: 20) - } - } - .navigationTitle("@\(username)") - .navigationBarTitleDisplayMode(.inline) - .task { await vm.load() } - .refreshable { await vm.load() } - .errorAlert($vm.error) - } - - // MARK: - Profile header - - @ViewBuilder - private func profileHeader(_ profile: PublicUserProfile) -> some View { - VStack(alignment: .center, spacing: 16) { - AvatarThumb(urlString: profile.avatarUrl, size: 80) - - VStack(spacing: 4) { - Text("@\(profile.username)") - .font(.title3.bold()) - if !profile.created.isEmpty { - Text("Joined \(shortDate(profile.created))") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - // Stats row - HStack(spacing: 32) { - VStack(spacing: 2) { - Text("\(profile.followerCount)") - .font(.subheadline.bold().monospacedDigit()) - Text("Followers") - .font(.caption2) - .foregroundStyle(.secondary) - } - VStack(spacing: 2) { - Text("\(profile.followingCount)") - .font(.subheadline.bold().monospacedDigit()) - Text("Following") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - - // Follow button — only shown for other users (not self) - if !profile.isSelf && authStore.isAuthenticated { - Button { - Task { await vm.toggleSubscribe() } - } label: { - if vm.isTogglingSubscribe { - ProgressView().controlSize(.small) - .frame(width: 120, height: 34) - } else if profile.isSubscribed { - Label("Following", systemImage: "checkmark") - .font(.subheadline.bold()) - .frame(width: 120, height: 34) - } else { - Text("Follow") - .font(.subheadline.bold()) - .frame(width: 120, height: 34) - } - } - .buttonStyle(.borderedProminent) - .tint(profile.isSubscribed ? Color(.systemGray4) : .amber) - .disabled(vm.isTogglingSubscribe) - } - } - .frame(maxWidth: .infinity) - .padding(.top, 24) - .padding(.horizontal) - } - - private func shortDate(_ iso: String) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" - if let date = formatter.date(from: iso) { - let out = DateFormatter() - out.dateStyle = .medium - out.timeStyle = .none - return out.string(from: date) - } - return String(iso.prefix(10)) - } -} - -// MARK: - Book card for profile shelves - -private struct ProfileBookCard: View { - let item: PublicLibraryItem - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - ZStack(alignment: .bottomLeading) { - AsyncCoverImage(url: item.book.cover) - .frame(width: 110, height: 158) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(color: .black.opacity(0.12), radius: 4, y: 2) - - // Chapter badge (if reading) - if let ch = item.lastChapter, ch > 0 { - Text("Ch.\(ch)") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(.black.opacity(0.85)) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .background(Capsule().fill(Color.amber)) - .padding(6) - } - } - - Text(item.book.title) - .font(.caption.bold()) - .lineLimit(2) - .frame(width: 110, alignment: .leading) - - Text(item.book.author) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(width: 110, alignment: .leading) - } - } -} diff --git a/ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift b/ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift deleted file mode 100644 index 4b6b057..0000000 --- a/ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift +++ /dev/null @@ -1,158 +0,0 @@ -import SwiftUI - -struct VoiceSelectionView: View { - @StateObject private var vm = VoiceSelectionViewModel() - @EnvironmentObject var authStore: AuthStore - @Environment(\.dismiss) private var dismiss - - @State private var selectedVoice: String - - init(currentVoice: String) { - _selectedVoice = State(initialValue: currentVoice) - } - - var body: some View { - NavigationStack { - Group { - if vm.isLoading { - ProgressView("Loading voices...") - } else if let error = vm.error { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 48)) - .foregroundStyle(.amber) - Text(error) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - } - .padding() - } else { - voiceList - } - } - .navigationTitle("Select Voice") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - saveAndDismiss() - } - .fontWeight(.semibold) - .disabled(selectedVoice == authStore.settings.voice) - } - } - .task { - await vm.loadVoices() - } - } - } - - // MARK: - Voice List - - @ViewBuilder - private var voiceList: some View { - List { - Section { - ForEach(vm.voices, id: \.self) { voice in - VoiceRow( - voice: voice, - isSelected: voice == selectedVoice, - isPlaying: vm.playingVoice == voice, - voiceLabel: vm.voiceLabel(voice), - voiceId: vm.voiceId(voice), - onSelect: { - vm.stopSample() - selectedVoice = voice - }, - onPlaySample: { - Task { - await vm.playSample(voice) - } - } - ) - } - } header: { - Text("Available Voices") - } footer: { - if selectedVoice != authStore.settings.voice { - Text("New voice will apply to next audio playback") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - - // MARK: - Actions - - private func saveAndDismiss() { - Task { - var settings = authStore.settings - settings.voice = selectedVoice - await authStore.saveSettings(settings) - dismiss() - } - } -} - -// MARK: - Voice Row - -private struct VoiceRow: View { - let voice: String - let isSelected: Bool - let isPlaying: Bool - let voiceLabel: String - let voiceId: String - let onSelect: () -> Void - let onPlaySample: () -> Void - - var body: some View { - HStack(spacing: 12) { - // Selection checkmark - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.system(size: 22)) - .foregroundStyle(isSelected ? .amber : .secondary.opacity(0.3)) - .frame(width: 28) - - // Voice info - VStack(alignment: .leading, spacing: 4) { - Text(voiceLabel) - .font(.body) - .fontWeight(isSelected ? .semibold : .regular) - - Text(voiceId) - .font(.caption) - .fontDesign(.monospaced) - .foregroundStyle(.secondary) - } - - Spacer() - - // Play sample button - Button { - onPlaySample() - } label: { - Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") - .font(.system(size: 28)) - .foregroundStyle(isPlaying ? .red : .amber) - .contentTransition(.symbolEffect(.replace)) - } - .buttonStyle(.plain) - } - .padding(.vertical, 4) - .contentShape(Rectangle()) - .onTapGesture { - onSelect() - } - } -} - -// MARK: - Preview - -#Preview { - VoiceSelectionView(currentVoice: "af_bella") - .environmentObject(AuthStore()) -} diff --git a/ios/LibNovel/LibNovel/Views/Search/SearchView.swift b/ios/LibNovel/LibNovel/Views/Search/SearchView.swift deleted file mode 100644 index 51c80aa..0000000 --- a/ios/LibNovel/LibNovel/Views/Search/SearchView.swift +++ /dev/null @@ -1,286 +0,0 @@ -import SwiftUI - -// MARK: - SearchView -// Dedicated search tab for intentional, fuzzy search. -// Live search as you type, shows recent searches when idle. - -struct SearchView: View { - @StateObject private var vm = SearchViewModel() - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - OfflineBanner() - - Group { - // ── Content ───────────────────────────────────────────────── - if vm.query.isEmpty && vm.results.isEmpty { - idleContent - } else if vm.isLoading && vm.results.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if vm.results.isEmpty && !vm.query.isEmpty { - EmptyStateView( - icon: "magnifyingglass", - title: "No results", - message: "Try a different title or author name." - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - resultsGrid - } - } - } - .navigationTitle("Search") - .searchable( - text: $vm.query, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Search novels, authors…" - ) - .autocorrectionDisabled() - .onChange(of: vm.query) { _, newValue in - vm.onQueryChange(newValue) - } - .onSubmit(of: .search) { - vm.submitSearch() - } - .appNavigationDestination() - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 16) { - DownloadQueueButton() - AvatarToolbarButton() - } - } - } - } - } - - // MARK: - Idle screen (recent searches) - - @ViewBuilder - private var idleContent: some View { - if vm.recentSearches.isEmpty { - // Empty state - prompt to search - VStack(spacing: 16) { - Image(systemName: "magnifyingglass") - .font(.system(size: 56)) - .foregroundStyle(.secondary.opacity(0.5)) - Text("Search for novels") - .font(.title2.bold()) - .foregroundStyle(.primary) - Text("Find your next favorite book by title, author, or genre") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - // Recent searches list - ScrollView { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text("Recent Searches") - .font(.title3.bold()) - Spacer() - Button("Clear") { vm.clearRecent() } - .font(.subheadline) - .foregroundStyle(.amber) - } - .padding(.horizontal) - .padding(.top, 16) - .padding(.bottom, 12) - - ForEach(vm.recentSearches, id: \.self) { term in - Button { - vm.query = term - vm.submitSearch() - } label: { - HStack(spacing: 12) { - Image(systemName: "clock") - .foregroundStyle(.secondary) - .frame(width: 20) - Text(term) - .foregroundStyle(.primary) - Spacer() - Image(systemName: "arrow.up.left") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal) - .padding(.vertical, 12) - } - - if term != vm.recentSearches.last { - Divider() - .padding(.leading, 44) - } - } - } - } - } - } - - // MARK: - Results grid - - @ViewBuilder - private var resultsGrid: some View { - ScrollView { - VStack(spacing: 8) { - // Result count - HStack { - Text("\(vm.results.count) result\(vm.results.count == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal) - .padding(.top, 8) - - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 14), - GridItem(.flexible(), spacing: 14) - ], - spacing: 14 - ) { - ForEach(vm.results) { novel in - NavigationLink(value: NavDestination.book(novel.slug)) { - SearchNovelCard(novel: novel) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal) - .padding(.bottom, 100) - } - } - } -} - -// MARK: - Search novel card (compact 2-column) - -private struct SearchNovelCard: View { - let novel: BrowseNovel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - AsyncCoverImage(url: novel.cover) - .frame(maxWidth: .infinity) - .aspectRatio(2/3, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .bookCoverZoomSource(slug: novel.slug) - - VStack(alignment: .leading, spacing: 3) { - Text(novel.title) - .font(.subheadline.bold()) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - .multilineTextAlignment(.leading) - - if !novel.author.isEmpty { - Text(novel.author) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 10) - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) - } -} - -// MARK: - SearchViewModel - -@MainActor -final class SearchViewModel: ObservableObject { - @Published var query: String = "" - @Published var results: [BrowseNovel] = [] - @Published var isLoading = false - - // Persisted in UserDefaults (max 10 recent terms) - @Published var recentSearches: [String] = [] - - private let recentKey = "searchRecentTerms" - private var searchTask: Task<Void, Never>? - - init() { - recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? []) - } - - /// Called when query changes - implements debounced live search - func onQueryChange(_ newValue: String) { - // Cancel previous search task - searchTask?.cancel() - - // If query is empty, clear results - guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - results = [] - return - } - - // Debounce: wait 300ms before searching - searchTask = Task { - try? await Task.sleep(nanoseconds: 300_000_000) // 300ms - guard !Task.isCancelled else { return } - await runSearch(newValue) - } - } - - func submitSearch() { - let term = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !term.isEmpty else { return } - saveRecent(term) - // Cancel debounce and search immediately - searchTask?.cancel() - Task { await runSearch(term) } - } - - func clear() { - query = "" - results = [] - searchTask?.cancel() - } - - func clearRecent() { - recentSearches = [] - UserDefaults.standard.removeObject(forKey: recentKey) - } - - private func runSearch(_ term: String) async { - let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - results = [] - return - } - - isLoading = true - do { - let result = try await APIClient.shared.search(query: trimmed) - // Only update results if query hasn't changed - if query.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed { - results = result.results - } - } catch { - if !(error is CancellationError) { - results = [] - } - } - isLoading = false - } - - private func saveRecent(_ term: String) { - var list = recentSearches.filter { $0 != term } - list.insert(term, at: 0) - if list.count > 10 { list = Array(list.prefix(10)) } - recentSearches = list - UserDefaults.standard.set(list, forKey: recentKey) - } -} diff --git a/ios/LibNovel/LibNovelTests/LibNovelTests.swift b/ios/LibNovel/LibNovelTests/LibNovelTests.swift deleted file mode 100644 index c076f88..0000000 --- a/ios/LibNovel/LibNovelTests/LibNovelTests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest -@testable import LibNovel - -final class LibNovelTests: XCTestCase { - func testExample() throws { - // Placeholder — add real tests here - XCTAssert(true) - } -} diff --git a/ios/LibNovel/fastlane/Fastfile b/ios/LibNovel/fastlane/Fastfile deleted file mode 100644 index be14624..0000000 --- a/ios/LibNovel/fastlane/Fastfile +++ /dev/null @@ -1,36 +0,0 @@ -default_platform(:ios) - -platform :ios do - desc "Build and upload to TestFlight" - lane :beta do - # Generate Xcode project from project.yml (one level up from fastlane/) - sh("cd .. && xcodegen generate --spec project.yml --project .") - - # Set build number from CI run number (passed as env var) - increment_build_number( - build_number: ENV["BUILD_NUMBER"] || "1", - xcodeproj: "LibNovel.xcodeproj" - ) - - # Build the app - signing settings are in project.yml Release config - build_app( - scheme: "LibNovel", - export_method: "app-store", - clean: true, - configuration: "Release", - export_options: { - method: "app-store", - teamID: "GHZXC6FVMU", - provisioningProfiles: { - "com.kalekber.LibNovel" => "LibNovel Distribution" - }, - signingStyle: "manual" - } - ) - - # Upload to TestFlight - upload_to_testflight( - skip_waiting_for_build_processing: true - ) - end -end diff --git a/ios/LibNovel/project.yml b/ios/LibNovel/project.yml deleted file mode 100644 index c8483ed..0000000 --- a/ios/LibNovel/project.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: LibNovel -options: - bundleIdPrefix: com.kalekber - deploymentTarget: - iOS: "17.0" - xcodeVersion: "16.0" - generateEmptyDirectories: true - indentWidth: 4 - tabWidth: 4 - usesTabs: false - -settings: - base: - SWIFT_VERSION: "5.10" - ENABLE_PREVIEWS: YES - MARKETING_VERSION: "1.0.0" - CURRENT_PROJECT_VERSION: "1" - LIBNOVEL_BASE_URL: "https://v2.libnovel.kalekber.cc" - configs: - Debug: - SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG - Release: - SWIFT_ACTIVE_COMPILATION_CONDITIONS: "" - -packages: - # Async image loading with caching - Kingfisher: - url: https://github.com/onevcat/Kingfisher - from: "8.0.0" - -targets: - LibNovel: - type: application - platform: iOS - deploymentTarget: "17.0" - sources: - - path: LibNovel - excludes: - - "**/.DS_Store" - - "Resources/Info.plist" - resources: - - path: LibNovel/Resources/Assets.xcassets - dependencies: - - package: Kingfisher - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel - ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad - GENERATE_INFOPLIST_FILE: NO - INFOPLIST_FILE: LibNovel/Resources/Info.plist - configs: - Release: - CODE_SIGN_STYLE: Manual - DEVELOPMENT_TEAM: GHZXC6FVMU - CODE_SIGN_IDENTITY: "Apple Distribution" - PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f" - - LibNovelTests: - type: bundle.unit-test - platform: iOS - deploymentTarget: "17.0" - sources: - - path: LibNovelTests - dependencies: - - target: LibNovel - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel.tests - -schemes: - LibNovel: - build: - targets: - LibNovel: all - run: - config: Debug - environmentVariables: - LIBNOVEL_BASE_URL: - value: "https://v2.libnovel.kalekber.cc" - isEnabled: true - test: - config: Debug - targets: - - LibNovelTests - profile: - config: Release - analyze: - config: Debug - archive: - config: Release diff --git a/ios/LibNovel/test-build.sh b/ios/LibNovel/test-build.sh deleted file mode 100755 index d652822..0000000 --- a/ios/LibNovel/test-build.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -set -e - -# Test script for local iOS build iteration -# Run from ios/LibNovel directory - -echo "=== Generating Xcode project ===" -xcodegen generate --spec project.yml --project . - -echo "" -echo "=== Listing available provisioning profiles ===" -ls -la ~/Library/MobileDevice/Provisioning\ Profiles/ || echo "No profiles found" - -echo "" -echo "=== Listing available signing identities ===" -security find-identity -v -p codesigning - -echo "" -echo "=== Attempting archive build ===" -xcodebuild archive \ - -project LibNovel.xcodeproj \ - -scheme LibNovel \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -archivePath ./build/LibNovel.xcarchive \ - -allowProvisioningUpdates \ - CODE_SIGN_STYLE=Manual \ - CODE_SIGN_IDENTITY="Apple Distribution" \ - DEVELOPMENT_TEAM="GHZXC6FVMU" - -echo "" -echo "=== Build succeeded! ===" diff --git a/ios/LibNovelV2/App/ContentView.swift b/ios/LibNovelV2/App/ContentView.swift deleted file mode 100644 index 824cf9a..0000000 --- a/ios/LibNovelV2/App/ContentView.swift +++ /dev/null @@ -1,19 +0,0 @@ -import SwiftUI - -// MARK: - Root content view -// Switches between AuthView (unauthenticated) and RootTabView (authenticated). - -struct ContentView: View { - @EnvironmentObject var authStore: AuthStore - - var body: some View { - Group { - if authStore.isAuthenticated { - RootTabView() - } else { - AuthView() - } - } - .animation(.easeInOut(duration: 0.25), value: authStore.isAuthenticated) - } -} diff --git a/ios/LibNovelV2/App/LibNovelV2App.swift b/ios/LibNovelV2/App/LibNovelV2App.swift deleted file mode 100644 index 80ace13..0000000 --- a/ios/LibNovelV2/App/LibNovelV2App.swift +++ /dev/null @@ -1,21 +0,0 @@ -import SwiftUI - -@main -struct LibNovelV2App: App { - @StateObject private var authStore = AuthStore() - @StateObject private var audioPlayer = AudioPlayerService() - @StateObject private var downloadService = AudioDownloadService.shared - @StateObject private var networkMonitor = NetworkMonitor() - @StateObject private var bookVoicePrefs = BookVoicePreferences.shared - - var body: some Scene { - WindowGroup { - ContentView() - .environmentObject(authStore) - .environmentObject(audioPlayer) - .environmentObject(downloadService) - .environmentObject(networkMonitor) - .environmentObject(bookVoicePrefs) - } - } -} diff --git a/ios/LibNovelV2/App/RootTabView.swift b/ios/LibNovelV2/App/RootTabView.swift deleted file mode 100644 index 43e8d88..0000000 --- a/ios/LibNovelV2/App/RootTabView.swift +++ /dev/null @@ -1,90 +0,0 @@ -import SwiftUI - -// MARK: - Root tab container with persistent mini-player overlay - -struct RootTabView: View { - @EnvironmentObject var authStore: AuthStore - @EnvironmentObject var audioPlayer: AudioPlayerService - - @State private var selectedTab: Tab = .home - @State private var showFullPlayer: Bool = false - @State private var readerIsActive: Bool = false - @State private var fullPlayerDragOffset: CGFloat = 0 - - enum Tab: Hashable { - case home, library, browse, search, profile - } - - var body: some View { - ZStack(alignment: .bottom) { - TabView(selection: $selectedTab) { - HomeView() - .tabItem { Label("Home", systemImage: "house.fill") } - .tag(Tab.home) - - LibraryView() - .tabItem { Label("Library", systemImage: "book.pages.fill") } - .tag(Tab.library) - - BrowseView() - .tabItem { Label("Discover", systemImage: "sparkles") } - .tag(Tab.browse) - - SearchView() - .tabItem { Label("Search", systemImage: "magnifyingglass") } - .tag(Tab.search) - - ProfileView() - .tabItem { Label("Profile", systemImage: "person.fill") } - .tag(Tab.profile) - } - - // Mini player bar — sits above the tab bar - if audioPlayer.isActive && !showFullPlayer && !readerIsActive { - MiniPlayerBar(showFullPlayer: $showFullPlayer) - .padding(.bottom, 49) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) - } - - // Full player — slides up from the bottom - if showFullPlayer { - FullPlayerView(onDismiss: { - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { - showFullPlayer = false - fullPlayerDragOffset = 0 - } - }) - .offset(y: max(fullPlayerDragOffset, 0)) - .gesture( - DragGesture(minimumDistance: 10) - .onChanged { value in - if value.translation.height > 0 { - fullPlayerDragOffset = value.translation.height - } - } - .onEnded { value in - let velocity = value.predictedEndTranslation.height - value.translation.height - if value.translation.height > 120 || velocity > 400 { - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { - showFullPlayer = false - fullPlayerDragOffset = 0 - } - } else { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - fullPlayerDragOffset = 0 - } - } - } - ) - .transition(.move(edge: .bottom)) - .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) - .ignoresSafeArea() - } - } - .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) - .onPreferenceChange(HideMiniPlayerKey.self) { hide in - readerIsActive = hide - } - } -} diff --git a/ios/LibNovelV2/Extensions/NavDestination.swift b/ios/LibNovelV2/Extensions/NavDestination.swift deleted file mode 100644 index d4dda25..0000000 --- a/ios/LibNovelV2/Extensions/NavDestination.swift +++ /dev/null @@ -1,138 +0,0 @@ -import SwiftUI - -// MARK: - Navigation destination enum - -enum NavDestination: Hashable { - case book(String) // slug - case chapter(String, Int) // slug + chapter number - case userProfile(String) // username - case browseCategory(sort: String, genre: String, status: String, title: String) -} - -// MARK: - View helpers - -extension View { - /// Registers app-wide navigationDestination for NavDestination values. - /// Apply once per NavigationStack. - func appNavigationDestination() -> some View { - modifier(AppNavigationDestinationModifier()) - } - - /// Standard "Error" alert driven by an optional String binding. - /// Suppresses network errors silently when offline (banner handles them). - func errorAlert(_ error: Binding<String?>) -> some View { - modifier(ErrorAlertModifier(error: error)) - } - - /// Signal to the root overlay that the mini player should be hidden. - func hideMiniPlayer() -> some View { - preference(key: HideMiniPlayerKey.self, value: true) - } - - /// Marks a cover image as the zoom source for a book navigation transition (iOS 18+). - func bookCoverZoomSource(slug: String) -> some View { - modifier(BookCoverZoomSource(slug: slug)) - } -} - -// MARK: - Error alert modifier - -private struct ErrorAlertModifier: ViewModifier { - @Binding var error: String? - @EnvironmentObject var networkMonitor: NetworkMonitor - - private var shouldShowAlert: Bool { - guard let msg = error else { return false } - if !networkMonitor.isConnected { - let keywords = ["internet", "offline", "network", "connection", "unreachable", "timed out", "no data"] - if keywords.contains(where: { msg.lowercased().contains($0) }) { - DispatchQueue.main.async { self.error = nil } - return false - } - } - return true - } - - func body(content: Content) -> some View { - content.alert("Error", isPresented: Binding( - get: { shouldShowAlert }, - set: { if !$0 { error = nil } } - )) { - Button("OK") { error = nil } - } message: { - Text(error ?? "") - } - } -} - -// MARK: - Navigation destination modifier - -private struct AppNavigationDestinationModifier: ViewModifier { - @Namespace private var zoomNamespace - - func body(content: Content) -> some View { - if #available(iOS 18.0, *) { - content - .navigationDestination(for: NavDestination.self) { dest in - switch dest { - case .book(let slug): - BookDetailView(slug: slug) - .navigationTransition(.zoom(sourceID: slug, in: zoomNamespace)) - case .chapter(let slug, let n): - ChapterReaderView(slug: slug, chapterNumber: n) - case .userProfile(let username): - UserProfileView(username: username) - case .browseCategory(let sort, let genre, let status, let title): - BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) - } - } - .environment(\.bookZoomNamespace, zoomNamespace) - } else { - content - .navigationDestination(for: NavDestination.self) { dest in - switch dest { - case .book(let slug): BookDetailView(slug: slug) - case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) - case .userProfile(let username): UserProfileView(username: username) - case .browseCategory(let sort, let genre, let status, let title): - BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) - } - } - } - } -} - -// MARK: - Environment key: zoom namespace - -struct BookZoomNamespaceKey: EnvironmentKey { - static var defaultValue: Namespace.ID? { nil } -} - -extension EnvironmentValues { - var bookZoomNamespace: Namespace.ID? { - get { self[BookZoomNamespaceKey.self] } - set { self[BookZoomNamespaceKey.self] = newValue } - } -} - -// MARK: - Preference key: hide mini player - -struct HideMiniPlayerKey: PreferenceKey { - static var defaultValue = false - static func reduce(value: inout Bool, nextValue: () -> Bool) { value = value || nextValue() } -} - -// MARK: - Cover zoom source modifier - -struct BookCoverZoomSource: ViewModifier { - let slug: String - @Environment(\.bookZoomNamespace) private var namespace - - func body(content: Content) -> some View { - if #available(iOS 18.0, *), let ns = namespace { - content.matchedTransitionSource(id: slug, in: ns) - } else { - content - } - } -} diff --git a/ios/LibNovelV2/Extensions/String+App.swift b/ios/LibNovelV2/Extensions/String+App.swift deleted file mode 100644 index e96307f..0000000 --- a/ios/LibNovelV2/Extensions/String+App.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -extension String { - /// Strips trailing date parentheticals from chapter titles. - /// Handles formats like: - /// " (January 5, 2025)" - /// " - Jan 01 2024" - func strippingTrailingDate() -> String { - let patterns = [ - #"\s*\([A-Za-z]+ \d{1,2},\s+\d{4}\)\s*$"#, - #"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"#, - ] - var result = self - for pattern in patterns { - result = result.replacingOccurrences(of: pattern, with: "", options: .regularExpression) - } - return result.trimmingCharacters(in: .whitespaces) - } -} diff --git a/ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj b/ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj deleted file mode 100644 index 227599a..0000000 --- a/ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj +++ /dev/null @@ -1,577 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXBuildFile section */ - 075C7E597E108D806195B2F0 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */; }; - 280AC764BC30130EDB27A3F0 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */; }; - 29D0FB039902E6691FBE40DA /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */; }; - 2FB2A044EBE6B90CFB51CF58 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2634D20198A966396121230 /* LibraryView.swift */; }; - 30EE28A725E2FA69F8FFCEF8 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */; }; - 43034688B18F6F6CD65C5DE5 /* BrowseCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */; }; - 464782001051686356AF728B /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736DA6CB7D7759E1791F6236 /* SearchView.swift */; }; - 4F72B63F12BB364C561B5B69 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378336A1684E738283821857 /* ContentView.swift */; }; - 5FCFCBFBEEFDFD2081068317 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4A3006B972DFF660959FE3 /* APIClient.swift */; }; - 6340BF19FE12FCEBE9607889 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D37A1BCABF9787BA6E243C8F /* ProfileView.swift */; }; - 64B17B6E30F44E87F33B886B /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */; }; - 7431E92F141CFFF28E891A11 /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C68D19B123EC191D53A694E /* BookDetailView.swift */; }; - 78F2392702ACB553CAFDB335 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D006B236F6FE653131FFD2 /* PlayerViews.swift */; }; - 792042C137942BCF8CB99C4F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125054A25A37A42295D49B10 /* NetworkMonitor.swift */; }; - 7C59289066AFD8A999DB9A0A /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF859D4970913FEBA89CB0F /* CommonViews.swift */; }; - 9F4A645472DC48AD32D5EDCD /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F48756100041DE38F573449 /* ChapterReaderView.swift */; }; - 9FD80E1B54ED74F430064904 /* LibNovelV2App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */; }; - A753C2AE73CAA00BF1AB0EA4 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880A0B86A80386BEA76FF388 /* NavDestination.swift */; }; - B1E2F3A4C5D6E7F8A9B0C1D2 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */; }; - ABB16424CEED3C5E9AAC08B2 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */; }; - ACCA21E0EDF8BED26E193A76 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92BE9AB59740382D85BD5296 /* DownloadsView.swift */; }; - ACE6D62D8E547A90380FB689 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */; }; - B4C6205A3A7A7A29EDA691FF /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5E37231B0150A128C72D49 /* HomeView.swift */; }; - B8C5C43F299C89CFAE4000F1 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC088495FFC3053AAE0F124 /* RootTabView.swift */; }; - BEE8DF9B5E6C35389FB07951 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D715E3B2A6FE40FB628ADD2D /* AuthView.swift */; }; - C0EA8DBE751CB22F058CBF20 /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB713100B2B1F429924A107C /* VoiceSelectionView.swift */; }; - DDBAD183F7974A6FDAECB93C /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */; }; - E64BCBBA92A983C3851754B5 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */; }; - E8112B785D129C26FEC054AB /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */; }; - F1DB9BC6DC6DFEEA010B7CDF /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98B6C380A20E783F1F7A7DB /* AuthStore.swift */; }; - F4DAA587A097C597A9841563 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B8078366569A958BE54D23 /* BrowseViewModel.swift */; }; - FC954C552CC0BDFB619BF207 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4180EB2AEECC51E4A7F5231 /* Models.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; }; - 125054A25A37A42295D49B10 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; }; - 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; }; - 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = "<group>"; }; - 378336A1684E738283821857 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; - 3C5E37231B0150A128C72D49 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; - 3EF859D4970913FEBA89CB0F /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = "<group>"; }; - 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = "<group>"; }; - 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; }; - 5C68D19B123EC191D53A694E /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; }; - 71D006B236F6FE653131FFD2 /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; }; - 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = "<group>"; }; - 736DA6CB7D7759E1791F6236 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; }; - 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; }; - 7F4A3006B972DFF660959FE3 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; }; - 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelV2App.swift; sourceTree = "<group>"; }; - 880A0B86A80386BEA76FF388 /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = "<group>"; }; - C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; }; - 8F48756100041DE38F573449 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = "<group>"; }; - 92BE9AB59740382D85BD5296 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = "<group>"; }; - 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; }; - 94CB555099A941E16AD0531A /* LibNovelV2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovelV2.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; }; - ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseCategoryView.swift; sourceTree = "<group>"; }; - B4180EB2AEECC51E4A7F5231 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; }; - BFC088495FFC3053AAE0F124 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; }; - D37A1BCABF9787BA6E243C8F /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; }; - D715E3B2A6FE40FB628ADD2D /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; }; - DB713100B2B1F429924A107C /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = "<group>"; }; - F2634D20198A966396121230 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; }; - F2B8078366569A958BE54D23 /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; }; - F98B6C380A20E783F1F7A7DB /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; }; - FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; }; -/* End PBXFileReference section */ - -/* Begin PBXGroup section */ - 03533E32FF0C2EAF1915AD15 /* Products */ = { - isa = PBXGroup; - children = ( - 94CB555099A941E16AD0531A /* LibNovelV2.app */, - ); - name = Products; - sourceTree = "<group>"; - }; - 19F98554C19DCB1FD6ED835E /* Services */ = { - isa = PBXGroup; - children = ( - 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */, - 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */, - F98B6C380A20E783F1F7A7DB /* AuthStore.swift */, - 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */, - 125054A25A37A42295D49B10 /* NetworkMonitor.swift */, - ); - path = Services; - sourceTree = "<group>"; - }; - 20E9B4B0C0EDDB3313149544 /* Common */ = { - isa = PBXGroup; - children = ( - 3EF859D4970913FEBA89CB0F /* CommonViews.swift */, - ); - path = Common; - sourceTree = "<group>"; - }; - 25D179F65B0041EE826DEF5B /* App */ = { - isa = PBXGroup; - children = ( - 378336A1684E738283821857 /* ContentView.swift */, - 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */, - BFC088495FFC3053AAE0F124 /* RootTabView.swift */, - ); - path = App; - sourceTree = "<group>"; - }; - 2F4B97A2A2234F71AE2C46B2 /* Home */ = { - isa = PBXGroup; - children = ( - 3C5E37231B0150A128C72D49 /* HomeView.swift */, - ); - path = Home; - sourceTree = "<group>"; - }; - 36240FA179A3701F15D1AAE1 /* Extensions */ = { - isa = PBXGroup; - children = ( - 880A0B86A80386BEA76FF388 /* NavDestination.swift */, - C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */, - ); - path = Extensions; - sourceTree = "<group>"; - }; - 3A6125CA86E249F3D6DC7F8C /* BookDetail */ = { - isa = PBXGroup; - children = ( - 5C68D19B123EC191D53A694E /* BookDetailView.swift */, - ); - path = BookDetail; - sourceTree = "<group>"; - }; - 448620B67D4AEEEF2CAED3C0 /* Models */ = { - isa = PBXGroup; - children = ( - B4180EB2AEECC51E4A7F5231 /* Models.swift */, - ); - path = Models; - sourceTree = "<group>"; - }; - 716D22431B17611F7A418D9F /* Profile */ = { - isa = PBXGroup; - children = ( - D37A1BCABF9787BA6E243C8F /* ProfileView.swift */, - 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */, - DB713100B2B1F429924A107C /* VoiceSelectionView.swift */, - ); - path = Profile; - sourceTree = "<group>"; - }; - 8BCE05349B706BF8EE0E16DD /* LibNovelV2 */ = { - isa = PBXGroup; - children = ( - ); - name = LibNovelV2; - path = .; - sourceTree = "<group>"; - }; - 9AFE0816FF2E9D8DBBA470BD /* Downloads */ = { - isa = PBXGroup; - children = ( - 92BE9AB59740382D85BD5296 /* DownloadsView.swift */, - ); - path = Downloads; - sourceTree = "<group>"; - }; - 9CFE23EEA1B9E264A36D0FC4 /* Search */ = { - isa = PBXGroup; - children = ( - 736DA6CB7D7759E1791F6236 /* SearchView.swift */, - ); - path = Search; - sourceTree = "<group>"; - }; - 9E5A2471B9D5ECAF6B65FD22 /* ViewModels */ = { - isa = PBXGroup; - children = ( - 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */, - F2B8078366569A958BE54D23 /* BrowseViewModel.swift */, - 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */, - 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */, - 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */, - FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */, - ); - path = ViewModels; - sourceTree = "<group>"; - }; - A05A1FE213A8E179B2302EF2 /* Auth */ = { - isa = PBXGroup; - children = ( - D715E3B2A6FE40FB628ADD2D /* AuthView.swift */, - ); - path = Auth; - sourceTree = "<group>"; - }; - AA1F8D9C3DA40A1ADCF2B432 = { - isa = PBXGroup; - children = ( - 25D179F65B0041EE826DEF5B /* App */, - 36240FA179A3701F15D1AAE1 /* Extensions */, - 8BCE05349B706BF8EE0E16DD /* LibNovelV2 */, - 448620B67D4AEEEF2CAED3C0 /* Models */, - AFDC950B142FEDA471F394EC /* Networking */, - C468271A8BC443D1B82A1BE0 /* Resources */, - 19F98554C19DCB1FD6ED835E /* Services */, - 9E5A2471B9D5ECAF6B65FD22 /* ViewModels */, - CBC1A32FA53E9B3D5E15995D /* Views */, - 03533E32FF0C2EAF1915AD15 /* Products */, - ); - indentWidth = 4; - sourceTree = "<group>"; - tabWidth = 4; - usesTabs = 0; - }; - AF1FE530FDE94947D4966251 /* ChapterReader */ = { - isa = PBXGroup; - children = ( - 8F48756100041DE38F573449 /* ChapterReaderView.swift */, - ); - path = ChapterReader; - sourceTree = "<group>"; - }; - AFDC950B142FEDA471F394EC /* Networking */ = { - isa = PBXGroup; - children = ( - 7F4A3006B972DFF660959FE3 /* APIClient.swift */, - ); - path = Networking; - sourceTree = "<group>"; - }; - BFA030D1CE2D312C539318DA /* Browse */ = { - isa = PBXGroup; - children = ( - ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */, - 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */, - ); - path = Browse; - sourceTree = "<group>"; - }; - C468271A8BC443D1B82A1BE0 /* Resources */ = { - isa = PBXGroup; - children = ( - ); - path = Resources; - sourceTree = "<group>"; - }; - CBC1A32FA53E9B3D5E15995D /* Views */ = { - isa = PBXGroup; - children = ( - A05A1FE213A8E179B2302EF2 /* Auth */, - 3A6125CA86E249F3D6DC7F8C /* BookDetail */, - BFA030D1CE2D312C539318DA /* Browse */, - AF1FE530FDE94947D4966251 /* ChapterReader */, - 20E9B4B0C0EDDB3313149544 /* Common */, - 9AFE0816FF2E9D8DBBA470BD /* Downloads */, - 2F4B97A2A2234F71AE2C46B2 /* Home */, - ED5843EA1B9CB1AD97664571 /* Library */, - F9025CCFC608DCEB21B4D9F5 /* Player */, - 716D22431B17611F7A418D9F /* Profile */, - 9CFE23EEA1B9E264A36D0FC4 /* Search */, - ); - path = Views; - sourceTree = "<group>"; - }; - ED5843EA1B9CB1AD97664571 /* Library */ = { - isa = PBXGroup; - children = ( - F2634D20198A966396121230 /* LibraryView.swift */, - ); - path = Library; - sourceTree = "<group>"; - }; - F9025CCFC608DCEB21B4D9F5 /* Player */ = { - isa = PBXGroup; - children = ( - 71D006B236F6FE653131FFD2 /* PlayerViews.swift */, - ); - path = Player; - sourceTree = "<group>"; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 7EEA688C50B734EA22C04CF1 /* LibNovelV2 */ = { - isa = PBXNativeTarget; - buildConfigurationList = 38B2D5E78E086CB61602C375 /* Build configuration list for PBXNativeTarget "LibNovelV2" */; - buildPhases = ( - BE6BEAD873B53447AABD2346 /* Sources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = LibNovelV2; - packageProductDependencies = ( - ); - productName = LibNovelV2; - productReference = 94CB555099A941E16AD0531A /* LibNovelV2.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 1AC8476B8E9026EB9CE2B4FF /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1600; - }; - buildConfigurationList = 92AD4EEF6E109D5DC11B2A6F /* Build configuration list for PBXProject "LibNovelV2" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - Base, - en, - ); - mainGroup = AA1F8D9C3DA40A1ADCF2B432; - minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; - productRefGroup = 03533E32FF0C2EAF1915AD15 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 7EEA688C50B734EA22C04CF1 /* LibNovelV2 */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXSourcesBuildPhase section */ - BE6BEAD873B53447AABD2346 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 5FCFCBFBEEFDFD2081068317 /* APIClient.swift in Sources */, - 280AC764BC30130EDB27A3F0 /* AudioDownloadService.swift in Sources */, - E64BCBBA92A983C3851754B5 /* AudioPlayerService.swift in Sources */, - F1DB9BC6DC6DFEEA010B7CDF /* AuthStore.swift in Sources */, - BEE8DF9B5E6C35389FB07951 /* AuthView.swift in Sources */, - 7431E92F141CFFF28E891A11 /* BookDetailView.swift in Sources */, - 30EE28A725E2FA69F8FFCEF8 /* BookDetailViewModel.swift in Sources */, - ACE6D62D8E547A90380FB689 /* BookVoicePreferences.swift in Sources */, - 43034688B18F6F6CD65C5DE5 /* BrowseCategoryView.swift in Sources */, - ABB16424CEED3C5E9AAC08B2 /* BrowseView.swift in Sources */, - F4DAA587A097C597A9841563 /* BrowseViewModel.swift in Sources */, - 9F4A645472DC48AD32D5EDCD /* ChapterReaderView.swift in Sources */, - 64B17B6E30F44E87F33B886B /* ChapterReaderViewModel.swift in Sources */, - 7C59289066AFD8A999DB9A0A /* CommonViews.swift in Sources */, - 4F72B63F12BB364C561B5B69 /* ContentView.swift in Sources */, - ACCA21E0EDF8BED26E193A76 /* DownloadsView.swift in Sources */, - B4C6205A3A7A7A29EDA691FF /* HomeView.swift in Sources */, - 075C7E597E108D806195B2F0 /* HomeViewModel.swift in Sources */, - 9FD80E1B54ED74F430064904 /* LibNovelV2App.swift in Sources */, - 2FB2A044EBE6B90CFB51CF58 /* LibraryView.swift in Sources */, - DDBAD183F7974A6FDAECB93C /* LibraryViewModel.swift in Sources */, - FC954C552CC0BDFB619BF207 /* Models.swift in Sources */, - A753C2AE73CAA00BF1AB0EA4 /* NavDestination.swift in Sources */, - B1E2F3A4C5D6E7F8A9B0C1D2 /* String+App.swift in Sources */, - 792042C137942BCF8CB99C4F /* NetworkMonitor.swift in Sources */, - 78F2392702ACB553CAFDB335 /* PlayerViews.swift in Sources */, - 6340BF19FE12FCEBE9607889 /* ProfileView.swift in Sources */, - B8C5C43F299C89CFAE4000F1 /* RootTabView.swift in Sources */, - 464782001051686356AF728B /* SearchView.swift in Sources */, - 29D0FB039902E6691FBE40DA /* SearchViewModel.swift in Sources */, - E8112B785D129C26FEC054AB /* UserProfileView.swift in Sources */, - C0EA8DBE751CB22F058CBF20 /* VoiceSelectionView.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 019B1386650D49B9F4F6CCF7 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_PREVIEWS = YES; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; - MARKETING_VERSION = 1.0.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.10; - }; - name = Release; - }; - 086D97837CBA0A9177D50BB2 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = GHZXC6FVMU; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovelV2; - PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f"; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - 1A953D152E39A2F172BB4DE4 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = GHZXC6FVMU; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovelV2; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - C91972DB753AE2CF04BED70E /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_PREVIEWS = YES; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "DEBUG=1", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; - MARKETING_VERSION = 1.0.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.10; - }; - name = Debug; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 38B2D5E78E086CB61602C375 /* Build configuration list for PBXNativeTarget "LibNovelV2" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 1A953D152E39A2F172BB4DE4 /* Debug */, - 086D97837CBA0A9177D50BB2 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; - 92AD4EEF6E109D5DC11B2A6F /* Build configuration list for PBXProject "LibNovelV2" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - C91972DB753AE2CF04BED70E /* Debug */, - 019B1386650D49B9F4F6CCF7 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; -/* End XCConfigurationList section */ - }; - rootObject = 1AC8476B8E9026EB9CE2B4FF /* Project object */; -} diff --git a/ios/LibNovelV2/LibNovelV2.xcodeproj/xcshareddata/xcschemes/LibNovelV2.xcscheme b/ios/LibNovelV2/LibNovelV2.xcodeproj/xcshareddata/xcschemes/LibNovelV2.xcscheme deleted file mode 100644 index 3a7f4a9..0000000 --- a/ios/LibNovelV2/LibNovelV2.xcodeproj/xcshareddata/xcschemes/LibNovelV2.xcscheme +++ /dev/null @@ -1,100 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Scheme - LastUpgradeVersion = "1600" - version = "1.7"> - <BuildAction - parallelizeBuildables = "YES" - buildImplicitDependencies = "YES" - runPostActionsOnFailure = "NO"> - <BuildActionEntries> - <BuildActionEntry - buildForTesting = "YES" - buildForRunning = "YES" - buildForProfiling = "YES" - buildForArchiving = "YES" - buildForAnalyzing = "YES"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "7EEA688C50B734EA22C04CF1" - BuildableName = "LibNovelV2.app" - BlueprintName = "LibNovelV2" - ReferencedContainer = "container:LibNovelV2.xcodeproj"> - </BuildableReference> - </BuildActionEntry> - </BuildActionEntries> - </BuildAction> - <TestAction - buildConfiguration = "Debug" - selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" - selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - onlyGenerateCoverageForSpecifiedTargets = "NO"> - <MacroExpansion> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "7EEA688C50B734EA22C04CF1" - BuildableName = "LibNovelV2.app" - BlueprintName = "LibNovelV2" - ReferencedContainer = "container:LibNovelV2.xcodeproj"> - </BuildableReference> - </MacroExpansion> - <Testables> - </Testables> - </TestAction> - <LaunchAction - buildConfiguration = "Debug" - selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" - selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - launchStyle = "0" - useCustomWorkingDirectory = "NO" - ignoresPersistentStateOnLaunch = "NO" - debugDocumentVersioning = "YES" - debugServiceExtension = "internal" - allowLocationSimulation = "YES"> - <BuildableProductRunnable - runnableDebuggingMode = "0"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "7EEA688C50B734EA22C04CF1" - BuildableName = "LibNovelV2.app" - BlueprintName = "LibNovelV2" - ReferencedContainer = "container:LibNovelV2.xcodeproj"> - </BuildableReference> - </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> - <EnvironmentVariables> - <EnvironmentVariable - key = "LIBNOVEL_BASE_URL" - value = "["value": "https://v2.libnovel.kalekber.cc", "isEnabled": true]" - isEnabled = "YES"> - </EnvironmentVariable> - </EnvironmentVariables> - </LaunchAction> - <ProfileAction - buildConfiguration = "Release" - shouldUseLaunchSchemeArgsEnv = "YES" - savedToolIdentifier = "" - useCustomWorkingDirectory = "NO" - debugDocumentVersioning = "YES"> - <BuildableProductRunnable - runnableDebuggingMode = "0"> - <BuildableReference - BuildableIdentifier = "primary" - BlueprintIdentifier = "7EEA688C50B734EA22C04CF1" - BuildableName = "LibNovelV2.app" - BlueprintName = "LibNovelV2" - ReferencedContainer = "container:LibNovelV2.xcodeproj"> - </BuildableReference> - </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> - </ProfileAction> - <AnalyzeAction - buildConfiguration = "Debug"> - </AnalyzeAction> - <ArchiveAction - buildConfiguration = "Release" - revealArchiveInOrganizer = "YES"> - </ArchiveAction> -</Scheme> diff --git a/ios/LibNovelV2/Models/Models.swift b/ios/LibNovelV2/Models/Models.swift deleted file mode 100644 index 24f8055..0000000 --- a/ios/LibNovelV2/Models/Models.swift +++ /dev/null @@ -1,417 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - Book - -struct Book: Identifiable, Codable, Hashable { - let id: String - let slug: String - let title: String - let author: String - let cover: String // proxied via /api/cover/... - let status: String - let genres: [String] - let summary: String - let totalChapters: Int - let sourceURL: String - let ranking: Int - let metaUpdated: String - - enum CodingKeys: String, CodingKey { - case id, slug, title, author, cover, status, genres, summary, ranking - case totalChapters = "total_chapters" - case sourceURL = "source_url" - case metaUpdated = "meta_updated" - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - slug = try c.decode(String.self, forKey: .slug) - title = try c.decode(String.self, forKey: .title) - author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" - cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" - status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" - totalChapters = try c.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0 - sourceURL = try c.decodeIfPresent(String.self, forKey: .sourceURL) ?? "" - ranking = try c.decodeIfPresent(Int.self, forKey: .ranking) ?? 0 - metaUpdated = try c.decodeIfPresent(String.self, forKey: .metaUpdated) ?? "" - summary = try c.decodeIfPresent(String.self, forKey: .summary) ?? "" - - // genres can arrive as a JSON-encoded string or a real array - if let arr = try? c.decode([String].self, forKey: .genres) { - genres = arr - } else if let raw = try? c.decode(String.self, forKey: .genres), - let data = raw.data(using: .utf8), - let arr = try? JSONDecoder().decode([String].self, from: data) { - genres = arr - } else { - genres = [] - } - } -} - -// MARK: - Chapter index - -struct ChapterIndex: Identifiable, Codable, Hashable { - let id: String - let slug: String - let number: Int - let title: String - let dateLabel: String - - enum CodingKeys: String, CodingKey { - case id, slug, number, title - case dateLabel = "date_label" - } -} - -struct ChapterBrief: Identifiable, Codable, Hashable { - var id: Int { number } - let number: Int - let title: String -} - -// Full chapter response from /api/chapter-text/{slug}/{n} -struct ChapterResponse: Decodable { - struct BookBrief: Decodable { - let slug: String - let title: String - let cover: String - } - struct ChapterDetail: Decodable { - let number: Int - let title: String - let dateLabel: String - - enum CodingKeys: String, CodingKey { - case number, title - case dateLabel = "date_label" - } - } - - let book: BookBrief - let chapter: ChapterDetail - let chapters: [ChapterBrief] - let html: String - let text: String - let prev: Int? - let next: Int? -} - -// MARK: - Ranking - -struct RankingItem: Codable, Identifiable { - var id: String { slug } - let rank: Int - let slug: String - let title: String - let author: String - let cover: String - let status: String - let genres: [String] - let sourceURL: String - - enum CodingKeys: String, CodingKey { - case rank, slug, title, author, cover, status, genres - case sourceURL = "source_url" - } -} - -// MARK: - Browse listing - -struct NovelListing: Codable, Identifiable { - var id: String { slug } - let slug: String - let title: String - let author: String? - let cover: String? - let status: String? - let genres: [String]? - let rank: Int? - let rating: String? - let chapters: String? // e.g. "123 chapters" - let url: String? - let sourceURL: String? - - enum CodingKeys: String, CodingKey { - case slug, title, author, cover, status, genres, rank, rating, chapters, url - case sourceURL = "source_url" - } -} - -// MARK: - Home - -struct HomeStats: Codable { - let totalBooks: Int - let totalChapters: Int - let booksInProgress: Int - - enum CodingKeys: String, CodingKey { - case totalBooks = "total_books" - case totalChapters = "total_chapters" - case booksInProgress = "books_in_progress" - } -} - -struct ContinueReadingItem: Identifiable { - var id: String { book.id } - let book: Book - let chapter: Int -} - -struct SubscriptionFeedItem: Identifiable, Decodable { - var id: String { book.id + readerUsername } - let book: Book - let readerUsername: String - - enum CodingKeys: String, CodingKey { - case book - case readerUsername = "readerUsername" - } -} - -// MARK: - User - -struct AppUser: Codable, Identifiable { - let id: String - let username: String - let role: String - let created: String - let avatarURL: String? - - var isAdmin: Bool { role == "admin" } - - enum CodingKeys: String, CodingKey { - case id, username, role, created - case avatarURL = "avatar_url" - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - username = try c.decode(String.self, forKey: .username) - role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" - created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" - avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL) - } - - init(id: String, username: String, role: String, created: String, avatarURL: String?) { - self.id = id - self.username = username - self.role = role - self.created = created - self.avatarURL = avatarURL - } -} - -// MARK: - User settings - -struct UserSettings: Codable { - var autoNext: Bool - var voice: String - var speed: Double - - static let `default` = UserSettings(autoNext: false, voice: "af_bella", speed: 1.0) -} - -// MARK: - Session - -struct UserSession: Codable, Identifiable { - let id: String - let userAgent: String - let ip: String - let createdAt: String - let lastSeen: String - var isCurrent: Bool - - enum CodingKeys: String, CodingKey { - case id, ip - case userAgent = "user_agent" - case createdAt = "created_at" - case lastSeen = "last_seen" - case isCurrent = "is_current" - } -} - -// MARK: - Comments - -struct BookComment: Identifiable, Codable, Hashable { - let id: String - let slug: String - let userId: String - let username: String - let body: String - var upvotes: Int - var downvotes: Int - let created: String - let parentId: String - var replies: [BookComment]? - - enum CodingKeys: String, CodingKey { - case id, slug, username, body, upvotes, downvotes, created, replies - case userId = "user_id" - case parentId = "parent_id" - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" - userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? "" - username = try c.decodeIfPresent(String.self, forKey: .username) ?? "" - body = try c.decodeIfPresent(String.self, forKey: .body) ?? "" - upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0 - downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0 - created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" - parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? "" - replies = try c.decodeIfPresent([BookComment].self, forKey: .replies) - } -} - -struct CommentsResponse: Decodable { - let comments: [BookComment] - let myVotes: [String: String] - let avatarUrls: [String: String] - - enum CodingKeys: String, CodingKey { - case comments, myVotes, avatarUrls - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - comments = try c.decode([BookComment].self, forKey: .comments) - myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:] - avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:] - } -} - -// MARK: - Public user profile - -struct PublicUserProfile: Decodable, Identifiable { - let id: String - let username: String - let avatarUrl: String? - let created: String - let followerCount: Int - let followingCount: Int - let isSubscribed: Bool - let isSelf: Bool - - enum CodingKeys: String, CodingKey { - case id, username, created - case avatarUrl = "avatarUrl" - case followerCount = "followerCount" - case followingCount = "followingCount" - case isSubscribed = "isSubscribed" - case isSelf = "isSelf" - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - username = try c.decode(String.self, forKey: .username) - avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl) - created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" - followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0 - followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0 - isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false - isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false - } -} - -struct PublicLibraryItem: Decodable, Identifiable { - var id: String { book.id } - let book: Book - let lastChapter: Int? - let saved: Bool - - enum CodingKeys: String, CodingKey { - case book - case lastChapter = "last_chapter" - case saved - } -} - -struct PublicUserLibraryResponse: Decodable { - let currentlyReading: [PublicLibraryItem] - let library: [PublicLibraryItem] - - enum CodingKeys: String, CodingKey { - case currentlyReading = "currently_reading" - case library - } -} - -// MARK: - Reader Settings (local — UserDefaults) - -enum ReaderTheme: String, CaseIterable, Codable { - case white, sepia, night - - var backgroundColor: Color { - switch self { - case .white: return Color(.sRGB, white: 1.0, opacity: 1) - case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82) - case .night: return Color(red: 0.10, green: 0.10, blue: 0.12) - } - } - - var textColor: Color { - switch self { - case .white: return Color(.sRGB, white: 0.10, opacity: 1) - case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08) - case .night: return Color(red: 0.85, green: 0.85, blue: 0.87) - } - } - - var colorScheme: ColorScheme? { - switch self { - case .white: return nil - case .sepia: return .light - case .night: return .dark - } - } -} - -enum ReaderFont: String, CaseIterable, Codable { - case system = "System" - case georgia = "Georgia" - case newYork = "New York" - - var fontName: String? { - switch self { - case .system: return nil - case .georgia: return "Georgia" - case .newYork: return "NewYorkMedium-Regular" - } - } -} - -struct ReaderSettings: Codable, Equatable { - var fontSize: CGFloat = 17 - var lineSpacing: CGFloat = 1.7 - var font: ReaderFont = .system - var theme: ReaderTheme = .white - var scrollMode: Bool = false - - private static let key = "v2.readerSettings" - - static func load() -> ReaderSettings { - guard let data = UserDefaults.standard.data(forKey: key), - let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data) - else { return ReaderSettings() } - return decoded - } - - func save() { - if let data = try? JSONEncoder().encode(self) { - UserDefaults.standard.set(data, forKey: ReaderSettings.key) - } - } -} - -// MARK: - Audio prefetch status - -enum NextPrefetchStatus { - case none, prefetching, prefetched, failed -} diff --git a/ios/LibNovelV2/Networking/APIClient.swift b/ios/LibNovelV2/Networking/APIClient.swift deleted file mode 100644 index 96f0196..0000000 --- a/ios/LibNovelV2/Networking/APIClient.swift +++ /dev/null @@ -1,520 +0,0 @@ -import Foundation - -// MARK: - API Client -// Communicates with the SvelteKit UI server (/api/* endpoints). -// Auth is carried via the libnovel_auth cookie (HMAC-signed token). - -actor APIClient { - static let shared = APIClient() - - var baseURL: URL - private var authCookie: String? // raw "libnovel_auth=<token>" header value - - private let session: URLSession = { - let config = URLSessionConfiguration.default - config.httpCookieAcceptPolicy = .always - config.httpShouldSetCookies = true - config.httpCookieStorage = HTTPCookieStorage.shared - return URLSession(configuration: config) - }() - - private init() { - let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String - ?? "https://v2.libnovel.kalekber.cc" - baseURL = URL(string: urlString)! - } - - // MARK: - Auth cookie management - - func setAuthCookie(_ value: String?) { - authCookie = value - if let value { - let cookieProps: [HTTPCookiePropertyKey: Any] = [ - .name: "libnovel_auth", - .value: value, - .domain: baseURL.host ?? "localhost", - .path: "/" - ] - if let cookie = HTTPCookie(properties: cookieProps) { - HTTPCookieStorage.shared.setCookie(cookie) - } - } else { - let storage = HTTPCookieStorage.shared - storage.cookies(for: baseURL)?.forEach { storage.deleteCookie($0) } - } - } - - // MARK: - Low-level request builder - - private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest { - let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - + "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard let url = URL(string: urlString) else { throw APIError.invalidResponse } - var req = URLRequest(url: url) - req.httpMethod = method - req.setValue("application/json", forHTTPHeaderField: "Accept") - if let body { - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.httpBody = try JSONEncoder().encode(body) - } - return req - } - - // MARK: - Generic fetch - - func fetch<T: Decodable>(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T { - let req = try makeRequest(path, method: method, body: body) - let (data, response) = try await session.data(for: req) - guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } - let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8, \(data.count) bytes>" - guard (200..<300).contains(http.statusCode) else { - if http.statusCode == 401 { throw APIError.unauthorized } - throw APIError.httpError(http.statusCode, rawBody) - } - do { - return try JSONDecoder.apiDecoder.decode(T.self, from: data) - } catch { - throw APIError.decodingError(error) - } - } - - func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws { - let req = try makeRequest(path, method: method, body: body) - let (data, response) = try await session.data(for: req) - guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } - guard (200..<300).contains(http.statusCode) else { - let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8, \(data.count) bytes>" - throw APIError.httpError(http.statusCode, rawBody) - } - } - - // MARK: - Auth - - private struct LoginRequest: Encodable { - let username: String - let password: String - } - - struct LoginResponse: Decodable { - let token: String - let user: AppUser - } - - func login(username: String, password: String) async throws -> LoginResponse { - try await fetch("/api/auth/login", method: "POST", - body: LoginRequest(username: username, password: password)) - } - - func register(username: String, password: String) async throws -> LoginResponse { - try await fetch("/api/auth/register", method: "POST", - body: LoginRequest(username: username, password: password)) - } - - func logout() async throws { - let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST") - setAuthCookie(nil) - } - - // MARK: - Home - - func homeData() async throws -> HomeDataResponse { - try await fetch("/api/home") - } - - // MARK: - Library - - func library() async throws -> [LibraryItem] { - try await fetch("/api/library") - } - - func saveBook(slug: String) async throws { - let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST") - } - - func unsaveBook(slug: String) async throws { - let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE") - } - - // MARK: - Book Detail - - func bookDetail(slug: String) async throws -> BookDetailResponse { - try await fetch("/api/book/\(slug)") - } - - // MARK: - Chapter - - func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse { - try await fetch("/api/chapter/\(slug)/\(chapter)") - } - - // MARK: - Browse - - func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse { - let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)" - return try await fetch("/api/browse-page\(query)") - } - - func search(query: String) async throws -> SearchResponse { - let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query - return try await fetch("/api/search?q=\(encoded)") - } - - func ranking() async throws -> [RankingItem] { - try await fetch("/api/ranking") - } - - // MARK: - Progress - - func progress() async throws -> [ProgressEntry] { - try await fetch("/api/progress") - } - - func setProgress(slug: String, chapter: Int) async throws { - struct Body: Encodable { let chapter: Int } - let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter)) - } - - func deleteProgress(slug: String) async throws { - let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE") - } - - func audioTime(slug: String, chapter: Int) async throws -> Double? { - struct Response: Decodable { - let audioTime: Double? - enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } - } - let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)") - return r.audioTime - } - - func setAudioTime(slug: String, chapter: Int, time: Double) async throws { - struct Body: Encodable { - let slug: String; let chapter: Int; let audioTime: Double - enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" } - } - let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH", - body: Body(slug: slug, chapter: chapter, audioTime: time)) - } - - // MARK: - Audio - - func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse { - struct Body: Encodable { let voice: String; let speed: Double } - return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed)) - } - - /// Poll until the TTS job is done, failed, or the task is cancelled. - /// Returns the playback URL on success. - func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String { - let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)" - struct StatusResponse: Decodable { - let status: String - let url: String? - let error: String? - } - while true { - try Task.checkCancellation() - let r: StatusResponse = try await fetch(path) - switch r.status { - case "done": - guard let url = r.url, !url.isEmpty else { throw URLError(.badServerResponse) } - return url - case "failed": - throw NSError(domain: "AudioGeneration", code: 0, - userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"]) - default: - try await Task.sleep(nanoseconds: 2_000_000_000) - } - } - } - - func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String { - struct Response: Decodable { let url: String } - let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)") - return r.url - } - - func presignVoiceSample(voice: String) async throws -> String { - struct Response: Decodable { let url: String } - let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)") - return r.url - } - - func voices() async throws -> [String] { - struct Response: Decodable { let voices: [String] } - let r: Response = try await fetch("/api/voices") - return r.voices - } - - // MARK: - Settings - - func settings() async throws -> UserSettings { - try await fetch("/api/settings") - } - - func updateSettings(_ settings: UserSettings) async throws { - let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings) - } - - // MARK: - Sessions - - func sessions() async throws -> [UserSession] { - struct Response: Decodable { let sessions: [UserSession] } - let r: Response = try await fetch("/api/sessions") - return r.sessions - } - - func revokeSession(id: String) async throws { - let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE") - } - - // MARK: - Avatar - - struct AvatarPresignResponse: Decodable { - let uploadURL: String - let key: String - enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key } - } - - struct AvatarResponse: Decodable { - let avatarURL: String? - enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" } - } - - func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? { - let presign: AvatarPresignResponse = try await fetch( - "/api/profile/avatar", method: "POST", body: ["mime_type": mimeType]) - - guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse } - var putReq = URLRequest(url: putURL) - putReq.httpMethod = "PUT" - putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type") - putReq.httpBody = imageData - let (_, putResp) = try await session.data(for: putReq) - guard let putHttp = putResp as? HTTPURLResponse, (200..<300).contains(putHttp.statusCode) else { - throw APIError.httpError((putResp as? HTTPURLResponse)?.statusCode ?? 0, "MinIO PUT failed") - } - - let result: AvatarResponse = try await fetch("/api/profile/avatar", method: "PATCH", body: ["key": presign.key]) - return result.avatarURL - } - - func fetchAvatarPresignedURL() async throws -> String? { - let result: AvatarResponse = try await fetch("/api/profile/avatar") - return result.avatarURL - } - - // MARK: - User Profiles & Subscriptions - - func fetchUserProfile(username: String) async throws -> PublicUserProfile { - try await fetch("/api/users/\(username)") - } - - @discardableResult - func subscribeUser(username: String) async throws -> Bool { - struct Response: Decodable { let subscribed: Bool } - let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST") - return r.subscribed - } - - @discardableResult - func unsubscribeUser(username: String) async throws -> Bool { - struct Response: Decodable { let subscribed: Bool } - let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE") - return r.subscribed - } - - func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse { - try await fetch("/api/users/\(username)/library") - } - - // MARK: - Comments - - func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse { - try await fetch("/api/comments/\(slug)?sort=\(sort)") - } - - private struct PostCommentBody: Encodable { - let body: String - let parent_id: String? - } - - func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment { - try await fetch("/api/comments/\(slug)", method: "POST", - body: PostCommentBody(body: body, parent_id: parentId)) - } - - func voteComment(commentId: String, vote: String) async throws -> BookComment { - struct VoteBody: Encodable { let vote: String } - return try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote)) - } - - func deleteComment(commentId: String) async throws { - try await fetchVoid("/api/comment/\(commentId)", method: "DELETE") - } -} - -// MARK: - Response types - -struct HomeDataResponse: Decodable { - struct ContinueItem: Decodable { - let book: Book - let chapter: Int - } - let continueReading: [ContinueItem] - let recentlyUpdated: [Book] - let stats: HomeStats - let subscriptionFeed: [SubscriptionFeedItem] - - enum CodingKeys: String, CodingKey { - case continueReading = "continue_reading" - case recentlyUpdated = "recently_updated" - case stats - case subscriptionFeed = "subscription_feed" - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? [] - recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? [] - stats = try c.decode(HomeStats.self, forKey: .stats) - subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? [] - } -} - -struct LibraryItem: Decodable, Identifiable { - var id: String { book.id } - let book: Book - let savedAt: String - let lastChapter: Int? - - enum CodingKeys: String, CodingKey { - case book - case savedAt = "saved_at" - case lastChapter = "last_chapter" - } -} - -struct BookDetailResponse: Decodable { - let book: Book - let chapters: [ChapterIndex] - let inLib: Bool - let saved: Bool - let lastChapter: Int? - - enum CodingKeys: String, CodingKey { - case book, chapters - case inLib = "in_lib" - case saved - case lastChapter = "last_chapter" - } -} - -struct BrowseResponse: Decodable { - let novels: [BrowseNovel] - let page: Int - let hasNext: Bool -} - -struct BrowseNovel: Decodable, Identifiable, Hashable { - var id: String { slug.isEmpty ? url : slug } - let slug: String - let title: String - let cover: String - let rank: String - let rating: String - let chapters: String - let url: String - let author: String - let status: String - let genres: [String] - - enum CodingKeys: String, CodingKey { - case slug, title, cover, rank, rating, chapters, url, author, status, genres - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" - title = try c.decode(String.self, forKey: .title) - cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" - rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? "" - rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? "" - chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? "" - url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" - author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" - status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" - genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? [] - } -} - -struct SearchResponse: Decodable { - let results: [BrowseNovel] - let localCount: Int - let remoteCount: Int - - enum CodingKeys: String, CodingKey { - case results - case localCount = "local_count" - case remoteCount = "remote_count" - } -} - -struct AudioTriggerResponse: Decodable { - let jobId: String? - let status: String? - let url: String? - let filename: String? - - enum CodingKeys: String, CodingKey { - case jobId = "job_id" - case status, url, filename - } - - var isAsync: Bool { jobId != nil } -} - -struct ProgressEntry: Decodable, Identifiable { - var id: String { slug } - let slug: String - let chapter: Int - let audioTime: Double? - let updated: String - - enum CodingKeys: String, CodingKey { - case slug, chapter, updated - case audioTime = "audio_time" - } -} - -struct EmptyResponse: Decodable {} - -// MARK: - API Error - -enum APIError: LocalizedError { - case invalidResponse - case httpError(Int, String) - case decodingError(Error) - case unauthorized - case networkError(Error) - - var errorDescription: String? { - switch self { - case .invalidResponse: return "Invalid server response" - case .httpError(let code, let m): return "HTTP \(code): \(m)" - case .decodingError(let e): return "Decode error: \(e.localizedDescription)" - case .unauthorized: return "Not authenticated" - case .networkError(let e): return e.localizedDescription - } - } -} - -// MARK: - JSONDecoder helper - -extension JSONDecoder { - static let apiDecoder: JSONDecoder = { - let d = JSONDecoder() - d.dateDecodingStrategy = .iso8601 - return d - }() -} diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/LibNovelV2/Resources/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index cea2357..0000000 --- a/ios/LibNovelV2/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { "alpha": "1.000", "blue": "0.043", "green": "0.620", "red": "0.961" } - }, - "idiom": "universal" - } - ], - "info": { "author": "xcode", "version": 1 } -} diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index efca0a1..0000000 --- a/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "images": [ - { - "filename": "icon-1024.png", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" - } - ], - "info": { "author": "xcode", "version": 1 } -} diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png deleted file mode 100644 index 820557a..0000000 Binary files a/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png and /dev/null differ diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/Contents.json b/ios/LibNovelV2/Resources/Assets.xcassets/Contents.json deleted file mode 100644 index 319a86b..0000000 --- a/ios/LibNovelV2/Resources/Assets.xcassets/Contents.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "info": { "author": "xcode", "version": 1 } -} diff --git a/ios/LibNovelV2/Resources/Info.plist b/ios/LibNovelV2/Resources/Info.plist deleted file mode 100644 index 43230b5..0000000 --- a/ios/LibNovelV2/Resources/Info.plist +++ /dev/null @@ -1,45 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>CFBundleDisplayName</key> - <string>LibNovel</string> - <key>CFBundleExecutable</key> - <string>$(EXECUTABLE_NAME)</string> - <key>CFBundleIdentifier</key> - <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> - <key>CFBundleName</key> - <string>LibNovel</string> - <key>CFBundlePackageType</key> - <string>APPL</string> - <key>CFBundleShortVersionString</key> - <string>$(MARKETING_VERSION)</string> - <key>CFBundleVersion</key> - <string>$(CURRENT_PROJECT_VERSION)</string> - <key>LIBNOVEL_BASE_URL</key> - <string>$(LIBNOVEL_BASE_URL)</string> - <key>LSRequiresIPhoneOS</key> - <true/> - <key>UIBackgroundModes</key> - <array> - <string>audio</string> - <string>fetch</string> - <string>processing</string> - </array> - <key>UILaunchScreen</key> - <dict/> - <key>UISupportedInterfaceOrientations</key> - <array> - <string>UIInterfaceOrientationPortrait</string> - <string>UIInterfaceOrientationLandscapeLeft</string> - <string>UIInterfaceOrientationLandscapeRight</string> - </array> - <key>UISupportedInterfaceOrientations~ipad</key> - <array> - <string>UIInterfaceOrientationPortrait</string> - <string>UIInterfaceOrientationPortraitUpsideDown</string> - <string>UIInterfaceOrientationLandscapeLeft</string> - <string>UIInterfaceOrientationLandscapeRight</string> - </array> -</dict> -</plist> diff --git a/ios/LibNovelV2/Services/AudioDownloadService.swift b/ios/LibNovelV2/Services/AudioDownloadService.swift deleted file mode 100644 index 8c7ac03..0000000 --- a/ios/LibNovelV2/Services/AudioDownloadService.swift +++ /dev/null @@ -1,230 +0,0 @@ -import Foundation -import Combine - -// MARK: - AudioDownloadService -// Manages offline TTS audio downloads with progress tracking. -// Uses a background URLSession so downloads survive app suspension. -// Keys use "::" separator (slugs contain hyphens). - -@MainActor -final class AudioDownloadService: NSObject, ObservableObject { - static let shared = AudioDownloadService() - - // MARK: - Published state - - @Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice" - @Published var downloadedChapters: Set<String> = [] // key: "slug::chapter::voice" - - // MARK: - Private - - private var session: URLSession! - private var activeTasks: [String: URLSessionDownloadTask] = [:] - private let fileManager = FileManager.default - private let metadataKey = "v2.downloadedChapters" - - // MARK: - Init - - private override init() { - super.init() - let config = URLSessionConfiguration.background( - withIdentifier: "cc.kalekber.libnovel.v2.audio-downloads") - config.isDiscretionary = false - config.sessionSendsLaunchEvents = true - session = URLSession(configuration: config, delegate: self, delegateQueue: nil) - loadMetadata() - } - - // MARK: - Public API - - func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool { - downloadedChapters.contains(makeKey(slug: slug, chapter: chapter, voice: voice)) - } - - func localURL(slug: String, chapter: Int, voice: String) -> URL? { - guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil } - return audioFileURL(slug: slug, chapter: chapter, voice: voice) - } - - func download(slug: String, chapter: Int, voice: String) async throws { - let key = makeKey(slug: slug, chapter: chapter, voice: voice) - guard !downloadedChapters.contains(key), activeTasks[key] == nil else { return } - - let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) - guard let url = URL(string: urlString) else { throw URLError(.badURL) } - - let task = session.downloadTask(with: url) - task.taskDescription = key - activeTasks[key] = task - - downloads[key] = DownloadProgress( - slug: slug, chapter: chapter, voice: voice, - progress: 0, totalBytes: 0, downloadedBytes: 0, status: .downloading) - task.resume() - } - - func cancelDownload(slug: String, chapter: Int, voice: String) { - let key = makeKey(slug: slug, chapter: chapter, voice: voice) - activeTasks[key]?.cancel() - activeTasks.removeValue(forKey: key) - downloads.removeValue(forKey: key) - } - - func deleteDownload(slug: String, chapter: Int, voice: String) throws { - let key = makeKey(slug: slug, chapter: chapter, voice: voice) - let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice) - if fileManager.fileExists(atPath: fileURL.path) { - try fileManager.removeItem(at: fileURL) - } - downloadedChapters.remove(key) - downloads.removeValue(forKey: key) - saveMetadata() - } - - func deleteAllDownloads() throws { - if let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { - let audioDir = docs.appendingPathComponent("audio") - if fileManager.fileExists(atPath: audioDir.path) { - try fileManager.removeItem(at: audioDir) - } - } - downloadedChapters.removeAll() - downloads.removeAll() - activeTasks.values.forEach { $0.cancel() } - activeTasks.removeAll() - saveMetadata() - } - - func totalStorageUsed() -> Int64 { - guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return 0 } - let audioDir = docs.appendingPathComponent("audio") - guard let enumerator = fileManager.enumerator(at: audioDir, - includingPropertiesForKeys: [.fileSizeKey]) else { return 0 } - var total: Int64 = 0 - for case let url as URL in enumerator { - if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize { - total += Int64(size) - } - } - return total - } - - func offlineBookSlugs() -> [String] { - Array(Set(downloadedChapters.compactMap { key -> String? in - let parts = key.split(separator: "::") - return parts.count == 3 ? String(parts[0]) : nil - })).sorted() - } - - func downloadedChapterCount(for slug: String) -> Int { - downloadedChapters.filter { $0.hasPrefix("\(slug)::") }.count - } - - // MARK: - Key / path helpers - - func makeKey(slug: String, chapter: Int, voice: String) -> String { - "\(slug)::\(chapter)::\(voice)" - } - - nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL { - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - return docs - .appendingPathComponent("audio") - .appendingPathComponent(slug) - .appendingPathComponent("\(chapter)-\(voice).mp3") - } - - // MARK: - Persistence - - private func loadMetadata() { - if let data = UserDefaults.standard.data(forKey: metadataKey), - let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) { - downloadedChapters = decoded - } - } - - private func saveMetadata() { - if let encoded = try? JSONEncoder().encode(downloadedChapters) { - UserDefaults.standard.set(encoded, forKey: metadataKey) - } - } -} - -// MARK: - URLSessionDownloadDelegate - -extension AudioDownloadService: URLSessionDownloadDelegate { - - nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, - didFinishDownloadingTo location: URL) { - guard let key = downloadTask.taskDescription else { return } - let parts = key.split(separator: "::") - guard parts.count == 3, let chapter = Int(parts[1]) else { return } - let slug = String(parts[0]) - let voice = String(parts[2]) - let dest = audioFileURL(slug: slug, chapter: chapter, voice: voice) - - do { - let dir = dest.deletingLastPathComponent() - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - if FileManager.default.fileExists(atPath: dest.path) { - try FileManager.default.removeItem(at: dest) - } - try FileManager.default.moveItem(at: location, to: dest) - Task { @MainActor in - self.downloadedChapters.insert(key) - self.downloads.removeValue(forKey: key) - self.activeTasks.removeValue(forKey: key) - self.saveMetadata() - } - } catch { - Task { @MainActor in - self.downloads[key]?.status = .failed(error.localizedDescription) - self.activeTasks.removeValue(forKey: key) - } - } - } - - nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, - didWriteData _: Int64, totalBytesWritten: Int64, - totalBytesExpectedToWrite: Int64) { - guard let key = downloadTask.taskDescription else { return } - let progress = totalBytesExpectedToWrite > 0 - ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0 - Task { @MainActor in - if var p = self.downloads[key] { - p.downloadedBytes = totalBytesWritten - p.totalBytes = totalBytesExpectedToWrite - p.progress = progress - self.downloads[key] = p - } - } - } - - nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, - didCompleteWithError error: Error?) { - guard let key = task.taskDescription, let error else { return } - let nsErr = error as NSError - guard nsErr.code != NSURLErrorCancelled else { return } - Task { @MainActor in - self.downloads[key]?.status = .failed(error.localizedDescription) - self.activeTasks.removeValue(forKey: key) - } - } -} - -// MARK: - Supporting types - -struct DownloadProgress: Equatable { - let slug: String - let chapter: Int - let voice: String - var progress: Double - var totalBytes: Int64 - var downloadedBytes: Int64 - var status: DownloadStatus -} - -enum DownloadStatus: Equatable { - case downloading - case completed - case failed(String) -} diff --git a/ios/LibNovelV2/Services/AudioPlayerService.swift b/ios/LibNovelV2/Services/AudioPlayerService.swift deleted file mode 100644 index 03d441d..0000000 --- a/ios/LibNovelV2/Services/AudioPlayerService.swift +++ /dev/null @@ -1,492 +0,0 @@ -import Foundation -import AVFoundation -import MediaPlayer -import Combine - -// MARK: - PlaybackProgress -// High-frequency playback state isolated into its own ObservableObject so that -// the 0.5-second time-observer ticks only invalidate views that explicitly -// subscribe to this object (seek bar, play/pause button), leaving menus and -// other stable UI untouched. - -@MainActor -final class PlaybackProgress: ObservableObject { - @Published var currentTime: Double = 0 - @Published var duration: Double = 0 - @Published var isPlaying: Bool = false -} - -// MARK: - AudioPlayerService -// Central singleton owning AVPlayer, lock-screen controls (NowPlayingInfoCenter -// + MPRemoteCommandCenter), and next-chapter prefetch. - -@MainActor -final class AudioPlayerService: ObservableObject { - - // MARK: - Published state - - @Published var slug: String = "" - @Published var chapter: Int = 0 - @Published var chapterTitle: String = "" - @Published var bookTitle: String = "" - @Published var coverURL: String = "" - @Published var voice: String = "af_bella" - @Published var speed: Double = 1.0 - @Published var chapters: [ChapterBrief] = [] - - @Published var status: AudioPlayerStatus = .idle - @Published var audioURL: String = "" - @Published var errorMessage: String = "" - @Published var generationProgress: Double = 0 - - /// High-frequency playback state — subscribe directly to avoid re-rendering parents. - let progress = PlaybackProgress() - - // Convenience forwarders for callers that don't need granular isolation. - var currentTime: Double { get { progress.currentTime } set { progress.currentTime = newValue } } - var duration: Double { get { progress.duration } set { progress.duration = newValue } } - var isPlaying: Bool { get { progress.isPlaying } set { progress.isPlaying = newValue } } - - @Published var autoNext: Bool = false - @Published var nextChapter: Int? = nil - @Published var prevChapter: Int? = nil - - @Published var sleepTimer: SleepTimerOption? = nil - @Published var sleepTimerRemainingText: String = "" - - @Published var nextPrefetchStatus: NextPrefetchStatus = .none - @Published var nextAudioURL: String = "" - @Published var nextPrefetchedChapter: Int? = nil - - var isActive: Bool { - if case .idle = status { return false } - return true - } - - // MARK: - Private - - private var player: AVPlayer? - private var playerItem: AVPlayerItem? - private var timeObserver: Any? - private var statusObserver: AnyCancellable? - private var durationObserver: AnyCancellable? - private var finishObserver: AnyCancellable? - private var generationTask: Task<Void, Never>? - private var prefetchTask: Task<Void, Never>? - - private var cachedCoverArtwork: MPMediaItemArtwork? - private var cachedCoverURL: String = "" - - private var sleepTimerTask: Task<Void, Never>? - private var sleepTimerStartChapter: Int = 0 - private var sleepTimerDeadline: Date? = nil - private var sleepTimerCountdownTask: Task<Void, Never>? = nil - - // MARK: - Init - - init() { - configureAudioSession() - setupRemoteCommandCenter() - } - - // MARK: - Public API - - func load(slug: String, chapter: Int, chapterTitle: String, - bookTitle: String, coverURL: String, voice: String, speed: Double, - chapters: [ChapterBrief], nextChapter: Int?, prevChapter: Int?) { - generationTask?.cancel() - prefetchTask?.cancel() - stop() - - self.slug = slug - self.chapter = chapter - self.chapterTitle = chapterTitle - self.bookTitle = bookTitle - self.coverURL = coverURL - self.voice = voice - self.speed = speed - self.chapters = chapters - self.nextChapter = nextChapter - self.prevChapter = prevChapter - self.nextPrefetchStatus = .none - self.nextAudioURL = "" - self.nextPrefetchedChapter = nil - - if case .chapters = sleepTimer { sleepTimerStartChapter = chapter } - - status = .generating - generationProgress = 0 - - if coverURL != cachedCoverURL { - cachedCoverArtwork = nil - cachedCoverURL = coverURL - Task { await prefetchCoverArtwork(from: coverURL) } - } - - generationTask = Task { await generateAudio() } - } - - func play() { - player?.play() - player?.rate = Float(speed) - isPlaying = true - updateNowPlaying() - } - - func pause() { - player?.pause() - isPlaying = false - updateNowPlaying() - } - - func togglePlayPause() { - isPlaying ? pause() : play() - } - - func seek(to seconds: Double) { - let time = CMTime(seconds: seconds, preferredTimescale: 600) - currentTime = seconds - player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in - guard let self else { return } - Task { @MainActor in self.updateNowPlaying() } - } - } - - func skip(by seconds: Double) { - seek(to: max(0, min(currentTime + seconds, duration))) - } - - func setSpeed(_ newSpeed: Double) { - speed = newSpeed - if isPlaying { player?.rate = Float(newSpeed) } - updateNowPlaying() - } - - func setSleepTimer(_ option: SleepTimerOption?) { - sleepTimerTask?.cancel(); sleepTimerTask = nil - sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil - sleepTimerDeadline = nil - sleepTimer = option - - guard let option else { sleepTimerRemainingText = ""; return } - - switch option { - case .chapters(let count): - sleepTimerStartChapter = chapter - updateChapterTimerLabel(chaptersRemaining: count) - - case .minutes(let minutes): - let deadline = Date().addingTimeInterval(Double(minutes) * 60) - sleepTimerDeadline = deadline - sleepTimerTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000) - guard let self, !Task.isCancelled else { return } - await MainActor.run { self.stop(); self.sleepTimer = nil; self.sleepTimerRemainingText = "" } - } - sleepTimerCountdownTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 1_000_000_000) - guard let self, !Task.isCancelled else { return } - await MainActor.run { - guard let d = self.sleepTimerDeadline else { return } - self.sleepTimerRemainingText = Self.formatCountdown(max(0, d.timeIntervalSinceNow)) - } - } - } - sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60) - } - } - - func stop() { - player?.pause() - teardownPlayer() - isPlaying = false - currentTime = 0 - duration = 0 - audioURL = "" - status = .idle - sleepTimerTask?.cancel(); sleepTimerTask = nil - sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil - sleepTimerDeadline = nil - sleepTimer = nil - sleepTimerRemainingText = "" - } - - // MARK: - Private helpers - - private func updateChapterTimerLabel(chaptersRemaining: Int) { - sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left" - } - - private static func formatCountdown(_ seconds: Double) -> String { - let s = Int(max(0, seconds)) - return "\(s / 60):\(String(format: "%02d", s % 60))" - } - - // MARK: - Audio generation - - private func generateAudio() async { - guard !slug.isEmpty, chapter > 0 else { return } - - // Local file first (offline download) - if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) { - audioURL = localURL.absoluteString - status = .ready - generationProgress = 100 - await playURL(localURL.absoluteString) - await prefetchNext() - return - } - - do { - // Fast path: audio already in MinIO - if let presigned = try? await APIClient.shared.presignAudio( - slug: slug, chapter: chapter, voice: voice) { - audioURL = presigned - status = .ready - generationProgress = 100 - await playURL(presigned) - await prefetchNext() - return - } - - // Slow path: trigger TTS generation - status = .generating - generationProgress = 10 - let trigger = try await APIClient.shared.triggerAudio( - slug: slug, chapter: chapter, voice: voice, speed: speed) - - let playableURL: String - if trigger.isAsync { - generationProgress = 30 - playableURL = try await APIClient.shared.pollAudioStatus( - slug: slug, chapter: chapter, voice: voice) - } else { - guard let url = trigger.url, !url.isEmpty else { throw URLError(.badServerResponse) } - playableURL = url - } - - audioURL = playableURL - status = .ready - generationProgress = 100 - await playURL(playableURL) - await prefetchNext() - } catch is CancellationError { - // Cancelled — no-op - } catch { - status = .error(error.localizedDescription) - errorMessage = error.localizedDescription - } - } - - // MARK: - Prefetch next chapter - - private func prefetchNext() async { - guard let next = nextChapter, !Task.isCancelled else { return } - nextPrefetchStatus = .prefetching - nextPrefetchedChapter = next - do { - if let presigned = try? await APIClient.shared.presignAudio( - slug: slug, chapter: next, voice: voice) { - nextAudioURL = presigned - nextPrefetchStatus = .prefetched - return - } - let trigger = try await APIClient.shared.triggerAudio( - slug: slug, chapter: next, voice: voice, speed: speed) - let url: String - if trigger.isAsync { - url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice) - } else { - guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) } - url = u - } - nextAudioURL = url - nextPrefetchStatus = .prefetched - } catch { - nextPrefetchStatus = .failed - } - } - - // MARK: - AVPlayer management - - private func playURL(_ urlString: String) async { - let resolved: URL? - if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") { - resolved = URL(string: urlString) - } else { - resolved = URL(string: urlString, - relativeTo: await APIClient.shared.baseURL)?.absoluteURL - } - guard let url = resolved else { return } - - teardownPlayer() - let item = AVPlayerItem(url: url) - playerItem = item - player = AVPlayer(playerItem: item) - - durationObserver = item.publisher(for: \.duration) - .receive(on: RunLoop.main) - .sink { [weak self] dur in - guard let self else { return } - let secs = dur.seconds - if secs.isFinite && secs > 0 { self.duration = secs; self.updateNowPlaying() } - } - - statusObserver = item.publisher(for: \.status) - .receive(on: RunLoop.main) - .sink { [weak self] s in - guard let self else { return } - switch s { - case .readyToPlay: - self.player?.rate = Float(self.speed) - self.isPlaying = true - self.updateNowPlaying() - case .failed: - self.status = .error(item.error?.localizedDescription ?? "Playback failed") - self.errorMessage = item.error?.localizedDescription ?? "Playback failed" - default: break - } - } - - timeObserver = player?.addPeriodicTimeObserver( - forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), - queue: .main - ) { [weak self] time in - guard let self else { return } - Task { @MainActor in - let secs = time.seconds - if secs.isFinite && secs >= 0 { self.currentTime = secs } - } - } - - finishObserver = NotificationCenter.default - .publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item) - .sink { [weak self] _ in Task { @MainActor in self?.handlePlaybackFinished() } } - - player?.play() - } - - private func teardownPlayer() { - if let obs = timeObserver { player?.removeTimeObserver(obs) } - timeObserver = nil; statusObserver = nil; durationObserver = nil; finishObserver = nil - player = nil; playerItem = nil - } - - private func handlePlaybackFinished() { - isPlaying = false - guard let next = nextChapter else { return } - - // Chapter-based sleep timer - if case .chapters(let count) = sleepTimer { - let played = chapter - sleepTimerStartChapter + 1 - if played >= count { stop(); return } - updateChapterTimerLabel(chaptersRemaining: count - played) - } - - NotificationCenter.default.post( - name: .audioDidFinishChapter, object: nil, - userInfo: ["next": next, "autoNext": autoNext]) - - guard autoNext else { return } - - let nextTitle = chapters.first(where: { $0.number == next })?.title ?? "" - let nextNextChapter = chapters.first(where: { $0.number > next })?.number - - if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty { - let url = nextAudioURL - chapter = next - chapterTitle = nextTitle - nextChapter = nextNextChapter - prevChapter = chapter - nextPrefetchStatus = .none - nextAudioURL = "" - nextPrefetchedChapter = nil - audioURL = url - status = .ready - generationProgress = 100 - if case .chapters = sleepTimer { sleepTimerStartChapter = next } - generationTask = Task { await playURL(url); await prefetchNext() } - } else { - load(slug: slug, chapter: next, chapterTitle: nextTitle, - bookTitle: bookTitle, coverURL: coverURL, - voice: voice, speed: speed, chapters: chapters, - nextChapter: nextNextChapter, prevChapter: chapter) - } - } - - // MARK: - Cover art (URLSession — no Kingfisher) - - private func prefetchCoverArtwork(from urlString: String) async { - guard !urlString.isEmpty, let url = URL(string: urlString) else { return } - guard let (data, _) = try? await URLSession.shared.data(from: url), - let image = UIImage(data: data) else { return } - let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } - cachedCoverArtwork = artwork - updateNowPlaying() - } - - // MARK: - Audio session - - private func configureAudioSession() { - try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) - try? AVAudioSession.sharedInstance().setActive(true) - } - - // MARK: - Lock-screen controls - - private func setupRemoteCommandCenter() { - let center = MPRemoteCommandCenter.shared() - center.playCommand.addTarget { [weak self] _ in self?.play(); return .success } - center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success } - center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success } - center.skipForwardCommand.preferredIntervals = [15] - center.skipForwardCommand.addTarget { [weak self] _ in self?.skip(by: 15); return .success } - center.skipBackwardCommand.preferredIntervals = [15] - center.skipBackwardCommand.addTarget { [weak self] _ in self?.skip(by: -15); return .success } - center.changePlaybackPositionCommand.addTarget { [weak self] event in - if let e = event as? MPChangePlaybackPositionCommandEvent { self?.seek(to: e.positionTime) } - return .success - } - } - - private func updateNowPlaying() { - var info: [String: Any] = [ - MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle, - MPMediaItemPropertyArtist: bookTitle, - MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, - MPMediaItemPropertyPlaybackDuration: duration, - MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0 - ] - if let artwork = cachedCoverArtwork { info[MPMediaItemPropertyArtwork] = artwork } - MPNowPlayingInfoCenter.default().nowPlayingInfo = info - } -} - -// MARK: - Supporting types - -enum AudioPlayerStatus: Equatable { - case idle - case generating - case ready - case error(String) - - static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool { - switch (lhs, rhs) { - case (.idle, .idle), (.generating, .generating), (.ready, .ready): return true - case (.error(let a), .error(let b)): return a == b - default: return false - } - } -} - -enum SleepTimerOption: Equatable { - case chapters(Int) - case minutes(Int) -} - -extension Notification.Name { - static let audioDidFinishChapter = Notification.Name("v2.audioDidFinishChapter") - static let skipToNextChapter = Notification.Name("v2.skipToNextChapter") - static let skipToPrevChapter = Notification.Name("v2.skipToPrevChapter") -} diff --git a/ios/LibNovelV2/Services/AuthStore.swift b/ios/LibNovelV2/Services/AuthStore.swift deleted file mode 100644 index ceebede..0000000 --- a/ios/LibNovelV2/Services/AuthStore.swift +++ /dev/null @@ -1,144 +0,0 @@ -import Foundation -import Combine - -// MARK: - AuthStore -// Owns the authenticated user, the HMAC auth token, and user settings. -// Persists the token to Keychain so the user stays logged in across launches. - -@MainActor -final class AuthStore: ObservableObject { - @Published var user: AppUser? - @Published var settings: UserSettings = .default - @Published var isLoading: Bool = false - @Published var error: String? - - var isAuthenticated: Bool { user != nil } - - private let keychainKey = "libnovel_v2_auth_token" - - init() { - if let token = loadToken() { - Task { await validateToken(token) } - } - } - - // MARK: - Login / Register - - func login(username: String, password: String) async { - isLoading = true - error = nil - do { - let response = try await APIClient.shared.login(username: username, password: password) - await APIClient.shared.setAuthCookie(response.token) - saveToken(response.token) - user = response.user - await loadSettings() - } catch { - self.error = error.localizedDescription - } - isLoading = false - } - - func register(username: String, password: String) async { - isLoading = true - error = nil - do { - let response = try await APIClient.shared.register(username: username, password: password) - await APIClient.shared.setAuthCookie(response.token) - saveToken(response.token) - user = response.user - await loadSettings() - } catch { - self.error = error.localizedDescription - } - isLoading = false - } - - func logout() async { - do { try await APIClient.shared.logout() } catch {} - clearToken() - user = nil - settings = .default - } - - // MARK: - Settings - - func loadSettings() async { - do { settings = try await APIClient.shared.settings() } catch {} - } - - func saveSettings(_ updated: UserSettings) async { - do { - try await APIClient.shared.updateSettings(updated) - settings = updated - } catch { - self.error = error.localizedDescription - } - } - - // MARK: - Token validation - - func validateToken() async { - guard let token = loadToken() else { return } - await validateToken(token) - } - - private func validateToken(_ token: String) async { - await APIClient.shared.setAuthCookie(token) - do { - async let me: AppUser = APIClient.shared.fetch("/api/auth/me") - async let s: UserSettings = APIClient.shared.settings() - var (restoredUser, restoredSettings) = try await (me, s) - // Exchange raw MinIO key for a presigned URL if needed. - if let key = restoredUser.avatarURL, !key.hasPrefix("http") { - if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() { - restoredUser = AppUser( - id: restoredUser.id, - username: restoredUser.username, - role: restoredUser.role, - created: restoredUser.created, - avatarURL: presignedURL - ) - } - } - user = restoredUser - settings = restoredSettings - } catch let e as APIError { - if case .httpError(let code, _) = e, code == 401 { clearToken() } - } catch {} - } - - // MARK: - Keychain helpers - - private func saveToken(_ token: String) { - let data = Data(token.utf8) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: keychainKey, - kSecValueData as String: data - ] - SecItemDelete(query as CFDictionary) - SecItemAdd(query as CFDictionary, nil) - } - - private func loadToken() -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: keychainKey, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - var item: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, - let data = item as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - private func clearToken() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: keychainKey - ] - SecItemDelete(query as CFDictionary) - } -} diff --git a/ios/LibNovelV2/Services/BookVoicePreferences.swift b/ios/LibNovelV2/Services/BookVoicePreferences.swift deleted file mode 100644 index f817c4f..0000000 --- a/ios/LibNovelV2/Services/BookVoicePreferences.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -// MARK: - BookVoicePreferences -// Manages per-book voice overrides with global fallback. -// Persisted in UserDefaults as a slug → voice dictionary. - -@MainActor -final class BookVoicePreferences: ObservableObject { - static let shared = BookVoicePreferences() - - @Published private(set) var bookVoices: [String: String] = [:] - - private let key = "v2.bookVoicePreferences" - - private init() { - if let data = UserDefaults.standard.data(forKey: key), - let decoded = try? JSONDecoder().decode([String: String].self, from: data) { - bookVoices = decoded - } - } - - // MARK: - Public API - - func voice(for slug: String) -> String? { - bookVoices[slug] - } - - /// Voice priority: book override → globalVoice → "af_bella" - func voiceWithFallback(for slug: String, globalVoice: String) -> String { - bookVoices[slug] ?? globalVoice - } - - func setVoice(_ voice: String, for slug: String) { - bookVoices[slug] = voice - save() - } - - func removeVoice(for slug: String) { - bookVoices.removeValue(forKey: slug) - save() - } - - func hasOverride(for slug: String) -> Bool { - bookVoices[slug] != nil - } - - func clearAll() { - bookVoices.removeAll() - save() - } - - // MARK: - Persistence - - private func save() { - if let encoded = try? JSONEncoder().encode(bookVoices) { - UserDefaults.standard.set(encoded, forKey: key) - } - } -} diff --git a/ios/LibNovelV2/Services/NetworkMonitor.swift b/ios/LibNovelV2/Services/NetworkMonitor.swift deleted file mode 100644 index d1a0800..0000000 --- a/ios/LibNovelV2/Services/NetworkMonitor.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import Network - -// MARK: - NetworkMonitor -// Monitors network connectivity. Inject as an environment object for offline UI. - -@MainActor -final class NetworkMonitor: ObservableObject { - static let shared = NetworkMonitor() - - @Published var isConnected: Bool = true - @Published var connectionType: NWInterface.InterfaceType? - - private let monitor = NWPathMonitor() - private let queue = DispatchQueue(label: "cc.kalekber.libnovel.v2.network-monitor") - - init() { - monitor.pathUpdateHandler = { [weak self] path in - Task { @MainActor [weak self] in - self?.isConnected = path.status == .satisfied - self?.connectionType = path.availableInterfaces.first?.type - } - } - monitor.start(queue: queue) - } - - deinit { - monitor.cancel() - } -} - -extension NWInterface.InterfaceType { - var displayName: String { - switch self { - case .wifi: return "Wi-Fi" - case .cellular: return "Cellular" - case .wiredEthernet: return "Ethernet" - case .loopback: return "Loopback" - case .other: return "Other" - @unknown default: return "Unknown" - } - } -} diff --git a/ios/LibNovelV2/ViewModels/BookDetailViewModel.swift b/ios/LibNovelV2/ViewModels/BookDetailViewModel.swift deleted file mode 100644 index f337b20..0000000 --- a/ios/LibNovelV2/ViewModels/BookDetailViewModel.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation - -// MARK: - BookDetailViewModel -// Loads book metadata, chapter index, save state, and reading progress. -// Uses @Observable (iOS 17+). - -@Observable -@MainActor -final class BookDetailViewModel { - let slug: String - - var book: Book? - var chapters: [ChapterIndex] = [] - var inLib: Bool = false - var saved: Bool = false - var lastChapter: Int? - - var isLoading = false - var isSaving = false - var error: String? - - init(slug: String) { - self.slug = slug - } - - // MARK: - Load - - func load() async { - guard !isLoading else { return } - isLoading = true - error = nil - do { - let response = try await APIClient.shared.bookDetail(slug: slug) - book = response.book - chapters = response.chapters - inLib = response.inLib - saved = response.saved - lastChapter = response.lastChapter - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } - - // MARK: - Toggle saved (bookmark) - - func toggleSaved() async { - guard !isSaving else { return } - isSaving = true - let targetSaved = !saved - saved = targetSaved // optimistic update - do { - if targetSaved { - try await APIClient.shared.saveBook(slug: slug) - if !inLib { inLib = true } - } else { - try await APIClient.shared.unsaveBook(slug: slug) - } - } catch { - saved = !targetSaved // revert on failure - self.error = error.localizedDescription - } - isSaving = false - } - - // MARK: - Chapter helpers - - /// Title stripped of trailing " - Month DD YYYY" date suffixes. - func displayTitle(for chapter: ChapterIndex) -> String { - let stripped = chapter.title.strippingTrailingDate() - if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { - return "Chapter \(chapter.number)" - } - return stripped - } -} - - diff --git a/ios/LibNovelV2/ViewModels/BrowseViewModel.swift b/ios/LibNovelV2/ViewModels/BrowseViewModel.swift deleted file mode 100644 index 7b78e9d..0000000 --- a/ios/LibNovelV2/ViewModels/BrowseViewModel.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation - -// MARK: - BrowseViewModel -// Powers both the Discover shelves (BrowseView) and the full paginated grid (BrowseCategoryView). -// Uses @Observable (iOS 17+). - -@Observable -@MainActor -final class BrowseViewModel { - - // MARK: - Discover shelves (BrowseView) - - var trending: [BrowseNovel] = [] - var newReleases: [BrowseNovel] = [] - var recentlyUpdated: [BrowseNovel] = [] - var ranking: [BrowseNovel] = [] - - // MARK: - Paginated grid (BrowseCategoryView) - - var novels: [BrowseNovel] = [] - var currentPage = 1 - var hasNext = false - - // Filter params (BrowseCategoryView sets these before calling loadFirstPage) - var sort: String = "popular" - var genre: String = "all" - var status: String = "all" - - // MARK: - UI state - - var isLoading = false - var isLoadingMore = false - var error: String? - - // MARK: - Discover load (fetches multiple shelves in parallel) - - func loadShelves() async { - isLoading = true - error = nil - - do { - async let trendingTask = APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all") - async let newTask = APIClient.shared.browse(page: 1, genre: "all", sort: "new", status: "all") - async let updatedTask = APIClient.shared.browse(page: 1, genre: "all", sort: "update", status: "all") - async let rankingTask = APIClient.shared.ranking() - - let (trendingResp, newResp, updatedResp, rankItems) = try await ( - trendingTask, newTask, updatedTask, rankingTask - ) - - trending = Array(trendingResp.novels.prefix(12)) - newReleases = Array(newResp.novels.prefix(12)) - recentlyUpdated = Array(updatedResp.novels.prefix(12)) - ranking = rankItems.prefix(12).map { item in - BrowseNovelFromRanking(item) - } - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } - - // MARK: - Paginated category load - - func loadFirstPage() async { - guard !isLoading else { return } - novels = [] - currentPage = 1 - hasNext = false - isLoading = true - error = nil - - do { - let resp = try await APIClient.shared.browse( - page: 1, genre: genre, sort: sort, status: status) - novels = resp.novels - currentPage = resp.page - hasNext = resp.hasNext - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } - - func loadNextPage() async { - guard hasNext, !isLoadingMore, !isLoading else { return } - isLoadingMore = true - - let next = currentPage + 1 - do { - let resp = try await APIClient.shared.browse( - page: next, genre: genre, sort: sort, status: status) - novels += resp.novels - currentPage = resp.page - hasNext = resp.hasNext - } catch { - // Silently ignore — user can scroll again - } - isLoadingMore = false - } - - // MARK: - Ranking load (for rank sort mode) - - func loadRanking() async { - guard !isLoading else { return } - novels = [] - hasNext = false - isLoading = true - error = nil - - do { - let items = try await APIClient.shared.ranking() - novels = items.map { BrowseNovelFromRanking($0) } - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } -} - -// MARK: - RankingItem → BrowseNovel adapter - -private func BrowseNovelFromRanking(_ item: RankingItem) -> BrowseNovel { - // Synthesise a minimal JSON blob so we can decode via the standard init - let rankStr = "#\(item.rank)" - let dict: [String: Any] = [ - "slug": item.slug, - "title": item.title, - "cover": item.cover, - "rank": rankStr, - "rating": "", - "chapters": "", - "url": item.sourceURL, - "author": item.author, - "status": item.status, - "genres": item.genres - ] - let data = try! JSONSerialization.data(withJSONObject: dict) - return try! JSONDecoder.apiDecoder.decode(BrowseNovel.self, from: data) -} diff --git a/ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift b/ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift deleted file mode 100644 index f32fa2e..0000000 --- a/ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation - -// MARK: - ChapterReaderViewModel - -@Observable @MainActor -final class ChapterReaderViewModel { - let slug: String - private(set) var chapter: Int - - var content: ChapterResponse? - var isLoading = false - var error: String? - - init(slug: String, chapter: Int) { - self.slug = slug - self.chapter = chapter - } - - /// Switch to a different chapter in-place; `chapter` change causes `.task(id: chapter)` to re-fire `load()`. - func switchChapter(to newChapter: Int) { - guard newChapter != chapter else { return } - chapter = newChapter - content = nil - error = nil - } - - func load() async { - isLoading = true - error = nil - do { - content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter) - try? await APIClient.shared.setProgress(slug: slug, chapter: chapter) - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } - - func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) { - guard let content else { return } - - let isCurrent = audioPlayer.isActive - && audioPlayer.slug == slug - && audioPlayer.chapter == chapter - - if isCurrent { - audioPlayer.togglePlayPause() - } else { - let voice = BookVoicePreferences.shared.voiceWithFallback( - for: slug, - globalVoice: settings.voice - ) - audioPlayer.load( - slug: slug, - chapter: chapter, - chapterTitle: content.chapter.title, - bookTitle: content.book.title, - coverURL: content.book.cover, - voice: voice, - speed: settings.speed, - chapters: content.chapters, - nextChapter: content.next, - prevChapter: content.prev - ) - } - } -} diff --git a/ios/LibNovelV2/ViewModels/HomeViewModel.swift b/ios/LibNovelV2/ViewModels/HomeViewModel.swift deleted file mode 100644 index 2a8cb7d..0000000 --- a/ios/LibNovelV2/ViewModels/HomeViewModel.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -// MARK: - HomeViewModel -// Fetches home-screen data: continue reading, recently updated, stats, subscription feed. -// Uses @Observable (iOS 17+). - -@Observable -@MainActor -final class HomeViewModel { - var continueReading: [ContinueReadingItem] = [] - var recentlyUpdated: [Book] = [] - var stats: HomeStats? - var subscriptionFeed: [SubscriptionFeedItem] = [] - var isLoading = false - var error: String? - - func load() async { - isLoading = true - error = nil - do { - let data = try await APIClient.shared.homeData() - continueReading = data.continueReading.map { - ContinueReadingItem(book: $0.book, chapter: $0.chapter) - } - recentlyUpdated = data.recentlyUpdated - stats = data.stats - subscriptionFeed = data.subscriptionFeed - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } -} diff --git a/ios/LibNovelV2/ViewModels/LibraryViewModel.swift b/ios/LibNovelV2/ViewModels/LibraryViewModel.swift deleted file mode 100644 index 1ae7765..0000000 --- a/ios/LibNovelV2/ViewModels/LibraryViewModel.swift +++ /dev/null @@ -1,164 +0,0 @@ -import Foundation - -// MARK: - LibraryViewModel -// Loads library items and exposes filtered/sorted views for LibraryView. -// Uses @Observable (iOS 17+). - -enum LibrarySortOrder: String, CaseIterable { - case recent = "Recent" - case title = "Title" - case author = "Author" - case progress = "Progress" -} - -enum LibraryReadingFilter: String, CaseIterable { - case all = "All" - case inProgress = "In Progress" - case completed = "Completed" -} - -@Observable -@MainActor -final class LibraryViewModel { - // Raw data - var items: [LibraryItem] = [] - var progressMap: [String: Int] = [:] // slug -> last chapter read - - // Filter & sort state - var sortOrder: LibrarySortOrder = .recent - var readingFilter: LibraryReadingFilter = .all - var selectedGenre: String = "All" - - // UI state - var isLoading = false - var error: String? - - // MARK: - Derived - - var allGenres: [String] { - var seen = Set<String>() - var result: [String] = ["All"] - for item in items { - for genre in item.book.genres where !seen.contains(genre) { - seen.insert(genre) - result.append(genre) - } - } - return result - } - - var filteredItems: [LibraryItem] { - var list = items - - // Genre filter - if selectedGenre != "All" { - list = list.filter { $0.book.genres.contains(selectedGenre) } - } - - // Reading filter - switch readingFilter { - case .all: - break - case .inProgress: - list = list.filter { item in - let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0 - return ch > 0 && ch < item.book.totalChapters - } - case .completed: - list = list.filter { item in - let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0 - return item.book.totalChapters > 0 && ch >= item.book.totalChapters - } - } - - // Sort - switch sortOrder { - case .recent: - // server already returns newest-saved first; preserve order - break - case .title: - list.sort { $0.book.title.localizedCaseInsensitiveCompare($1.book.title) == .orderedAscending } - case .author: - list.sort { $0.book.author.localizedCaseInsensitiveCompare($1.book.author) == .orderedAscending } - case .progress: - list.sort { a, b in - let pa = progressFraction(for: a) - let pb = progressFraction(for: b) - return pa > pb - } - } - - return list - } - - // MARK: - Progress helpers - - func lastChapter(for item: LibraryItem) -> Int { - progressMap[item.book.slug] ?? item.lastChapter ?? 0 - } - - func progressFraction(for item: LibraryItem) -> Double { - let total = item.book.totalChapters - guard total > 0 else { return 0 } - return Double(lastChapter(for: item)) / Double(total) - } - - func progressPercent(for item: LibraryItem) -> String { - let fraction = progressFraction(for: item) - let pct = fraction * 100 - if pct < 10 { - return String(format: "%.1f%%", pct) - } else { - return String(format: "%.0f%%", pct) - } - } - - func isCompleted(for item: LibraryItem) -> Bool { - let total = item.book.totalChapters - guard total > 0 else { return false } - return lastChapter(for: item) >= total - } - - // MARK: - Load - - func load() async { - isLoading = true - error = nil - do { - async let libraryTask = APIClient.shared.library() - async let progressTask = APIClient.shared.progress() - - let (library, progressEntries) = try await (libraryTask, progressTask) - items = library - progressMap = Dictionary(uniqueKeysWithValues: progressEntries.map { ($0.slug, $0.chapter) }) - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - } - } - isLoading = false - } - - // MARK: - Mutations - - func removeFromLibrary(slug: String) async { - // Optimistic remove - items.removeAll { $0.book.slug == slug } - do { - try await APIClient.shared.unsaveBook(slug: slug) - } catch { - // Silently fail — user can pull-to-refresh to restore - } - } - - func markFinished(item: LibraryItem) async { - let total = item.book.totalChapters - guard total > 0 else { return } - progressMap[item.book.slug] = total - do { - try await APIClient.shared.setProgress(slug: item.book.slug, chapter: total) - } catch { - // Silently fail - } - } -} diff --git a/ios/LibNovelV2/ViewModels/SearchViewModel.swift b/ios/LibNovelV2/ViewModels/SearchViewModel.swift deleted file mode 100644 index dc18be3..0000000 --- a/ios/LibNovelV2/ViewModels/SearchViewModel.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Foundation - -// MARK: - SearchViewModel -// Debounced live search (300 ms) + recent searches persisted in UserDefaults. -// Uses @Observable (iOS 17+). - -@Observable -@MainActor -final class SearchViewModel { - var query: String = "" - var results: [BrowseNovel] = [] - var localCount: Int = 0 - var remoteCount: Int = 0 - var isLoading = false - var error: String? - - // Persisted recent searches (max 10, prefixed with "v2.") - var recentSearches: [String] = [] - - private let recentKey = "v2.searchRecentTerms" - private var searchTask: Task<Void, Never>? - - init() { - recentSearches = UserDefaults.standard.stringArray(forKey: recentKey) ?? [] - } - - // MARK: - Query change (debounced) - - func onQueryChange(_ newValue: String) { - searchTask?.cancel() - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - results = [] - localCount = 0 - remoteCount = 0 - return - } - searchTask = Task { - try? await Task.sleep(nanoseconds: 300_000_000) // 300 ms debounce - guard !Task.isCancelled else { return } - await runSearch(trimmed) - } - } - - // MARK: - Submit (immediate, saves to recent) - - func submitSearch() { - let term = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !term.isEmpty else { return } - saveRecent(term) - searchTask?.cancel() - searchTask = Task { await runSearch(term) } - } - - // MARK: - Recent search tap - - func selectRecent(_ term: String) { - query = term - searchTask?.cancel() - searchTask = Task { await runSearch(term) } - } - - // MARK: - Clear - - func clear() { - query = "" - results = [] - localCount = 0 - remoteCount = 0 - error = nil - searchTask?.cancel() - } - - func clearRecent() { - recentSearches = [] - UserDefaults.standard.removeObject(forKey: recentKey) - } - - // MARK: - Core search - - private func runSearch(_ term: String) async { - guard !term.isEmpty else { - results = [] - return - } - isLoading = true - error = nil - do { - let response = try await APIClient.shared.search(query: term) - // Only update if the query hasn't changed since we started - let currentTrimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if currentTrimmed == term || currentTrimmed.isEmpty { - results = response.results - localCount = response.localCount - remoteCount = response.remoteCount - } - } catch { - if !(error is CancellationError) { - self.error = error.localizedDescription - results = [] - } - } - isLoading = false - } - - // MARK: - Persist recent - - private func saveRecent(_ term: String) { - var list = recentSearches.filter { $0 != term } - list.insert(term, at: 0) - if list.count > 10 { list = Array(list.prefix(10)) } - recentSearches = list - UserDefaults.standard.set(list, forKey: recentKey) - } -} diff --git a/ios/LibNovelV2/Views/Auth/AuthView.swift b/ios/LibNovelV2/Views/Auth/AuthView.swift deleted file mode 100644 index b046ef6..0000000 --- a/ios/LibNovelV2/Views/Auth/AuthView.swift +++ /dev/null @@ -1,386 +0,0 @@ -import SwiftUI - -// MARK: - AuthView -// Full-screen login / register view. -// Mirrors the web UI's login page: zinc-900 background, tab switcher with -// amber underline indicator, zinc-800 text fields with amber focus ring, -// amber CTA button, inline error banner, loading state. - -struct AuthView: View { - @EnvironmentObject var authStore: AuthStore - @EnvironmentObject var networkMonitor: NetworkMonitor - - @State private var mode: AuthMode = .login - - // Login fields - @State private var loginUsername: String = "" - @State private var loginPassword: String = "" - - // Register fields - @State private var regUsername: String = "" - @State private var regPassword: String = "" - @State private var regConfirm: String = "" - - // Focus management - @FocusState private var focus: AuthField? - - // Local validation error (client-side, e.g. password mismatch) - @State private var localError: String? - - private var displayError: String? { localError ?? authStore.error } - - var body: some View { - ZStack { - Color.appBackground.ignoresSafeArea() - - ScrollView { - VStack(spacing: 0) { - Spacer(minLength: 60) - - // ── Wordmark ────────────────────────────────────────── - wordmark - - Spacer(minLength: 48) - - // ── Card ────────────────────────────────────────────── - VStack(spacing: 0) { - tabSwitcher - formContent - } - .background(Color.cardBackground) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .padding(.horizontal, 24) - - Spacer(minLength: 40) - } - } - .scrollDismissesKeyboard(.interactively) - } - .onChange(of: mode) { _, _ in - localError = nil - authStore.error = nil - } - } - - // MARK: - Wordmark - - private var wordmark: some View { - VStack(spacing: 6) { - Image(systemName: "books.vertical.fill") - .font(.system(size: 44)) - .foregroundStyle(Color.amber) - .symbolEffect(.bounce, value: mode) - - Text("libnovel") - .font(.title.bold()) - .fontDesign(.serif) - .foregroundStyle(.primary) - } - } - - // MARK: - Tab switcher - - private var tabSwitcher: some View { - HStack(spacing: 0) { - tabButton(label: "Sign in", tab: .login) - tabButton(label: "Create account", tab: .register) - } - .overlay(alignment: .bottom) { - Rectangle() - .fill(Color.cardBorder) - .frame(height: 1) - } - } - - @ViewBuilder - private func tabButton(label: String, tab: AuthMode) -> some View { - let isActive = mode == tab - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - mode = tab - } - } label: { - VStack(spacing: 0) { - Text(label) - .font(.subheadline.weight(.medium)) - .foregroundStyle(isActive ? Color.amber : Color.secondary) - .padding(.vertical, 14) - .frame(maxWidth: .infinity) - - // Active underline indicator - Rectangle() - .fill(isActive ? Color.amber : Color.clear) - .frame(height: 2) - .offset(y: 1) // sits on top of the border - } - } - .accessibilityAddTraits(isActive ? [.isSelected] : []) - } - - // MARK: - Form content - - @ViewBuilder - private var formContent: some View { - VStack(spacing: 16) { - // Error banner - if let err = displayError { - errorBanner(err) - .transition(.move(edge: .top).combined(with: .opacity)) - } - - switch mode { - case .login: loginForm - case .register: registerForm - } - } - .padding(20) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: displayError) - .animation(.spring(response: 0.35, dampingFraction: 0.75), value: mode) - } - - // MARK: - Error banner - - private func errorBanner(_ message: String) -> some View { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Color.errorText) - .font(.footnote) - Text(message) - .font(.footnote) - .foregroundStyle(Color.errorText) - .multilineTextAlignment(.leading) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.errorBackground) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Color.errorBorder, lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - // MARK: - Login form - - private var loginForm: some View { - VStack(spacing: 16) { - AuthInputField( - label: "Username", - placeholder: "your_username", - text: $loginUsername, - contentType: .username, - keyboardType: .default, - focusState: $focus, - field: .loginUsername, - nextField: .loginPassword - ) - - AuthInputField( - label: "Password", - placeholder: "••••••••", - text: $loginPassword, - contentType: .password, - isSecure: true, - focusState: $focus, - field: .loginPassword, - onSubmit: submitLogin - ) - - ctaButton(label: "Sign in", action: submitLogin) - } - } - - // MARK: - Register form - - private var registerForm: some View { - VStack(spacing: 16) { - VStack(spacing: 4) { - AuthInputField( - label: "Username", - placeholder: "your_username", - text: $regUsername, - contentType: .username, - focusState: $focus, - field: .regUsername, - nextField: .regPassword - ) - Text("3–32 characters: letters, numbers, _ or -") - .font(.caption) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 2) - } - - VStack(spacing: 4) { - AuthInputField( - label: "Password", - placeholder: "••••••••", - text: $regPassword, - contentType: .newPassword, - isSecure: true, - focusState: $focus, - field: .regPassword, - nextField: .regConfirm - ) - Text("At least 8 characters") - .font(.caption) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 2) - } - - AuthInputField( - label: "Confirm password", - placeholder: "••••••••", - text: $regConfirm, - contentType: .newPassword, - isSecure: true, - focusState: $focus, - field: .regConfirm, - onSubmit: submitRegister - ) - - ctaButton(label: "Create account", action: submitRegister) - } - } - - // MARK: - CTA button - - private func ctaButton(label: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - ZStack { - if authStore.isLoading { - ProgressView() - .tint(Color(uiColor: .systemBackground)) - } else { - Text(label) - .font(.subheadline.bold()) - .foregroundStyle(Color.ctaText) - } - } - .frame(maxWidth: .infinity) - .frame(height: 44) - } - .background(Color.amber) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .disabled(authStore.isLoading || !networkMonitor.isConnected) - .opacity(authStore.isLoading ? 0.8 : 1) - .animation(.easeInOut(duration: 0.15), value: authStore.isLoading) - .accessibilityLabel(label) - } - - // MARK: - Actions - - private func submitLogin() { - guard !authStore.isLoading else { return } - localError = nil - focus = nil - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - Task { await authStore.login(username: loginUsername, password: loginPassword) } - } - - private func submitRegister() { - guard !authStore.isLoading else { return } - localError = nil - // Client-side validation - if regUsername.count < 3 || regUsername.count > 32 { - localError = "Username must be 3–32 characters." - return - } - if regPassword.count < 8 { - localError = "Password must be at least 8 characters." - return - } - if regPassword != regConfirm { - localError = "Passwords do not match." - return - } - focus = nil - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - Task { await authStore.register(username: regUsername, password: regPassword) } - } -} - -// MARK: - Auth mode enum - -private enum AuthMode: Equatable { case login, register } - -// MARK: - Focus field enum - -private enum AuthField: Hashable { - case loginUsername, loginPassword - case regUsername, regPassword, regConfirm -} - -// MARK: - AuthInputField component - -private struct AuthInputField: View { - let label: String - let placeholder: String - @Binding var text: String - var contentType: UITextContentType? = nil - var keyboardType: UIKeyboardType = .default - var isSecure: Bool = false - @FocusState.Binding var focusState: AuthField? - let field: AuthField - var nextField: AuthField? = nil - var onSubmit: (() -> Void)? = nil - - private var isFocused: Bool { focusState == field } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - - Group { - if isSecure { - SecureField(placeholder, text: $text) - } else { - TextField(placeholder, text: $text) - .keyboardType(keyboardType) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - } - } - .textContentType(contentType) - .focused($focusState, equals: field) - .submitLabel(nextField != nil ? .next : .done) - .onSubmit { - if let next = nextField { - focusState = next - } else { - onSubmit?() - } - } - .padding(.horizontal, 12) - .frame(height: 44) - .background(Color.fieldBackground) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke( - isFocused ? Color.amber : Color.cardBorder, - lineWidth: isFocused ? 1.5 : 1 - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .animation(.spring(response: 0.2, dampingFraction: 0.7), value: isFocused) - } - } -} - -// MARK: - Local color helpers - -private extension Color { - static let appBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.09, green: 0.09, blue: 0.11, alpha: 1) : UIColor.systemGroupedBackground }) - static let cardBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1) : UIColor.secondarySystemGroupedBackground }) - static let cardBorder = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(white: 0.25, alpha: 1) : UIColor.separator }) - static let fieldBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.11, green: 0.11, blue: 0.13, alpha: 1) : UIColor.secondarySystemBackground }) - static let ctaText = Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)) // zinc-900 - static let errorBackground = Color(red: 0.40, green: 0.05, blue: 0.05).opacity(0.40) - static let errorBorder = Color(red: 0.70, green: 0.20, blue: 0.20).opacity(0.60) - static let errorText = Color(red: 0.98, green: 0.60, blue: 0.60) -} diff --git a/ios/LibNovelV2/Views/BookDetail/BookDetailView.swift b/ios/LibNovelV2/Views/BookDetail/BookDetailView.swift deleted file mode 100644 index b079fa6..0000000 --- a/ios/LibNovelV2/Views/BookDetail/BookDetailView.swift +++ /dev/null @@ -1,671 +0,0 @@ -import SwiftUI - -// MARK: - BookDetailView -// Displays book hero (blurred cover bg + cover art + title), meta stats, -// expandable summary, CTA buttons, chapters row (→ sheet), and bottom save toggle. -// Matches the web UI at ui/src/routes/books/[slug]/+page.svelte. - -struct BookDetailView: View { - let slug: String - - @State private var vm: BookDetailViewModel - @State private var showChapters = false - @State private var summaryExpanded = false - @EnvironmentObject private var networkMonitor: NetworkMonitor - @EnvironmentObject private var authStore: AuthStore - - init(slug: String) { - self.slug = slug - _vm = State(initialValue: BookDetailViewModel(slug: slug)) - } - - var body: some View { - VStack(spacing: 0) { - OfflineBanner() - - Group { - if vm.isLoading && vm.book == nil { - loadingState - } else if let book = vm.book { - content(book: book) - } else if vm.error != nil { - errorState - } - } - } - .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) - .navigationBarTitleDisplayMode(.inline) - .appNavigationDestination() - .toolbar { toolbarContent } - .task { - guard networkMonitor.isConnected else { return } - await vm.load() - } - .errorAlert($vm.error) - .sheet(isPresented: $showChapters) { - BookChaptersSheet( - slug: slug, - chapters: vm.chapters, - lastChapter: vm.lastChapter - ) - } - } - - // MARK: - Main content - - private func content(book: Book) -> some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - heroSection(book: book) - statsRow(book: book) - Divider() - .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) - .padding(.horizontal, 16) - summarySection(book: book) - Divider() - .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) - .padding(.horizontal, 16) - ctaButtons - Divider() - .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) - chaptersRow - Divider() - .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) - - Color.clear.frame(height: 120) - } - } - .ignoresSafeArea(edges: .top) - } - - // MARK: - Hero - - private func heroSection(book: Book) -> some View { - ZStack(alignment: .bottom) { - // Blurred cover background - AsyncCoverImage(url: book.cover, isBackground: true) - .frame(maxWidth: .infinity) - .frame(height: 340) - .blur(radius: 28) - .clipped() - .overlay( - LinearGradient( - colors: [ - Color.black.opacity(0.2), - Color.black.opacity(0.72), - ], - startPoint: .top, - endPoint: .bottom - ) - ) - - VStack(spacing: 16) { - // Cover art - AsyncCoverImage(url: book.cover) - .frame(width: 130, height: 188) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10) - .shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3) - - // Title + author - VStack(spacing: 5) { - Text(book.title) - .font(.title3.bold()) - .foregroundStyle(.white) - .multilineTextAlignment(.center) - .lineLimit(3) - .padding(.horizontal, 24) - - if !book.author.isEmpty { - Text(book.author) - .font(.subheadline) - .foregroundStyle(.white.opacity(0.7)) - } - } - - // Status badge + genre chips - VStack(spacing: 8) { - if !book.status.isEmpty { - BookStatusBadge(status: book.status) - } - if !book.genres.isEmpty { - HStack(spacing: 6) { - ForEach(book.genres.prefix(3), id: \.self) { genre in - Text(genre) - .font(.caption2.bold()) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.ultraThinMaterial, in: Capsule()) - .foregroundStyle(.white.opacity(0.9)) - } - } - } - } - - // "Not in library" badge - if !vm.inLib { - HStack(spacing: 6) { - Image(systemName: "icloud.and.arrow.down") - .font(.caption2) - Text("Not in library") - .font(.caption2) - } - .foregroundStyle(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(.regularMaterial, in: Capsule()) - } - } - .padding(.horizontal) - .padding(.bottom, 28) - } - .frame(minHeight: 340) - } - - // MARK: - Stats row - - private func statsRow(book: Book) -> some View { - HStack(spacing: 0) { - BookMetaStat( - value: "\(vm.chapters.isEmpty ? book.totalChapters : vm.chapters.count)", - label: "Chapters", - icon: "doc.text" - ) - Divider().frame(height: 36) - BookMetaStat( - value: book.status.isEmpty ? "—" : book.status.capitalized, - label: "Status", - icon: "flag" - ) - if book.ranking > 0 { - Divider().frame(height: 36) - BookMetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill") - } - } - .padding(.vertical, 16) - .frame(maxWidth: .infinity) - .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) - } - - // MARK: - Summary - - private func summarySection(book: Book) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("About") - .font(.headline) - .padding(.horizontal, 16) - - if book.summary.isEmpty { - Text("No description available.") - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.horizontal, 16) - } else { - Text(book.summary) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(summaryExpanded ? nil : 4) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: summaryExpanded) - .padding(.horizontal, 16) - - if book.summary.count > 200 { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - summaryExpanded.toggle() - } - } label: { - Text(summaryExpanded ? "Less" : "More") - .font(.caption.bold()) - .foregroundStyle(Color.amber) - } - .buttonStyle(.plain) - .frame(minWidth: 44, minHeight: 44) - .padding(.horizontal, 16) - } - } - } - .padding(.vertical, 16) - .frame(maxWidth: .infinity, alignment: .leading) - } - - // MARK: - CTA buttons - - private var ctaButtons: some View { - HStack(spacing: 10) { - if let last = vm.lastChapter, last > 0 { - // Continue reading - NavigationLink(value: NavDestination.chapter(slug, last)) { - Label("Continue Ch.\(last)", systemImage: "play.fill") - .font(.subheadline.bold()) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(Color.amber) - .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(.plain) - .simultaneousGesture(TapGesture().onEnded { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - }) - - // Start from ch.1 - NavigationLink(value: NavDestination.chapter(slug, 1)) { - Label("Ch.1", systemImage: "arrow.counterclockwise") - .font(.subheadline.bold()) - .frame(height: 44) - .padding(.horizontal, 16) - .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) - .foregroundStyle(.primary) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(.plain) - .simultaneousGesture(TapGesture().onEnded { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - }) - } else { - // Start reading - NavigationLink(value: NavDestination.chapter(slug, 1)) { - Label(vm.inLib ? "Start Reading" : "Preview Ch.1", systemImage: "book.fill") - .font(.subheadline.bold()) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(vm.chapters.isEmpty ? Color.amber.opacity(0.4) : Color.amber) - .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(.plain) - .disabled(vm.chapters.isEmpty) - .simultaneousGesture(TapGesture().onEnded { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - }) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 16) - } - - // MARK: - Chapters row - - private var chaptersRow: some View { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - showChapters = true - } label: { - HStack(spacing: 12) { - Image(systemName: "list.number") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Color.amber) - .frame(width: 28) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 2) { - Text("Chapters") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - - let count = vm.chapters.count - if let last = vm.lastChapter, last > 0, count > 0 { - Text("Reading Ch.\(last) of \(count)") - .font(.caption) - .foregroundStyle(.secondary) - } else if count > 0 { - Text("\(count) chapter\(count == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.secondary) - } else if vm.isLoading { - Text("Loading…") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .frame(minHeight: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Chapters list") - } - - // MARK: - Toolbar - - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - Task { await vm.toggleSaved() } - } label: { - Image(systemName: vm.saved ? "bookmark.fill" : "bookmark") - .foregroundStyle(vm.saved ? Color.amber : .primary) - .contentTransition(.symbolEffect(.replace.downUp)) - } - .disabled(vm.isSaving) - .accessibilityLabel(vm.saved ? "Remove from library" : "Save to library") - } - } - - // MARK: - Loading / Error states - - private var loadingState: some View { - VStack { - Spacer() - ProgressView() - .tint(Color.amber) - .scaleEffect(1.4) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var errorState: some View { - VStack { - Spacer() - EmptyStateView( - icon: "wifi.slash", - title: "Couldn't load book", - message: vm.error ?? "Something went wrong.", - ctaLabel: "Retry", - ctaAction: { - Task { - guard networkMonitor.isConnected else { return } - await vm.load() - } - } - ) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -// MARK: - BookChaptersSheet -// Shows all chapters in groups of 100 with a searchable list and right-edge jump bar. - -struct BookChaptersSheet: View { - let slug: String - let chapters: [ChapterIndex] - let lastChapter: Int? - - @Environment(\.dismiss) private var dismiss - @State private var searchText = "" - - private var filtered: [ChapterIndex] { - guard !searchText.isEmpty else { return chapters } - let q = searchText.lowercased() - return chapters.filter { - "\($0.number)".contains(q) || $0.title.lowercased().contains(q) - } - } - - /// Chapters in blocks of 100, or a flat "Results" group when searching. - private var groups: [(label: String, chapters: [ChapterIndex])] { - guard searchText.isEmpty else { - return filtered.isEmpty ? [] : [("Results", filtered)] - } - guard !filtered.isEmpty else { return [] } - let blockSize = 100 - let minN = filtered.map(\.number).min() ?? 1 - let maxN = filtered.map(\.number).max() ?? 1 - let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 - var result: [(label: String, chapters: [ChapterIndex])] = [] - var blockStart = firstBlock - while blockStart <= maxN { - let blockEnd = blockStart + blockSize - 1 - let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } - if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) } - blockStart += blockSize - } - return result - } - - @State private var activeBlock: String? - - var body: some View { - NavigationStack { - ZStack(alignment: .trailing) { - List { - ForEach(groups, id: \.label) { group in - Section { - ForEach(group.chapters, id: \.number) { ch in - ChapterListRow( - chapter: ch, - slug: slug, - isCurrent: ch.number == lastChapter - ) - .id(ch.number) - } - } header: { - if searchText.isEmpty { - Text(group.label) - .font(.caption.bold()) - .foregroundStyle(.secondary) - .id("header_\(group.label)") - } - } - } - - if chapters.isEmpty { - Section { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.vertical, 24) - .listRowBackground(Color.clear) - } - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Chapter number or title" - ) - .scrollPosition(id: $activeBlock, anchor: .top) - .appNavigationDestination() - - // Jump bar (hidden while searching) - if searchText.isEmpty && groups.count > 1 { - ChapterJumpBar( - labels: groups.map(\.label), - currentChapter: lastChapter ?? 0, - groups: groups - ) { label in - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - activeBlock = label - } - } - .padding(.trailing, 4) - } - } - .navigationTitle("Chapters (\(filtered.count))") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - .fontWeight(.semibold) - .foregroundStyle(Color.amber) - } - } - .onAppear { - // Scroll to current chapter's block on open - if let block = groups.first(where: { g in - g.chapters.contains(where: { $0.number == (lastChapter ?? 0) }) - }) { - activeBlock = block.label - } - } - } - .presentationDetents([.large]) - .presentationDragIndicator(.visible) - } -} - -// MARK: - ChapterListRow - -private struct ChapterListRow: View { - let chapter: ChapterIndex - let slug: String - let isCurrent: Bool - - private var displayTitle: String { - let pattern = #"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"# - let stripped = (try? NSRegularExpression(pattern: pattern))? - .stringByReplacingMatches( - in: chapter.title, - range: NSRange(chapter.title.startIndex..., in: chapter.title), - withTemplate: "" - ).trimmingCharacters(in: .whitespaces) ?? chapter.title - if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { - return "Chapter \(chapter.number)" - } - return stripped - } - - var body: some View { - NavigationLink(value: NavDestination.chapter(slug, chapter.number)) { - HStack(spacing: 14) { - // Number badge - ZStack { - Circle() - .fill(isCurrent ? Color.amber : Color(.systemGray5)) - .frame(width: 40, height: 40) - Text("\(chapter.number)") - .font(.caption.bold().monospacedDigit()) - .foregroundStyle(isCurrent ? .white : .secondary) - .minimumScaleFactor(0.6) - .frame(width: 40, height: 40) - } - - VStack(alignment: .leading, spacing: 3) { - Text(displayTitle) - .font(.subheadline.weight(isCurrent ? .semibold : .regular)) - .foregroundStyle(isCurrent ? Color.amber : .primary) - .lineLimit(1) - - if isCurrent { - Label("Reading", systemImage: "bookmark.fill") - .font(.caption2) - .foregroundStyle(Color.amber) - } else if !chapter.dateLabel.isEmpty { - Text(chapter.dateLabel) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - - Spacer(minLength: 4) - } - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) - .listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) - } -} - -// MARK: - ChapterJumpBar - -private struct ChapterJumpBar: View { - let labels: [String] - let currentChapter: Int - let groups: [(label: String, chapters: [ChapterIndex])] - let onSelect: (String) -> Void - - private func shortLabel(_ full: String) -> String { - full.components(separatedBy: "–").first ?? full - } - - private var currentBlock: String? { - groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label - } - - var body: some View { - VStack(spacing: 0) { - ForEach(labels, id: \.self) { label in - let isCurrent = label == currentBlock - Text(shortLabel(label)) - .font(.system(size: 10, weight: isCurrent ? .bold : .regular)) - .foregroundStyle(isCurrent ? Color.amber : Color.secondary) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - .onTapGesture { onSelect(label) } - } - } - .padding(.vertical, 6) - .background( - Capsule() - .fill(.ultraThinMaterial) - .shadow(color: .black.opacity(0.15), radius: 4) - ) - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .local) - .onChanged { value in - let itemHeight: CGFloat = 28 - let index = Int(value.location.y / itemHeight) - let clamped = max(0, min(labels.count - 1, index)) - onSelect(labels[clamped]) - } - ) - } -} - -// MARK: - BookStatusBadge - -private struct BookStatusBadge: View { - let status: String - - private var color: Color { - switch status.lowercased() { - case "ongoing", "active": return .green - case "completed": return .blue - case "hiatus": return .orange - default: return .secondary - } - } - - var body: some View { - HStack(spacing: 4) { - Circle().fill(color).frame(width: 6, height: 6) - Text(status.capitalized) - .font(.caption.weight(.medium)) - .foregroundStyle(color) - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(color.opacity(0.12), in: Capsule()) - } -} - -// MARK: - BookMetaStat - -private struct BookMetaStat: View { - let value: String - let label: String - let icon: String - - var body: some View { - VStack(spacing: 4) { - Image(systemName: icon) - .font(.caption) - .foregroundStyle(Color.amber) - Text(value) - .font(.subheadline.bold()) - .lineLimit(1) - .minimumScaleFactor(0.7) - Text(label) - .font(.caption2) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - } -} diff --git a/ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift b/ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift deleted file mode 100644 index c6b5a28..0000000 --- a/ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift +++ /dev/null @@ -1,446 +0,0 @@ -import SwiftUI - -// MARK: - BrowseCategoryView -// Full paginated grid for "See All" / genre deep-dives. -// Supports browse (infinite scroll) and rank (flat list) modes. -// Sort/genre/status can be adjusted via the filters sheet. - -struct BrowseCategoryView: View { - let sort: String - let genre: String - let status: String - let title: String - - @State private var vm = BrowseViewModel() - @State private var showFilters = false - @EnvironmentObject private var networkMonitor: NetworkMonitor - - init(sort: String, genre: String, status: String, title: String) { - self.sort = sort - self.genre = genre - self.status = status - self.title = title - } - - private var isRankMode: Bool { sort == "rank" } - - var body: some View { - Group { - if vm.isLoading && vm.novels.isEmpty { - loadingState - } else if let err = vm.error, vm.novels.isEmpty { - errorState(message: err) - } else if vm.novels.isEmpty && !vm.isLoading { - emptyState - } else if isRankMode { - rankList - } else { - novelGrid - } - } - .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) - .navigationTitle(title) - .navigationBarTitleDisplayMode(.large) - .appNavigationDestination() - .toolbar { toolbarContent } - .task { - guard networkMonitor.isConnected else { return } - vm.sort = sort - vm.genre = genre - vm.status = status - if vm.novels.isEmpty { - if isRankMode { - await vm.loadRanking() - } else { - await vm.loadFirstPage() - } - } - } - .onChange(of: vm.sort) { _, _ in - Task { await refreshForFilters() } - } - .onChange(of: vm.genre) { _, _ in - Task { await refreshForFilters() } - } - .onChange(of: vm.status) { _, _ in - Task { await refreshForFilters() } - } - .sheet(isPresented: $showFilters) { - BrowseFiltersSheet(vm: vm) - } - .errorAlert($vm.error) - } - - // MARK: - Grid view - - private let columns = [ - GridItem(.flexible(), spacing: 14), - GridItem(.flexible(), spacing: 14) - ] - - private var novelGrid: some View { - ScrollView { - LazyVGrid(columns: columns, spacing: 14) { - ForEach(vm.novels) { novel in - NavigationLink(value: NavDestination.book(novel.slug)) { - BrowseCategoryCard(novel: novel) - } - .buttonStyle(.plain) - .onAppear { - if novel.id == vm.novels.last?.id && vm.hasNext { - Task { await vm.loadNextPage() } - } - } - } - } - .padding(.horizontal, 16) - .padding(.top, 12) - - // Load-more indicator - if vm.isLoadingMore { - ProgressView() - .padding(.vertical, 24) - .tint(Color.amber) - } else if !vm.hasNext && !vm.novels.isEmpty { - Text("All novels loaded") - .font(.caption) - .foregroundStyle(.quaternary) - .padding(.vertical, 24) - } - - Color.clear.frame(height: 120) - } - .refreshable { await vm.loadFirstPage() } - } - - // MARK: - Rank list view - - private var rankList: some View { - List { - ForEach(vm.novels) { novel in - NavigationLink(value: NavDestination.book(novel.slug)) { - RankListRow(novel: novel) - } - .listRowBackground(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) - .listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .refreshable { await vm.loadRanking() } - } - - // MARK: - Loading / error / empty - - private var loadingState: some View { - ScrollView { - LazyVGrid(columns: columns, spacing: 14) { - ForEach(0..<10, id: \.self) { _ in - BrowseCategoryCardSkeleton() - } - } - .padding(.horizontal, 16) - .padding(.top, 12) - } - } - - private func errorState(message: String) -> some View { - VStack(spacing: 16) { - Spacer() - EmptyStateView( - icon: "wifi.slash", - title: "Couldn't load", - message: message, - ctaLabel: "Retry", - ctaAction: { - Task { - if isRankMode { await vm.loadRanking() } - else { await vm.loadFirstPage() } - } - } - ) - Spacer() - } - } - - private var emptyState: some View { - VStack { - Spacer() - EmptyStateView( - icon: "books.vertical", - title: "No novels found", - message: "Try different filters.", - ctaLabel: "Change Filters", - ctaAction: { showFilters = true } - ) - Spacer() - } - } - - // MARK: - Toolbar - - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - showFilters = true - } label: { - Image(systemName: "slider.horizontal.3") - .foregroundStyle(Color.amber) - } - .accessibilityLabel("Filter novels") - } - } - - // MARK: - Filter change - - private func refreshForFilters() async { - if vm.sort == "rank" { - await vm.loadRanking() - } else { - await vm.loadFirstPage() - } - } -} - -// MARK: - BrowseCategoryCard - -struct BrowseCategoryCard: View { - let novel: BrowseNovel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - ZStack(alignment: .topLeading) { - AsyncCoverImage(url: novel.cover) - .frame(maxWidth: .infinity) - .aspectRatio(2/3, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .bookCoverZoomSource(slug: novel.slug) - - if !novel.rank.isEmpty { - Text(novel.rank) - .font(.caption2.bold()) - .foregroundStyle(Color.amber) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) - .padding(6) - } - } - - VStack(alignment: .leading, spacing: 3) { - Text(novel.title) - .font(.subheadline.bold()) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - - if !novel.author.isEmpty { - Text(novel.author) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - if !novel.chapters.isEmpty { - Text(novel.chapters) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 10) - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) - } -} - -// MARK: - BrowseCategoryCardSkeleton - -private struct BrowseCategoryCardSkeleton: View { - var body: some View { - VStack(alignment: .leading, spacing: 0) { - RoundedRectangle(cornerRadius: 10) - .fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1))) - .aspectRatio(2/3, contentMode: .fit) - - VStack(alignment: .leading, spacing: 6) { - RoundedRectangle(cornerRadius: 4) - .fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1))) - .frame(height: 14) - RoundedRectangle(cornerRadius: 4) - .fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1))) - .frame(width: 80, height: 11) - } - .padding(.horizontal, 10) - .padding(.vertical, 10) - } - .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } -} - -// MARK: - RankListRow - -private struct RankListRow: View { - let novel: BrowseNovel - - var body: some View { - HStack(spacing: 12) { - // Rank number - Text(novel.rank.isEmpty ? "–" : novel.rank) - .font(.subheadline.bold()) - .foregroundStyle(Color.amber) - .frame(width: 36, alignment: .trailing) - - // Cover thumbnail - AsyncCoverImage(url: novel.cover) - .frame(width: 44, height: 62) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - - // Title + meta - VStack(alignment: .leading, spacing: 3) { - Text(novel.title) - .font(.subheadline.bold()) - .lineLimit(2) - .foregroundStyle(.primary) - - if !novel.author.isEmpty { - Text(novel.author) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - HStack(spacing: 6) { - if !novel.status.isEmpty { - TagChip(label: novel.status.capitalized) - } - if !novel.rating.isEmpty { - TagChip(label: "★ \(novel.rating)") - } - } - } - - Spacer() - } - .padding(.vertical, 6) - .frame(minHeight: 44) - } -} - -// MARK: - BrowseFiltersSheet - -struct BrowseFiltersSheet: View { - var vm: BrowseViewModel - @Environment(\.dismiss) private var dismiss - - private let sortOptions: [(value: String, label: String)] = [ - ("popular", "Popular"), - ("new", "New"), - ("update", "Updated"), - ("rank", "Ranking"), - ] - private let genreOptions: [(value: String, label: String)] = [ - ("all", "All Genres"), - ("action", "Action"), - ("adventure", "Adventure"), - ("comedy", "Comedy"), - ("drama", "Drama"), - ("fantasy", "Fantasy"), - ("harem", "Harem"), - ("historical", "Historical"), - ("horror", "Horror"), - ("isekai", "Isekai"), - ("martial-arts", "Martial Arts"), - ("mystery", "Mystery"), - ("psychological", "Psychological"), - ("romance", "Romance"), - ("sci-fi", "Sci-Fi"), - ("system", "System"), - ("xianxia", "Xianxia"), - ] - private let statusOptions: [(value: String, label: String)] = [ - ("all", "All"), - ("ongoing", "Ongoing"), - ("completed", "Completed"), - ] - - var body: some View { - NavigationStack { - Form { - Section("Sort") { - ForEach(sortOptions, id: \.value) { opt in - filterRow(label: opt.label, isSelected: vm.sort == opt.value) { - vm.sort = opt.value - dismiss() - } - } - } - - Section("Genre") { - ForEach(genreOptions, id: \.value) { opt in - filterRow(label: opt.label, isSelected: vm.genre == opt.value) { - vm.genre = opt.value - dismiss() - } - } - } - .disabled(vm.sort == "rank") - - Section("Status") { - ForEach(statusOptions, id: \.value) { opt in - filterRow(label: opt.label, isSelected: vm.status == opt.value) { - vm.status = opt.value - dismiss() - } - } - } - .disabled(vm.sort == "rank") - - if vm.sort == "rank" { - Section { - Text("Genre & status filters apply to Browse only") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .navigationTitle("Filters") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - .fontWeight(.semibold) - .foregroundStyle(Color.amber) - } - } - } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - - @ViewBuilder - private func filterRow(label: String, isSelected: Bool, action: @escaping () -> Void) -> some View { - HStack { - Text(label) - Spacer() - if isSelected { - Image(systemName: "checkmark") - .foregroundStyle(Color.amber) - .fontWeight(.semibold) - } - } - .contentShape(Rectangle()) - .onTapGesture { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - action() - } - .frame(minHeight: 44) - } -} diff --git a/ios/LibNovelV2/Views/Browse/BrowseView.swift b/ios/LibNovelV2/Views/Browse/BrowseView.swift deleted file mode 100644 index 5383ff2..0000000 --- a/ios/LibNovelV2/Views/Browse/BrowseView.swift +++ /dev/null @@ -1,411 +0,0 @@ -import SwiftUI - -// MARK: - BrowseView -// "Discover" tab: curated horizontal shelves (Trending, New, Updated, Ranking) -// plus a genre picker sheet. Mirrors the web UI's serendipitous browse experience. - -struct BrowseView: View { - @State private var vm = BrowseViewModel() - @State private var showGenreSheet = false - @EnvironmentObject private var networkMonitor: NetworkMonitor - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - OfflineBanner() - - Group { - if vm.isLoading && vm.trending.isEmpty { - loadingState - } else if let err = vm.error, vm.trending.isEmpty { - errorState(message: err) - } else { - shelvesContent - } - } - } - .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) - .navigationTitle("Discover") - .navigationBarTitleDisplayMode(.large) - .appNavigationDestination() - .task { - guard networkMonitor.isConnected else { return } - if vm.trending.isEmpty { await vm.loadShelves() } - } - .refreshable { await vm.loadShelves() } - .errorAlert($vm.error) - .sheet(isPresented: $showGenreSheet) { - GenrePickerSheet() - } - } - } - - // MARK: - Shelves content - - private var shelvesContent: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 32) { - - // Trending Now - if !vm.trending.isEmpty { - BrowseShelf( - title: "Trending Now", - novels: vm.trending, - destination: NavDestination.browseCategory( - sort: "popular", genre: "all", status: "all", title: "Trending Now" - ) - ) - } - - // New Releases - if !vm.newReleases.isEmpty { - BrowseShelf( - title: "New Releases", - novels: vm.newReleases, - destination: NavDestination.browseCategory( - sort: "new", genre: "all", status: "all", title: "New Releases" - ) - ) - } - - // Recently Updated - if !vm.recentlyUpdated.isEmpty { - BrowseShelf( - title: "Recently Updated", - novels: vm.recentlyUpdated, - destination: NavDestination.browseCategory( - sort: "update", genre: "all", status: "all", title: "Recently Updated" - ) - ) - } - - // Rankings (list-style shelf) - if !vm.ranking.isEmpty { - BrowseShelf( - title: "Rankings", - novels: vm.ranking, - destination: NavDestination.browseCategory( - sort: "rank", genre: "all", status: "all", title: "Rankings" - ), - showRank: true - ) - } - - // Browse by Genre - CategoriesRow { showGenreSheet = true } - .padding(.horizontal, 16) - - Color.clear.frame(height: 120) - } - .padding(.top, 8) - } - } - - // MARK: - Loading / error states - - private var loadingState: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 32) { - ForEach(0..<3, id: \.self) { _ in - BrowseShelfSkeleton() - } - } - .padding(.top, 8) - } - } - - private func errorState(message: String) -> some View { - VStack(spacing: 16) { - Spacer() - EmptyStateView( - icon: "wifi.slash", - title: "Couldn't load", - message: message, - ctaLabel: "Retry", - ctaAction: { Task { await vm.loadShelves() } } - ) - Spacer() - } - } -} - -// MARK: - BrowseShelf -// Amber-accented header + horizontal card scroll + "See All" link. - -struct BrowseShelf: View { - let title: String - let novels: [BrowseNovel] - let destination: NavDestination - var showRank: Bool = false - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header row - HStack(spacing: 10) { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(Color.amber) - .frame(width: 3, height: 18) - Text(title) - .font(.title3.bold()) - Spacer() - NavigationLink(value: destination) { - HStack(spacing: 4) { - Text("See All") - .font(.subheadline) - Image(systemName: "chevron.right") - .font(.caption.bold()) - } - .foregroundStyle(Color.amber) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 16) - - // Horizontal scroll - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 12) { - ForEach(novels) { novel in - NavigationLink(value: NavDestination.book(novel.slug)) { - BrowseShelfCard(novel: novel, showRank: showRank) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 4) - } - } - } -} - -// MARK: - BrowseShelfCard - -struct BrowseShelfCard: View { - let novel: BrowseNovel - var showRank: Bool = false - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - ZStack(alignment: .topLeading) { - AsyncCoverImage(url: novel.cover) - .frame(width: 120, height: 173) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .bookCoverZoomSource(slug: novel.slug) - - if showRank && !novel.rank.isEmpty { - Text(novel.rank) - .font(.caption2.bold()) - .foregroundStyle(Color.amber) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) - .padding(6) - } else if !novel.rank.isEmpty { - Text(novel.rank) - .font(.caption2.bold()) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) - .padding(6) - } - } - - VStack(alignment: .leading, spacing: 3) { - Text(novel.title) - .font(.caption.bold()) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame(width: 120, alignment: .leading) - - if !novel.author.isEmpty { - Text(novel.author) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(width: 120, alignment: .leading) - } else if !novel.chapters.isEmpty { - Text(novel.chapters) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(width: 120, alignment: .leading) - } - } - .padding(.horizontal, 6) - .padding(.vertical, 8) - } - .frame(width: 132) - .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) - } -} - -// MARK: - BrowseShelfSkeleton - -struct BrowseShelfSkeleton: View { - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header skeleton - HStack(spacing: 10) { - RoundedRectangle(cornerRadius: 2) - .fill(Color.amber.opacity(0.3)) - .frame(width: 3, height: 18) - RoundedRectangle(cornerRadius: 6) - .fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1))) - .frame(width: 140, height: 20) - Spacer() - } - .padding(.horizontal, 16) - - // Cards skeleton - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(0..<5, id: \.self) { _ in - RoundedRectangle(cornerRadius: 14) - .fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1))) - .frame(width: 132, height: 220) - } - } - .padding(.horizontal, 16) - } - } - } -} - -// MARK: - CategoriesRow - -struct CategoriesRow: View { - let onTap: () -> Void - - var body: some View { - Button(action: { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - onTap() - }) { - HStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.amber.opacity(0.15)) - .frame(width: 44, height: 44) - Image(systemName: "square.grid.2x2") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(Color.amber) - } - - VStack(alignment: .leading, spacing: 2) { - Text("Browse by Genre") - .font(.body.weight(.semibold)) - .foregroundStyle(.primary) - Text("Action, Fantasy, Romance & more") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.tertiary) - } - .padding(14) - .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - .buttonStyle(.plain) - .accessibilityLabel("Browse by Genre") - } -} - -// MARK: - GenrePickerSheet - -struct GenrePickerSheet: View { - @Environment(\.dismiss) private var dismiss - - private let genres: [(label: String, value: String, icon: String)] = [ - ("All Novels", "all", "books.vertical.fill"), - ("Action", "action", "bolt.fill"), - ("Adventure", "adventure", "map.fill"), - ("Comedy", "comedy", "face.smiling.fill"), - ("Drama", "drama", "theatermasks.fill"), - ("Fantasy", "fantasy", "wand.and.stars"), - ("Harem", "harem", "person.3.fill"), - ("Historical", "historical", "building.columns.fill"), - ("Horror", "horror", "moon.fill"), - ("Isekai", "isekai", "globe.americas.fill"), - ("Martial Arts", "martial-arts", "figure.martial.arts"), - ("Mystery", "mystery", "magnifyingglass"), - ("Psychological","psychological","brain.head.profile"), - ("Romance", "romance", "heart.fill"), - ("Sci-Fi", "sci-fi", "sparkles"), - ("System", "system", "cpu"), - ("Xianxia", "xianxia", "leaf.fill"), - ] - - private let columns = [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ] - - var body: some View { - NavigationStack { - ScrollView { - LazyVGrid(columns: columns, spacing: 12) { - ForEach(genres, id: \.value) { item in - NavigationLink(value: NavDestination.browseCategory( - sort: "popular", - genre: item.value, - status: "all", - title: item.label - )) { - GenreTile(label: item.label, icon: item.icon) - } - .buttonStyle(.plain) - .simultaneousGesture(TapGesture().onEnded { dismiss() }) - } - } - .padding(16) - .padding(.bottom, 20) - } - .navigationTitle("Genres") - .navigationBarTitleDisplayMode(.large) - .appNavigationDestination() - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - .fontWeight(.semibold) - .foregroundStyle(Color.amber) - } - } - } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .presentationCornerRadius(20) - } -} - -// MARK: - GenreTile - -private struct GenreTile: View { - let label: String - let icon: String - - var body: some View { - HStack(spacing: 10) { - Image(systemName: icon) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(Color.amber) - .frame(width: 24) - Text(label) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.primary) - .lineLimit(1) - Spacer() - } - .padding(.horizontal, 14) - .padding(.vertical, 14) - .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .frame(minHeight: 44) - } -} diff --git a/ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift deleted file mode 100644 index 5ee68e0..0000000 --- a/ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift +++ /dev/null @@ -1,1232 +0,0 @@ -import SwiftUI -import CoreText - -// MARK: - Chapter Reader View - -struct ChapterReaderView: View { - let slug: String - let chapterNumber: Int - - @State private var currentChapter: Int - @State private var vm: ChapterReaderViewModel - @State private var readerSettings = ReaderSettingsStore() - @EnvironmentObject var audioPlayer: AudioPlayerService - @EnvironmentObject var authStore: AuthStore - - @State private var chromeVisible = true - @State private var showSettingsPanel = false - @State private var showToCSheet = false - - @Environment(\.dismiss) private var dismiss - - init(slug: String, chapterNumber: Int) { - self.slug = slug - self.chapterNumber = chapterNumber - _currentChapter = State(initialValue: chapterNumber) - _vm = State(initialValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber)) - } - - var body: some View { - ZStack { - // Full-bleed background - readerSettings.settings.theme.backgroundColor - .ignoresSafeArea() - - if vm.isLoading { - ProgressView() - .tint(readerSettings.settings.theme.textColor) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let content = vm.content { - if readerSettings.settings.scrollMode { - ScrollReaderContent( - content: content, - readerSettings: readerSettings, - chromeVisible: $chromeVisible, - onNavigateChapter: navigateToChapter - ) - } else { - PaginatedReaderContent( - content: content, - readerSettings: readerSettings, - chromeVisible: $chromeVisible, - onNavigateChapter: navigateToChapter - ) - } - } else if let errMsg = vm.error { - readerErrorView(errMsg) - } - - // Chrome overlay - if chromeVisible { - VStack(spacing: 0) { - topChrome - Spacer() - if let content = vm.content { - bottomChrome(content: content) - } - } - .transition(.opacity.animation(.easeInOut(duration: 0.22))) - .ignoresSafeArea(edges: .top) - } - } - .ignoresSafeArea(edges: .all) - .navigationBarHidden(true) - .toolbar(.hidden, for: .tabBar) - .preferredColorScheme(readerSettings.settings.theme.colorScheme) - .hideMiniPlayer() - .task(id: currentChapter) { await vm.load() } - .sheet(isPresented: $showSettingsPanel) { - ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel) - .presentationDetents([.height(460)]) - .presentationDragIndicator(.visible) - .presentationCornerRadius(24) - .presentationBackground(.regularMaterial) - } - .sheet(isPresented: $showToCSheet) { - if let content = vm.content { - ChaptersListSheet( - chapters: content.chapters, - currentChapter: currentChapter, - onChapterSelect: { selected in - showToCSheet = false - navigateToChapter(selected) - } - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - } - .onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in - guard let next = note.userInfo?["next"] as? Int, - let autoNext = note.userInfo?["autoNext"] as? Bool, - autoNext, currentChapter == audioPlayer.chapter else { return } - navigateToChapter(next) - } - .onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in - guard let next = note.userInfo?["next"] as? Int, - currentChapter == audioPlayer.chapter else { return } - navigateToChapter(next) - } - .onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in - guard let prev = note.userInfo?["prev"] as? Int, - currentChapter == audioPlayer.chapter else { return } - navigateToChapter(prev) - } - } - - // MARK: - Top chrome - - private var topChrome: some View { - ZStack(alignment: .bottom) { - Rectangle() - .fill(.ultraThinMaterial) - .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) - .ignoresSafeArea(edges: .top) - - VStack(spacing: 0) { - HStack(spacing: 0) { - // Back - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - dismiss() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(readerSettings.settings.theme.textColor) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .accessibilityLabel("Back") - - Spacer() - - // Chapter title - if let content = vm.content { - Text(content.chapter.title.strippingTrailingDate()) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) - .lineLimit(1) - .frame(maxWidth: 200) - } - - Spacer() - - // ToC + Aa - HStack(spacing: 0) { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - showToCSheet = true - } label: { - Image(systemName: "list.bullet") - .font(.system(size: 16, weight: .regular)) - .foregroundStyle(readerSettings.settings.theme.textColor) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .accessibilityLabel("Table of Contents") - - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - showSettingsPanel.toggle() - } - } label: { - Text("Aa") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(readerSettings.settings.theme.textColor) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .accessibilityLabel("Reader Settings") - } - } - .padding(.horizontal, 4) - .frame(height: 44) - - // Progress bar - if let content = vm.content { - ChapterProgressBar( - currentChapter: content.chapter.number, - totalChapters: content.chapters.last?.number ?? content.chapter.number, - color: accentColor - ) - } - } - } - .fixedSize(horizontal: false, vertical: true) - } - - // MARK: - Bottom chrome - - private func bottomChrome(content: ChapterResponse) -> some View { - HStack(alignment: .center, spacing: 12) { - - // Prev chapter - if let prev = content.prev { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - navigateToChapter(prev) - } label: { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 12, weight: .bold)) - Text("Ch.\(prev)") - .font(.caption.weight(.semibold)) - } - .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) - .frame(minWidth: 64) - .padding(.vertical, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Previous chapter \(prev)") - } else { - Color.clear.frame(width: 64, height: 40) - } - - Spacer(minLength: 0) - - // Download - DownloadAudioButton( - slug: slug, - chapter: currentChapter, - voice: audioPlayer.voice, - theme: readerSettings.settings.theme - ) - - // Listen pill - ListenButton( - audioPlayer: audioPlayer, - vm: vm, - authStore: authStore, - theme: readerSettings.settings.theme - ) - - Spacer(minLength: 0) - - // Next chapter - if let next = content.next { - Button { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - navigateToChapter(next) - } label: { - HStack(spacing: 4) { - Text("Ch.\(next)") - .font(.caption.weight(.semibold)) - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .bold)) - } - .foregroundStyle(.white) - .frame(minWidth: 64) - .padding(.vertical, 10) - .background(Capsule().fill(accentColor)) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .accessibilityLabel("Next chapter \(next)") - } else { - Color.clear.frame(width: 64, height: 40) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background( - Rectangle() - .fill(.ultraThinMaterial) - .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) - .ignoresSafeArea(edges: .bottom) - ) - } - - // MARK: - Helpers - - private var accentColor: Color { - readerSettings.settings.theme == .sepia - ? Color(red: 0.65, green: 0.45, blue: 0.15) - : .amber - } - - private func readerErrorView(_ msg: String) -> some View { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundStyle(.orange) - Text(msg) - .multilineTextAlignment(.center) - .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) - .padding(.horizontal) - Button("Retry") { Task { await vm.load() } } - .buttonStyle(.borderedProminent) - .tint(.amber) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func navigateToChapter(_ chapter: Int) { - vm.switchChapter(to: chapter) - currentChapter = chapter - } -} - -// MARK: - Paginated reader content - -private struct PaginatedReaderContent: View { - let content: ChapterResponse - let readerSettings: ReaderSettingsStore - @Binding var chromeVisible: Bool - let onNavigateChapter: (Int) -> Void - - @State private var pages: [AttributedString] = [] - @State private var currentPage: Int = 0 - @State private var geometrySize: CGSize = .zero - - private let topReserve: CGFloat = 80 - private let bottomReserve: CGFloat = 64 - - var body: some View { - GeometryReader { geo in - let size = geo.size - TabView(selection: $currentPage) { - ChapterTitlePage(content: content, readerSettings: readerSettings) - .tag(-1) - .onTapGesture { toggleChrome() } - - ForEach(Array(pages.enumerated()), id: \.offset) { idx, page in - ReaderPage( - text: page, - readerSettings: readerSettings, - pageNumber: idx + 1, - totalPages: pages.count - ) - .tag(idx) - .onTapGesture { toggleChrome() } - } - - ChapterEndPage( - content: content, - readerSettings: readerSettings, - onNavigateChapter: onNavigateChapter - ) - .tag(pages.count) - .onTapGesture { toggleChrome() } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .onAppear { - if geometrySize != size { - geometrySize = size - repaginate(size: size) - } - } - .onChange(of: size) { _, newSize in - geometrySize = newSize - repaginate(size: newSize) - } - .onChange(of: readerSettings.settings) { _, _ in - repaginate(size: geometrySize) - } - .onChange(of: content.chapter.number) { _, _ in - currentPage = -1 - repaginate(size: geometrySize) - } - } - .ignoresSafeArea() - .onAppear { currentPage = -1 } - .simultaneousGesture( - DragGesture(minimumDistance: 40, coordinateSpace: .global) - .onEnded { value in - let isHorizontal = abs(value.translation.width) > abs(value.translation.height) * 1.5 - guard isHorizontal else { return } - if value.translation.width > 0, currentPage == -1, let prev = content.prev { - onNavigateChapter(prev) - } else if value.translation.width < 0, currentPage == pages.count, let next = content.next { - onNavigateChapter(next) - } - } - ) - } - - private func toggleChrome() { - withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } - } - - private func repaginate(size: CGSize) { - guard size.width > 0, size.height > 0 else { return } - let settings = readerSettings.settings - let hPad: CGFloat = 28 - let textWidth = size.width - hPad * 2 - let textHeight = size.height - topReserve - bottomReserve - - let attributed = HTMLParser.toAttributedString( - html: content.html, - fontSize: settings.fontSize, - lineSpacing: settings.lineSpacing, - fontName: settings.font.fontName, - textColor: settings.theme.textColor - ) - pages = TextPaginator.paginate( - attributed: attributed, - width: textWidth, - height: textHeight, - fontSize: settings.fontSize - ) - if currentPage > pages.count - 1 { - currentPage = max(0, pages.count - 1) - } - } -} - -// MARK: - Scroll mode reader content - -private struct ScrollReaderContent: View { - let content: ChapterResponse - let readerSettings: ReaderSettingsStore - @Binding var chromeVisible: Bool - let onNavigateChapter: (Int) -> Void - - var body: some View { - let settings = readerSettings.settings - let hPad: CGFloat = 24 - let accent: Color = settings.theme == .sepia - ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber - - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - // Chapter header - VStack(alignment: .leading, spacing: 10) { - Text(content.book.title) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(settings.theme.textColor.opacity(0.45)) - .textCase(.uppercase) - .tracking(1.2) - Rectangle() - .fill(accent.opacity(0.6)) - .frame(width: 36, height: 2) - Text(content.chapter.title.strippingTrailingDate()) - .font(.system(size: 22, weight: .bold, design: .serif)) - .foregroundStyle(settings.theme.textColor) - if !content.chapter.dateLabel.isEmpty { - Text(content.chapter.dateLabel) - .font(.caption) - .foregroundStyle(settings.theme.textColor.opacity(0.4)) - } - } - .padding(.horizontal, hPad) - .padding(.top, 20) - .padding(.bottom, 20) - - // Body - let attributed = HTMLParser.toAttributedString( - html: content.html, - fontSize: settings.fontSize, - lineSpacing: settings.lineSpacing, - fontName: settings.font.fontName, - textColor: settings.theme.textColor - ) - Text(attributed) - .padding(.horizontal, hPad) - - // Footer - VStack(spacing: 16) { - Divider().padding(.horizontal, hPad) - if let next = content.next { - Button { onNavigateChapter(next) } label: { - HStack { - Text("Next Chapter") - .fontWeight(.semibold) - Image(systemName: "arrow.right") - } - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 50) - .background(Capsule().fill(accent)) - } - .buttonStyle(.plain) - .padding(.horizontal, hPad) - } - } - .padding(.vertical, 24) - .padding(.bottom, 80) - } - } - .safeAreaInset(edge: .top) { Color.clear.frame(height: 52) } - .background(settings.theme.backgroundColor) - .ignoresSafeArea() - .onTapGesture { - withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } - } - } -} - -// MARK: - Individual reader page (paginated mode) - -private struct ReaderPage: View { - let text: AttributedString - let readerSettings: ReaderSettingsStore - let pageNumber: Int - let totalPages: Int - - var body: some View { - let settings = readerSettings.settings - let hPad: CGFloat = 28 - let topPad: CGFloat = 80 - let bottomPad: CGFloat = 56 - - GeometryReader { geo in - ZStack(alignment: .bottom) { - Text(text) - .frame(width: geo.size.width - hPad * 2, alignment: .topLeading) - .frame(maxHeight: .infinity, alignment: .top) - .padding(.horizontal, hPad) - .padding(.top, topPad) - .padding(.bottom, bottomPad) - .frame(maxWidth: .infinity) - - Text("\(pageNumber) of \(totalPages)") - .font(.system(size: 11, weight: .regular).monospacedDigit()) - .foregroundStyle(settings.theme.textColor.opacity(0.3)) - .padding(.bottom, bottomPad - 24) - .frame(maxWidth: .infinity, alignment: .center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(settings.theme.backgroundColor) - } - } -} - -// MARK: - Chapter title page - -private struct ChapterTitlePage: View { - let content: ChapterResponse - let readerSettings: ReaderSettingsStore - - private var totalChapters: Int { - content.chapters.last?.number ?? content.chapter.number - } - - private var progressPercent: Int { - guard totalChapters > 1 else { return 100 } - return Int((Double(content.chapter.number) / Double(totalChapters)) * 100) - } - - private var accentColor: Color { - readerSettings.settings.theme == .sepia - ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber - } - - var body: some View { - let settings = readerSettings.settings - GeometryReader { geo in - VStack(alignment: .leading, spacing: 0) { - Spacer() - - VStack(alignment: .leading, spacing: 14) { - Text(content.book.title) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(settings.theme.textColor.opacity(0.45)) - .textCase(.uppercase) - .tracking(1.4) - .lineLimit(2) - - Rectangle() - .fill(accentColor) - .frame(width: 36, height: 2) - .clipShape(Capsule()) - - Text(content.chapter.title.strippingTrailingDate()) - .font(.system(size: min(32, geo.size.width / 10.5), weight: .bold, design: .serif)) - .foregroundStyle(settings.theme.textColor) - .fixedSize(horizontal: false, vertical: true) - .lineSpacing(4) - - HStack(spacing: 8) { - if !content.chapter.dateLabel.isEmpty { - Text(content.chapter.dateLabel) - .font(.caption) - .foregroundStyle(settings.theme.textColor.opacity(0.4)) - } - if totalChapters > 1 { - if !content.chapter.dateLabel.isEmpty { - Circle() - .fill(settings.theme.textColor.opacity(0.25)) - .frame(width: 3, height: 3) - } - Text("\(progressPercent)% through") - .font(.caption.weight(.medium)) - .foregroundStyle(accentColor.opacity(0.85)) - } - } - } - .padding(.horizontal, 36) - - Spacer() - Spacer() - - HStack(spacing: 6) { - Image(systemName: "arrow.right") - .font(.caption2.weight(.semibold)) - Text("Swipe to read") - .font(.caption2) - } - .foregroundStyle(settings.theme.textColor.opacity(0.5)) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 96) - .phaseAnimator([false, true]) { v, phase in - v.offset(x: phase ? 4 : -2).opacity(phase ? 0.55 : 0.15) - } animation: { _ in .easeInOut(duration: 0.9) } - } - .frame(maxWidth: .infinity) - .background(settings.theme.backgroundColor) - } - } -} - -// MARK: - Chapter end page - -private struct ChapterEndPage: View { - let content: ChapterResponse - let readerSettings: ReaderSettingsStore - let onNavigateChapter: (Int) -> Void - - @State private var appeared = false - - private var accentColor: Color { - readerSettings.settings.theme == .sepia - ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber - } - - var body: some View { - let settings = readerSettings.settings - VStack(spacing: 32) { - Spacer() - - VStack(spacing: 20) { - ZStack { - Circle().fill(accentColor.opacity(0.07)).frame(width: 96, height: 96) - Circle().fill(accentColor.opacity(0.14)).frame(width: 72, height: 72) - Image(systemName: "checkmark") - .font(.system(size: 28, weight: .semibold)) - .foregroundStyle(accentColor) - .symbolEffect(.bounce, value: appeared) - } - .scaleEffect(appeared ? 1 : 0.7) - .opacity(appeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.65).delay(0.05), value: appeared) - - VStack(spacing: 6) { - Text("Chapter \(content.chapter.number)") - .font(.caption.weight(.semibold)) - .foregroundStyle(accentColor) - .textCase(.uppercase) - .tracking(1.2) - Text("Complete") - .font(.title2.bold()) - .foregroundStyle(settings.theme.textColor) - if content.next == nil { - Text("You've reached the latest chapter") - .font(.subheadline) - .foregroundStyle(settings.theme.textColor.opacity(0.4)) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 10) - .animation(.easeOut(duration: 0.35).delay(0.15), value: appeared) - } - - if let next = content.next { - Button { onNavigateChapter(next) } label: { - HStack(spacing: 8) { - Text("Chapter \(next)").fontWeight(.semibold) - Image(systemName: "arrow.right").font(.system(size: 14, weight: .semibold)) - } - .foregroundStyle(.white) - .frame(height: 52) - .frame(maxWidth: 240) - .background(Capsule().fill(accentColor)) - } - .buttonStyle(.plain) - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 12) - .animation(.easeOut(duration: 0.35).delay(0.25), value: appeared) - .accessibilityLabel("Go to chapter \(next)") - } - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(settings.theme.backgroundColor) - .onAppear { appeared = true } - .onDisappear { appeared = false } - } -} - -// MARK: - Chapter progress bar - -private struct ChapterProgressBar: View { - let currentChapter: Int - let totalChapters: Int - let color: Color - - private var progress: Double { - guard totalChapters > 1 else { return 1.0 } - return Double(currentChapter) / Double(totalChapters) - } - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Rectangle().fill(color.opacity(0.10)) - Rectangle() - .fill(LinearGradient( - colors: [color.opacity(0.7), color], - startPoint: .leading, - endPoint: .trailing - )) - .frame(width: geo.size.width * progress) - .animation(.spring(response: 0.5, dampingFraction: 0.85), value: progress) - } - } - .frame(height: 3) - } -} - -// MARK: - Listen button - -/// Isolated sub-view to avoid re-rendering ChapterReaderView on every audioPlayer update. -private struct ListenButton: View { - @ObservedObject var audioPlayer: AudioPlayerService - let vm: ChapterReaderViewModel - @ObservedObject var authStore: AuthStore - let theme: ReaderTheme - - private var isActive: Bool { - audioPlayer.isActive && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter - } - - private var isGenerating: Bool { - audioPlayer.status == .generating - && audioPlayer.slug == vm.slug - && audioPlayer.chapter == vm.chapter - } - - private var accentColor: Color { - theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber - } - - var body: some View { - Button { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings) - } label: { - HStack(spacing: 7) { - if isGenerating { - ProgressView() - .scaleEffect(0.75) - .tint(isActive ? .white : accentColor) - } else { - Image(systemName: isActive ? "waveform" : "headphones") - .font(.system(size: 15, weight: .semibold)) - .contentTransition(.symbolEffect(.replace.downUp)) - .symbolEffect(.variableColor.cumulative, isActive: isActive) - } - Text(isGenerating ? "Generating…" : (isActive ? "Listening" : "Listen")) - .font(.subheadline.weight(.semibold)) - } - .foregroundStyle(isActive ? .white : accentColor) - .padding(.horizontal, 18) - .padding(.vertical, 10) - .background(Capsule().fill(isActive ? accentColor : accentColor.opacity(0.13))) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isActive) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isGenerating) - .accessibilityLabel(isGenerating ? "Generating audio" : (isActive ? "Pause audio" : "Listen")) - } -} - -// MARK: - Download audio button - -private struct DownloadAudioButton: View { - let slug: String - let chapter: Int - let voice: String - let theme: ReaderTheme - - @EnvironmentObject private var downloadService: AudioDownloadService - - private var key: String { "\(slug)::\(chapter)::\(voice)" } - private var isDownloaded: Bool { downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice) } - private var progress: Double? { downloadService.downloads[key]?.progress } - - private var accentColor: Color { - theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber - } - private var iconColor: Color { theme.textColor.opacity(0.6) } - - var body: some View { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - if isDownloaded { - try? downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice) - } else if downloadService.downloads[key] != nil { - downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice) - } else { - Task { try? await downloadService.download(slug: slug, chapter: chapter, voice: voice) } - } - } label: { - Group { - if let frac = progress { - // In-progress ring - ZStack { - Circle().stroke(accentColor.opacity(0.2), lineWidth: 2) - .frame(width: 22, height: 22) - Circle().trim(from: 0, to: frac) - .stroke(accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .frame(width: 22, height: 22) - .rotationEffect(.degrees(-90)) - .animation(.linear(duration: 0.2), value: frac) - } - } else { - Image(systemName: isDownloaded ? "arrow.down.circle.fill" : "arrow.down.circle") - .font(.system(size: 20)) - .foregroundStyle(isDownloaded ? accentColor : iconColor) - .contentTransition(.symbolEffect(.replace.downUp)) - } - } - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(isDownloaded ? "Delete downloaded audio" : "Download audio") - } -} - -// MARK: - Chapters list sheet (ToC) - -private struct ChaptersListSheet: View { - let chapters: [ChapterBrief] - let currentChapter: Int - let onChapterSelect: (Int) -> Void - - @State private var searchText = "" - - private var filtered: [ChapterBrief] { - guard !searchText.isEmpty else { return chapters } - return chapters.filter { $0.title.localizedCaseInsensitiveContains(searchText) } - } - - var body: some View { - NavigationStack { - List(filtered) { ch in - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - onChapterSelect(ch.number) - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(ch.title) - .font(.subheadline) - .foregroundStyle(ch.number == currentChapter ? Color.amber : .primary) - Text("Chapter \(ch.number)") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if ch.number == currentChapter { - Image(systemName: "bookmark.fill") - .font(.caption) - .foregroundStyle(Color.amber) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - .listStyle(.plain) - .searchable(text: $searchText, prompt: "Search chapters") - .navigationTitle("Chapters") - .navigationBarTitleDisplayMode(.inline) - } - } -} - -// MARK: - Reader settings panel - -struct ReaderSettingsPanel: View { - @ObservedObject var store: ReaderSettingsStore - @Binding var isPresented: Bool - - var body: some View { - VStack(spacing: 0) { - Capsule() - .fill(Color(.systemGray4)) - .frame(width: 36, height: 5) - .padding(.top, 10) - .padding(.bottom, 18) - - ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 22) { - - // Font size - VStack(alignment: .leading, spacing: 10) { - ReaderSectionLabel("Font Size") - HStack(spacing: 0) { - Button { adjustFontSize(-1) } label: { - Text("A").font(.system(size: 13, weight: .regular)) - .frame(width: 44, height: 44).contentShape(Rectangle()) - } - .buttonStyle(.plain).foregroundStyle(.primary) - Slider( - value: Binding( - get: { store.settings.fontSize }, - set: { v in var s = store.settings; s.fontSize = v; store.update(s) } - ), - in: 12...26, step: 1 - ) - .tint(.amber) - .padding(.horizontal, 8) - Button { adjustFontSize(1) } label: { - Text("A").font(.system(size: 21, weight: .semibold)) - .frame(width: 44, height: 44).contentShape(Rectangle()) - } - .buttonStyle(.plain).foregroundStyle(.primary) - } - } - - Divider().padding(.horizontal, 4) - - // Font family - VStack(alignment: .leading, spacing: 10) { - ReaderSectionLabel("Font") - HStack(spacing: 8) { - ForEach(ReaderFont.allCases, id: \.self) { font in - ReaderFontChip(font: font, isSelected: store.settings.font == font) { - var s = store.settings; s.font = font; store.update(s) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - } - } - } - - Divider().padding(.horizontal, 4) - - // Theme - VStack(alignment: .leading, spacing: 10) { - ReaderSectionLabel("Theme") - HStack(spacing: 8) { - ForEach(ReaderTheme.allCases, id: \.self) { theme in - ReaderThemeChip(theme: theme, isSelected: store.settings.theme == theme) { - var s = store.settings; s.theme = theme; store.update(s) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - } - } - } - - Divider().padding(.horizontal, 4) - - // Line spacing - VStack(alignment: .leading, spacing: 10) { - ReaderSectionLabel("Line Spacing") - HStack(spacing: 8) { - Image(systemName: "text.alignleft") - .font(.system(size: 13)).foregroundStyle(.secondary).frame(width: 28) - Slider( - value: Binding( - get: { store.settings.lineSpacing }, - set: { v in var s = store.settings; s.lineSpacing = v; store.update(s) } - ), - in: 1.2...2.4, step: 0.1 - ) - .tint(.amber) - Image(systemName: "text.alignleft") - .font(.system(size: 20)).foregroundStyle(.secondary).frame(width: 28) - } - } - - Divider().padding(.horizontal, 4) - - // Scroll vs pages - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(store.settings.scrollMode ? "Scroll" : "Pages") - .font(.subheadline.weight(.medium)) - Text(store.settings.scrollMode - ? "Continuous vertical scroll" - : "Swipe horizontally between pages") - .font(.caption).foregroundStyle(.secondary) - } - Spacer() - Toggle("", isOn: Binding( - get: { store.settings.scrollMode }, - set: { v in - var s = store.settings; s.scrollMode = v; store.update(s) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - )) - .tint(.amber) - .labelsHidden() - } - - Color.clear.frame(height: 8) - } - .padding(.horizontal, 20) - } - } - } - - private func adjustFontSize(_ delta: CGFloat) { - var s = store.settings - s.fontSize = max(12, min(26, s.fontSize + delta)) - store.update(s) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } -} - -// MARK: - ReaderSettingsStore - -final class ReaderSettingsStore: ObservableObject { - @Published private(set) var settings: ReaderSettings - - init() { settings = ReaderSettings.load() } - - func update(_ new: ReaderSettings) { - settings = new - new.save() - } -} - -// MARK: - Settings sub-components - -private struct ReaderSectionLabel: View { - let title: String - init(_ title: String) { self.title = title } - var body: some View { - Text(title) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.8) - } -} - -private struct ReaderFontChip: View { - let font: ReaderFont - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(font.rawValue) - .font(font.fontName.map { Font.custom($0, size: 15) } ?? .system(size: 15)) - .frame(maxWidth: .infinity).frame(height: 46) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5) - ) - ) - .foregroundStyle(isSelected ? Color.amber : .primary) - .scaleEffect(isSelected ? 1.03 : 1.0) - } - .buttonStyle(.plain) - .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) - } -} - -private struct ReaderThemeChip: View { - let theme: ReaderTheme - let isSelected: Bool - let action: () -> Void - - private var label: String { - switch theme { - case .white: return "White" - case .sepia: return "Sepia" - case .night: return "Night" - } - } - - var body: some View { - Button(action: action) { - Text(label) - .font(.subheadline.weight(isSelected ? .semibold : .regular)) - .frame(maxWidth: .infinity).frame(height: 46) - .background(theme.backgroundColor) - .foregroundStyle(theme.textColor) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Color.amber : Color(.systemGray4), - lineWidth: isSelected ? 2 : 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .scaleEffect(isSelected ? 1.03 : 1.0) - } - .buttonStyle(.plain) - .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) - } -} - -// MARK: - HTML → AttributedString parser - -enum HTMLParser { - static func stripLeadingChapterHeader(from html: String) -> String { - var result = html - for _ in 0..<3 { - let pattern = #"^(\s*<p[^>]*>)(.*?)(</p>)"# - guard let regex = try? NSRegularExpression( - pattern: pattern, options: [.dotMatchesLineSeparators, .caseInsensitive] - ) else { break } - - guard let match = regex.firstMatch( - in: result, range: NSRange(result.startIndex..., in: result) - ) else { break } - - let innerRange = match.range(at: 2) - guard innerRange.location != NSNotFound, - let swiftRange = Range(innerRange, in: result) else { break } - - let inner = String(result[swiftRange]) - let plain = inner - .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - - guard plain.range(of: #"^\d*\s*[Cc]hapter\s+\d+"#, options: .regularExpression) != nil - else { break } - - guard let fullRange = Range(match.range(at: 0), in: result) else { break } - result.removeSubrange(fullRange) - } - return result - } - - static func toAttributedString( - html: String, - fontSize: CGFloat, - lineSpacing: CGFloat, - fontName: String?, - textColor: Color - ) -> AttributedString { - let uiFont: UIFont = fontName.flatMap { UIFont(name: $0, size: fontSize) } - ?? UIFont.systemFont(ofSize: fontSize) - - let uiColor = UIColor(textColor) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = (lineSpacing - 1.0) * fontSize - paragraphStyle.paragraphSpacing = fontSize * 0.7 - - let cleanedHtml = stripLeadingChapterHeader(from: html) - let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ - .documentType: NSAttributedString.DocumentType.html, - .characterEncoding: String.Encoding.utf8.rawValue - ] - - let nsAttr: NSMutableAttributedString - if let parsed = try? NSMutableAttributedString( - data: Data(cleanedHtml.utf8), options: options, documentAttributes: nil - ) { - nsAttr = parsed - } else { - let plain = cleanedHtml.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) - nsAttr = NSMutableAttributedString(string: plain) - } - - let fullRange = NSRange(location: 0, length: nsAttr.length) - nsAttr.addAttribute(.font, value: uiFont, range: fullRange) - nsAttr.addAttribute(.foregroundColor, value: uiColor, range: fullRange) - nsAttr.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) - - return (try? AttributedString(nsAttr, including: \.uiKit)) ?? AttributedString(nsAttr.string) - } -} - -// MARK: - Text paginator - -enum TextPaginator { - static func paginate( - attributed: AttributedString, - width: CGFloat, - height: CGFloat, - fontSize: CGFloat - ) -> [AttributedString] { - guard width > 0, height > 0 else { return [attributed] } - let nsAttr = NSAttributedString(attributed) - guard nsAttr.length > 0 else { return [] } - - let framesetter = CTFramesetterCreateWithAttributedString(nsAttr) - let path = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: height), transform: nil) - - var pages: [AttributedString] = [] - var startIndex = 0 - let totalLength = nsAttr.length - var guard_ = 0 - - while startIndex < totalLength { - guard_ += 1 - if guard_ > 2000 { break } - - let range = CFRange(location: startIndex, length: totalLength - startIndex) - let frame = CTFramesetterCreateFrame(framesetter, range, path, nil) - let visible = CTFrameGetVisibleStringRange(frame) - - let pageLength = visible.length > 0 ? visible.length : max(1, totalLength - startIndex) - let endIndex = min(startIndex + pageLength, totalLength) - - let pageAttr = nsAttr.attributedSubstring(from: NSRange(location: startIndex, length: endIndex - startIndex)) - if let pageAS = try? AttributedString(pageAttr, including: \.uiKit) { - pages.append(pageAS) - } - - if visible.length <= 0 { break } - startIndex = endIndex - } - - return pages.isEmpty ? [attributed] : pages - } -} - - diff --git a/ios/LibNovelV2/Views/Common/CommonViews.swift b/ios/LibNovelV2/Views/Common/CommonViews.swift deleted file mode 100644 index 27ae76e..0000000 --- a/ios/LibNovelV2/Views/Common/CommonViews.swift +++ /dev/null @@ -1,235 +0,0 @@ -import SwiftUI - -// MARK: - CommonViews -// Shared reusable components used across multiple screens. -// No external dependencies — images are loaded via URLSession with an in-memory cache. - -// MARK: - Color extensions (design system tokens) - -extension Color { - /// Amber-400 accent — #f59e0b - static let amber = Color(red: 0.961, green: 0.620, blue: 0.043) -} - -// MARK: - AsyncCoverImage -// URLSession-backed cover image loader with in-memory cache. -// Displays a zinc-800 placeholder skeleton while loading, book-closed icon on failure. - -private actor ImageCache { - static let shared = ImageCache() - private var cache: [URL: Data] = [:] - private var inFlight: [URL: Task<Data?, Never>] = [:] - - func data(for url: URL) async -> Data? { - if let cached = cache[url] { return cached } - if let existing = inFlight[url] { return await existing.value } - - let task = Task<Data?, Never> { - do { - let (d, _) = try await URLSession.shared.data(from: url) - return d - } catch { return nil } - } - inFlight[url] = task - let result = await task.value - inFlight.removeValue(forKey: url) - if let result { cache[url] = result } - return result - } -} - -struct AsyncCoverImage: View { - let url: String? - /// When true the placeholder is a plain colour fill (used for blurred hero backgrounds). - var isBackground: Bool = false - - @State private var image: UIImage? - @State private var hasFailed = false - - var body: some View { - Group { - if let image { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else if hasFailed { - placeholder - } else { - placeholder - .task(id: url) { await load() } - } - } - } - - @ViewBuilder - private var placeholder: some View { - if isBackground { - Color(uiColor: UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1)) - } else { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(uiColor: UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1))) - .overlay( - Image(systemName: "book.closed") - .font(.title3) - .foregroundStyle(.tertiary) - ) - } - } - - private func load() async { - guard let urlString = url, let parsedURL = URL(string: urlString) else { - hasFailed = true - return - } - guard let data = await ImageCache.shared.data(for: parsedURL), - let loaded = UIImage(data: data) else { - hasFailed = true - return - } - image = loaded - } -} - -// MARK: - EmptyStateView - -struct EmptyStateView: View { - let icon: String - let title: String - let message: String - var ctaLabel: String? = nil - var ctaAction: (() -> Void)? = nil - - var body: some View { - VStack(spacing: 16) { - Image(systemName: icon) - .font(.system(size: 52)) - .foregroundStyle(.tertiary) - .symbolEffect(.pulse) - - Text(title) - .font(.headline) - .foregroundStyle(.primary) - - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - - if let label = ctaLabel, let action = ctaAction { - Button(action: action) { - Text(label) - .font(.subheadline.bold()) - .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) - .padding(.horizontal, 24) - .frame(height: 44) - .background(Color.amber) - .clipShape(Capsule()) - } - .padding(.top, 4) - } - } - } -} - -// MARK: - ShelfHeader -// Amber accent-bar + bold title. Used by Home, Profile, Browse shelves. - -struct ShelfHeader: View { - let title: String - - var body: some View { - HStack(spacing: 10) { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(Color.amber) - .frame(width: 3, height: 18) - Text(title) - .font(.title3.bold()) - } - .padding(.horizontal, 16) - .padding(.bottom, 10) - } -} - -// MARK: - ChipButton -// Unified selection chip (filled or outlined style). - -enum ChipButtonStyle { case filled, outlined } - -struct ChipButton: View { - let label: String - let isSelected: Bool - var style: ChipButtonStyle = .filled - let action: () -> Void - - var body: some View { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - action() - } label: { - Text(label) - .font(style == .filled - ? .caption.weight(isSelected ? .semibold : .regular) - : .subheadline.weight(isSelected ? .semibold : .regular)) - .padding(.horizontal, style == .filled ? 12 : 14) - .padding(.vertical, 6) - .foregroundStyle(isSelected - ? (style == .filled ? Color.white : Color.amber) - : Color.primary) - .background(chipBackground) - } - .buttonStyle(.plain) - .frame(minWidth: 44, minHeight: 44) - .accessibilityAddTraits(isSelected ? [.isSelected] : []) - } - - @ViewBuilder - private var chipBackground: some View { - switch style { - case .filled: - Capsule().fill(isSelected ? Color.amber : Color(.systemGray5)) - case .outlined: - Capsule() - .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) - .overlay(Capsule().stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5)) - } - } -} - -// MARK: - TagChip (read-only label) - -struct TagChip: View { - let label: String - - var body: some View { - Text(label) - .font(.caption2.bold()) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color(.systemGray5), in: Capsule()) - } -} - -// MARK: - OfflineBanner -// Shown at the top of any view when the device is offline. - -struct OfflineBanner: View { - @EnvironmentObject var networkMonitor: NetworkMonitor - - var body: some View { - if !networkMonitor.isConnected { - HStack(spacing: 8) { - Image(systemName: "wifi.slash") - .font(.caption.bold()) - Text("You're offline — showing cached content") - .font(.caption) - Spacer() - } - .foregroundStyle(.primary) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.regularMaterial) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } -} diff --git a/ios/LibNovelV2/Views/Downloads/DownloadsView.swift b/ios/LibNovelV2/Views/Downloads/DownloadsView.swift deleted file mode 100644 index d485448..0000000 --- a/ios/LibNovelV2/Views/Downloads/DownloadsView.swift +++ /dev/null @@ -1,359 +0,0 @@ -import SwiftUI - -// MARK: - DownloadsView -// Shows active downloads (in-progress), downloaded chapters grouped by book, and storage usage. -// Purely local — no network calls needed. - -struct DownloadsView: View { - @ObservedObject private var downloadService = AudioDownloadService.shared - @Environment(\.dismiss) private var dismiss - - // Completed chapters grouped by slug, sorted alphabetically - private var groupedDownloads: [(slug: String, keys: [String])] { - let slugs = downloadService.offlineBookSlugs() - return slugs.map { slug in - let keys = downloadService.downloadedChapters - .filter { $0.hasPrefix("\(slug)::") } - .sorted { lhs, rhs in - let lhsChapter = chapterNumber(from: lhs) - let rhsChapter = chapterNumber(from: rhs) - return lhsChapter < rhsChapter - } - return (slug: slug, keys: keys) - } - } - - private var activeDownloads: [(key: String, progress: DownloadProgress)] { - downloadService.downloads - .sorted { $0.key < $1.key } - .map { (key: $0.key, progress: $0.value) } - } - - private var storageFormatted: String { - ByteCountFormatter.string( - fromByteCount: downloadService.totalStorageUsed(), - countStyle: .file - ) - } - - private var hasAnyContent: Bool { - !downloadService.downloadedChapters.isEmpty || !downloadService.downloads.isEmpty - } - - var body: some View { - NavigationStack { - Group { - if hasAnyContent { - contentList - } else { - emptyState - } - } - .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) - .navigationTitle("Downloads") - .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - dismiss() - } - .foregroundStyle(Color.amber) - } - } - } - } - - // MARK: - Content list - - private var contentList: some View { - List { - // Storage info - storageSection - - // Active downloads - if !activeDownloads.isEmpty { - Section { - ForEach(activeDownloads, id: \.key) { item in - ActiveDownloadRow(key: item.key, progress: item.progress) - } - } header: { - Text("Downloading") - .font(.subheadline.bold()) - .foregroundStyle(Color.amber) - .textCase(nil) - } - } - - // Completed, grouped by book - ForEach(groupedDownloads, id: \.slug) { group in - Section { - ForEach(group.keys, id: \.self) { key in - DownloadedChapterRow(key: key) - } - } header: { - HStack(spacing: 6) { - Image(systemName: "book.closed.fill") - .font(.caption) - .foregroundStyle(.secondary) - Text(group.slug) - .font(.subheadline.bold()) - .foregroundStyle(.primary) - .textCase(nil) - Spacer() - Text("\(group.keys.count) ch.") - .font(.caption) - .foregroundStyle(.secondary) - .textCase(nil) - } - } - } - - // Delete all - if !downloadService.downloadedChapters.isEmpty { - Section { - Button(role: .destructive) { - UIImpactFeedbackGenerator(style: .heavy).impactOccurred() - try? downloadService.deleteAllDownloads() - } label: { - HStack { - Spacer() - Label("Delete All Downloads", systemImage: "trash.fill") - .font(.subheadline.bold()) - Spacer() - } - } - .accessibilityLabel("Delete all downloaded audio chapters") - } - } - } - .scrollContentBackground(.hidden) - .listStyle(.insetGrouped) - } - - // MARK: - Storage section - - private var storageSection: some View { - Section { - HStack(spacing: 12) { - Image(systemName: "internaldrive.fill") - .font(.body) - .foregroundStyle(Color.amber) - .frame(width: 28) - - VStack(alignment: .leading, spacing: 2) { - Text("Storage Used") - .font(.subheadline) - Text(storageFormatted) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Text("\(downloadService.downloadedChapters.count) chapters") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - } - } - - // MARK: - Empty state - - private var emptyState: some View { - VStack(spacing: 0) { - Spacer() - EmptyStateView( - icon: "arrow.down.circle", - title: "No Downloads", - message: "Downloaded audio chapters appear here for offline listening." - ) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - // MARK: - Helpers - - private func chapterNumber(from key: String) -> Int { - let parts = key.split(separator: "::") - guard parts.count >= 2, let n = Int(parts[1]) else { return 0 } - return n - } -} - -// MARK: - ActiveDownloadRow - -private struct ActiveDownloadRow: View { - let key: String - let progress: DownloadProgress - @ObservedObject private var downloadService = AudioDownloadService.shared - - var body: some View { - HStack(spacing: 12) { - // Icon with status - ZStack { - Circle() - .fill(statusColor.opacity(0.15)) - .frame(width: 36, height: 36) - Image(systemName: statusIcon) - .font(.subheadline.bold()) - .foregroundStyle(statusColor) - .contentTransition(.symbolEffect(.replace.downUp)) - } - - VStack(alignment: .leading, spacing: 3) { - Text("Chapter \(progress.chapter)") - .font(.subheadline.bold()) - .lineLimit(1) - - HStack(spacing: 4) { - Text(progress.slug) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - Text("·") - .font(.caption) - .foregroundStyle(.tertiary) - Text(formatVoice(progress.voice)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - // Progress or error indicator - if case .failed(let msg) = progress.status { - VStack(alignment: .trailing, spacing: 2) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.subheadline) - .foregroundStyle(.red) - .symbolEffect(.pulse) - Text("Failed") - .font(.caption2) - .foregroundStyle(.red) - } - .accessibilityLabel("Download failed: \(msg)") - } else { - VStack(alignment: .trailing, spacing: 4) { - Text("\(Int(progress.progress * 100))%") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - ProgressView(value: progress.progress) - .tint(Color.amber) - .frame(width: 64) - } - } - - // Cancel button (only while downloading) - if progress.status == .downloading { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - downloadService.cancelDownload( - slug: progress.slug, - chapter: progress.chapter, - voice: progress.voice - ) - } label: { - Image(systemName: "xmark.circle.fill") - .font(.title3) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .frame(minWidth: 44, minHeight: 44) - .accessibilityLabel("Cancel download for chapter \(progress.chapter)") - } - } - .padding(.vertical, 4) - } - - private var statusColor: Color { - if case .failed = progress.status { return .red } - return Color.amber - } - - private var statusIcon: String { - if case .failed = progress.status { return "exclamationmark.triangle" } - return "arrow.down" - } -} - -// MARK: - DownloadedChapterRow - -private struct DownloadedChapterRow: View { - let key: String - @ObservedObject private var downloadService = AudioDownloadService.shared - - // Parse "slug::chapterNumber::voice" — v2 keys use "::" separator - private var components: (slug: String, chapter: Int, voice: String) { - let parts = key.split(separator: "::") - guard parts.count == 3, let chapter = Int(parts[1]) else { - return ("", 0, "") - } - return (String(parts[0]), chapter, String(parts[2])) - } - - var body: some View { - let c = components - HStack(spacing: 12) { - // Checkmark badge - ZStack { - Circle() - .fill(Color.green.opacity(0.15)) - .frame(width: 36, height: 36) - Image(systemName: "checkmark") - .font(.caption.bold()) - .foregroundStyle(.green) - } - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 3) { - Text("Chapter \(c.chapter)") - .font(.subheadline.bold()) - .lineLimit(1) - - Text(formatVoice(c.voice)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "waveform") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 4) - .accessibilityLabel("Chapter \(c.chapter), voice \(formatVoice(c.voice)), downloaded") - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - try? downloadService.deleteDownload( - slug: c.slug, - chapter: c.chapter, - voice: c.voice - ) - } label: { - Label("Delete", systemImage: "trash") - } - } - } -} - -// MARK: - Shared voice formatter - -private func formatVoice(_ voice: String) -> String { - let parts = voice.split(separator: "_") - guard parts.count == 2 else { return voice } - let prefix = String(parts[0]) - let name = String(parts[1]).capitalized - let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : "" - let accent = prefix.hasPrefix("af") || prefix.hasPrefix("am") ? "US" - : prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK" - : "" - if !gender.isEmpty && !accent.isEmpty { return "\(name) (\(accent) \(gender))" } - if !gender.isEmpty { return "\(name) (\(gender))" } - return name -} diff --git a/ios/LibNovelV2/Views/Home/HomeView.swift b/ios/LibNovelV2/Views/Home/HomeView.swift deleted file mode 100644 index 67617d9..0000000 --- a/ios/LibNovelV2/Views/Home/HomeView.swift +++ /dev/null @@ -1,384 +0,0 @@ -import SwiftUI - -// MARK: - HomeView -// "Reading Now" tab: stats bar + Continue Reading shelf + Recently Updated shelf -// + Subscription Feed shelf + empty state. -// Design mirrors the web UI home page (zinc-900 bg, amber accents, horizontal shelves). - -struct HomeView: View { - @State private var vm = HomeViewModel() - @EnvironmentObject var networkMonitor: NetworkMonitor - @EnvironmentObject var authStore: AuthStore - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - OfflineBanner() - - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - - - // ── Stats bar ─────────────────────────────────────── - if let stats = vm.stats { - StatsBar(stats: stats) - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 28) - .transition(.opacity) - } - - // ── Continue Reading ──────────────────────────────── - if !vm.continueReading.isEmpty { - ShelfHeader(title: "Continue Reading") - horizontalShelf { - ForEach(vm.continueReading) { item in - NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) { - ContinueReadingCard(item: item) - } - .buttonStyle(.plain) - .contextMenu { - continueReadingContextMenu(item: item) - } - } - } - } - - // ── Recently Updated ──────────────────────────────── - if !vm.recentlyUpdated.isEmpty { - ShelfHeader(title: "Recently Updated") - horizontalShelf { - ForEach(vm.recentlyUpdated) { book in - NavigationLink(value: NavDestination.book(book.slug)) { - ShelfBookCard(book: book) - } - .buttonStyle(.plain) - } - } - } - - // ── Subscription Feed ─────────────────────────────── - if !vm.subscriptionFeed.isEmpty { - ShelfHeader(title: "From People You Follow") - horizontalShelf { - ForEach(vm.subscriptionFeed) { item in - NavigationLink(value: NavDestination.book(item.book.slug)) { - SubscriptionFeedCard(item: item) - } - .buttonStyle(.plain) - } - } - } - - // ── Empty state ───────────────────────────────────── - if !vm.isLoading && - vm.continueReading.isEmpty && - vm.recentlyUpdated.isEmpty && - vm.subscriptionFeed.isEmpty { - EmptyStateView( - icon: "books.vertical", - title: "Your library is empty", - message: "Head to Discover to find novels to read.", - ctaLabel: "Discover Novels", - ctaAction: nil // tab switching handled externally - ) - .frame(maxWidth: .infinity) - .padding(.top, 60) - } - - // ── Loading indicator ─────────────────────────────── - if vm.isLoading { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.top, 60) - } - - Color.clear.frame(height: 24) - } - } - .refreshable { await vm.load() } - } - .navigationTitle("Reading Now") - .appNavigationDestination() - .task { - guard networkMonitor.isConnected else { return } - await vm.load() - } - .errorAlert($vm.error) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: vm.isLoading) - } - } - - // MARK: - Horizontal shelf wrapper - - @ViewBuilder - private func horizontalShelf<Content: View>(@ViewBuilder content: () -> Content) -> some View { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(alignment: .top, spacing: 14) { - content() - } - .padding(.horizontal, 16) - .padding(.bottom, 4) - } - .padding(.bottom, 28) - } - - // MARK: - Context menu for continue reading cards - - @ViewBuilder - private func continueReadingContextMenu(item: ContinueReadingItem) -> some View { - let isFinished = item.book.totalChapters > 0 && item.chapter >= item.book.totalChapters - - ShareLink(item: shareURL(for: item.book)) { - Label("Share", systemImage: "square.and.arrow.up") - } - - if !isFinished { - Button { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - Task { await markAsFinished(item.book) } - } label: { - Label("Mark as Finished", systemImage: "checkmark.circle") - } - } - - Button(role: .destructive) { - Task { await removeFromLibrary(item.book.slug) } - } label: { - Label("Remove from Library", systemImage: "trash") - } - } - - // MARK: - Actions - - private func markAsFinished(_ book: Book) async { - do { - try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters) - await vm.load() - } catch { - vm.error = error.localizedDescription - } - } - - private func removeFromLibrary(_ slug: String) async { - do { - try await APIClient.shared.deleteProgress(slug: slug) - await vm.load() - } catch { - vm.error = error.localizedDescription - } - } - - private func shareURL(for book: Book) -> URL { - let base = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String - ?? "https://v2.libnovel.kalekber.cc" - return URL(string: "\(base)/books/\(book.slug)")! - } -} - -// MARK: - Stats bar -// Three amber-value cards: Books / Chapters / In Progress - -private struct StatsBar: View { - let stats: HomeStats - - var body: some View { - HStack(spacing: 12) { - StatCard( - icon: "books.vertical.fill", - value: "\(stats.totalBooks)", - label: "Books" - ) - StatCard( - icon: "text.alignleft", - value: stats.totalChapters.formatted(), - label: "Chapters" - ) - StatCard( - icon: "bookmark.fill", - value: "\(stats.booksInProgress)", - label: "In Progress" - ) - } - } -} - -private struct StatCard: View { - let icon: String - let value: String - let label: String - - var body: some View { - VStack(spacing: 5) { - Image(systemName: icon) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(Color.amber) - Text(value) - .font(.title3.bold().monospacedDigit()) - .foregroundStyle(.primary) - Text(label) - .font(.caption2) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } -} - -// MARK: - Continue Reading card (Apple Books style with progress bar) - -private struct ContinueReadingCard: View { - let item: ContinueReadingItem - - private static let cardWidth: CGFloat = 130 - private static let cardHeight: CGFloat = 188 // 2:3 aspect - - private var progressFraction: Double { - guard item.book.totalChapters > 0 else { return 0 } - return min(1.0, Double(item.chapter) / Double(item.book.totalChapters)) - } - - private var progressText: String { - let pct = progressFraction * 100 - if pct > 0 && pct < 10 { - return String(format: "%.1f%% complete", pct) - } - return "\(max(1, Int(round(pct))))% complete" - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - // Cover with gradient scrim + chapter badge - ZStack(alignment: .bottom) { - AsyncCoverImage(url: item.book.cover) - .frame(width: Self.cardWidth, height: Self.cardHeight) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .shadow(color: .black.opacity(0.22), radius: 8, y: 4) - .bookCoverZoomSource(slug: item.book.slug) - - // Gradient scrim - LinearGradient( - colors: [.clear, .black.opacity(0.55)], - startPoint: .center, - endPoint: .bottom - ) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .frame(height: 60) - - // Chapter pill - HStack(spacing: 4) { - Image(systemName: "play.fill") - .font(.system(size: 8, weight: .bold)) - Text("Ch.\(item.chapter)") - .font(.system(size: 10, weight: .bold)) - } - .foregroundStyle(.white) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Capsule().fill(Color.amber)) - .padding(.bottom, 10) - } - - // Title - Text(item.book.title) - .font(.caption.bold()) - .lineLimit(2) - .frame(width: Self.cardWidth, alignment: .leading) - .foregroundStyle(.primary) - - // Progress bar (min 4pt sliver so early chapters are visible) - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(Color.secondary.opacity(0.2)) - Capsule() - .fill(Color.amber.opacity(0.9)) - .frame(width: max(4, geo.size.width * progressFraction)) - } - } - .frame(width: Self.cardWidth, height: 3) - - Text(progressText) - .font(.caption2) - .foregroundStyle(.secondary) - } - .frame(width: Self.cardWidth) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(item.book.title), chapter \(item.chapter), \(progressText)") - } -} - -// MARK: - Shelf book card (recently updated) - -private struct ShelfBookCard: View { - let book: Book - private static let cardWidth: CGFloat = 110 - private static let cardHeight: CGFloat = 158 - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - ZStack(alignment: .topTrailing) { - AsyncCoverImage(url: book.cover) - .frame(width: Self.cardWidth, height: Self.cardHeight) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .shadow(color: .black.opacity(0.12), radius: 4, y: 2) - .bookCoverZoomSource(slug: book.slug) - - Text("\(book.totalChapters) ch") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Capsule().fill(Color.black.opacity(0.55))) - .padding(6) - } - - Text(book.title) - .font(.caption.bold()) - .lineLimit(2) - .frame(width: Self.cardWidth, alignment: .leading) - - Text(book.author) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(width: Self.cardWidth, alignment: .leading) - } - .accessibilityElement(children: .combine) - .accessibilityLabel("\(book.title) by \(book.author), \(book.totalChapters) chapters") - } -} - -// MARK: - Subscription feed card - -private struct SubscriptionFeedCard: View { - let item: SubscriptionFeedItem - private static let cardWidth: CGFloat = 110 - private static let cardHeight: CGFloat = 158 - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - AsyncCoverImage(url: item.book.cover) - .frame(width: Self.cardWidth, height: Self.cardHeight) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .shadow(color: .black.opacity(0.12), radius: 4, y: 2) - .bookCoverZoomSource(slug: item.book.slug) - - Text(item.book.title) - .font(.caption.bold()) - .lineLimit(2) - .frame(width: Self.cardWidth, alignment: .leading) - - NavigationLink(value: NavDestination.userProfile(item.readerUsername)) { - Text("via @\(item.readerUsername)") - .font(.caption2) - .foregroundStyle(Color.amber) - .lineLimit(1) - .frame(width: Self.cardWidth, alignment: .leading) - } - .buttonStyle(.plain) - } - .accessibilityElement(children: .combine) - .accessibilityLabel("\(item.book.title), via \(item.readerUsername)") - } -} diff --git a/ios/LibNovelV2/Views/Library/LibraryView.swift b/ios/LibNovelV2/Views/Library/LibraryView.swift deleted file mode 100644 index 1ac174d..0000000 --- a/ios/LibNovelV2/Views/Library/LibraryView.swift +++ /dev/null @@ -1,325 +0,0 @@ -import SwiftUI - -// MARK: - LibraryView -// 2-column grid of saved books with progress overlay, genre/sort/reading-status filters. - -struct LibraryView: View { - @State private var viewModel = LibraryViewModel() - @EnvironmentObject private var networkMonitor: NetworkMonitor - - // Sort sheet - @State private var showingSortSheet = false - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - OfflineBanner() - - // Filter bar - filterBar - - if viewModel.isLoading && viewModel.items.isEmpty { - loadingState - } else if viewModel.filteredItems.isEmpty && !viewModel.isLoading { - emptyState - } else { - bookGrid - } - } - .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) - .navigationTitle("Library") - .navigationBarTitleDisplayMode(.large) - .toolbar { toolbarContent } - .appNavigationDestination() - .task { - guard networkMonitor.isConnected else { return } - await viewModel.load() - } - .refreshable { await viewModel.load() } - .errorAlert($viewModel.error) - .confirmationDialog("Sort By", isPresented: $showingSortSheet, titleVisibility: .visible) { - ForEach(LibrarySortOrder.allCases, id: \.self) { order in - Button(order.rawValue) { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - viewModel.sortOrder = order - } - } - } - Button("Cancel", role: .cancel) {} - } - } - } - - // MARK: - Filter bar - - private var filterBar: some View { - VStack(spacing: 0) { - // Reading filter chips - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(LibraryReadingFilter.allCases, id: \.self) { filter in - ChipButton(label: filter.rawValue, - isSelected: viewModel.readingFilter == filter, - style: .filled) { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - viewModel.readingFilter = filter - } - } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - - // Genre chips (only show if there are genres) - if viewModel.allGenres.count > 1 { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(viewModel.allGenres, id: \.self) { genre in - ChipButton(label: genre, - isSelected: viewModel.selectedGenre == genre, - style: .outlined) { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - viewModel.selectedGenre = genre - } - } - } - } - .padding(.horizontal, 16) - .padding(.bottom, 8) - } - } - - Divider() - .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) - } - } - - // MARK: - Book grid - - private let columns = [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ] - - private var bookGrid: some View { - ScrollView { - LazyVGrid(columns: columns, spacing: 16) { - ForEach(viewModel.filteredItems) { item in - NavigationLink(value: NavDestination.book(item.book.slug)) { - LibraryBookCard( - item: item, - progress: viewModel.progressFraction(for: item), - progressLabel: viewModel.progressPercent(for: item), - isCompleted: viewModel.isCompleted(for: item), - lastChapter: viewModel.lastChapter(for: item) - ) - .bookCoverZoomSource(slug: item.book.slug) - .contextMenu { - contextMenu(for: item) - } - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 120) // clear mini player - } - } - - // MARK: - Context menu - - @ViewBuilder - private func contextMenu(for item: LibraryItem) -> some View { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - // Share: nothing to share without a URL from API, placeholder - } label: { - Label("Share", systemImage: "square.and.arrow.up") - } - - if !viewModel.isCompleted(for: item) { - Button { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - Task { await viewModel.markFinished(item: item) } - } label: { - Label("Mark as Finished", systemImage: "checkmark.circle") - } - } - - Button(role: .destructive) { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - Task { await viewModel.removeFromLibrary(slug: item.book.slug) } - } label: { - Label("Remove from Library", systemImage: "trash") - } - } - - // MARK: - Toolbar - - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - showingSortSheet = true - } label: { - Label("Sort", systemImage: "arrow.up.arrow.down") - .labelStyle(.iconOnly) - } - .accessibilityLabel("Sort library") - } - } - - // MARK: - Loading state - - private var loadingState: some View { - ScrollView { - LazyVGrid(columns: columns, spacing: 16) { - ForEach(0..<8, id: \.self) { _ in - LibraryBookCardSkeleton() - } - } - .padding(.horizontal, 16) - .padding(.top, 16) - } - } - - // MARK: - Empty state - - private var emptyState: some View { - VStack { - Spacer() - EmptyStateView( - icon: "books.vertical", - title: viewModel.items.isEmpty ? "Your library is empty" : "No books match", - message: viewModel.items.isEmpty - ? "Browse and save books to build your collection." - : "Try a different filter or genre.", - ctaLabel: viewModel.items.isEmpty ? "Browse Books" : nil, - ctaAction: nil - ) - Spacer() - } - } -} - -// MARK: - LibraryBookCard - -struct LibraryBookCard: View { - let item: LibraryItem - let progress: Double // 0…1 - let progressLabel: String // "47%" or "3.4%" - let isCompleted: Bool - let lastChapter: Int - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - // Cover with progress arc overlay - ZStack(alignment: .topTrailing) { - AsyncCoverImage(url: item.book.cover) - .aspectRatio(2/3, contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - - if isCompleted { - completedBadge - } else if progress > 0 { - progressArcBadge - } - } - - // Title - Text(item.book.title) - .font(.caption.bold()) - .foregroundStyle(.primary) - .lineLimit(2) - - // Chapter subtitle - if lastChapter > 0 { - Text(isCompleted ? "Completed" : "Ch. \(lastChapter)") - .font(.caption2) - .foregroundStyle(isCompleted ? Color.amber : .secondary) - } - } - } - - // MARK: - Completed badge - - private var completedBadge: some View { - Image(systemName: "checkmark.circle.fill") - .font(.title3) - .foregroundStyle(Color.amber) - .padding(6) - .background(.regularMaterial, in: Circle()) - .padding(6) - .accessibilityLabel("Completed") - } - - // MARK: - Progress arc - - private var progressArcBadge: some View { - ZStack { - // Track - Circle() - .stroke(Color.white.opacity(0.25), lineWidth: 3) - .frame(width: 32, height: 32) - - // Fill - Circle() - .trim(from: 0, to: progress) - .stroke(Color.amber, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .frame(width: 32, height: 32) - .animation(.spring(response: 0.5, dampingFraction: 0.7), value: progress) - - Text(progressLabel) - .font(.system(size: 7, weight: .bold)) - .foregroundStyle(.white) - } - .padding(6) - .background(.ultraThinMaterial, in: Circle()) - .padding(6) - .accessibilityLabel("Progress: \(progressLabel)") - } -} - -// MARK: - LibraryBookCardSkeleton -// Shimmer placeholder used while data is loading. - -struct LibraryBookCardSkeleton: View { - @State private var phase: Double = 0 - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(shimmerGradient) - .aspectRatio(2/3, contentMode: .fill) - - RoundedRectangle(cornerRadius: 4) - .fill(shimmerGradient) - .frame(height: 12) - - RoundedRectangle(cornerRadius: 4) - .fill(shimmerGradient) - .frame(width: 60, height: 10) - } - .onAppear { - withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: true)) { - phase = 1 - } - } - } - - private var shimmerGradient: LinearGradient { - LinearGradient( - colors: [ - Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1)), - Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)), - Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1)) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } -} diff --git a/ios/LibNovelV2/Views/Player/PlayerViews.swift b/ios/LibNovelV2/Views/Player/PlayerViews.swift deleted file mode 100644 index b79f3ab..0000000 --- a/ios/LibNovelV2/Views/Player/PlayerViews.swift +++ /dev/null @@ -1,1826 +0,0 @@ -import SwiftUI -import AVFoundation -import AVKit // AVRoutePickerView - -// MARK: - VoiceSelectionViewModel -// Minimal inline VM for the FullPlayerView voice panel and DownloadManagementSheet. -// New type → @Observable (iOS 17+). - -@Observable @MainActor -final class VoiceSelectionViewModel { - var voices: [String] = [] - var isLoading = false - var error: String? - var playingVoice: String? - - private var audioPlayer: AVPlayer? - private var endObserverToken: NSObjectProtocol? - - func voiceLabel(_ voice: String) -> String { - let parts = voice.split(separator: "_") - guard parts.count >= 2 else { return voice } - let prefix = String(parts[0]) - let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") - var info = "" - switch prefix { - case "af": info = "US F" - case "am": info = "US M" - case "bf": info = "UK F" - case "bm": info = "UK M" - default: info = prefix.uppercased() - } - return "\(name) (\(info))" - } - - func voiceId(_ voice: String) -> String { voice } - - func loadVoices() async { - isLoading = true - error = nil - defer { isLoading = false } - do { - let fetched = try await APIClient.shared.voices() - voices = fetched.isEmpty ? fallbackVoices() : fetched - } catch { - self.error = error.localizedDescription - voices = fallbackVoices() - } - } - - func playSample(_ voice: String) async { - if playingVoice == voice { stopSample(); return } - stopSample() - playingVoice = voice - do { - let url = try await APIClient.shared.presignVoiceSample(voice: voice) - guard let parsed = URL(string: url) else { playingVoice = nil; return } - let item = AVPlayerItem(url: parsed) - audioPlayer = AVPlayer(playerItem: item) - endObserverToken = NotificationCenter.default.addObserver( - forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in self?.stopSample() } - } - audioPlayer?.play() - } catch { - playingVoice = nil - } - } - - func stopSample() { - audioPlayer?.pause() - audioPlayer = nil - if let token = endObserverToken { - NotificationCenter.default.removeObserver(token) - endObserverToken = nil - } - playingVoice = nil - } - - private func fallbackVoices() -> [String] { - ["af_bella", "af_sarah", "af_nicole", - "am_adam", "am_michael", - "bf_emma", "bf_isabella", - "bm_george", "bm_lewis", "af_sky"] - } -} - -// MARK: - MiniPlayerBar -// Spotify-style bar fixed above the tab bar. -// Swipe up → full player. Swipe down → stop. - -struct MiniPlayerBar: View { - @Binding var showFullPlayer: Bool - @EnvironmentObject var audioPlayer: AudioPlayerService - @EnvironmentObject var downloadService: AudioDownloadService - - @State private var dragOffset: CGFloat = 0 - - private var isCurrentChapterDownloaded: Bool { - downloadService.isDownloaded( - slug: audioPlayer.slug, - chapter: audioPlayer.chapter, - voice: audioPlayer.voice - ) - } - - var body: some View { - VStack(spacing: 0) { - // Amber progress strip - MiniBarProgress(progress: audioPlayer.progress) - - HStack(spacing: 12) { - // Cover art - Button { showFullPlayer = true } label: { - AsyncCoverImage(url: audioPlayer.coverURL) - .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) - .shadow(color: .black.opacity(0.18), radius: 6, y: 2) - } - .buttonStyle(.plain) - .accessibilityLabel("Open full player") - - // Track info - Button { showFullPlayer = true } label: { - VStack(alignment: .leading, spacing: 2) { - Text(chapterLabel) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - HStack(spacing: 4) { - Text(audioPlayer.bookTitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - if isCurrentChapterDownloaded { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 9)) - .foregroundStyle(.green) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - - // Prev chapter - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - if let prev = audioPlayer.prevChapter { - NotificationCenter.default.post( - name: .skipToPrevChapter, object: nil, - userInfo: ["prev": prev] - ) - } - } label: { - Image(systemName: "backward.end.fill") - .font(.system(size: 19, weight: .semibold)) - .foregroundStyle(.primary) - .frame(width: 36, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(audioPlayer.prevChapter == nil) - .opacity(audioPlayer.prevChapter == nil ? 0.3 : 1) - .accessibilityLabel("Previous chapter") - - // Play / Pause — isolated observer - MiniBarPlayPause(progress: audioPlayer.progress) { - audioPlayer.togglePlayPause() - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } - .disabled(audioPlayer.status == .generating) - - // Next chapter - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - if let next = audioPlayer.nextChapter { - NotificationCenter.default.post( - name: .skipToNextChapter, object: nil, - userInfo: ["next": next] - ) - } - } label: { - Image(systemName: "forward.end.fill") - .font(.system(size: 19, weight: .semibold)) - .foregroundStyle(.primary) - .frame(width: 36, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(audioPlayer.nextChapter == nil) - .opacity(audioPlayer.nextChapter == nil ? 0.3 : 1) - .accessibilityLabel("Next chapter") - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - } - .background(.regularMaterial) - .offset(y: dragOffset) - .opacity(dragOffset > 0 ? max(0.3, 1 - dragOffset / 200) : 1) - .gesture( - DragGesture(minimumDistance: 8, coordinateSpace: .local) - .onChanged { value in - let dy = value.translation.height - dragOffset = dy < 0 ? dy * 0.25 : dy * 0.7 - } - .onEnded { value in - let dy = value.translation.height - let velocity = value.predictedEndTranslation.height - value.translation.height - if dy < -30 || velocity < -150 { - withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { dragOffset = 0 } - showFullPlayer = true - } else if dy > 60 || velocity > 200 { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { dragOffset = 200 } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 150_000_000) - audioPlayer.stop() - } - } else { - withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { dragOffset = 0 } - } - } - ) - } - - private var chapterLabel: String { - let raw = audioPlayer.chapterTitle.isEmpty - ? "Chapter \(audioPlayer.chapter)" - : audioPlayer.chapterTitle - return raw.strippingTrailingDate() - } -} - -// MARK: - Isolated progress strip - -private struct MiniBarProgress: View { - @ObservedObject var progress: PlaybackProgress - - var body: some View { - GeometryReader { geo in - let fraction = progress.duration > 0 - ? CGFloat(progress.currentTime / progress.duration) - : 0 - Rectangle() - .fill(Color.amber) - .frame(width: geo.size.width * max(0, min(1, fraction)), height: 2) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(height: 2) - } -} - -// MARK: - Isolated play/pause for mini bar - -private struct MiniBarPlayPause: View { - @ObservedObject var progress: PlaybackProgress - let onToggle: () -> Void - - var body: some View { - Button(action: onToggle) { - Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 21, weight: .semibold)) - .foregroundStyle(.primary) - .frame(width: 36, height: 44) - .contentShape(Rectangle()) - .contentTransition(.symbolEffect(.replace.downUp)) - } - .buttonStyle(.plain) - .accessibilityLabel(progress.isPlaying ? "Pause" : "Play") - } -} - -// MARK: - FullPlayerView - -struct FullPlayerView: View { - @EnvironmentObject var audioPlayer: AudioPlayerService - @EnvironmentObject var downloadService: AudioDownloadService - @EnvironmentObject var authStore: AuthStore - var onDismiss: () -> Void = {} - - @State private var showingChaptersList = false - @State private var showingSleepTimer = false - @State private var showingVoiceSelector = false - @State private var voiceVM = VoiceSelectionViewModel() - @State private var coverAppeared = false - - private var isCurrentChapterDownloaded: Bool { - downloadService.isDownloaded( - slug: audioPlayer.slug, - chapter: audioPlayer.chapter, - voice: audioPlayer.voice - ) - } - - private var currentDownloadProgress: DownloadProgress? { - let key = downloadService.makeKey( - slug: audioPlayer.slug, - chapter: audioPlayer.chapter, - voice: audioPlayer.voice - ) - return downloadService.downloads[key] - } - - var body: some View { - GeometryReader { geo in - ZStack { - // Blurred cover background - AsyncCoverImage(url: audioPlayer.coverURL, isBackground: true) - .frame(width: geo.size.width, height: geo.size.height) - .clipped() - .blur(radius: 55, opaque: true) - .overlay(Color.black.opacity(0.55)) - .ignoresSafeArea() - .id(audioPlayer.coverURL) - - VStack(spacing: 0) { - // Drag handle - Capsule() - .fill(Color.white.opacity(0.25)) - .frame(width: 36, height: 4) - .padding(.top, 14) - - // Cover art - let coverSize = min(geo.size.width - 56, geo.size.height * 0.42) - ZStack { - AsyncCoverImage(url: audioPlayer.coverURL) - .frame(width: coverSize, height: coverSize) - .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) - .shadow(color: .black.opacity(0.55), radius: 36, y: 18) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(Color.black.opacity(audioPlayer.status == .generating ? 0.5 : 0)) - .animation(.easeInOut(duration: 0.3), value: audioPlayer.status == .generating) - ) - .scaleEffect(audioPlayer.progress.isPlaying && coverAppeared ? 1.02 : 0.97) - .animation(.spring(response: 0.45, dampingFraction: 0.7), value: audioPlayer.progress.isPlaying) - - // Generating overlay - if audioPlayer.status == .generating { - VStack(spacing: 10) { - ProgressView() - .tint(.white) - .scaleEffect(1.4) - Text("Generating audio…") - .font(.caption.weight(.medium)) - .foregroundStyle(.white.opacity(0.8)) - } - .transition(.opacity) - } - - // Voice watermark - VStack { - Spacer() - HStack { - Text(voiceName) - .font(.custom("Snell Roundhand", size: 17)) - .foregroundStyle(.white.opacity(0.5)) - .shadow(color: .black.opacity(0.5), radius: 2) - .padding(12) - Spacer() - } - } - .frame(width: coverSize, height: coverSize) - } - .frame(width: coverSize, height: coverSize) - .padding(.top, 18) - .onAppear { - withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1)) { - coverAppeared = true - } - } - .onChange(of: audioPlayer.slug) { _, _ in - coverAppeared = false - withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.05)) { - coverAppeared = true - } - } - - // Title block - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text((audioPlayer.chapterTitle.isEmpty - ? "Chapter \(audioPlayer.chapter)" - : audioPlayer.chapterTitle).strippingTrailingDate()) - .font(.title3.weight(.bold)) - .foregroundStyle(.white) - .lineLimit(2) - Text(audioPlayer.bookTitle) - .font(.subheadline) - .foregroundStyle(.white.opacity(0.55)) - .lineLimit(1) - - HStack(spacing: 8) { - if !audioPlayer.chapters.isEmpty { - Text(chapterPositionText) - .font(.caption2.monospacedDigit()) - .foregroundStyle(.white.opacity(0.3)) - } - if let p = currentDownloadProgress { - Label("\(Int(p.progress * 100))%", systemImage: "arrow.down.circle") - .font(.caption2) - .foregroundStyle(.blue) - } else if isCurrentChapterDownloaded { - Label("Offline", systemImage: "checkmark.circle.fill") - .font(.caption2) - .foregroundStyle(.green) - } - } - .padding(.top, 1) - } - .frame(maxWidth: .infinity, alignment: .leading) - - // Quick download - if !isCurrentChapterDownloaded && currentDownloadProgress == nil { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - Task { - try? await downloadService.download( - slug: audioPlayer.slug, - chapter: audioPlayer.chapter, - voice: audioPlayer.voice - ) - } - } label: { - Image(systemName: "arrow.down.circle") - .font(.system(size: 24)) - .foregroundStyle(.white.opacity(0.65)) - .frame(minWidth: 44, minHeight: 44) - } - .buttonStyle(.plain) - .accessibilityLabel("Download chapter") - } - - // Auto-next toggle - Button { - audioPlayer.autoNext.toggle() - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } label: { - Image(systemName: audioPlayer.autoNext ? "infinity.circle.fill" : "infinity.circle") - .font(.system(size: 28)) - .foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.4)) - .contentTransition(.symbolEffect(.replace)) - .frame(minWidth: 44, minHeight: 44) - } - .buttonStyle(.plain) - .accessibilityLabel(audioPlayer.autoNext ? "Auto-next on" : "Auto-next off") - } - .padding(.horizontal, 28) - .padding(.top, 22) - - // Seek bar (isolated) - PlayerProgressSection( - progress: audioPlayer.progress, - onSeek: { audioPlayer.seek(to: $0) } - ) - .padding(.top, 18) - .opacity(audioPlayer.status == .generating ? 0.3 : 1) - .allowsHitTesting(audioPlayer.status != .generating) - - // Transport row - HStack(spacing: 0) { - PlayerSecondaryButton(systemName: "gobackward.15", size: 24, - disabled: audioPlayer.status == .generating) { - audioPlayer.skip(by: -15) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - - PlayerChapterSkipButton(systemName: "backward.end.fill", size: 30, - disabled: audioPlayer.prevChapter == nil) { - if let prev = audioPlayer.prevChapter { - NotificationCenter.default.post( - name: .skipToPrevChapter, object: nil, userInfo: ["prev": prev]) - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } - } - - PlayerPlayPauseButton( - progress: audioPlayer.progress, - isGenerating: audioPlayer.status == .generating - ) { - audioPlayer.togglePlayPause() - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } - - PlayerChapterSkipButton( - systemName: "forward.end.fill", size: 30, - disabled: audioPlayer.nextChapter == nil, - prefetching: audioPlayer.nextPrefetchStatus == .prefetching - ) { - if let next = audioPlayer.nextChapter { - NotificationCenter.default.post( - name: .skipToNextChapter, object: nil, userInfo: ["next": next]) - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } - } - - PlayerSecondaryButton(systemName: "goforward.15", size: 24, - disabled: audioPlayer.status == .generating) { - audioPlayer.skip(by: 15) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 8) - - // Bottom toolbar - HStack(spacing: 0) { - // AirPlay - AirPlayButton() - .frame(width: 24, height: 24) - .frame(maxWidth: .infinity, minHeight: 44) - .accessibilityLabel("AirPlay") - - // Speed picker - Menu { - ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in - Button { - audioPlayer.setSpeed(s) - } label: { - if s == audioPlayer.speed { - Label("\(s, specifier: "%.2g")×", systemImage: "checkmark") - } else { - Text("\(s, specifier: "%.2g")×") - } - } - } - } label: { - Text("\(audioPlayer.speed, specifier: "%.2g")×") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white.opacity(0.65)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Capsule().fill(.white.opacity(0.12))) - .frame(maxWidth: .infinity) - .frame(height: 44) - } - .buttonStyle(.plain) - - // Voice selector toggle - Button { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - showingVoiceSelector.toggle() - } - UIImpactFeedbackGenerator(style: .light).impactOccurred() - if !showingVoiceSelector { voiceVM.stopSample() } - } label: { - Image(systemName: showingVoiceSelector ? "mic.fill" : "mic") - .font(.system(size: 20)) - .foregroundStyle(showingVoiceSelector ? Color.amber : .white.opacity(0.65)) - .frame(maxWidth: .infinity) - .frame(height: 44) - .contentTransition(.symbolEffect(.replace)) - } - .buttonStyle(.plain) - .accessibilityLabel(showingVoiceSelector ? "Hide voice selector" : "Select voice") - - // Collapse - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - onDismiss() - } label: { - Image(systemName: "chevron.down") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white.opacity(0.65)) - .frame(maxWidth: .infinity) - .frame(height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel("Collapse player") - - // Chapters list - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - showingChaptersList = true - } label: { - Image(systemName: "list.bullet") - .font(.system(size: 20)) - .foregroundStyle(.white.opacity(0.65)) - .frame(maxWidth: .infinity) - .frame(height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel("Chapters list") - - // Sleep timer - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - showingSleepTimer = true - } label: { - VStack(spacing: 1) { - Image(systemName: sleepTimerIcon) - .font(.system(size: 20)) - .foregroundStyle(audioPlayer.sleepTimer != nil ? Color.amber : .white.opacity(0.65)) - .contentTransition(.symbolEffect(.replace)) - if !audioPlayer.sleepTimerRemainingText.isEmpty { - Text(audioPlayer.sleepTimerRemainingText) - .font(.system(size: 9, weight: .semibold).monospacedDigit()) - .foregroundStyle(Color.amber) - .lineLimit(1) - } - } - .frame(maxWidth: .infinity) - .frame(height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel(audioPlayer.sleepTimer != nil ? "Sleep timer active" : "Sleep timer") - } - .padding(.horizontal, 12) - .padding(.bottom, showingVoiceSelector ? 0 : 8) - - // Voice selector panel (expandable) - if showingVoiceSelector { - VoiceSelectorPanel( - voiceVM: voiceVM, - selectedVoice: audioPlayer.voice, - onSelectVoice: { newVoice in - voiceVM.stopSample() - audioPlayer.voice = newVoice - BookVoicePreferences.shared.setVoice(newVoice, for: audioPlayer.slug) - Task { - var settings = authStore.settings - settings.voice = newVoice - await authStore.saveSettings(settings) - } - } - ) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .task { - if voiceVM.voices.isEmpty { await voiceVM.loadVoices() } - } - } - } - .ignoresSafeArea(edges: .bottom) - } - } - .ignoresSafeArea() - .sheet(isPresented: $showingChaptersList) { - PlayerChaptersListSheet( - chapters: audioPlayer.chapters, - currentChapter: audioPlayer.chapter, - onChapterSelect: { selected in - showingChaptersList = false - guard selected != audioPlayer.chapter else { return } - let title = audioPlayer.chapters.first(where: { $0.number == selected })?.title ?? "" - let next = audioPlayer.chapters.filter({ $0.number > selected }).min(by: { $0.number < $1.number })?.number - let prev: Int? = selected > 1 ? selected - 1 : nil - audioPlayer.load( - slug: audioPlayer.slug, chapter: selected, chapterTitle: title, - bookTitle: audioPlayer.bookTitle, coverURL: audioPlayer.coverURL, - voice: audioPlayer.voice, speed: audioPlayer.speed, - chapters: audioPlayer.chapters, nextChapter: next, prevChapter: prev - ) - } - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - .sheet(isPresented: $showingSleepTimer) { - SleepTimerSheet(audioPlayer: audioPlayer) - .presentationDetents([.height(500)]) - .presentationDragIndicator(.visible) - } - } - - // MARK: - Helpers - - private var chapterPositionText: String { - let total = audioPlayer.chapters.count - guard total > 0 else { return "" } - let sorted = audioPlayer.chapters.sorted(by: { $0.number < $1.number }) - let idx = (sorted.firstIndex(where: { $0.number == audioPlayer.chapter }) ?? 0) + 1 - return "Chapter \(idx) of \(total)" - } - - private var voiceName: String { - let parts = audioPlayer.voice.split(separator: "_") - if parts.count > 1 { return String(parts[1]).capitalized } - return audioPlayer.voice.capitalized - } - - private var sleepTimerIcon: String { - audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz" - } -} - -// MARK: - Secondary transport button (±15 s skips) - -private struct PlayerSecondaryButton: View { - let systemName: String - let size: CGFloat - let disabled: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: systemName) - .font(.system(size: size, weight: .regular)) - .foregroundStyle(.white.opacity(disabled ? 0.3 : 0.85)) - .frame(maxWidth: .infinity) - .frame(height: 64) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(disabled) - } -} - -// MARK: - Chapter-skip button (prev / next chapter) - -private struct PlayerChapterSkipButton: View { - let systemName: String - let size: CGFloat - let disabled: Bool - var prefetching: Bool = false - let action: () -> Void - - var body: some View { - Button(action: action) { - ZStack { - Image(systemName: systemName) - .font(.system(size: size, weight: .regular)) - .foregroundStyle(.white.opacity(disabled ? 0.3 : 0.9)) - - if prefetching { - VStack { - Spacer() - HStack { - Spacer() - ProgressView() - .scaleEffect(0.55) - .tint(.amber) - .padding(3) - .background(Circle().fill(.black.opacity(0.6))) - } - } - } - } - .frame(maxWidth: .infinity) - .frame(height: 64) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(disabled) - .opacity(disabled ? 0.4 : 1.0) - } -} - -// MARK: - AirPlay Button - -struct AirPlayButton: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> UIViewController { - let vc = UIViewController() - vc.view.backgroundColor = .clear - let picker = AVRoutePickerView() - picker.tintColor = UIColor.white.withAlphaComponent(0.7) - picker.activeTintColor = UIColor.systemOrange - picker.prioritizesVideoDevices = false - picker.translatesAutoresizingMaskIntoConstraints = false - vc.view.addSubview(picker) - NSLayoutConstraint.activate([ - picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor), - picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor), - picker.topAnchor.constraint(equalTo: vc.view.topAnchor), - picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor), - ]) - return vc - } - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} -} - -// MARK: - Isolated seek bar + timestamps - -private struct PlayerProgressSection: View { - @ObservedObject var progress: PlaybackProgress - let onSeek: (Double) -> Void - - var body: some View { - VStack(spacing: 4) { - PlayerSlider( - value: Binding(get: { progress.currentTime }, set: { onSeek($0) }), - range: 0...max(progress.duration, 1) - ) - HStack { - Text(formatTime(progress.currentTime)) - Spacer() - Text("-" + formatTime(progress.duration - progress.currentTime)) - } - .font(.caption.monospacedDigit()) - .foregroundStyle(.white.opacity(0.5)) - } - .padding(.horizontal, 28) - } - - private func formatTime(_ seconds: Double) -> String { - guard seconds.isFinite, seconds >= 0 else { return "0:00" } - let s = Int(seconds) - return "\(s / 60):\(String(format: "%02d", s % 60))" - } -} - -// MARK: - Custom amber seek slider - -struct PlayerSlider: View { - @Binding var value: Double - let range: ClosedRange<Double> - - @State private var isDragging = false - @State private var didFireHaptic = false - - var body: some View { - GeometryReader { geo in - let width = geo.size.width - let fraction = (value - range.lowerBound) / (range.upperBound - range.lowerBound) - let clamped = max(0, min(1, fraction)) - let filled = width * clamped - let thumbSize: CGFloat = isDragging ? 26 : 20 - let trackHeight: CGFloat = isDragging ? 5 : 4 - - ZStack(alignment: .leading) { - Capsule() - .fill(Color.white.opacity(0.2)) - .frame(height: trackHeight) - - Capsule() - .fill(LinearGradient( - colors: [Color.amber.opacity(0.9), Color.amber], - startPoint: .leading, endPoint: .trailing - )) - .frame(width: max(filled, thumbSize / 2), height: trackHeight) - - Circle() - .fill(Color.white) - .frame(width: thumbSize, height: thumbSize) - .shadow(color: .black.opacity(0.3), radius: isDragging ? 6 : 3, - y: isDragging ? 2 : 1) - .offset(x: max(0, filled - thumbSize / 2)) - .animation(.spring(response: 0.2, dampingFraction: 0.65), value: isDragging) - } - .frame(height: 36) - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { drag in - if !isDragging { - isDragging = true - if !didFireHaptic { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - didFireHaptic = true - } - } - let raw = drag.location.x / width - value = range.lowerBound + max(0, min(1, raw)) * (range.upperBound - range.lowerBound) - } - .onEnded { _ in isDragging = false; didFireHaptic = false } - ) - } - .frame(height: 36) - } -} - -// MARK: - Isolated play/pause button (full player) - -private struct PlayerPlayPauseButton: View { - @ObservedObject var progress: PlaybackProgress - let isGenerating: Bool - let onToggle: () -> Void - - @State private var isPressed = false - - var body: some View { - Button { onToggle() } label: { - ZStack { - Circle() - .fill(Color.amber.opacity(progress.isPlaying ? 0.18 : 0)) - .frame(width: 80, height: 80) - .animation(.easeInOut(duration: 0.35), value: progress.isPlaying) - - Circle() - .fill(LinearGradient( - colors: [Color.amber.opacity(0.9), Color.amber.opacity(0.65)], - startPoint: .topLeading, endPoint: .bottomTrailing - )) - .frame(width: 64, height: 64) - .shadow(color: Color.amber.opacity(0.45), radius: 12, y: 4) - .scaleEffect(isPressed ? 0.92 : 1.0) - .animation(.spring(response: 0.2, dampingFraction: 0.6), value: isPressed) - - if isGenerating { - ProgressView().tint(.white).scaleEffect(1.2) - } else { - Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 28, weight: .bold)) - .foregroundStyle(.white) - .offset(x: progress.isPlaying ? 0 : 2) - .contentTransition(.symbolEffect(.replace.downUp)) - } - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.plain) - .disabled(isGenerating) - ._onButtonGesture(pressing: { isPressed = $0 }, perform: {}) - .accessibilityLabel(progress.isPlaying ? "Pause" : "Play") - } -} - -// MARK: - Sleep Timer Sheet - -struct SleepTimerSheet: View { - @ObservedObject var audioPlayer: AudioPlayerService - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 20) { - // Off - TimerCard { - TimerOptionRow( - label: "Off", systemImage: "moon.zzz", - isSelected: audioPlayer.sleepTimer == nil - ) { - audioPlayer.setSleepTimer(nil) - dismiss() - } - } - - // Chapter-based - VStack(spacing: 0) { - SectionLabel("Chapter-based") - TimerCard { - ForEach([1, 2, 3, 4], id: \.self) { count in - let isSelected: Bool = { - if case .chapters(let c) = audioPlayer.sleepTimer { return c == count } - return false - }() - TimerOptionRow( - label: "\(count) \(count == 1 ? "chapter" : "chapters")", - systemImage: "book", isSelected: isSelected - ) { - audioPlayer.setSleepTimer(.chapters(count)) - dismiss() - } - if count < 4 { Divider().padding(.leading, 56) } - } - } - } - - // Time-based - VStack(spacing: 0) { - SectionLabel("Time-based") - TimerCard { - ForEach([20, 40, 60, 120], id: \.self) { mins in - let isSelected: Bool = { - if case .minutes(let m) = audioPlayer.sleepTimer { return m == mins } - return false - }() - TimerOptionRow( - label: formatTimerOption(mins), systemImage: "clock", - isSelected: isSelected - ) { - audioPlayer.setSleepTimer(.minutes(mins)) - dismiss() - } - if mins != 120 { Divider().padding(.leading, 56) } - } - } - } - } - .padding(20) - } - .background(Color(.systemGroupedBackground)) - .navigationTitle("Sleep Timer") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() }.fontWeight(.semibold) - } - } - } - } - - private func formatTimerOption(_ minutes: Int) -> String { - if minutes < 60 { return "\(minutes) mins" } - let h = minutes / 60 - return "\(h) \(h == 1 ? "hour" : "hours")" - } -} - -// MARK: - Sleep timer helper views - -private struct TimerCard<Content: View>: View { - @ViewBuilder let content: Content - var body: some View { - VStack(spacing: 0) { content } - .background(Color(.secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } -} - -private struct SectionLabel: View { - let text: String - init(_ text: String) { self.text = text } - var body: some View { - Text(text.uppercased()) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 4) - .padding(.bottom, 8) - } -} - -private struct TimerOptionRow: View { - let label: String - let systemImage: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - action() - } label: { - HStack(spacing: 14) { - Image(systemName: systemImage) - .font(.system(size: 16)) - .foregroundStyle(isSelected ? Color.amber : .secondary) - .frame(width: 28) - Text(label) - .font(.body) - .foregroundStyle(.primary) - Spacer() - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.amber) - .transition(.scale.combined(with: .opacity)) - } - } - .padding(.horizontal, 18) - .padding(.vertical, 14) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) - } -} - -// MARK: - Player Chapters List Sheet -// Groups chapters into blocks of 100 with a right-edge jump bar. -// Includes per-chapter download status and swipe actions. - -struct PlayerChaptersListSheet: View { - let chapters: [ChapterBrief] - let currentChapter: Int - let onChapterSelect: (Int) -> Void - - @Environment(\.dismiss) private var dismiss - @EnvironmentObject var audioPlayer: AudioPlayerService - @EnvironmentObject var downloadService: AudioDownloadService - - @State private var searchText: String = "" - @State private var filterOfflineOnly = false - @State private var showingManage = false - @State private var activeBlock: String? = nil - - // MARK: Derived data - - private var downloadedCount: Int { - chapters.filter { - downloadService.isDownloaded(slug: audioPlayer.slug, chapter: $0.number, voice: audioPlayer.voice) - }.count - } - - private var downloadingCount: Int { - downloadService.downloads.filter { key, _ in key.hasPrefix("\(audioPlayer.slug)::") }.count - } - - private var filtered: [ChapterBrief] { - var result = chapters - if filterOfflineOnly { - result = result.filter { - downloadService.isDownloaded(slug: audioPlayer.slug, chapter: $0.number, voice: audioPlayer.voice) - } - } - if !searchText.isEmpty { - let q = searchText.lowercased() - result = result.filter { "\($0.number)".contains(q) || $0.title.lowercased().contains(q) } - } - return result - } - - private var groups: [(label: String, chapters: [ChapterBrief])] { - guard searchText.isEmpty && !filterOfflineOnly else { - return filtered.isEmpty ? [] : [("Results", filtered)] - } - guard !filtered.isEmpty else { return [] } - let blockSize = 100 - let minN = filtered.map(\.number).min() ?? 1 - let maxN = filtered.map(\.number).max() ?? 1 - let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 - var result: [(label: String, chapters: [ChapterBrief])] = [] - var blockStart = firstBlock - while blockStart <= maxN { - let blockEnd = blockStart + blockSize - 1 - let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } - if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) } - blockStart += blockSize - } - return result - } - - private var jumpLabels: [String] { groups.map(\.label) } - - var body: some View { - NavigationStack { - ZStack(alignment: .trailing) { - List { - // Download summary - if downloadedCount > 0 || downloadingCount > 0 { - Section { - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Offline Downloads").font(.headline) - Text("\(downloadedCount) of \(chapters.count) chapters") - .font(.subheadline).foregroundStyle(.secondary) - } - Spacer() - Button { showingManage = true } label: { - Label("Manage", systemImage: "arrow.down.circle") - .font(.subheadline.weight(.semibold)) - } - .buttonStyle(.bordered).tint(.blue) - } - if downloadingCount > 0 { - HStack(spacing: 8) { - ProgressView().scaleEffect(0.8) - Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")") - .font(.caption).foregroundStyle(.secondary) - } - } - Toggle("Show offline only", isOn: $filterOfflineOnly) - .font(.subheadline).tint(Color.amber) - } - .padding(.vertical, 8) - } - } - - ForEach(groups, id: \.label) { group in - Section { - ForEach(group.chapters, id: \.number) { ch in - PlayerChapterRow( - chapter: ch, - isCurrent: ch.number == currentChapter, - onSelect: { onChapterSelect(ch.number) } - ) - .id(group.label) - } - } header: { - if searchText.isEmpty && !filterOfflineOnly { - Text(group.label) - .font(.caption.bold()) - .foregroundStyle(.secondary) - .id("header_\(group.label)") - } - } - } - } - .listStyle(.plain) - .searchable(text: $searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Chapter number or title") - .scrollPosition(id: $activeBlock, anchor: .top) - - // Jump bar - if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 { - PlayerJumpBar(labels: jumpLabels, currentChapter: currentChapter, groups: groups) { label in - withAnimation { activeBlock = label } - } - .padding(.trailing, 4) - } - } - .navigationTitle("Chapters (\(filtered.count))") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() }.fontWeight(.semibold) - } - } - .sheet(isPresented: $showingManage) { - DownloadManagementSheet( - chapters: chapters, slug: audioPlayer.slug, - voice: Binding(get: { audioPlayer.voice }, set: { audioPlayer.voice = $0 }) - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - .onAppear { - if let block = groups.first(where: { g in - g.chapters.contains(where: { $0.number == currentChapter }) - }) { - activeBlock = block.label - } - } - } - } -} - -// MARK: - Individual chapter row (player chapters list) - -private struct PlayerChapterRow: View { - let chapter: ChapterBrief - let isCurrent: Bool - let onSelect: () -> Void - - @EnvironmentObject var audioPlayer: AudioPlayerService - @EnvironmentObject var downloadService: AudioDownloadService - - private var isDownloaded: Bool { - downloadService.isDownloaded(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) - } - private var downloadProgress: DownloadProgress? { - let key = downloadService.makeKey(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) - return downloadService.downloads[key] - } - private var isDownloading: Bool { downloadProgress != nil } - - var body: some View { - Button(action: onSelect) { - HStack(spacing: 14) { - // Number badge - ZStack { - Text("\(chapter.number)") - .font(.caption.bold()) - .foregroundStyle(isCurrent ? .white : .secondary) - .frame(width: 40, height: 40) - .background(Circle().fill(isCurrent ? Color.amber : Color(.systemGray5))) - - if isDownloading, let p = downloadProgress { - Circle() - .trim(from: 0, to: p.progress) - .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .frame(width: 44, height: 44) - .animation(.easeInOut(duration: 0.3), value: p.progress) - } - } - - // Title + status - VStack(alignment: .leading, spacing: 3) { - Text(chapter.title.strippingTrailingDate()) - .font(.subheadline.weight(isCurrent ? .semibold : .regular)) - .foregroundStyle(.primary) - .lineLimit(2) - - HStack(spacing: 8) { - if isCurrent { - Label("Now Playing", systemImage: "waveform") - .font(.caption2) - .foregroundStyle(Color.amber) - .symbolEffect(.variableColor.cumulative, isActive: isCurrent) - } - if isDownloading, let p = downloadProgress { - Label("\(Int(p.progress * 100))%", systemImage: "arrow.down.circle") - .font(.caption2).foregroundStyle(.blue) - } else if isDownloaded { - Label("Downloaded", systemImage: "checkmark.circle.fill") - .font(.caption2).foregroundStyle(.green) - } - } - } - - Spacer() - - if isCurrent { - Image(systemName: "waveform") - .font(.caption.bold()) - .foregroundStyle(Color.amber) - .symbolEffect(.variableColor.cumulative, isActive: isCurrent) - } else if isDownloaded { - Image(systemName: "arrow.down.circle.fill") - .font(.body).foregroundStyle(.green) - } else if isDownloading { - ProgressView().scaleEffect(0.8) - } - } - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if isDownloaded { - Button(role: .destructive) { - try? downloadService.deleteDownload( - slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) - } label: { Label("Delete", systemImage: "trash") } - } else if isDownloading { - Button(role: .destructive) { - downloadService.cancelDownload( - slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) - } label: { Label("Cancel", systemImage: "xmark") } - } else { - Button { - Task { - try? await downloadService.download( - slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) - } - } label: { Label("Download", systemImage: "arrow.down.circle") } - .tint(.blue) - } - } - } -} - -// MARK: - Jump bar (right edge) - -private struct PlayerJumpBar: View { - let labels: [String] - let currentChapter: Int - let groups: [(label: String, chapters: [ChapterBrief])] - let onSelect: (String) -> Void - - @State private var isDragging = false - - private func shortLabel(_ full: String) -> String { - full.components(separatedBy: "–").first ?? full - } - - private var currentBlock: String? { - groups.first(where: { $0.chapters.contains(where: { $0.number == currentChapter }) })?.label - } - - var body: some View { - VStack(spacing: 0) { - ForEach(labels, id: \.self) { label in - let isCurrent = label == currentBlock - Text(shortLabel(label)) - .font(.system(size: 10, weight: isCurrent ? .bold : .regular)) - .foregroundStyle(isCurrent ? Color.amber : Color.secondary) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - .onTapGesture { onSelect(label) } - } - } - .padding(.vertical, 6) - .background(Capsule().fill(.ultraThinMaterial).shadow(color: .black.opacity(0.15), radius: 4)) - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .local) - .onChanged { value in - isDragging = true - let index = max(0, min(labels.count - 1, Int(value.location.y / 28))) - onSelect(labels[index]) - } - .onEnded { _ in isDragging = false } - ) - .animation(.easeInOut(duration: 0.15), value: isDragging) - } -} - -// MARK: - Voice selector panel (inline, expandable inside FullPlayerView) - -private struct VoiceSelectorPanel: View { - let voiceVM: VoiceSelectionViewModel - let selectedVoice: String - let onSelectVoice: (String) -> Void - - var body: some View { - VStack(spacing: 0) { - HStack { - Text("Choose Voice") - .font(.caption.weight(.semibold)) - .foregroundStyle(.white.opacity(0.45)) - .textCase(.uppercase) - .tracking(0.8) - Spacer() - } - .padding(.horizontal, 18) - .padding(.top, 10) - .padding(.bottom, 6) - - ScrollView { - VStack(spacing: 0) { - ForEach(voiceVM.voices, id: \.self) { voice in - VoiceOptionRow( - voice: voice, - isSelected: selectedVoice == voice, - isPlaying: voiceVM.playingVoice == voice, - voiceLabel: voiceVM.voiceLabel(voice), - voiceId: voiceVM.voiceId(voice), - onSelect: { onSelectVoice(voice) }, - onPlaySample: { Task { await voiceVM.playSample(voice) } } - ) - if voice != voiceVM.voices.last { - Divider().overlay(Color.white.opacity(0.08)).padding(.leading, 52) - } - } - } - } - .frame(maxHeight: 220) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .padding(.horizontal, 16) - - Text("New voice applies on next chapter") - .font(.caption2) - .foregroundStyle(.white.opacity(0.35)) - .padding(.top, 7) - .padding(.bottom, 10) - } - .background(.ultraThinMaterial) - } -} - -// MARK: - Voice option row (inside VoiceSelectorPanel) - -private struct VoiceOptionRow: View { - let voice: String - let isSelected: Bool - let isPlaying: Bool - let voiceLabel: String - let voiceId: String - let onSelect: () -> Void - let onPlaySample: () -> Void - - var body: some View { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - onSelect() - } label: { - HStack(spacing: 12) { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.system(size: 18)) - .foregroundStyle(isSelected ? Color.amber : .white.opacity(0.25)) - .scaleEffect(isSelected ? 1.1 : 1.0) - .animation(.spring(response: 0.3, dampingFraction: 0.55), value: isSelected) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { - Text(voiceLabel) - .font(.subheadline) - .foregroundStyle(isSelected ? Color.amber : .white) - .fontWeight(isSelected ? .semibold : .regular) - .animation(.easeInOut(duration: 0.2), value: isSelected) - Text(voiceId) - .font(.caption2) - .fontDesign(.monospaced) - .foregroundStyle(.white.opacity(0.4)) - } - - Spacer() - - Button { onPlaySample() } label: { - Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") - .font(.system(size: 24)) - .foregroundStyle(isPlaying ? Color.red : Color.amber.opacity(0.8)) - .contentTransition(.symbolEffect(.replace.downUp)) - .frame(minWidth: 44, minHeight: 44) - } - .buttonStyle(.plain) - .accessibilityLabel(isPlaying ? "Stop sample" : "Play sample") - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .background(isSelected ? Color.amber.opacity(0.08) : Color.clear) - .animation(.easeInOut(duration: 0.2), value: isSelected) - } -} - -// MARK: - Download Management Sheet - -struct DownloadManagementSheet: View { - let chapters: [ChapterBrief] - let slug: String - @Binding var voice: String - - @Environment(\.dismiss) private var dismiss - @EnvironmentObject var downloadService: AudioDownloadService - @EnvironmentObject var authStore: AuthStore - - @State private var showingDeleteAll = false - @State private var isDownloadingAll = false - @State private var showingVoiceSelector = false - @State private var showingRangeSelector = false - @State private var voiceVM = VoiceSelectionViewModel() - - private var downloadedChapters: [ChapterBrief] { - chapters.filter { downloadService.isDownloaded(slug: slug, chapter: $0.number, voice: voice) } - } - private var notDownloadedChapters: [ChapterBrief] { - chapters.filter { !downloadService.isDownloaded(slug: slug, chapter: $0.number, voice: voice) } - } - - var body: some View { - NavigationStack { - List { - // Voice info - Section { - Button { showingVoiceSelector = true } label: { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Download Voice").font(.subheadline).foregroundStyle(.secondary) - HStack(spacing: 6) { - Text(voiceLabel(voice)).font(.body.weight(.semibold)) - if BookVoicePreferences.shared.hasOverride(for: slug) { - Text("(Custom)").font(.caption).foregroundStyle(.blue) - } - } - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)).foregroundStyle(.tertiary) - } - .padding(.vertical, 4) - } - .buttonStyle(.plain) - } footer: { - Text("Tap to change voice. Downloads will use the selected voice for this book.") - .font(.caption) - } - - // Stats + actions - Section { - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("\(downloadedChapters.count) Downloaded").font(.title2.bold()) - Text("\(notDownloadedChapters.count) remaining") - .font(.subheadline).foregroundStyle(.secondary) - } - Spacer() - ZStack { - Circle().stroke(Color(.systemGray5), lineWidth: 4) - Circle() - .trim(from: 0, to: chapters.isEmpty ? 0 : CGFloat(downloadedChapters.count) / CGFloat(chapters.count)) - .stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.4), value: downloadedChapters.count) - Text("\(chapters.isEmpty ? 0 : Int(Double(downloadedChapters.count) / Double(chapters.count) * 100))%") - .font(.caption2.bold()).foregroundStyle(.secondary) - } - .frame(width: 44, height: 44) - } - - HStack(spacing: 10) { - if notDownloadedChapters.count > 0 { - Button { showingRangeSelector = true } label: { - Label("Range", systemImage: "list.number").frame(maxWidth: .infinity) - } - .buttonStyle(.bordered).tint(.blue) - - Button { downloadAllRemaining() } label: { - HStack(spacing: 6) { - if isDownloadingAll { ProgressView().scaleEffect(0.75) } - else { Image(systemName: "arrow.down.circle.fill") } - Text("All (\(notDownloadedChapters.count))") - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent).tint(.blue) - .disabled(isDownloadingAll) - } - - if downloadedChapters.count > 0 { - Button { showingDeleteAll = true } label: { - Label("Delete All", systemImage: "trash").frame(maxWidth: .infinity) - } - .buttonStyle(.bordered).tint(.red) - } - } - } - .padding(.vertical, 8) - } - - // Downloaded list - if downloadedChapters.count > 0 { - Section { - ForEach(downloadedChapters, id: \.number) { ch in - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Chapter \(ch.number)").font(.subheadline.weight(.semibold)) - Text(ch.title.strippingTrailingDate()) - .font(.caption).foregroundStyle(.secondary).lineLimit(1) - } - Spacer() - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - } - } - .onDelete { indexSet in - for i in indexSet { - let ch = downloadedChapters[i] - try? downloadService.deleteDownload(slug: slug, chapter: ch.number, voice: voice) - } - } - } header: { - Text("Downloaded (\(downloadedChapters.count))") - } - } - } - .navigationTitle("Manage Downloads") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() }.fontWeight(.semibold) - } - } - .confirmationDialog("Delete all downloads?", isPresented: $showingDeleteAll, titleVisibility: .visible) { - Button("Delete All Downloads", role: .destructive) { - for ch in downloadedChapters { - try? downloadService.deleteDownload(slug: slug, chapter: ch.number, voice: voice) - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This will delete \(downloadedChapters.count) downloaded chapters. You can re-download them later.") - } - .sheet(isPresented: $showingVoiceSelector) { - VoiceSelectorSheet( - selectedVoice: voice, slug: slug, voiceVM: voiceVM, - onSelectVoice: { newVoice in - voice = newVoice - BookVoicePreferences.shared.setVoice(newVoice, for: slug) - showingVoiceSelector = false - } - ) - } - .sheet(isPresented: $showingRangeSelector) { - RangeDownloadSheet( - chapters: notDownloadedChapters, slug: slug, voice: voice, - onDownload: { start, end in - downloadRange(from: start, to: end) - showingRangeSelector = false - } - ) - .presentationDetents([.medium]) - } - } - } - - private func downloadAllRemaining() { - isDownloadingAll = true - Task { - for ch in notDownloadedChapters { - try? await downloadService.download(slug: slug, chapter: ch.number, voice: voice) - try? await Task.sleep(nanoseconds: 500_000_000) - } - isDownloadingAll = false - } - } - - private func downloadRange(from start: Int, to end: Int) { - isDownloadingAll = true - Task { - let toDownload = notDownloadedChapters.filter { $0.number >= start && $0.number <= end } - for ch in toDownload { - try? await downloadService.download(slug: slug, chapter: ch.number, voice: voice) - try? await Task.sleep(nanoseconds: 500_000_000) - } - isDownloadingAll = false - } - } - - private func voiceLabel(_ voice: String) -> String { - let parts = voice.split(separator: "_") - guard parts.count >= 2 else { return voice } - let prefix = String(parts[0]) - let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") - var info = "" - switch prefix { - case "af": info = "US F"; case "am": info = "US M" - case "bf": info = "UK F"; case "bm": info = "UK M" - default: info = prefix.uppercased() - } - return "\(name) (\(info))" - } -} - -// MARK: - Voice Selector Sheet (for DownloadManagementSheet) - -private struct VoiceSelectorSheet: View { - let selectedVoice: String - let slug: String - let voiceVM: VoiceSelectionViewModel - let onSelectVoice: (String) -> Void - - @Environment(\.dismiss) private var dismiss - @EnvironmentObject var authStore: AuthStore - - var body: some View { - NavigationStack { - List { - Section { - ForEach(voiceVM.voices, id: \.self) { voice in - Button { onSelectVoice(voice) } label: { - HStack(spacing: 12) { - Image(systemName: "checkmark") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.blue) - .opacity(voice == selectedVoice ? 1 : 0) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(voiceVM.voiceLabel(voice)).font(.body).foregroundStyle(.primary) - Text(voice).font(.caption.monospaced()).foregroundStyle(.secondary) - } - - Spacer() - - Button { - Task { await voiceVM.playSample(voice) } - } label: { - Image(systemName: voiceVM.playingVoice == voice ? "stop.circle.fill" : "play.circle") - .font(.system(size: 24)) - .foregroundStyle(voiceVM.playingVoice == voice ? .red : .blue) - .frame(minWidth: 44, minHeight: 44) - } - .buttonStyle(.plain) - .accessibilityLabel(voiceVM.playingVoice == voice ? "Stop sample" : "Play sample") - } - .padding(.vertical, 4) - } - .buttonStyle(.plain) - } - } header: { - Text("Select Voice") - } footer: { - if BookVoicePreferences.shared.hasOverride(for: slug) { - Button("Reset to Global Voice") { - BookVoicePreferences.shared.removeVoice(for: slug) - onSelectVoice(authStore.settings.voice) - } - .font(.subheadline) - } - } - } - .navigationTitle("Download Voice") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { voiceVM.stopSample(); dismiss() }.fontWeight(.semibold) - } - } - .task { - if voiceVM.voices.isEmpty { await voiceVM.loadVoices() } - } - } - } -} - -// MARK: - Range Download Sheet - -private struct RangeDownloadSheet: View { - let chapters: [ChapterBrief] - let slug: String - let voice: String - let onDownload: (Int, Int) -> Void - - @Environment(\.dismiss) private var dismiss - @State private var startChapter: Int - @State private var endChapter: Int - - init(chapters: [ChapterBrief], slug: String, voice: String, onDownload: @escaping (Int, Int) -> Void) { - self.chapters = chapters - self.slug = slug - self.voice = voice - self.onDownload = onDownload - let first = chapters.first?.number ?? 1 - let last = chapters.last?.number ?? 1 - _startChapter = State(initialValue: first) - _endChapter = State(initialValue: min(first + 9, last)) - } - - private var chapterRange: [Int] { - guard let first = chapters.first?.number, let last = chapters.last?.number else { return [] } - return Array(first...last) - } - private var selectedCount: Int { - guard startChapter <= endChapter else { return 0 } - return endChapter - startChapter + 1 - } - - var body: some View { - NavigationStack { - Form { - Section { - Picker("Start Chapter", selection: $startChapter) { - ForEach(chapterRange, id: \.self) { n in Text("Chapter \(n)").tag(n) } - } - Picker("End Chapter", selection: $endChapter) { - ForEach(chapterRange.filter { $0 >= startChapter }, id: \.self) { n in - Text("Chapter \(n)").tag(n) - } - } - } header: { Text("Select Range") } - footer: { Text("\(selectedCount) chapters will be downloaded") } - - Section { - Button { - onDownload(startChapter, endChapter) - dismiss() - } label: { - HStack { - Spacer() - Image(systemName: "arrow.down.circle.fill") - Text("Download \(selectedCount) Chapters") - Spacer() - } - } - .disabled(selectedCount == 0) - } - } - .navigationTitle("Download Range") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Cancel") { dismiss() } - } - } - } - } -} diff --git a/ios/LibNovelV2/Views/Profile/ProfileView.swift b/ios/LibNovelV2/Views/Profile/ProfileView.swift deleted file mode 100644 index df6757e..0000000 --- a/ios/LibNovelV2/Views/Profile/ProfileView.swift +++ /dev/null @@ -1,702 +0,0 @@ -import SwiftUI -import PhotosUI - -// MARK: - ProfileViewModel -// Loads and manages active sessions. Uses @Observable (iOS 17+). - -@Observable @MainActor -final class ProfileViewModel { - var sessions: [UserSession] = [] - var sessionsLoading = false - var error: String? - - func loadSessions() async { - sessionsLoading = true - error = nil - do { - sessions = try await APIClient.shared.sessions() - } catch { - self.error = error.localizedDescription - } - sessionsLoading = false - } - - func revokeSession(id: String) async { - do { - try await APIClient.shared.revokeSession(id: id) - sessions.removeAll { $0.id == id } - } catch { - self.error = error.localizedDescription - } - } -} - -// MARK: - ProfileView -// Full-screen profile/account management tab. - -struct ProfileView: View { - @EnvironmentObject private var authStore: AuthStore - @EnvironmentObject private var networkMonitor: NetworkMonitor - @State private var vm = ProfileViewModel() - - @State private var showChangePassword = false - @State private var showVoiceSelection = false - @State private var showDownloads = false - - // Avatar upload - @State private var photoPickerItem: PhotosPickerItem? - @State private var pendingCropImage: UIImage? - @State private var localAvatarURL: String? - @State private var avatarUploading = false - @State private var avatarError: String? - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - OfflineBanner() - - List { - // ── User header ───────────────────────────────────────── - Section { - HStack(spacing: 16) { - avatarPickerView - VStack(alignment: .leading, spacing: 3) { - Text(authStore.user?.username ?? "") - .font(.headline) - Text(authStore.user?.role.capitalized ?? "") - .font(.caption) - .foregroundStyle(.secondary) - if let err = avatarError { - Text(err) - .font(.caption2) - .foregroundStyle(.red) - } - } - } - .padding(.vertical, 6) - } - - // ── Reading settings ───────────────────────────────────── - Section("Reading Settings") { - // Voice picker row — opens VoiceSelectionView sheet - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - showVoiceSelection = true - } label: { - HStack { - Text("TTS Voice") - .foregroundStyle(.primary) - Spacer() - Text(formatVoiceLabel(authStore.settings.voice)) - .foregroundStyle(.secondary) - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .accessibilityLabel("TTS Voice: \(formatVoiceLabel(authStore.settings.voice)). Tap to change.") - - // Speed slider - speedSliderRow - - // Auto-advance toggle - Toggle("Auto-advance chapter", isOn: Binding( - get: { authStore.settings.autoNext }, - set: { newVal in - Task { - var s = authStore.settings - s.autoNext = newVal - await authStore.saveSettings(s) - } - } - )) - .tint(Color.amber) - - // Downloads row - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - showDownloads = true - } label: { - HStack { - Text("Downloads") - .foregroundStyle(.primary) - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - } - - // ── Active sessions ────────────────────────────────────── - Section("Active Sessions") { - if vm.sessionsLoading { - HStack { - Spacer() - ProgressView() - Spacer() - } - .padding(.vertical, 4) - } else if vm.sessions.isEmpty { - Text("No sessions found") - .font(.subheadline) - .foregroundStyle(.secondary) - } else { - ForEach(vm.sessions) { session in - SessionRow(session: session) { - Task { await vm.revokeSession(id: session.id) } - } - } - } - } - - // ── Account ─────────────────────────────────────────────── - Section("Account") { - Button("Change Password") { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - showChangePassword = true - } - Button("Sign Out", role: .destructive) { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - Task { await authStore.logout() } - } - } - } - .scrollContentBackground(.hidden) - } - .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) - .navigationTitle("Profile") - .navigationBarTitleDisplayMode(.large) - .task { - guard networkMonitor.isConnected else { return } - await vm.loadSessions() - } - .sheet(isPresented: $showChangePassword) { - ChangePasswordView() - } - .sheet(isPresented: $showVoiceSelection) { - VoiceSelectionView(currentVoice: authStore.settings.voice) - } - .sheet(isPresented: $showDownloads) { - DownloadsView() - } - .sheet(item: Binding( - get: { pendingCropImage.map { CropImageItem(image: $0) } }, - set: { if $0 == nil { pendingCropImage = nil } } - )) { item in - AvatarCropView(image: item.image) { croppedData in - pendingCropImage = nil - Task { await uploadCroppedData(croppedData) } - } onCancel: { - pendingCropImage = nil - } - } - .errorAlert(Binding( - get: { vm.error }, - set: { vm.error = $0 } - )) - } - } - - // MARK: - Avatar upload - - private func loadImageForCrop(_ item: PhotosPickerItem) async { - guard let data = try? await item.loadTransferable(type: Data.self), - let image = UIImage(data: data) else { - avatarError = "Could not read image" - return - } - pendingCropImage = image - } - - private func uploadCroppedData(_ data: Data) async { - avatarUploading = true - avatarError = nil - defer { avatarUploading = false } - do { - let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg") - localAvatarURL = url - await authStore.validateToken() - } catch { - avatarError = "Upload failed: \(error.localizedDescription)" - } - } - - // MARK: - Avatar picker view - - @ViewBuilder - private var avatarPickerView: some View { - PhotosPicker(selection: $photoPickerItem, - matching: .images, - photoLibrary: .shared()) { - ZStack { - Circle() - .fill(Color(uiColor: .systemGray5)) - .frame(width: 72, height: 72) - - if avatarUploading { - ProgressView() - .frame(width: 72, height: 72) - } else { - let urlStr = localAvatarURL ?? authStore.user?.avatarURL - if let urlStr, !urlStr.isEmpty { - AsyncImage(url: URL(string: urlStr)) { phase in - switch phase { - case .success(let img): - img.resizable() - .scaledToFill() - .frame(width: 72, height: 72) - .clipShape(Circle()) - default: - Image(systemName: "person.circle.fill") - .font(.system(size: 52)) - .foregroundStyle(Color.amber) - .frame(width: 72, height: 72) - } - } - } else { - Image(systemName: "person.circle.fill") - .font(.system(size: 52)) - .foregroundStyle(Color.amber) - .frame(width: 72, height: 72) - } - } - - // Camera badge - if !avatarUploading { - VStack { - Spacer() - HStack { - Spacer() - ZStack { - Circle() - .fill(Color.amber) - .frame(width: 22, height: 22) - Image(systemName: "camera.fill") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.black) - } - .offset(x: 2, y: 2) - } - } - .frame(width: 72, height: 72) - } - } - } - .buttonStyle(.plain) - .accessibilityLabel("Change avatar photo") - .onChange(of: photoPickerItem) { _, item in - guard let item else { return } - Task { await loadImageForCrop(item) } - } - } - - // MARK: - Speed slider row - - @ViewBuilder - private var speedSliderRow: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Playback Speed") - Spacer() - Text("\(authStore.settings.speed, specifier: "%.2g")×") - .foregroundStyle(.secondary) - .monospacedDigit() - } - Slider( - value: Binding( - get: { authStore.settings.speed }, - set: { newSpeed in - Task { - var s = authStore.settings - s.speed = newSpeed - await authStore.saveSettings(s) - } - } - ), - in: 0.5...2.0, step: 0.25 - ) - .tint(Color.amber) - } - .padding(.vertical, 2) - } - - // MARK: - Helpers - - private func formatVoiceLabel(_ voice: String) -> String { - let parts = voice.split(separator: "_") - guard parts.count >= 2 else { return voice } - return parts.dropFirst().map { $0.capitalized }.joined(separator: " ") - } -} - -// MARK: - SessionRow - -private struct SessionRow: View { - let session: UserSession - let onRevoke: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Image(systemName: "iphone") - .foregroundStyle(.secondary) - .accessibilityHidden(true) - Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent) - .font(.subheadline) - .lineLimit(1) - Spacer() - if session.isCurrent { - Text("This device") - .font(.caption2.bold()) - .foregroundStyle(Color.amber) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.amber.opacity(0.12), in: Capsule()) - } else { - Button("Revoke", role: .destructive, action: onRevoke) - .font(.caption) - } - } - Text("Last seen \(session.lastSeen.prefix(10))") - .font(.caption2) - .foregroundStyle(.secondary) - } - .padding(.vertical, 2) - } -} - -// MARK: - CropImageItem - -private struct CropImageItem: Identifiable { - let id = UUID() - let image: UIImage -} - -// MARK: - ChangePasswordView - -struct ChangePasswordView: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var authStore: AuthStore - - @State private var current = "" - @State private var newPwd = "" - @State private var confirm = "" - @State private var isLoading = false - @State private var error: String? - @State private var success = false - - var body: some View { - NavigationStack { - Form { - Section { - SecureField("Current password", text: $current) - SecureField("New password", text: $newPwd) - SecureField("Confirm new password", text: $confirm) - } - if let error { - Section { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - } - if success { - Section { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Password changed successfully") - .font(.caption) - .foregroundStyle(.green) - } - } - } - } - .navigationTitle("Change Password") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .topBarTrailing) { - if isLoading { - ProgressView() - } else { - Button("Save") { save() } - .fontWeight(.semibold) - .foregroundStyle(Color.amber) - .disabled(current.isEmpty || newPwd.count < 4 || newPwd != confirm) - } - } - } - } - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - - private func save() { - guard newPwd == confirm else { error = "Passwords do not match"; return } - isLoading = true - error = nil - Task { - do { - struct Body: Encodable { let currentPassword, newPassword: String } - let _: EmptyResponse = try await APIClient.shared.fetch( - "/api/auth/change-password", method: "POST", - body: Body(currentPassword: current, newPassword: newPwd) - ) - success = true - try? await Task.sleep(nanoseconds: 1_200_000_000) - dismiss() - } catch { - self.error = error.localizedDescription - } - isLoading = false - } - } -} - -// MARK: - AvatarToolbarButton -// Drop-in toolbar button showing the user's avatar. Opens the profile tab or an account sheet. - -struct AvatarToolbarButton: View { - @EnvironmentObject private var authStore: AuthStore - @State private var showAccount = false - - var body: some View { - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - showAccount = true - } label: { - AvatarThumb(urlString: authStore.user?.avatarURL, size: 30) - } - .accessibilityLabel("Account") - .sheet(isPresented: $showAccount) { - ProfileView() - } - } -} - -// MARK: - AvatarThumb -// Small circular avatar used in toolbars and list headers. - -struct AvatarThumb: View { - let urlString: String? - let size: CGFloat - - var body: some View { - Group { - if let str = urlString, let url = URL(string: str) { - AsyncImage(url: url) { phase in - switch phase { - case .success(let img): - img.resizable().scaledToFill() - default: - placeholderFill - } - } - } else { - placeholderFill - } - } - .frame(width: size, height: size) - .clipShape(Circle()) - .overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5)) - } - - private var placeholderFill: some View { - Circle() - .fill(Color(uiColor: .systemGray4)) - .overlay( - Image(systemName: "person.fill") - .font(.system(size: size * 0.5)) - .foregroundStyle(Color.amber) - ) - } -} - -// MARK: - AvatarCropView -// Sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region. - -struct AvatarCropView: View { - let image: UIImage - let onConfirm: (Data) -> Void - let onCancel: () -> Void - - private let cropSize: CGFloat = 280 - - @State private var scale: CGFloat = 1.0 - @State private var lastScale: CGFloat = 1.0 - @State private var offset: CGSize = .zero - @State private var lastOffset: CGSize = .zero - @State private var containerSize: CGSize = .zero - - var body: some View { - NavigationStack { - GeometryReader { geo in - ZStack { - Color.black.ignoresSafeArea() - - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: geo.size.width, height: geo.size.height) - .scaleEffect(scale, anchor: .center) - .offset(offset) - .gesture( - SimultaneousGesture( - MagnificationGesture() - .onChanged { value in - let proposed = lastScale * value - scale = max(1.0, proposed) - } - .onEnded { _ in - lastScale = scale - offset = clampedOffset(offset, in: geo.size) - lastOffset = offset - }, - DragGesture() - .onChanged { value in - let proposed = CGSize( - width: lastOffset.width + value.translation.width, - height: lastOffset.height + value.translation.height - ) - offset = clampedOffset(proposed, in: geo.size) - } - .onEnded { _ in lastOffset = offset } - ) - ) - .clipped() - - CropOverlay(cropSize: cropSize, containerSize: geo.size) - .allowsHitTesting(false) - } - .onAppear { - containerSize = geo.size - scale = 1.0; lastScale = 1.0 - offset = .zero; lastOffset = .zero - } - } - .navigationTitle("Crop Photo") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel", action: onCancel) - .foregroundStyle(.white) - } - ToolbarItem(placement: .topBarTrailing) { - Button("Use Photo") { confirmCrop() } - .fontWeight(.semibold) - .foregroundStyle(Color.amber) - } - } - .toolbarColorScheme(.dark, for: .navigationBar) - } - } - - // MARK: - Clamp helpers - - private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize { - let imgAspect = image.size.width / image.size.height - let conAspect = containerSize.width / containerSize.height - let baseW: CGFloat - let baseH: CGFloat - if imgAspect > conAspect { - baseH = containerSize.height; baseW = baseH * imgAspect - } else { - baseW = containerSize.width; baseH = baseW / imgAspect - } - return CGSize(width: baseW * userScale, height: baseH * userScale) - } - - private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize { - let displayed = displayedImageSize(in: containerSize, userScale: scale) - let maxX = max(0, (displayed.width - cropSize) / 2) - let maxY = max(0, (displayed.height - cropSize) / 2) - return CGSize( - width: min(maxX, max(-maxX, proposed.width)), - height: min(maxY, max(-maxY, proposed.height)) - ) - } - - // MARK: - Confirm crop - - private func confirmCrop() { - let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844) - let outputSize = CGSize(width: 400, height: 400) - - let imgAspect = image.size.width / image.size.height - let conAspect = size.width / size.height - let baseDisplayW: CGFloat - let baseDisplayH: CGFloat - if imgAspect > conAspect { - baseDisplayH = size.height; baseDisplayW = baseDisplayH * imgAspect - } else { - baseDisplayW = size.width; baseDisplayH = baseDisplayW / imgAspect - } - let displayW = baseDisplayW * scale - let displayH = baseDisplayH * scale - - let imageCentreX = size.width / 2 + offset.width - let imageCentreY = size.height / 2 + offset.height - let cropOriginX = (size.width - cropSize) / 2 - let cropOriginY = (size.height - cropSize) / 2 - let imageOriginX = imageCentreX - displayW / 2 - let imageOriginY = imageCentreY - displayH / 2 - let cropInImageX = cropOriginX - imageOriginX - let cropInImageY = cropOriginY - imageOriginY - - let dtpX = image.size.width / displayW - let dtpY = image.size.height / displayH - let cropRect = CGRect( - x: cropInImageX * dtpX, y: cropInImageY * dtpY, - width: cropSize * dtpX, height: cropSize * dtpY - ).intersection(CGRect(origin: .zero, size: image.size)) - - guard cropRect.width > 0, cropRect.height > 0 else { - if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) } - return - } - - let renderer = UIGraphicsImageRenderer(size: outputSize) - let cropped = renderer.image { _ in - if let cgImg = image.cgImage?.cropping(to: cropRect) { - UIImage(cgImage: cgImg, scale: image.scale, - orientation: image.imageOrientation) - .draw(in: CGRect(origin: .zero, size: outputSize)) - } else { - image.draw(in: CGRect(origin: .zero, size: outputSize)) - } - } - if let jpeg = cropped.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) } - } -} - -// MARK: - CropOverlay (internal) - -private struct CropOverlay: View { - let cropSize: CGFloat - let containerSize: CGSize - - var body: some View { - Canvas { context, size in - context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55))) - let origin = CGPoint(x: (size.width - cropSize) / 2, y: (size.height - cropSize) / 2) - let rect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize)) - context.blendMode = .destinationOut - context.fill(Path(ellipseIn: rect), with: .color(.white)) - } - .compositingGroup() - .overlay { - let ox = (containerSize.width - cropSize) / 2 - let oy = (containerSize.height - cropSize) / 2 - Circle() - .stroke(Color.amber.opacity(0.8), lineWidth: 2) - .frame(width: cropSize, height: cropSize) - .position(x: ox + cropSize / 2, y: oy + cropSize / 2) - } - .frame(width: containerSize.width, height: containerSize.height) - .allowsHitTesting(false) - } -} diff --git a/ios/LibNovelV2/Views/Profile/UserProfileView.swift b/ios/LibNovelV2/Views/Profile/UserProfileView.swift deleted file mode 100644 index 60c3e07..0000000 --- a/ios/LibNovelV2/Views/Profile/UserProfileView.swift +++ /dev/null @@ -1,13 +0,0 @@ -import SwiftUI - -// Public user profile — shown when navigating to another user's page. -// Displays their public library and follower info. -// NOTE: This is distinct from ProfileView (self-account management tab). -struct UserProfileView: View { - let username: String - - var body: some View { - Text(username) - .navigationTitle(username) - } -} diff --git a/ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift b/ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift deleted file mode 100644 index 96e6b8f..0000000 --- a/ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift +++ /dev/null @@ -1,189 +0,0 @@ -import SwiftUI - -// MARK: - VoiceSelectionView -// Sheet for selecting TTS voice. Loads voices from the API, plays sample audio, and -// saves the selection back to user settings on confirm. -// VoiceSelectionViewModel is defined in PlayerViews.swift (shared with the full player). - -struct VoiceSelectionView: View { - @EnvironmentObject private var authStore: AuthStore - @Environment(\.dismiss) private var dismiss - - @State private var selectedVoice: String - @State private var vm = VoiceSelectionViewModel() - - init(currentVoice: String) { - _selectedVoice = State(initialValue: currentVoice) - } - - var body: some View { - NavigationStack { - Group { - if vm.isLoading { - loadingState - } else if let error = vm.error { - errorState(error) - } else { - voiceList - } - } - .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) - .navigationTitle("Select Voice") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - vm.stopSample() - dismiss() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Done") { saveAndDismiss() } - .fontWeight(.semibold) - .foregroundStyle(Color.amber) - .disabled(selectedVoice == authStore.settings.voice) - } - } - .task { await vm.loadVoices() } - .onDisappear { vm.stopSample() } - } - } - - // MARK: - States - - private var loadingState: some View { - VStack(spacing: 16) { - ProgressView() - .scaleEffect(1.3) - Text("Loading voices…") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func errorState(_ message: String) -> some View { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 48)) - .foregroundStyle(Color.amber) - .symbolEffect(.pulse) - Text(message) - .font(.subheadline) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding(.horizontal, 32) - Button("Retry") { Task { await vm.loadVoices() } } - .font(.subheadline.bold()) - .foregroundStyle(Color.amber) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - // MARK: - Voice list - - private var voiceList: some View { - List { - Section { - ForEach(vm.voices, id: \.self) { voice in - VoiceSelectionRow( - voice: voice, - isSelected: voice == selectedVoice, - isPlaying: vm.playingVoice == voice, - voiceLabel: vm.voiceLabel(voice), - voiceId: vm.voiceId(voice), - onSelect: { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - vm.stopSample() - selectedVoice = voice - }, - onPlaySample: { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - Task { await vm.playSample(voice) } - } - ) - } - } header: { - Text("Available Voices") - .font(.subheadline.bold()) - .foregroundStyle(.secondary) - .textCase(nil) - } footer: { - if selectedVoice != authStore.settings.voice { - Text("New voice will apply to the next audio playback.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .scrollContentBackground(.hidden) - .listStyle(.insetGrouped) - } - - // MARK: - Save - - private func saveAndDismiss() { - vm.stopSample() - Task { - var s = authStore.settings - s.voice = selectedVoice - await authStore.saveSettings(s) - dismiss() - } - } -} - -// MARK: - VoiceSelectionRow - -private struct VoiceSelectionRow: View { - let voice: String - let isSelected: Bool - let isPlaying: Bool - let voiceLabel: String - let voiceId: String - let onSelect: () -> Void - let onPlaySample: () -> Void - - var body: some View { - HStack(spacing: 12) { - // Selection indicator - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.system(size: 22)) - .foregroundStyle(isSelected ? Color.amber : Color.secondary.opacity(0.4)) - .frame(width: 28) - .contentTransition(.symbolEffect(.replace.downUp)) - .accessibilityHidden(true) - - // Voice name + id - VStack(alignment: .leading, spacing: 3) { - Text(voiceLabel) - .font(.body) - .fontWeight(isSelected ? .semibold : .regular) - Text(voiceId) - .font(.caption) - .fontDesign(.monospaced) - .foregroundStyle(.secondary) - } - - Spacer() - - // Play sample button - Button { - onPlaySample() - } label: { - Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") - .font(.system(size: 28)) - .foregroundStyle(isPlaying ? Color.red : Color.amber) - .contentTransition(.symbolEffect(.replace.downUp)) - } - .buttonStyle(.plain) - .frame(minWidth: 44, minHeight: 44) - .accessibilityLabel(isPlaying ? "Stop sample for \(voiceLabel)" : "Play sample for \(voiceLabel)") - } - .padding(.vertical, 4) - .contentShape(Rectangle()) - .onTapGesture { onSelect() } - .accessibilityElement(children: .combine) - .accessibilityAddTraits(isSelected ? [.isSelected] : []) - } -} diff --git a/ios/LibNovelV2/Views/Search/SearchView.swift b/ios/LibNovelV2/Views/Search/SearchView.swift deleted file mode 100644 index f25689a..0000000 --- a/ios/LibNovelV2/Views/Search/SearchView.swift +++ /dev/null @@ -1,255 +0,0 @@ -import SwiftUI - -// MARK: - SearchView -// Full-screen search tab. -// Idle: recent searches list (or prompt if empty). -// Active: debounced live results in a 2-col grid with local/remote count header. - -struct SearchView: View { - @State private var vm = SearchViewModel() - @EnvironmentObject private var networkMonitor: NetworkMonitor - - private let columns = [ - GridItem(.flexible(), spacing: 14), - GridItem(.flexible(), spacing: 14), - ] - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - OfflineBanner() - - Group { - if vm.isLoading { - loadingState - } else if !vm.query.isEmpty && vm.results.isEmpty { - emptyResultsState - } else if !vm.results.isEmpty { - resultsGrid - } else { - idleState - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .appNavigationDestination() - .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) - .navigationTitle("Search") - .navigationBarTitleDisplayMode(.large) - .searchable( - text: $vm.query, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Search novels, authors…" - ) - .onChange(of: vm.query) { _, newValue in - guard networkMonitor.isConnected else { return } - vm.onQueryChange(newValue) - } - .onSubmit(of: .search) { - guard networkMonitor.isConnected else { return } - UIImpactFeedbackGenerator(style: .light).impactOccurred() - vm.submitSearch() - } - .errorAlert($vm.error) - } - } - - // MARK: - Idle state - - @ViewBuilder - private var idleState: some View { - if vm.recentSearches.isEmpty { - emptyIdleState - } else { - recentSearchesList - } - } - - private var emptyIdleState: some View { - VStack(spacing: 16) { - Spacer() - Image(systemName: "magnifyingglass") - .font(.system(size: 60)) - .foregroundStyle(.tertiary) - Text("Search for novels") - .font(.title3.bold()) - .foregroundStyle(.primary) - Text("Find books by title, author, or genre") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - Spacer() - } - } - - private var recentSearchesList: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text("Recent Searches") - .font(.subheadline.bold()) - .foregroundStyle(.secondary) - Spacer() - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - vm.clearRecent() - } label: { - Text("Clear") - .font(.subheadline) - .foregroundStyle(Color.amber) - } - .frame(minWidth: 44, minHeight: 44) - } - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 4) - - ForEach(vm.recentSearches, id: \.self) { term in - Button { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - vm.selectRecent(term) - } label: { - HStack(spacing: 12) { - Image(systemName: "clock") - .font(.subheadline) - .foregroundStyle(.tertiary) - .frame(width: 24) - Text(term) - .font(.body) - .foregroundStyle(.primary) - .lineLimit(1) - Spacer() - Image(systemName: "arrow.up.left") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .frame(minHeight: 44) - } - .buttonStyle(.plain) - .contentShape(Rectangle()) - - Divider() - .padding(.leading, 52) - } - } - - Color.clear.frame(height: 120) - } - } - - // MARK: - Loading state - - private var loadingState: some View { - VStack { - Spacer() - ProgressView() - .tint(Color.amber) - .scaleEffect(1.4) - Spacer() - } - } - - // MARK: - Empty results state - - private var emptyResultsState: some View { - VStack { - Spacer() - EmptyStateView( - icon: "magnifyingglass", - title: "No results", - message: "Nothing matched \"\(vm.query)\". Try a different term." - ) - Spacer() - } - } - - // MARK: - Results grid - - private var resultsGrid: some View { - ScrollView { - // Count header - HStack(spacing: 6) { - Text("\(vm.results.count) results") - .font(.subheadline.bold()) - .foregroundStyle(.primary) - - if vm.localCount > 0 || vm.remoteCount > 0 { - Text("·") - .foregroundStyle(.tertiary) - if vm.localCount > 0 { - Text("\(vm.localCount) in library") - .font(.caption) - .foregroundStyle(Color.amber) - } - if vm.localCount > 0 && vm.remoteCount > 0 { - Text("+") - .font(.caption) - .foregroundStyle(.tertiary) - } - if vm.remoteCount > 0 { - Text("\(vm.remoteCount) online") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - } - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 4) - - LazyVGrid(columns: columns, spacing: 14) { - ForEach(vm.results) { novel in - NavigationLink(value: NavDestination.book(novel.slug)) { - SearchNovelCard(novel: novel) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 16) - .padding(.top, 4) - - Color.clear.frame(height: 120) - } - } -} - -// MARK: - SearchNovelCard - -private struct SearchNovelCard: View { - let novel: BrowseNovel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - AsyncCoverImage(url: novel.cover) - .frame(maxWidth: .infinity) - .aspectRatio(2/3, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .bookCoverZoomSource(slug: novel.slug) - - VStack(alignment: .leading, spacing: 3) { - Text(novel.title) - .font(.subheadline.bold()) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - - if !novel.author.isEmpty { - Text(novel.author) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 10) - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) - } -} diff --git a/ios/LibNovelV2/features.md b/ios/LibNovelV2/features.md deleted file mode 100644 index 726b266..0000000 --- a/ios/LibNovelV2/features.md +++ /dev/null @@ -1,57 +0,0 @@ -# LibNovel v2 iOS — Feature Tracker - -Design reference: `ui/src/routes/` (SvelteKit web UI) -All new code lives in `ios/LibNovelV2/`. - ---- - -## Status legend -- ✅ Done -- 🔨 In progress -- ⏳ Not started - ---- - -## Features - -| # | Feature | Files | Status | -|---|---------|-------|--------| -| 1 | Directory scaffold | `ios/LibNovelV2/` tree | ✅ | -| 2 | Models | `Models/Models.swift` | ✅ | -| 3 | Networking | `Networking/APIClient.swift` | ✅ | -| 4 | Services | `AuthStore`, `AudioPlayerService`, `AudioDownloadService`, `NetworkMonitor`, `BookVoicePreferences` | ✅ | -| 4b | App entry + RootTabView + stub views | `App/LibNovelV2App.swift`, `App/ContentView.swift`, `App/RootTabView.swift`, `Extensions/NavDestination.swift` | ✅ | -| 5 | Auth / Login | `Views/Auth/AuthView.swift` | ✅ | -| 6 | Home screen | `Views/Home/HomeView.swift`, `ViewModels/HomeViewModel.swift`, `Views/Common/CommonViews.swift` | ✅ | -| 7 | Library screen | `Views/Library/LibraryView.swift`, `ViewModels/LibraryViewModel.swift` | ✅ | -| 8 | Browse / Discover | `Views/Browse/BrowseView.swift`, `Views/Browse/BrowseCategoryView.swift`, `ViewModels/BrowseViewModel.swift` | ✅ | -| 9 | Search | `Views/Search/SearchView.swift`, `ViewModels/SearchViewModel.swift` | ✅ | -| 10 | Book Detail | `Views/BookDetail/BookDetailView.swift`, `ViewModels/BookDetailViewModel.swift` | ✅ | -| 11 | Chapter Reader | `Views/ChapterReader/ChapterReaderView.swift`, `ViewModels/ChapterReaderViewModel.swift` | ✅ | -| 12 | Audio mini-player + full player | `Views/Player/PlayerViews.swift` | ✅ | -| 13 | Downloads screen | `Views/Downloads/DownloadsView.swift` | ✅ | -| 14 | Profile / Account | `Views/Profile/ProfileView.swift`, `Views/Profile/VoiceSelectionView.swift` | ✅ | - ---- - -## Design system recap - -| Token | Value | -|-------|-------| -| Main bg | `zinc-900` `#18181b` | -| Card bg | `zinc-800` `#27272a` | -| Border | `zinc-700` `#3f3f46` | -| Primary text | `zinc-100` `#f4f4f5` | -| Secondary text | `zinc-400` `#a1a1aa` | -| Accent / CTA | `amber-400` `#f59e0b` | - -## Key patterns (quick ref) - -- **Cover images**: always proxy via `/api/cover/{domain}/{slug}` -- **Download keys**: `slug::chapterN::voice` (`::` separator — slugs contain `-`) -- **Voice fallback**: book override → global default → `"af_bella"` -- **Offline**: `NetworkMonitor` env object + `OfflineBanner` at top of every networked view -- **Observable**: new types use `@Observable`; existing services use `ObservableObject` -- **Navigation**: `NavigationStack` + `NavDestination` enum + `.appNavigationDestination()` -- **Haptics**: `.light` for selection, `.medium` for primary actions -- **Animations**: `.spring(response:dampingFraction:)` for all interactive transitions diff --git a/ios/LibNovelV2/project.yml b/ios/LibNovelV2/project.yml deleted file mode 100644 index 80fd17c..0000000 --- a/ios/LibNovelV2/project.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: LibNovelV2 -options: - bundleIdPrefix: com.kalekber - deploymentTarget: - iOS: "17.0" - xcodeVersion: "16.0" - generateEmptyDirectories: true - indentWidth: 4 - tabWidth: 4 - usesTabs: false - -settings: - base: - SWIFT_VERSION: "5.10" - ENABLE_PREVIEWS: YES - MARKETING_VERSION: "1.0.0" - CURRENT_PROJECT_VERSION: "1" - LIBNOVEL_BASE_URL: "https://v2.libnovel.kalekber.cc" - configs: - Debug: - SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG - Release: - SWIFT_ACTIVE_COMPILATION_CONDITIONS: "" - -targets: - LibNovelV2: - type: application - platform: iOS - deploymentTarget: "17.0" - sources: - - path: . - excludes: - - "**/.DS_Store" - - "Resources/Info.plist" - - "Resources/Assets.xcassets" - - "features.md" - - "project.yml" - resources: - - path: Resources/Assets.xcassets - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovelV2 - ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad - GENERATE_INFOPLIST_FILE: NO - INFOPLIST_FILE: Resources/Info.plist - configs: - Release: - CODE_SIGN_STYLE: Manual - DEVELOPMENT_TEAM: GHZXC6FVMU - CODE_SIGN_IDENTITY: "Apple Distribution" - PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f" - -schemes: - LibNovelV2: - build: - targets: - LibNovelV2: all - run: - config: Debug - environmentVariables: - LIBNOVEL_BASE_URL: - value: "https://v2.libnovel.kalekber.cc" - isEnabled: true - profile: - config: Release - analyze: - config: Debug - archive: - config: Release diff --git a/justfile b/justfile index 9ffca1c..9bfeb83 100644 --- a/justfile +++ b/justfile @@ -1,234 +1,118 @@ -# justfile — libnovel-v2 task runner -# Install just: https://just.systems +# ── LibNovel v3 — justfile ──────────────────────────────────────────────────── +# All commands that touch docker-compose are wrapped with `doppler run` so that +# secrets are injected into the environment at runtime — no .env files needed. +# +# Prerequisites: +# brew install doppler just +# doppler setup (run once; selects project=libnovel config=prd) +# +# Usage: +# just up # start all services (detached) +# just down # stop all services +# just logs # tail all service logs +# just ps # show running containers +# just build # rebuild backend + ui images +# just restart # full stop + start cycle +# just secrets # print all injected secrets (debug) -scraper_dir := "scraper" -ui_dir := "ui" -ios_dir := "ios/LibNovel" -ios_scheme := "LibNovel" -ios_sim := "platform=iOS Simulator,name=iPhone 17" -ios_spm := ".spm-cache" -runner_temp := env_var_or_default("RUNNER_TEMP", "/tmp") +set dotenv-load := false # Doppler handles all env; never load a .env file -# ─── Build ──────────────────────────────────────────────────────────────────── +# ── Helpers ─────────────────────────────────────────────────────────────────── -# Build the scraper binary -build: - cd {{scraper_dir}} && go build -o bin/scraper ./cmd/scraper +# Inject secrets from Doppler, then run the given command +doppler := "doppler run --" -# Build and verify all Go packages compile cleanly -build-all: - cd {{scraper_dir}} && go build ./... +# ── Core compose commands ───────────────────────────────────────────────────── -# ─── Tests ──────────────────────────────────────────────────────────────────── - -# Run unit tests only (no integration services required) -test: - cd {{scraper_dir}} && go test -race -count=1 -timeout=60s ./... - -# Run integration tests (requires MinIO, PocketBase, optional Browserless) -# Override env vars as needed, e.g.: -# just test-integration MINIO_ENDPOINT=localhost:9000 -test-integration: - cd {{scraper_dir}} && go test -v -tags integration -timeout 600s ./... - -# Run unit + integration tests -test-all: test test-integration - -# Run a specific package's integration tests, e.g.: -# just test-pkg internal/storage -test-pkg pkg: - cd {{scraper_dir}} && go test -v -tags integration -timeout 600s ./{{pkg}}/... - -# Run end-to-end tests against live services. -# All services must be running first (docker compose up -d or just e2e-up). -# Override env vars as needed, e.g.: -# just test-e2e SCRAPER_URL=http://localhost:8080 KOKORO_VOICE=af_bella -test-e2e \ - browserless_url="http://localhost:3030" \ - minio_endpoint="localhost:9000" \ - pocketbase_url="http://localhost:8090" \ - scraper_url="http://localhost:8080": - cd {{scraper_dir}} && \ - BROWSERLESS_URL={{browserless_url}} \ - MINIO_ENDPOINT={{minio_endpoint}} \ - POCKETBASE_URL={{pocketbase_url}} \ - SCRAPER_URL={{scraper_url}} \ - go test -v -tags integration -timeout 900s ./internal/e2e/... - -# Start all services required for e2e tests, then run them -e2e: up test-e2e - -# ─── Code quality ───────────────────────────────────────────────────────────── - -# Run go vet on all packages (including integration build tag) -lint: - cd {{scraper_dir}} && go vet ./... - cd {{scraper_dir}} && go vet -tags integration ./... - -# ─── UI ─────────────────────────────────────────────────────────────────────── - -# Type-check the SvelteKit UI -ui-check: - cd {{ui_dir}} && npx svelte-check - -# Start the SvelteKit dev server -ui-dev: - cd {{ui_dir}} && npm run dev - -# Install UI dependencies -ui-install: - cd {{ui_dir}} && npm install - -# Build the UI for production -ui-build: - cd {{ui_dir}} && npm run build - -# ─── iOS ────────────────────────────────────────────────────────────────────── - -# Regenerate LibNovel.xcodeproj from project.yml (run after structural changes) -ios-gen: - cd {{ios_dir}} && xcodegen generate --spec project.yml --project . - -# Resolve SPM package dependencies (cached to {{ios_spm}}) -ios-resolve: - cd {{ios_dir}} && xcodebuild \ - -project {{ios_scheme}}.xcodeproj \ - -scheme {{ios_scheme}} \ - -resolvePackageDependencies \ - -clonedSourcePackagesDirPath {{ios_spm}} - -# Build the iOS app for the simulator (no signing required) -# Runs ios-gen first to ensure the project is up to date. -ios-build: ios-gen ios-resolve - cd {{ios_dir}} && set -o pipefail && xcodebuild \ - -project {{ios_scheme}}.xcodeproj \ - -scheme {{ios_scheme}} \ - -configuration Debug \ - -destination 'generic/platform=iOS Simulator' \ - -clonedSourcePackagesDirPath {{ios_spm}} \ - CODE_SIGNING_ALLOWED=NO \ - | xcpretty || xcodebuild \ - -project {{ios_scheme}}.xcodeproj \ - -scheme {{ios_scheme}} \ - -configuration Debug \ - -destination 'generic/platform=iOS Simulator' \ - -clonedSourcePackagesDirPath {{ios_spm}} \ - CODE_SIGNING_ALLOWED=NO - -# Run unit tests on the simulator -# Runs ios-gen first to ensure the project is up to date. -ios-test: ios-gen ios-resolve - cd {{ios_dir}} && set -o pipefail && xcodebuild test \ - -project {{ios_scheme}}.xcodeproj \ - -scheme {{ios_scheme}} \ - -configuration Debug \ - -destination '{{ios_sim}}' \ - -clonedSourcePackagesDirPath {{ios_spm}} \ - CODE_SIGNING_ALLOWED=NO \ - | xcpretty --report junit --output test-results.xml || true - -# Archive a signed Release build (requires valid signing identity in keychain). -# Output: {{runner_temp}}/LibNovel.xcarchive -# Typically called from CI after importing certificate + provisioning profile. -# Usage: just ios-archive <team-id> <profile-uuid> -ios-archive team_id profile_uuid: ios-gen ios-resolve - cd {{ios_dir}} && xcodebuild archive \ - -project {{ios_scheme}}.xcodeproj \ - -scheme {{ios_scheme}} \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -clonedSourcePackagesDirPath {{ios_spm}} \ - -archivePath {{runner_temp}}/LibNovel.xcarchive \ - CODE_SIGN_IDENTITY="Apple Distribution" \ - "PROVISIONING_PROFILE[sdk=iphoneos*]={{profile_uuid}}" \ - DEVELOPMENT_TEAM="{{team_id}}" - -# Export an IPA from the archive produced by ios-archive. -# Requires ios/LibNovel/ExportOptions.plist. -# Output: {{runner_temp}}/ipa/LibNovel.ipa -ios-export: - cd {{ios_dir}} && xcodebuild -exportArchive \ - -archivePath {{runner_temp}}/LibNovel.xcarchive \ - -exportPath {{runner_temp}}/ipa \ - -exportOptionsPlist ExportOptions.plist - -# Set the build number (CFBundleVersion) in project.yml before archiving. -# Usage: just ios-set-build-number 42 -ios-set-build-number number: - cd {{ios_dir}} && sed -i '' \ - 's/CURRENT_PROJECT_VERSION: .*/CURRENT_PROJECT_VERSION: {{number}}/' \ - project.yml - -# Upload the exported IPA to TestFlight via App Store Connect API. -# Requires env vars: ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY_PATH -# The private key (.p8 file) must be present at ASC_PRIVATE_KEY_PATH. -ios-upload: - xcrun altool --upload-app \ - --type ios \ - --file {{runner_temp}}/ipa/LibNovel.ipa \ - --apiKey "$ASC_KEY_ID" \ - --apiIssuer "$ASC_ISSUER_ID" - -# ─── Docker Compose ─────────────────────────────────────────────────────────── - -# Start all services (browserless, kokoro, scraper, minio, pocketbase) +# Start all services in the background up: - docker compose up -d + {{doppler}} docker compose up -d -# Stop all services +# Start and stream logs (foreground) +up-fg: + {{doppler}} docker compose up + +# Stop all running services down: - docker compose down + {{doppler}} docker compose down -# Tail logs for all services +# Stop and remove volumes (full reset — destructive!) +down-volumes: + {{doppler}} docker compose down -v + +# Show service status +ps: + {{doppler}} docker compose ps + +# ── Build ───────────────────────────────────────────────────────────────────── + +# Build (or rebuild) all images +build: + {{doppler}} docker compose build + +# Build a specific service, e.g.: just build-svc backend +build-svc svc: + {{doppler}} docker compose build {{svc}} + +# Pull latest base images +pull: + {{doppler}} docker compose pull + +# ── Logs ───────────────────────────────────────────────────────────────────── + +# Tail all service logs (last 50 lines + follow) logs: - docker compose logs -f + {{doppler}} docker compose logs -f --tail=50 -# Tail logs for a specific service, e.g.: just logs-service scraper -logs-service service: - docker compose logs -f {{service}} +# Tail a specific service, e.g.: just log backend +log svc: + {{doppler}} docker compose logs -f --tail=50 {{svc}} -# Rebuild and restart a specific service -restart service: - docker compose up -d --build {{service}} +# ── Lifecycle ───────────────────────────────────────────────────────────────── -# ─── Local dev: individual services ────────────────────────────────────────── +# Full restart: stop then start +restart: down up -# Start only PocketBase (for local storage testing) -pb-up: - docker compose up -d pocketbase +# Restart a single service, e.g.: just restart-svc backend +restart-svc svc: + {{doppler}} docker compose restart {{svc}} -# Start only MinIO (for local storage testing) -minio-up: - docker compose up -d minio +# Pull → build → recreate (rolling update without clearing volumes) +update: + {{doppler}} docker compose pull + {{doppler}} docker compose build + {{doppler}} docker compose up -d -# Start only Browserless (for local scraping tests) -browserless-up: - docker compose up -d browserless +# ── Initialisation ──────────────────────────────────────────────────────────── -# Start storage backends only (MinIO + PocketBase) -storage-up: - docker compose up -d minio pocketbase +# Run one-shot init containers (minio-init, pb-init, postgres-init) +init: + {{doppler}} docker compose run --rm minio-init + {{doppler}} docker compose run --rm pb-init + {{doppler}} docker compose run --rm postgres-init -# ─── Convenience ───────────────────────────────────────────────────────────── +# ── Shell access ────────────────────────────────────────────────────────────── -# Show status of all docker compose services -status: - docker compose ps +# Open a shell in a running service, e.g.: just shell backend +shell svc: + {{doppler}} docker compose exec {{svc}} sh -# Remove all stopped containers and unused images -prune: - docker compose down --remove-orphans - docker image prune -f +# ── Secrets ─────────────────────────────────────────────────────────────────── -# One-shot scrape of the full catalogue (requires services to be running) -scrape-run: build - cd {{scraper_dir}} && ./bin/scraper run +# Print all secrets Doppler will inject (never redirected to a file) +secrets: + doppler secrets --project libnovel --config prd -# One-shot scrape of a single book URL, e.g.: -# just scrape-book https://novelfire.net/book/my-novel -scrape-book url: build - cd {{scraper_dir}} && ./bin/scraper run --url {{url}} +# Print secrets as a .env-formatted list (useful for debugging) +secrets-env: + doppler secrets download --project libnovel --config prd --format env --no-file -# Start the HTTP server -serve: build - cd {{scraper_dir}} && ./bin/scraper serve +# Open Doppler dashboard in browser +secrets-dashboard: + doppler open dashboard + +# ── Gitea CI ────────────────────────────────────────────────────────────────── + +# Validate workflow files +ci-lint: + actionlint .gitea/workflows/*.yaml diff --git a/opencode.json b/opencode.json index e15c722..1337657 100644 --- a/opencode.json +++ b/opencode.json @@ -8,6 +8,5 @@ } }, "instructions": [ - "ios/AGENTS.md" ] } diff --git a/scraper/.dockerignore b/scraper/.dockerignore deleted file mode 100644 index f7714ca..0000000 --- a/scraper/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -bin/ -static/ -*.md -.git diff --git a/scraper/Dockerfile b/scraper/Dockerfile deleted file mode 100644 index c596bd4..0000000 --- a/scraper/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -# ── Build stage ──────────────────────────────────────────────────────────────── -FROM golang:1.25-alpine AS builder - -WORKDIR /build - -# Cache dependency downloads separately from source compilation. -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . - -# Build-time version info — injected by docker-compose or CI via --build-arg. -ARG VERSION=dev -ARG COMMIT=unknown - -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build \ - -ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \ - -o /scraper ./cmd/scraper - -# ── Runtime stage ────────────────────────────────────────────────────────────── -FROM alpine:3.21 - -# ca-certificates: HTTPS to novelfire.net -# tzdata: timezone data -RUN apk add --no-cache ca-certificates tzdata - -WORKDIR /app - -COPY --from=builder /scraper /app/scraper - -# Create the default static output directory. -RUN mkdir -p /app/static/books - -# Non-root user. -RUN addgroup -S scraper && adduser -S scraper -G scraper -RUN chown -R scraper:scraper /app -USER scraper - -# ── Configuration ───────────────────────────────────────────────────────────── -ENV SCRAPER_WORKERS=0 -ENV SCRAPER_STATIC_ROOT=/app/static/books -ENV SCRAPER_HTTP_ADDR=:8080 - -EXPOSE 8080 - -# Default: run as an HTTP server. Override CMD to use "run" for one-shot. -ENTRYPOINT ["/app/scraper"] -CMD ["serve"] diff --git a/scraper/cmd/scraper/main.go b/scraper/cmd/scraper/main.go deleted file mode 100644 index d4d8e36..0000000 --- a/scraper/cmd/scraper/main.go +++ /dev/null @@ -1,517 +0,0 @@ -// Command scraper is the entrypoint for the libnovel scraper service. -// -// Usage (CLI one-shot): -// -// scraper run [--url <book-url>] -// -// Usage (HTTP server): -// -// scraper serve -// -// Environment variables: -// -// SCRAPER_WORKERS Chapter goroutine count (default: NumCPU) -// SCRAPER_HTTP_ADDR HTTP listen address (default: :8080) -// SCRAPER_PROXY Outbound proxy for all scraper requests, e.g. -// http://user:pass@proxy-host:3128 — use a -// residential proxy to bypass datacenter IP blocks. -// Falls back to HTTP_PROXY / HTTPS_PROXY if unset. -// KOKORO_URL Kokoro-FastAPI base URL (default: "") -// KOKORO_VOICE Default TTS voice (default: af_bella) -// POCKETBASE_URL PocketBase API base URL (default: http://localhost:8090) -// POCKETBASE_ADMIN_EMAIL PocketBase admin email (default: admin@libnovel.local) -// POCKETBASE_ADMIN_PASSWORD PocketBase admin password (default: changeme123) -// MINIO_ENDPOINT MinIO endpoint host:port (default: localhost:9000) -// MINIO_ACCESS_KEY MinIO access key (default: admin) -// MINIO_SECRET_KEY MinIO secret key (default: changeme123) -// MINIO_USE_SSL Use TLS for MinIO (default: false) -// MINIO_BUCKET_CHAPTERS Chapter objects bucket (default: libnovel-chapters) -// MINIO_BUCKET_AUDIO Audio objects bucket (default: libnovel-audio) -// LOG_LEVEL debug | info | warn | error (default: info) -package main - -import ( - "context" - "fmt" - "log/slog" - "os" - "os/exec" - "os/signal" - "runtime" - "strconv" - "strings" - "syscall" - "time" - - "github.com/libnovel/scraper/internal/browser" - "github.com/libnovel/scraper/internal/novelfire" - "github.com/libnovel/scraper/internal/orchestrator" - "github.com/libnovel/scraper/internal/scraper/htmlutil" - "github.com/libnovel/scraper/internal/server" - "github.com/libnovel/scraper/internal/storage" -) - -// Build-time version info — injected via -ldflags during docker build. -// Falls back to "dev" / "unknown" when built without -ldflags (local dev). -var ( - Version = "dev" - Commit = "unknown" -) - -func main() { - logLevel := slog.LevelInfo - if v := os.Getenv("LOG_LEVEL"); v != "" { - if err := logLevel.UnmarshalText([]byte(v)); err != nil { - fmt.Fprintf(os.Stderr, "invalid LOG_LEVEL %q, using info\n", v) - } - } - log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: logLevel, - })) - - if err := run(log); err != nil { - log.Error("fatal", "err", err) - os.Exit(1) - } -} - -func run(log *slog.Logger) error { - args := os.Args[1:] - if len(args) == 0 { - printUsage() - return nil - } - - cmd := strings.ToLower(args[0]) - - // All scraping uses direct HTTP — novelfire.net pages are server-rendered - // and do not require a headless browser. A direct HTTP client is faster, - // more reliable, and has no Browserless dependency. - directCfg := browser.Config{MaxConcurrent: 5} - if s := os.Getenv("SCRAPER_TIMEOUT"); s != "" { - if n, err := strconv.Atoi(s); err == nil && n > 0 { - directCfg.Timeout = time.Duration(n) * time.Second - } - } - directClient := browser.NewDirectHTTPClient(directCfg) - - // ── Storage backends ──────────────────────────────────────────────────── - minioCfg := storage.MinioConfig{ - Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), - PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""), - AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), - SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), - UseSSL: strings.ToLower(os.Getenv("MINIO_USE_SSL")) == "true", - PublicUseSSL: strings.ToLower(os.Getenv("MINIO_PUBLIC_USE_SSL")) != "false", - BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), - BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), - BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"), - BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"), - } - pbCfg := storage.PocketBaseConfig{ - BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"), - AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"), - AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"), - } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - store, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, log) - if err != nil { - return fmt.Errorf("storage init failed: %w", err) - } - - nf := novelfire.New(directClient, log, directClient, directClient, store) - - workers := 0 - if s := os.Getenv("SCRAPER_WORKERS"); s != "" { - n, err := strconv.Atoi(s) - if err == nil && n > 0 { - workers = n - } - } - if workers == 0 { - workers = runtime.NumCPU() - } - - oCfg := orchestrator.Config{ - Workers: workers, - } - - switch cmd { - case "run": - // Optional --url flag. - if len(args) >= 3 && args[1] == "--url" { - oCfg.SingleBookURL = args[2] - } - log.Info("starting one-shot scrape", - "strategy", "direct", - "workers", workers, - "single_book", oCfg.SingleBookURL, - "pocketbase_url", pbCfg.BaseURL, - "pocketbase_email", pbCfg.AdminEmail, - ) - o := orchestrator.New(oCfg, nf, log, store) - return o.Run(ctx) - - case "refresh": - // refresh <slug> - re-scrape a book from its saved source_url - if len(args) < 2 { - return fmt.Errorf("refresh command requires a book slug argument") - } - slug := args[1] - meta, ok, err := store.ReadMetadata(ctx, slug) - if err != nil { - return fmt.Errorf("failed to read metadata for %s: %w", slug, err) - } - if !ok { - return fmt.Errorf("book %q not found in store", slug) - } - if meta.SourceURL == "" { - return fmt.Errorf("book %q has no source_url in metadata", slug) - } - oCfg.SingleBookURL = meta.SourceURL - log.Info("refreshing book from source_url", - "slug", slug, - "source_url", meta.SourceURL, - "pocketbase_url", pbCfg.BaseURL, - "pocketbase_email", pbCfg.AdminEmail, - ) - o := orchestrator.New(oCfg, nf, log, store) - return o.Run(ctx) - - case "serve": - addr := envOr("SCRAPER_HTTP_ADDR", ":8080") - kokoroURL := envOr("KOKORO_URL", "https://kokoro.kalekber.cc") - kokoroVoice := envOr("KOKORO_VOICE", "af_bella") - log.Info("starting HTTP server", - "addr", addr, - "strategy", "direct", - "workers", workers, - "kokoro_url", kokoroURL, - "kokoro_voice", kokoroVoice, - "pocketbase_url", pbCfg.BaseURL, - "pocketbase_email", pbCfg.AdminEmail, - ) - srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice, Version, Commit) - return srv.ListenAndServe(ctx) - - case "save-browse": - return runSaveBrowse(ctx, args[1:], store, log) - - default: - return fmt.Errorf("unknown command %q; use 'run', 'refresh', 'serve', or 'save-browse'", cmd) - } -} - -// runSaveBrowse implements the `save-browse` subcommand. -// It iterates over browse pages on novelfire.net, captures each using -// SingleFile CLI (connected to the existing Browserless instance), and -// stores the resulting self-contained HTML in the MinIO browse bucket. -// After storing each page it parses the HTML, upserts ranking records in -// PocketBase, and fires background goroutines to download cover images. -// -// Flags (all optional): -// -// --genre <value> genre slug (default: all) -// --sort <value> sort order (default: popular) -// --status <value> status (default: all) -// --type <value> novel type (default: all-novel) -// --max-pages <n> max pages (default: 5) -func runSaveBrowse(ctx context.Context, args []string, store storage.Store, log *slog.Logger) error { - // Parse flags manually to avoid importing flag package. - genre := "all" - sortBy := "popular" - status := "all" - novelType := "all-novel" - maxPages := 5 - - for i := 0; i < len(args); i++ { - switch args[i] { - case "--genre": - if i+1 < len(args) { - genre = args[i+1] - i++ - } - case "--sort": - if i+1 < len(args) { - sortBy = args[i+1] - i++ - } - case "--status": - if i+1 < len(args) { - status = args[i+1] - i++ - } - case "--type": - if i+1 < len(args) { - novelType = args[i+1] - i++ - } - case "--max-pages": - if i+1 < len(args) { - if n, err := strconv.Atoi(args[i+1]); err == nil && n > 0 { - maxPages = n - } - i++ - } - } - } - - singleFilePath := envOr("SINGLEFILE_PATH", "single-file") - browserlessURL := envOr("BROWSERLESS_URL", "http://localhost:3030") - // SingleFile expects a WebSocket CDP endpoint. - // Browserless exposes /chromium at the WS root. - wsEndpoint := strings.Replace(browserlessURL, "http://", "ws://", 1) - wsEndpoint = strings.Replace(wsEndpoint, "https://", "wss://", 1) - - log.Info("save-browse: starting", - "genre", genre, "sort", sortBy, "status", status, - "type", novelType, "max_pages", maxPages, - "singlefile", singleFilePath, - "browserless_ws", wsEndpoint, - ) - - tmpDir, err := os.MkdirTemp("", "libnovel-browse-*") - if err != nil { - return fmt.Errorf("save-browse: create temp dir: %w", err) - } - defer os.RemoveAll(tmpDir) - - const novelFireBase = "https://novelfire.net" - const novelFireDomain = "novelfire.net" - - for page := 1; page <= maxPages; page++ { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - pageURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%d", - novelFireBase, genre, sortBy, status, novelType, page) - - // Use the new domain-based key layout: {domain}/html/page-{n}.html - key := store.BrowseHTMLKey(novelFireDomain, page) - - outFile := fmt.Sprintf("%s/page-%d.html", tmpDir, page) - - log.Info("save-browse: capturing page", "page", page, "url", pageURL) - - //nolint:gosec // singleFilePath and pageURL are config/URL values, not user input. - cmd := exec.CommandContext(ctx, singleFilePath, - pageURL, - "--browser-server="+wsEndpoint, - "--output="+outFile, - ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if runErr := cmd.Run(); runErr != nil { - log.Warn("save-browse: SingleFile failed, skipping page", - "page", page, "err", runErr) - continue - } - - htmlBytes, readErr := os.ReadFile(outFile) - if readErr != nil { - log.Warn("save-browse: failed to read output file", - "page", page, "file", outFile, "err", readErr) - continue - } - - if putErr := store.SaveBrowsePage(ctx, key, string(htmlBytes)); putErr != nil { - log.Warn("save-browse: failed to store snapshot in MinIO", - "page", page, "key", key, "err", putErr) - continue - } - - log.Info("save-browse: snapshot stored", "page", page, "key", key, - "bytes", len(htmlBytes)) - - // Parse the stored HTML and populate the ranking collection. - novels := parseSaveBrowseListings(htmlBytes, novelFireBase) - for i, novel := range novels { - rank := i + 1 - coverKey := store.BrowseCoverKey(novelFireDomain, novel.slug) - - item := storage.RankingItem{ - Rank: rank, - Slug: novel.slug, - Title: novel.title, - Cover: coverKey, - SourceURL: novel.url, - } - if werr := store.WriteRankingItem(ctx, item); werr != nil { - log.Warn("save-browse: WriteRankingItem failed", - "slug", novel.slug, "err", werr) - } - - // Download cover image in the background (best-effort). - if novel.coverURL != "" { - go storage.DownloadAndStoreCover(store, log, coverKey, novel.coverURL) - } - } - if len(novels) > 0 { - log.Info("save-browse: ranking populated", "page", page, "count", len(novels)) - } - } - - log.Info("save-browse: done") - return nil -} - -// novelListingCLI is a minimal novel listing used within the CLI command. -type novelListingCLI struct { - slug string - title string - url string - coverURL string -} - -// parseSaveBrowseListings extracts novel listings from raw HTML bytes. -// It reuses the same parsing logic as the server's parseBrowsePage but -// operates on []byte to avoid importing the server package. -func parseSaveBrowseListings(htmlBytes []byte, novelFireBase string) []novelListingCLI { - type listing = novelListingCLI - - // Minimal tokeniser-based walk to find <li class="novel-item"> blocks. - // We use the golang.org/x/net/html parser via a local import. - // Because main.go already imports golang.org/x/net/html indirectly through - // the server package build, we do a simple line-scan here instead to keep - // the dependency surface small. - // - // Strategy: scan for href="/book/{slug}", img data-src/src, h4.novel-title text. - var novels []listing - - lines := strings.Split(string(htmlBytes), "\n") - var cur listing - inNovelItem := false - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - - // Detect start of a novel-item list element. - if strings.Contains(trimmed, `class="novel-item"`) || strings.Contains(trimmed, "novel-item") && strings.HasPrefix(trimmed, "<li") { - inNovelItem = true - cur = listing{} - } - - if !inNovelItem { - continue - } - - // Detect end of list element. - if trimmed == "</li>" && cur.slug != "" { - novels = append(novels, cur) - inNovelItem = false - cur = listing{} - continue - } - - // Extract slug from href="/book/{slug}". - if cur.slug == "" { - if idx := strings.Index(trimmed, `href="/book/`); idx >= 0 { - rest := trimmed[idx+len(`href="/book/`):] - if end := strings.IndexAny(rest, `"/ `); end > 0 { - cur.slug = rest[:end] - cur.url = novelFireBase + "/book/" + cur.slug - } else if end := strings.Index(rest, `"`); end > 0 { - cur.slug = strings.TrimSuffix(rest[:end], "/") - cur.url = novelFireBase + "/book/" + cur.slug - } - } - } - - // Extract cover URL from data-src or src on img tags. - if cur.coverURL == "" && strings.Contains(trimmed, "<img") { - if src := extractAttr(trimmed, "data-src"); src != "" { - cur.coverURL = htmlutil.ResolveURL(novelFireBase, src) - } else if src := extractAttr(trimmed, "src"); src != "" && !strings.Contains(src, "data:") { - cur.coverURL = htmlutil.ResolveURL(novelFireBase, src) - } - } - - // Extract title from novel-title element. - if cur.title == "" && strings.Contains(trimmed, "novel-title") { - // Try to grab inner text: <h4 class="novel-title">Title Here</h4> - if start := strings.Index(trimmed, ">"); start >= 0 { - rest := trimmed[start+1:] - if end := strings.Index(rest, "<"); end > 0 { - title := strings.TrimSpace(rest[:end]) - if title != "" { - cur.title = title - } - } - } - } - } - - // Flush any open item that wasn't closed by </li> (e.g. last item in file). - if inNovelItem && cur.slug != "" { - novels = append(novels, cur) - } - - return novels -} - -// extractAttr extracts an HTML attribute value from a raw tag string. -// e.g. extractAttr(`<img data-src="foo.jpg">`, "data-src") → "foo.jpg" -func extractAttr(tag, attr string) string { - needle := attr + `="` - idx := strings.Index(tag, needle) - if idx < 0 { - return "" - } - rest := tag[idx+len(needle):] - end := strings.Index(rest, `"`) - if end < 0 { - return "" - } - return rest[:end] -} - -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func printUsage() { - fmt.Fprintf(os.Stderr, `libnovel scraper - -Commands: - run [--url <book-url>] One-shot: scrape full catalogue, or a single book - refresh <slug> Re-scrape a book from its saved source_url - serve Start HTTP server (POST /scrape, POST /scrape/book) - save-browse Capture browse pages via SingleFile → MinIO - --genre <slug> genre filter (default: all) - --sort <value> sort order (default: popular) - --status <value> status filter (default: all) - --type <value> novel type (default: all-novel) - --max-pages <n> pages to capture (default: 5) - -Environment variables: - SCRAPER_WORKERS Chapter goroutines (default: NumCPU = %d) - SCRAPER_HTTP_ADDR HTTP listen address (default: :8080) - SCRAPER_TIMEOUT HTTP request timeout sec (default: 90) - SCRAPER_PROXY Outbound proxy URL (default: "", falls back to HTTP_PROXY/HTTPS_PROXY) - KOKORO_URL Kokoro-FastAPI base URL (default: "", TTS disabled) - KOKORO_VOICE Default TTS voice (default: af_bella) - POCKETBASE_URL PocketBase base URL (default: http://localhost:8090) - POCKETBASE_ADMIN_EMAIL PocketBase admin email (default: admin@libnovel.local) - POCKETBASE_ADMIN_PASSWORD PocketBase admin password (default: changeme123) - MINIO_ENDPOINT MinIO endpoint host:port (default: localhost:9000) - MINIO_ACCESS_KEY MinIO access key (default: admin) - MINIO_SECRET_KEY MinIO secret key (default: changeme123) - MINIO_USE_SSL MinIO TLS (default: false) - MINIO_BUCKET_CHAPTERS Chapter objects bucket (default: libnovel-chapters) - MINIO_BUCKET_AUDIO Audio objects bucket (default: libnovel-audio) - MINIO_BUCKET_BROWSE Browse snapshots bucket (default: libnovel-browse) - BROWSERLESS_URL Browserless WS endpoint (default: http://localhost:3030) - SINGLEFILE_PATH Path to single-file CLI (default: single-file) - LOG_LEVEL debug|info|warn|error (default: info) -`, runtime.NumCPU()) -} diff --git a/scraper/go.mod b/scraper/go.mod deleted file mode 100644 index 31fc562..0000000 --- a/scraper/go.mod +++ /dev/null @@ -1,38 +0,0 @@ -module github.com/libnovel/scraper - -go 1.25.0 - -require ( - github.com/minio/minio-go/v7 v7.0.98 - golang.org/x/net v0.51.0 - honnef.co/go/tools v0.7.0 -) - -require ( - github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect - github.com/andybalholm/brotli v1.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-ini/ini v1.67.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.18.2 // indirect - github.com/klauspost/cpuid/v2 v2.2.11 // indirect - github.com/klauspost/crc32 v1.3.0 // indirect - github.com/minio/crc64nvme v1.1.1 // indirect - github.com/minio/md5-simd v1.1.2 // indirect - github.com/philhofer/fwd v1.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rs/xid v1.6.0 // indirect - github.com/tinylib/msgp v1.6.1 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -tool honnef.co/go/tools/cmd/staticcheck diff --git a/scraper/go.sum b/scraper/go.sum deleted file mode 100644 index 2f4f489..0000000 --- a/scraper/go.sum +++ /dev/null @@ -1,63 +0,0 @@ -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= -github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= -github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= -github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= -github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= -github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= -github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= -github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= -github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= -github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= -github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= -golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= -golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= -honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= diff --git a/scraper/internal/browser/common.go b/scraper/internal/browser/common.go deleted file mode 100644 index a84adf3..0000000 --- a/scraper/internal/browser/common.go +++ /dev/null @@ -1,127 +0,0 @@ -package browser - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -// Config holds the connection parameters for a Browserless instance. -type Config struct { - // BaseURL is the HTTP base URL, e.g. "http://localhost:3030". - BaseURL string - // Token is the optional API token (BROWSERLESS_TOKEN env var). - Token string - // Timeout is the per-request HTTP timeout; defaults to 60 s. - Timeout time.Duration - // MaxConcurrent caps the number of simultaneous in-flight requests sent to - // Browserless. When all slots are occupied new calls block until one - // completes (or ctx is cancelled). 0 means no limit. - MaxConcurrent int -} - -// makeSem returns a buffered channel used as a counting semaphore. -// If n <= 0 a nil channel is returned, which causes acquire/release to be no-ops. -func makeSem(n int) chan struct{} { - if n <= 0 { - return nil - } - return make(chan struct{}, n) -} - -// acquire takes one slot from sem. It returns an error if ctx is cancelled -// before a slot becomes available. If sem is nil it returns immediately. -func acquire(ctx context.Context, sem chan struct{}) error { - if sem == nil { - return nil - } - select { - case sem <- struct{}{}: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -// release frees the slot previously obtained by acquire. -// If sem is nil it is a no-op. -func release(sem chan struct{}) { - if sem != nil { - <-sem - } -} - -// ─── /content client ────────────────────────────────────────────────────────── - -// contentClient implements BrowserClient using the /content endpoint. -type contentClient struct { - cfg Config - http *http.Client - sem chan struct{} -} - -// NewContentClient returns a BrowserClient that uses POST /content. -func NewContentClient(cfg Config) BrowserClient { - if cfg.Timeout == 0 { - cfg.Timeout = 90 * time.Second - } - return &contentClient{ - cfg: cfg, - http: &http.Client{Timeout: cfg.Timeout}, - sem: makeSem(cfg.MaxConcurrent), - } -} - -func (c *contentClient) Strategy() Strategy { return StrategyContent } - -func (c *contentClient) GetContent(ctx context.Context, req ContentRequest) (string, error) { - if err := acquire(ctx, c.sem); err != nil { - return "", fmt.Errorf("content: semaphore: %w", err) - } - defer release(c.sem) - - body, err := json.Marshal(req) - if err != nil { - return "", fmt.Errorf("content: marshal request: %w", err) - } - - url := c.cfg.BaseURL + "/content" - if c.cfg.Token != "" { - url += "?token=" + c.cfg.Token - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("content: build request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - - resp, err := c.http.Do(httpReq) - if err != nil { - return "", fmt.Errorf("content: do request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("content: unexpected status %d: %s", resp.StatusCode, b) - } - - raw, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("content: read body: %w", err) - } - return string(raw), nil -} - -func (c *contentClient) ScrapePage(_ context.Context, _ ScrapeRequest) (ScrapeResponse, error) { - return ScrapeResponse{}, fmt.Errorf("content client does not support /scrape; use NewScrapeClient") -} - -func (c *contentClient) CDPSession(_ context.Context, _ string, _ CDPSessionFunc) error { - return fmt.Errorf("content client does not support CDP") -} diff --git a/scraper/internal/browser/http.go b/scraper/internal/browser/http.go deleted file mode 100644 index ce4ecdd..0000000 --- a/scraper/internal/browser/http.go +++ /dev/null @@ -1,136 +0,0 @@ -package browser - -import ( - "compress/gzip" - "context" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/andybalholm/brotli" -) - -type httpClient struct { - cfg Config - http *http.Client - sem chan struct{} -} - -func NewDirectHTTPClient(cfg Config) BrowserClient { - if cfg.Timeout == 0 { - cfg.Timeout = 30 * time.Second - } - - transport := http.DefaultTransport.(*http.Transport).Clone() - - // Wire in proxy from environment (HTTP_PROXY / HTTPS_PROXY / NO_PROXY). - // This lets operators route traffic through a residential proxy by simply - // setting HTTPS_PROXY=http://user:pass@proxy-host:port without any code - // changes — the standard approach for bypassing datacenter IP blocks. - if proxyURL := proxyFromEnv(); proxyURL != nil { - transport.Proxy = http.ProxyURL(proxyURL) - } else { - transport.Proxy = http.ProxyFromEnvironment - } - - return &httpClient{ - cfg: cfg, - http: &http.Client{ - Timeout: cfg.Timeout, - Transport: transport, - }, - sem: makeSem(cfg.MaxConcurrent), - } -} - -// proxyFromEnv returns an explicit proxy URL if SCRAPER_PROXY is set, otherwise -// nil (and http.ProxyFromEnvironment handles the standard HTTP_PROXY / HTTPS_PROXY). -func proxyFromEnv() *url.URL { - raw := os.Getenv("SCRAPER_PROXY") - if raw == "" { - return nil - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return nil - } - return u -} - -func (c *httpClient) Strategy() Strategy { return StrategyDirect } - -func (c *httpClient) GetContent(ctx context.Context, req ContentRequest) (string, error) { - if err := acquire(ctx, c.sem); err != nil { - return "", fmt.Errorf("http: semaphore: %w", err) - } - defer release(c.sem) - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, req.URL, nil) - if err != nil { - return "", fmt.Errorf("http: build request: %w", err) - } - - // Mimic a real Chrome browser request to reduce bot-detection likelihood. - // These headers match what Chrome 124 sends for a top-level navigation. - httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") - httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") - httpReq.Header.Set("Accept-Language", "en-US,en;q=0.9") - httpReq.Header.Set("Accept-Encoding", "gzip, deflate, br") - httpReq.Header.Set("Connection", "keep-alive") - httpReq.Header.Set("Upgrade-Insecure-Requests", "1") - httpReq.Header.Set("Sec-Fetch-Dest", "document") - httpReq.Header.Set("Sec-Fetch-Mode", "navigate") - httpReq.Header.Set("Sec-Fetch-Site", "none") - httpReq.Header.Set("Sec-Fetch-User", "?1") - httpReq.Header.Set("Cache-Control", "max-age=0") - - // Set Referer for subsequent page requests (anything that is not the root). - if parsed, pErr := url.Parse(req.URL); pErr == nil && parsed.Path != "" && parsed.Path != "/" { - httpReq.Header.Set("Referer", parsed.Scheme+"://"+parsed.Host+"/") - } - - resp, err := c.http.Do(httpReq) - if err != nil { - return "", fmt.Errorf("http: do request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("http: unexpected status %d: %s", resp.StatusCode, b) - } - - // Decompress gzip/br responses when the server honours Accept-Encoding. - // net/http decompresses gzip automatically only when it sets the header - // itself; since we set Accept-Encoding explicitly we must do it ourselves. - body := resp.Body - switch strings.ToLower(resp.Header.Get("Content-Encoding")) { - case "gzip": - gr, gzErr := gzip.NewReader(resp.Body) - if gzErr != nil { - return "", fmt.Errorf("http: gzip reader: %w", gzErr) - } - defer gr.Close() - body = gr - case "br": - body = io.NopCloser(brotli.NewReader(resp.Body)) - } - - raw, err := io.ReadAll(body) - if err != nil { - return "", fmt.Errorf("http: read body: %w", err) - } - return string(raw), nil -} - -func (c *httpClient) ScrapePage(_ context.Context, _ ScrapeRequest) (ScrapeResponse, error) { - return ScrapeResponse{}, fmt.Errorf("http client does not support ScrapePage; use browserless") -} - -func (c *httpClient) CDPSession(_ context.Context, _ string, _ CDPSessionFunc) error { - return fmt.Errorf("http client does not support CDP; use browserless") -} diff --git a/scraper/internal/browser/integration_test.go b/scraper/internal/browser/integration_test.go deleted file mode 100644 index e389677..0000000 --- a/scraper/internal/browser/integration_test.go +++ /dev/null @@ -1,152 +0,0 @@ -//go:build integration - -// Integration tests for the Browserless /content API. -// -// These tests require a live Browserless instance and are gated behind the -// "integration" build tag so they never run in normal `go test ./...` passes. -// -// Run them with: -// -// BROWSERLESS_URL=http://localhost:3030 \ -// BROWSERLESS_TOKEN=your-token \ # omit if auth is disabled -// go test -v -tags integration -timeout 120s \ -// github.com/libnovel/scraper/internal/browser -package browser_test - -import ( - "context" - "os" - "strings" - "testing" - "time" - - "github.com/libnovel/scraper/internal/browser" -) - -// chapterURL is the novelfire chapter used in every integration sub-test. -const chapterURL = "https://novelfire.net/book/a-dragon-against-the-whole-world/chapter-1" - -// newIntegrationClient reads BROWSERLESS_URL / BROWSERLESS_TOKEN from the -// environment and returns a configured contentClient. -// The test is skipped when BROWSERLESS_URL is not set. -func newIntegrationClient(t *testing.T) browser.BrowserClient { - t.Helper() - baseURL := os.Getenv("BROWSERLESS_URL") - if baseURL == "" { - t.Skip("BROWSERLESS_URL not set — skipping integration test") - } - return browser.NewContentClient(browser.Config{ - BaseURL: baseURL, - Token: os.Getenv("BROWSERLESS_TOKEN"), - // Use a generous per-request HTTP timeout so the wait-for-selector - // (75 s) doesn't get cut off by the transport layer. - Timeout: 120 * time.Second, - MaxConcurrent: 1, - }) -} - -// TestIntegration_ChapterContent_ReturnsHTML verifies that a POST /content -// request with the production wait-for-selector settings succeeds and that the -// returned HTML contains the #content div expected on novelfire chapter pages. -func TestIntegration_ChapterContent_ReturnsHTML(t *testing.T) { - client := newIntegrationClient(t) - - ctx, cancel := context.WithTimeout(context.Background(), 110*time.Second) - defer cancel() - - req := browser.ContentRequest{ - URL: chapterURL, - WaitFor: &browser.WaitForSelector{ - Selector: "#content", - Timeout: 5000, - }, - RejectResourceTypes: productionRejectTypes(), - } - - html, err := client.GetContent(ctx, req) - if err != nil { - t.Fatalf("GetContent failed: %v", err) - } - - // The #content div must not be empty; presence of <p> tags inside it is a - // reliable indicator that chapter paragraphs were rendered. - contentIdx := strings.Index(html, `id="content"`) - if contentIdx == -1 { - t.Fatalf("id=\"content\" not found in response (%d bytes)", len(html)) - } - - // Look for <p> tags after the #content marker — the chapter text lives there. - afterContent := html[contentIdx:] - if !strings.Contains(afterContent, "<p") { - t.Errorf("#content section contains no <p> tags; JS rendering may have failed.\nSection preview:\n%s", - truncate(afterContent, 1000)) - } - - t.Logf("chapter content section starts at byte %d (total response: %d bytes)", contentIdx, len(html)) -} - -// TestIntegration_ChapterContent_TimeoutSurfacedCorrectly verifies that a -// deliberately too-short timeout returns an error containing "TimeoutError" (the -// Browserless error string seen in the failing log entry). This ensures our -// error-classification logic in retryGetContent matches real Browserless output. -func TestIntegration_ChapterContent_TimeoutSurfacedCorrectly(t *testing.T) { - client := newIntegrationClient(t) - - ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) - defer cancel() - - req := browser.ContentRequest{ - URL: chapterURL, - WaitFor: &browser.WaitForSelector{ - Selector: "#content", - Timeout: 500, // intentionally too short (500 ms) → Browserless will time out - }, - RejectResourceTypes: productionRejectTypes(), - } - - _, err := client.GetContent(ctx, req) - if err == nil { - t.Fatal("expected a timeout error from Browserless, but GetContent succeeded — " + - "the page may now load very fast; adjust the timeout threshold") - } - - t.Logf("got expected error: %v", err) - - // Browserless wraps navigation timeouts in a 500 response with - // "TimeoutError: Navigation timeout" in the body — this is the exact - // error that is triggering retries in production. - if !strings.Contains(err.Error(), "500") { - t.Errorf("expected HTTP 500 status in error, got: %v", err) - } -} - -// ── helpers ─────────────────────────────────────────────────────────────────── - -// productionRejectTypes returns the same resource-type block-list the -// novelfire scraper uses in production, so integration tests exercise the -// identical request shape. -func productionRejectTypes() []string { - return []string{ - "cspviolationreport", - "eventsource", - "fedcm", - "font", - "image", - "manifest", - "media", - "other", - "ping", - "signedexchange", - "stylesheet", - "texttrack", - "websocket", - } -} - -// truncate returns the first n bytes of s as a string. -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "…" -} diff --git a/scraper/internal/browser/interface.go b/scraper/internal/browser/interface.go deleted file mode 100644 index c2c8fd4..0000000 --- a/scraper/internal/browser/interface.go +++ /dev/null @@ -1,120 +0,0 @@ -// Package browser defines the BrowserClient interface and helper types for -// communicating with a Browserless instance. -package browser - -import "context" - -// Strategy selects which Browserless API endpoint / protocol to use. -type Strategy string - -const ( - // StrategyContent uses the POST /content endpoint, which returns the final - // rendered HTML of the page. Fastest; suitable for most JS-rendered sites. - StrategyContent Strategy = "content" - - // StrategyScrape uses the POST /scrape endpoint, which accepts a list of - // CSS selectors and returns structured JSON. Good when you know exactly - // which elements you need. - StrategyScrape Strategy = "scrape" - - // StrategyCDP uses the WebSocket /devtools/browser endpoint (Chrome - // DevTools Protocol). Most powerful; required for complex interactions - // (clicking, scrolling, waiting for network idle, etc.). - StrategyCDP Strategy = "cdp" - - // StrategyDirect uses a plain HTTP client to fetch HTML directly. - // Suitable for sites that don't require JavaScript rendering. - StrategyDirect Strategy = "direct" -) - -// WaitForSelector describes the waitForSelector option sent to Browserless. -type WaitForSelector struct { - Selector string `json:"selector"` - Timeout int `json:"timeout,omitempty"` // ms -} - -// GotoOptions controls page navigation behavior. -type GotoOptions struct { - Timeout int `json:"timeout,omitempty"` // ms - WaitUntil string `json:"waitUntil,omitempty"` // e.g., "networkidle2", "load" -} - -// ContentRequest is the body sent to POST /content. -type ContentRequest struct { - URL string `json:"url"` - WaitFor *WaitForSelector `json:"waitForSelector,omitempty"` - WaitForTimeout int `json:"waitForTimeout,omitempty"` // ms - RejectResourceTypes []string `json:"rejectResourceTypes,omitempty"` // e.g. ["image","stylesheet"] - GotoOptions *GotoOptions `json:"gotoOptions,omitempty"` - BestAttempt bool `json:"bestAttempt,omitempty"` // return partial content on timeout/error -} - -// ScrapeElement is one element descriptor inside a ScrapeRequest. -type ScrapeElement struct { - Selector string `json:"selector"` - Timeout int `json:"timeout,omitempty"` // ms -} - -// ScrapeRequest is the body sent to POST /scrape. -type ScrapeRequest struct { - URL string `json:"url"` - Elements []ScrapeElement `json:"elements"` - WaitFor *WaitForSelector `json:"waitForSelector,omitempty"` - GotoOptions *GotoOptions `json:"gotoOptions,omitempty"` -} - -// ScrapeResult is one entry in the response from POST /scrape. -type ScrapeResult struct { - Selector string `json:"selector"` - Results []ScrapeElement `json:"results"` -} - -// ScrapeAttribute holds a single attribute value from a scraped element. -type ScrapeAttribute struct { - Name string `json:"name"` - Value string `json:"value"` -} - -// ScrapedElement is one item inside ScrapeResult.Results. -type ScrapedElement struct { - Text string `json:"text"` - Attributes []ScrapeAttribute `json:"attributes"` -} - -// ScrapeResponse is the top-level response from POST /scrape. -type ScrapeResponse struct { - Data []ScrapeResult `json:"data"` -} - -// BrowserClient is an abstraction over the three Browserless API strategies. -// Callers choose the strategy best suited to the target site; the interface -// signature is identical regardless of strategy. -type BrowserClient interface { - // Strategy returns the strategy this client uses. - Strategy() Strategy - - // GetContent fetches the fully-rendered HTML of url using the /content - // endpoint. Only meaningful when Strategy() == StrategyContent. - GetContent(ctx context.Context, req ContentRequest) (string, error) - - // ScrapePage calls the /scrape endpoint and returns structured data. - // Only meaningful when Strategy() == StrategyScrape. - ScrapePage(ctx context.Context, req ScrapeRequest) (ScrapeResponse, error) - - // CDPSession opens a CDP WebSocket session and calls fn with the raw - // WebSocket connection. Only meaningful when Strategy() == StrategyCDP. - // The session is closed when fn returns. - CDPSession(ctx context.Context, pageURL string, fn CDPSessionFunc) error -} - -// CDPSessionFunc is the callback invoked inside a CDP session. -// conn is a live *websocket.Conn connected to a Browserless CDP endpoint. -type CDPSessionFunc func(ctx context.Context, conn CDPConn) error - -// CDPConn is the minimal interface the orchestrator needs over a CDP WebSocket. -type CDPConn interface { - // Send sends a raw CDP command (JSON-encoded) and returns the response. - Send(ctx context.Context, method string, params map[string]any) (map[string]any, error) - // Close closes the underlying connection. - Close() error -} diff --git a/scraper/internal/e2e/e2e_test.go b/scraper/internal/e2e/e2e_test.go deleted file mode 100644 index 0ddd588..0000000 --- a/scraper/internal/e2e/e2e_test.go +++ /dev/null @@ -1,818 +0,0 @@ -//go:build integration - -// End-to-end integration test for libnovel. -// -// Scenario (executed in order): -// 1. Health-check all Docker services (PocketBase, MinIO, Browserless, scraper). -// 2. Register a test user in the app_users PocketBase collection. -// 3. Scrape the popular-ranking page 1 and capture the first book. -// 4. Scrape full metadata for that book and persist it; verify in PocketBase. -// 5. Scrape chapters 1–3 and persist them; verify in MinIO + PocketBase. -// 6. Generate TTS audio for the first 100 chars of each chapter via the scraper -// HTTP API; verify MinIO object + PocketBase audio_cache entry. -// 7. Fetch presigned URLs for each chapter's markdown and audio; verify HTTP 200. -// -// Prerequisites (all must be running): -// -// docker-compose up -d minio pocketbase browserless scraper -// -// Run with: -// -// BROWSERLESS_URL=http://localhost:3030 \ -// MINIO_ENDPOINT=localhost:9000 \ -// POCKETBASE_URL=http://localhost:8090 \ -// SCRAPER_URL=http://localhost:8080 \ -// go test -v -tags integration -timeout 900s \ -// github.com/libnovel/scraper/internal/e2e -package e2e - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "os" - "strings" - "testing" - "time" - - "github.com/libnovel/scraper/internal/browser" - "github.com/libnovel/scraper/internal/novelfire" - "github.com/libnovel/scraper/internal/scraper" - "github.com/libnovel/scraper/internal/storage" -) - -// ─── env helpers ───────────────────────────────────────────────────────────── - -func envOr(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} - -// ─── fixture ───────────────────────────────────────────────────────────────── - -type e2eFixture struct { - sc *novelfire.Scraper - hs *storage.HybridStore - scraperURL string // base URL of the running scraper HTTP server - pbBaseURL string - pbEmail string - pbPassword string -} - -func newE2EFixture(t *testing.T) *e2eFixture { - t.Helper() - - browserlessURL := envOr("BROWSERLESS_URL", "") - if browserlessURL == "" { - t.Skip("BROWSERLESS_URL not set — skipping e2e test") - } - if os.Getenv("MINIO_ENDPOINT") == "" { - t.Skip("MINIO_ENDPOINT not set — skipping e2e test") - } - if os.Getenv("POCKETBASE_URL") == "" { - t.Skip("POCKETBASE_URL not set — skipping e2e test") - } - scraperURL := envOr("SCRAPER_URL", "http://localhost:8080") - - pbBaseURL := envOr("POCKETBASE_URL", "http://localhost:8090") - pbEmail := envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local") - pbPassword := envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123") - - pbCfg := storage.PocketBaseConfig{ - BaseURL: pbBaseURL, - AdminEmail: pbEmail, - AdminPassword: pbPassword, - } - minioCfg := storage.MinioConfig{ - Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), - AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), - SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), - UseSSL: envOr("MINIO_USE_SSL", "false") == "true", - BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), - BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default()) - if err != nil { - t.Fatalf("NewHybridStore: %v", err) - } - - // directClient: plain HTTP GET — used for chapter text, metadata, and ranking - // (novelfire.net serves these pages server-side; no JS rendering needed). - directClient := browser.NewDirectHTTPClient(browser.Config{ - Timeout: 60 * time.Second, - MaxConcurrent: 2, - }) - // urlClient: Browserless content strategy — used only for chapter-list - // pagination pages which require JS rendering to populate the list. - urlClient := browser.NewContentClient(browser.Config{ - BaseURL: browserlessURL, - Token: os.Getenv("BROWSERLESS_TOKEN"), - Timeout: 120 * time.Second, - MaxConcurrent: 2, - }) - log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) - sc := novelfire.New(directClient, log, urlClient, directClient, nil) - - return &e2eFixture{ - sc: sc, - hs: hs, - scraperURL: scraperURL, - pbBaseURL: pbBaseURL, - pbEmail: pbEmail, - pbPassword: pbPassword, - } -} - -// ─── The single end-to-end test ─────────────────────────────────────────────── - -// TestE2E_FullScenario executes the complete end-to-end scenario in order. -func TestE2E_FullScenario(t *testing.T) { - f := newE2EFixture(t) - - // ── Step 1: Health-check services ──────────────────────────────────────── - t.Run("step1_health_checks", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - // PocketBase health - pbHealth := f.pbBaseURL + "/api/health" - checkHTTP(t, ctx, pbHealth, "PocketBase") - - // MinIO health — the MinIO console liveness endpoint - minioEndpoint := envOr("MINIO_ENDPOINT", "localhost:9000") - scheme := "http" - if envOr("MINIO_USE_SSL", "false") == "true" { - scheme = "https" - } - minioHealth := fmt.Sprintf("%s://%s/minio/health/live", scheme, minioEndpoint) - checkHTTP(t, ctx, minioHealth, "MinIO") - - // Browserless health — /pressure is the liveness endpoint - browserlessURL := envOr("BROWSERLESS_URL", "http://localhost:3030") - blHealth := browserlessURL + "/pressure" - checkHTTP(t, ctx, blHealth, "Browserless") - - // Scraper server health — wait up to 10 s for it to be ready - scraperHealth := f.scraperURL + "/health" - waitForHTTP(t, ctx, scraperHealth, "scraper server", 10*time.Second) - }) - - // ── Step 2: Register test user ──────────────────────────────────────────── - var testUsername string - t.Run("step2_register_user", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - testUsername = fmt.Sprintf("e2euser-%d", time.Now().UnixMilli()%100000) - passwordHash := "pbkdf2:sha256:dummy-hash-for-test" - - t.Cleanup(func() { - cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cleanCancel() - deleteAppUser(t, f, cleanCtx, testUsername) - }) - - if err := createAppUser(ctx, f, testUsername, passwordHash, "reader"); err != nil { - t.Fatalf("createAppUser: %v", err) - } - t.Logf("created user %q", testUsername) - - // Verify the user exists in PocketBase. - rec, err := getAppUserByUsername(ctx, f, testUsername) - if err != nil { - t.Fatalf("getAppUserByUsername: %v", err) - } - if rec == nil { - t.Fatal("user not found in app_users after creation") - } - if rec["username"] != testUsername { - t.Errorf("username = %q, want %q", rec["username"], testUsername) - } - t.Logf("user verified in PocketBase: id=%v username=%v role=%v", rec["id"], rec["username"], rec["role"]) - }) - - // ── Step 3: Scrape ranking page 1, capture first book ──────────────────── - var firstBook scraper.BookMeta - t.Run("step3_scrape_ranking", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - - entries, errs := f.sc.ScrapeRanking(ctx, 1) // maxPages=1 → only page 1 - - select { - case meta, ok := <-entries: - if !ok { - t.Fatal("ranking channel closed without any entry") - } - firstBook = meta - case err := <-errs: - t.Fatalf("ScrapeRanking error: %v", err) - case <-ctx.Done(): - t.Fatal("ScrapeRanking timed out waiting for first entry") - } - - // Drain remaining entries and errors. - for range entries { - } - for range errs { - } - - if firstBook.Slug == "" { - t.Fatal("first book has empty slug") - } - if firstBook.Title == "" { - t.Fatal("first book has empty title") - } - if firstBook.SourceURL == "" { - t.Fatal("first book has empty SourceURL") - } - t.Logf("first ranked book: slug=%q title=%q rank=%d url=%s", - firstBook.Slug, firstBook.Title, firstBook.Ranking, firstBook.SourceURL) - }) - - if firstBook.Slug == "" || firstBook.SourceURL == "" { - t.Fatal("cannot continue: step3 did not produce a valid first book") - } - - // Use a unique slug for the test to avoid colliding with real scraped data. - testSlug := fmt.Sprintf("%s-e2e-%d", firstBook.Slug, time.Now().UnixMilli()%100000) - t.Logf("using test slug: %q", testSlug) - - // Register cleanup for all data written by subsequent steps. - t.Cleanup(func() { - cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cleanCancel() - cleanupTestData(t, f, cleanCtx, testSlug) - }) - - // ── Step 4: Scrape book metadata and persist ────────────────────────────── - var fullMeta scraper.BookMeta - t.Run("step4_scrape_metadata", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - - meta, err := f.sc.ScrapeMetadata(ctx, firstBook.SourceURL) - if err != nil { - t.Fatalf("ScrapeMetadata: %v", err) - } - t.Logf("scraped metadata: title=%q author=%q totalChapters=%d", - meta.Title, meta.Author, meta.TotalChapters) - - // Override slug so data lands under our test slug. - meta.Slug = testSlug - fullMeta = meta - - storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second) - defer storeCancel() - - if err := f.hs.WriteMetadata(storeCtx, meta); err != nil { - t.Fatalf("WriteMetadata: %v", err) - } - - // Verify in PocketBase. - got, found, err := f.hs.ReadMetadata(storeCtx, testSlug) - if err != nil { - t.Fatalf("ReadMetadata: %v", err) - } - if !found { - t.Fatal("book not found in PocketBase after WriteMetadata") - } - if got.Title == "" { - t.Error("book title is empty after round-trip") - } - if got.Author == "" { - t.Logf("WARNING: book author is empty after round-trip (site may not expose author for this book)") - } - t.Logf("PocketBase verified: title=%q author=%q totalChapters=%d", got.Title, got.Author, got.TotalChapters) - }) - - if fullMeta.SourceURL == "" { - fullMeta.SourceURL = firstBook.SourceURL - } - - // ── Step 5: Scrape first 3 chapters and persist ─────────────────────────── - var chapterRefs []scraper.ChapterRef - t.Run("step5_scrape_chapters", func(t *testing.T) { - // Fetch only page 1 of the chapter list from - // https://novelfire.net/book/{slug}/chapters?page=1 - // to avoid paginating through hundreds of pages for popular books. - listCtx, listCancel := context.WithTimeout(context.Background(), 60*time.Second) - defer listCancel() - - chaptersPageURL := firstBook.SourceURL + "/chapters?page=1" - refs, err := scrapeChapterListPage1(listCtx, f, chaptersPageURL) - if err != nil { - t.Fatalf("scrapeChapterListPage1: %v", err) - } - if len(refs) == 0 { - t.Fatal("chapter list page 1 returned no chapters") - } - t.Logf("chapter list page 1: %d chapters found", len(refs)) - - // Take the first 3 (or fewer if page 1 has < 3 chapters). - n := 3 - if len(refs) < n { - n = len(refs) - } - chapterRefs = refs[:n] - t.Logf("will scrape first %d chapters: %v", n, chapterNumbers(chapterRefs)) - - for _, ref := range chapterRefs { - ref := ref - t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) { - scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 180*time.Second) - defer scrapeCancel() - - ch, err := f.sc.ScrapeChapterText(scrapeCtx, ref) - if err != nil { - t.Fatalf("ScrapeChapterText(%d): %v", ref.Number, err) - } - t.Logf("scraped chapter %d: %d bytes", ref.Number, len(ch.Text)) - if len(ch.Text) < 50 { - t.Errorf("chapter %d text too short (%d bytes)", ref.Number, len(ch.Text)) - } - - // Override ref slug with our test slug. - ch.Ref.Number = ref.Number - ch.Ref.Title = ref.Title - - storeCtx, storeCancel := context.WithTimeout(context.Background(), 20*time.Second) - defer storeCancel() - - if err := f.hs.WriteChapter(storeCtx, testSlug, ch); err != nil { - t.Fatalf("WriteChapter(%d): %v", ref.Number, err) - } - - // Verify in MinIO via ReadChapter. - got, err := f.hs.ReadChapter(storeCtx, testSlug, ref.Number) - if err != nil { - t.Fatalf("ReadChapter(%d): %v", ref.Number, err) - } - if got == "" { - t.Errorf("chapter %d: ReadChapter returned empty content", ref.Number) - } - if !strings.HasPrefix(got, "# ") { - t.Errorf("chapter %d: stored content missing markdown header (got %q)", ref.Number, got[:min(len(got), 80)]) - } - - // Verify PocketBase chapters_idx entry. - idxCtx, idxCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer idxCancel() - count := f.hs.CountChapters(idxCtx, testSlug) - if count == 0 { - t.Errorf("chapter %d: chapters_idx count = 0 after WriteChapter", ref.Number) - } - t.Logf("chapter %d stored; chapters_idx count=%d", ref.Number, count) - }) - } - }) - - if len(chapterRefs) == 0 { - t.Fatal("cannot continue: step5 produced no chapter refs") - } - - // ── Step 6: Generate TTS audio via scraper HTTP API ─────────────────────── - t.Run("step6_tts_audio", func(t *testing.T) { - if os.Getenv("SCRAPER_URL") == "" { - t.Skip("SCRAPER_URL not set — skipping TTS step") - } - - voice := envOr("KOKORO_VOICE", "af_bella") - - for _, ref := range chapterRefs { - ref := ref - t.Run(fmt.Sprintf("audio-chapter-%d", ref.Number), func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - audioURL := fmt.Sprintf("%s/api/audio/%s/%d", f.scraperURL, testSlug, ref.Number) - body, _ := json.Marshal(map[string]interface{}{ - "voice": voice, - "speed": 1.0, - "max_chars": 200, - }) - - audioReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, audioURL, bytes.NewReader(body)) - audioReq.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(audioReq) - if err != nil { - t.Fatalf("POST %s: %v", audioURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - raw, _ := io.ReadAll(resp.Body) - t.Fatalf("audio generation status=%d body=%s", resp.StatusCode, raw) - } - - var audioResp struct { - URL string `json:"url"` - Filename string `json:"filename"` - } - if err := json.NewDecoder(resp.Body).Decode(&audioResp); err != nil { - t.Fatalf("decode audio response: %v", err) - } - if audioResp.URL == "" { - t.Error("audio response has empty url field") - } - if audioResp.Filename == "" { - t.Error("audio response has empty filename field") - } - t.Logf("chapter %d audio: url=%s filename=%s", ref.Number, audioResp.URL, audioResp.Filename) - - // Verify audio_cache entry exists in PocketBase. - pbCtx, pbCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer pbCancel() - - cacheKey := fmt.Sprintf("%s/%d/%s/1.00", testSlug, ref.Number, voice) - filename, found := f.hs.GetAudioCache(pbCtx, cacheKey) - if !found { - t.Errorf("audio_cache entry not found for key=%q", cacheKey) - } else { - t.Logf("audio_cache[%q] = %q", cacheKey, filename) - } - }) - } - }) - - // ── Step 7: Presigned URLs ──────────────────────────────────────────────── - t.Run("step7_presigned_urls", func(t *testing.T) { - if os.Getenv("SCRAPER_URL") == "" { - t.Skip("SCRAPER_URL not set — skipping presign step") - } - - // Give the background MinIO upload goroutines (launched by handleAudioGenerate) - // a moment to complete before we attempt to access the presigned URLs. - time.Sleep(5 * time.Second) - - voice := envOr("KOKORO_VOICE", "af_bella") - - for _, ref := range chapterRefs { - ref := ref - t.Run(fmt.Sprintf("presign-chapter-%d", ref.Number), func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Chapter markdown presign. - chPresignURL := fmt.Sprintf("%s/api/presign/chapter/%s/%d", - f.scraperURL, testSlug, ref.Number) - chPresigned := fetchPresignedURL(t, ctx, chPresignURL, "chapter presign") - if chPresigned != "" { - assertURLAccessible(t, ctx, chPresigned, fmt.Sprintf("chapter %d presigned URL", ref.Number)) - } - - // Audio presign — poll with retries to allow background MinIO upload to finish. - auPresignURL := fmt.Sprintf("%s/api/presign/audio/%s/%d?voice=%s&speed=1.0", - f.scraperURL, testSlug, ref.Number, voice) - auPresigned := fetchPresignedURL(t, ctx, auPresignURL, "audio presign") - if auPresigned != "" { - assertURLAccessibleWithRetry(t, ctx, auPresigned, fmt.Sprintf("chapter %d audio presigned URL", ref.Number), 6, 5*time.Second) - } - }) - } - }) -} - -// ─── PocketBase admin helpers ───────────────────────────────────────────────── - -// pbAuthToken obtains a PocketBase superuser JWT. -func pbAuthToken(ctx context.Context, f *e2eFixture) (string, error) { - body, _ := json.Marshal(map[string]string{ - "identity": f.pbEmail, - "password": f.pbPassword, - }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - f.pbBaseURL+"/api/collections/_superusers/auth-with-password", - bytes.NewReader(body)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("pbAuthToken: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("pbAuthToken status %d: %s", resp.StatusCode, b) - } - var result struct { - Token string `json:"token"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("pbAuthToken decode: %w", err) - } - return result.Token, nil -} - -// createAppUser inserts a record into app_users via PocketBase admin API. -func createAppUser(ctx context.Context, f *e2eFixture, username, passwordHash, role string) error { - tok, err := pbAuthToken(ctx, f) - if err != nil { - return err - } - payload, _ := json.Marshal(map[string]interface{}{ - "username": username, - "password_hash": passwordHash, - "role": role, - "created": time.Now().UTC().Format(time.RFC3339), - }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - f.pbBaseURL+"/api/collections/app_users/records", - bytes.NewReader(payload)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", tok) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("createAppUser: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("createAppUser status %d: %s", resp.StatusCode, b) - } - return nil -} - -// getAppUserByUsername fetches an app_users record by username. -// Returns nil, nil when not found. -func getAppUserByUsername(ctx context.Context, f *e2eFixture, username string) (map[string]interface{}, error) { - tok, err := pbAuthToken(ctx, f) - if err != nil { - return nil, err - } - url := fmt.Sprintf("%s/api/collections/app_users/records?filter=username%%3D%%22%s%%22&perPage=1", - f.pbBaseURL, username) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", tok) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("getAppUserByUsername: %w", err) - } - defer resp.Body.Close() - var result struct { - Items []map[string]interface{} `json:"items"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("getAppUserByUsername decode: %w", err) - } - if len(result.Items) == 0 { - return nil, nil - } - return result.Items[0], nil -} - -// deleteAppUser removes app_users records matching username. -func deleteAppUser(t *testing.T, f *e2eFixture, ctx context.Context, username string) { - t.Helper() - tok, err := pbAuthToken(ctx, f) - if err != nil { - t.Logf("deleteAppUser: pbAuthToken error: %v", err) - return - } - // List matching records. - url := fmt.Sprintf("%s/api/collections/app_users/records?filter=username%%3D%%22%s%%22&perPage=10", - f.pbBaseURL, username) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - req.Header.Set("Authorization", tok) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Logf("deleteAppUser list error: %v", err) - return - } - defer resp.Body.Close() - var result struct { - Items []map[string]interface{} `json:"items"` - } - _ = json.NewDecoder(resp.Body).Decode(&result) - for _, item := range result.Items { - id, _ := item["id"].(string) - delURL := fmt.Sprintf("%s/api/collections/app_users/records/%s", f.pbBaseURL, id) - delReq, _ := http.NewRequestWithContext(ctx, http.MethodDelete, delURL, nil) - delReq.Header.Set("Authorization", tok) - delResp, _ := http.DefaultClient.Do(delReq) - if delResp != nil { - delResp.Body.Close() - } - } -} - -// cleanupTestData removes all PocketBase + MinIO data for the given slug. -func cleanupTestData(t *testing.T, f *e2eFixture, ctx context.Context, slug string) { - t.Helper() - tok, err := pbAuthToken(ctx, f) - if err != nil { - t.Logf("cleanupTestData: pbAuthToken error: %v", err) - return - } - pbDelete := func(collection, filter string) { - listURL := fmt.Sprintf("%s/api/collections/%s/records?filter=%s&perPage=500", - f.pbBaseURL, collection, filter) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil) - req.Header.Set("Authorization", tok) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Logf("cleanupTestData list %s error: %v", collection, err) - return - } - defer resp.Body.Close() - var result struct { - Items []map[string]interface{} `json:"items"` - } - _ = json.NewDecoder(resp.Body).Decode(&result) - for _, item := range result.Items { - id, _ := item["id"].(string) - delURL := fmt.Sprintf("%s/api/collections/%s/records/%s", f.pbBaseURL, collection, id) - delReq, _ := http.NewRequestWithContext(ctx, http.MethodDelete, delURL, nil) - delReq.Header.Set("Authorization", tok) - delResp, _ := http.DefaultClient.Do(delReq) - if delResp != nil { - delResp.Body.Close() - } - } - } - slugFilter := fmt.Sprintf("slug%%3D%%22%s%%22", slug) - ckFilter := fmt.Sprintf("cache_key%%7E%%22%s%%2F%%22", slug) // cache_key ~ "slug/" - pbDelete("books", slugFilter) - pbDelete("chapters_idx", slugFilter) - pbDelete("audio_cache", ckFilter) - t.Logf("cleanup complete for slug=%q", slug) -} - -// ─── HTTP assertion helpers ─────────────────────────────────────────────────── - -// checkHTTP asserts that a GET to url returns 2xx within the context deadline. -func checkHTTP(t *testing.T, ctx context.Context, url, name string) { - t.Helper() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - t.Errorf("%s health check: build request: %v", name, err) - return - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Errorf("%s health check failed: %v", name, err) - return - } - resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - t.Errorf("%s health check: status %d, want 2xx", name, resp.StatusCode) - return - } - t.Logf("%s health OK (HTTP %d)", name, resp.StatusCode) -} - -// waitForHTTP retries GET url until a 2xx is received or timeout is reached. -func waitForHTTP(t *testing.T, ctx context.Context, url, name string, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - var lastErr error - for time.Now().Before(deadline) { - select { - case <-ctx.Done(): - t.Errorf("%s: context cancelled while waiting for health", name) - return - default: - } - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - resp, err := http.DefaultClient.Do(req) - if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { - resp.Body.Close() - t.Logf("%s health OK (HTTP %d)", name, resp.StatusCode) - return - } - if resp != nil { - resp.Body.Close() - lastErr = fmt.Errorf("status %d", resp.StatusCode) - } else { - lastErr = err - } - time.Sleep(500 * time.Millisecond) - } - t.Errorf("%s not healthy after %s: %v", name, timeout, lastErr) -} - -// fetchPresignedURL calls the presign endpoint and returns the presigned URL. -// It logs and returns "" on failure (non-fatal) so the caller can decide. -func fetchPresignedURL(t *testing.T, ctx context.Context, presignEndpoint, label string) string { - t.Helper() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, presignEndpoint, nil) - if err != nil { - t.Errorf("fetchPresignedURL %s: %v", label, err) - return "" - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Errorf("fetchPresignedURL %s: %v", label, err) - return "" - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - t.Errorf("fetchPresignedURL %s: status %d body=%s", label, resp.StatusCode, b) - return "" - } - var body struct { - URL string `json:"url"` - } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - t.Errorf("fetchPresignedURL %s decode: %v", label, err) - return "" - } - if body.URL == "" { - t.Errorf("fetchPresignedURL %s: empty url in response", label) - return "" - } - t.Logf("%s presigned URL: %s", label, body.URL) - return body.URL -} - -// assertURLAccessible does a GET to url and asserts HTTP 200. -func assertURLAccessible(t *testing.T, ctx context.Context, url, label string) { - t.Helper() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - t.Errorf("%s: build request: %v", label, err) - return - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Errorf("%s: GET error: %v", label, err) - return - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Errorf("%s: status %d, want 200", label, resp.StatusCode) - return - } - t.Logf("%s: HTTP 200 OK", label) -} - -// assertURLAccessibleWithRetry retries GET url up to maxAttempts times with -// interval between attempts, asserting HTTP 200 on any success. -func assertURLAccessibleWithRetry(t *testing.T, ctx context.Context, url, label string, maxAttempts int, interval time.Duration) { - t.Helper() - var lastStatus int - for attempt := 1; attempt <= maxAttempts; attempt++ { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - t.Errorf("%s: build request: %v", label, err) - return - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Logf("%s: attempt %d GET error: %v", label, attempt, err) - } else { - resp.Body.Close() - lastStatus = resp.StatusCode - if resp.StatusCode == http.StatusOK { - t.Logf("%s: HTTP 200 OK (attempt %d)", label, attempt) - return - } - t.Logf("%s: attempt %d status %d", label, attempt, resp.StatusCode) - } - if attempt < maxAttempts { - select { - case <-ctx.Done(): - t.Errorf("%s: context cancelled before success", label) - return - case <-time.After(interval): - } - } - } - t.Errorf("%s: status %d after %d attempts, want 200", label, lastStatus, maxAttempts) -} - -// ─── stdlib helpers ─────────────────────────────────────────────────────────── - -func chapterNumbers(refs []scraper.ChapterRef) []int { - ns := make([]int, len(refs)) - for i, r := range refs { - ns[i] = r.Number - } - return ns -} - -// scrapeChapterListPage1 fetches a single chapter-list page URL via Browserless -// and returns the chapter refs found on that page (no pagination). -// URL should be: https://novelfire.net/book/{slug}/chapters?page=1 -func scrapeChapterListPage1(ctx context.Context, f *e2eFixture, pageURL string) ([]scraper.ChapterRef, error) { - return f.sc.ScrapeChapterListPage(ctx, pageURL) -} diff --git a/scraper/internal/novelfire/integration_test.go b/scraper/internal/novelfire/integration_test.go deleted file mode 100644 index 39f821a..0000000 --- a/scraper/internal/novelfire/integration_test.go +++ /dev/null @@ -1,346 +0,0 @@ -//go:build integration - -// Integration tests for the novelfire.net Scraper against a live Browserless instance. -// -// These tests exercise the full scraping stack — Browserless → raw HTML → -// novelfire HTML parser — for the book: -// -// https://novelfire.net/book/a-dragon-against-the-whole-world -// -// They are gated behind the "integration" build tag so they never run in a -// normal `go test ./...` pass. -// -// Run with: -// -// BROWSERLESS_URL=http://localhost:3030 \ -// BROWSERLESS_TOKEN=your-token \ # omit if auth is disabled -// go test -v -tags integration -timeout 600s \ -// github.com/libnovel/scraper/internal/novelfire -package novelfire - -import ( - "context" - "fmt" - "log/slog" - "os" - "strings" - "testing" - "time" - - "github.com/libnovel/scraper/internal/browser" - "github.com/libnovel/scraper/internal/scraper" -) - -const ( - integrationBookURL = "https://novelfire.net/book/a-dragon-against-the-whole-world" - integrationBookSlug = "a-dragon-against-the-whole-world" - integrationBookTitle = "A Dragon against the Whole World" -) - -// newIntegrationScraper reads BROWSERLESS_URL / BROWSERLESS_TOKEN from the -// environment, constructs a real contentClient, and returns a novelfire Scraper -// wired to it. The test is skipped when BROWSERLESS_URL is not set. -func newIntegrationScraper(t *testing.T) *Scraper { - t.Helper() - baseURL := os.Getenv("BROWSERLESS_URL") - if baseURL == "" { - t.Skip("BROWSERLESS_URL not set — skipping integration test") - } - client := browser.NewContentClient(browser.Config{ - BaseURL: baseURL, - Token: os.Getenv("BROWSERLESS_TOKEN"), - Timeout: 120 * time.Second, - MaxConcurrent: 1, - }) - log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) - return New(client, log, client, nil, nil) -} - -// ── Metadata ────────────────────────────────────────────────────────────────── - -// TestIntegration_Novelfire_ScrapeMetadata_ReturnsTitle verifies that -// ScrapeMetadata fetches the book page and correctly parses at minimum -// the slug, title, and source URL. -func TestIntegration_Novelfire_ScrapeMetadata_ReturnsTitle(t *testing.T) { - s := newIntegrationScraper(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - meta, err := s.ScrapeMetadata(ctx, integrationBookURL) - if err != nil { - t.Fatalf("ScrapeMetadata failed: %v", err) - } - - t.Logf("slug: %s", meta.Slug) - t.Logf("title: %s", meta.Title) - t.Logf("author: %s", meta.Author) - t.Logf("status: %s", meta.Status) - t.Logf("genres: %v", meta.Genres) - t.Logf("total_chapters: %d", meta.TotalChapters) - t.Logf("source_url: %s", meta.SourceURL) - - if meta.Slug != integrationBookSlug { - t.Errorf("slug = %q, want %q", meta.Slug, integrationBookSlug) - } - if meta.Title == "" { - t.Error("title is empty") - } - if !strings.EqualFold(meta.Title, integrationBookTitle) { - // Warn rather than hard-fail — the site may reword the title. - t.Logf("WARN: title = %q, expected something like %q", meta.Title, integrationBookTitle) - } - if meta.SourceURL != integrationBookURL { - t.Errorf("source_url = %q, want %q", meta.SourceURL, integrationBookURL) - } -} - -// TestIntegration_Novelfire_ScrapeMetadata_ReturnsFullFields verifies that -// every optional field (author, status, genres, summary, total_chapters) is -// populated. A missing field is a warning, not a hard failure, because the -// site may change its HTML structure. -func TestIntegration_Novelfire_ScrapeMetadata_ReturnsFullFields(t *testing.T) { - s := newIntegrationScraper(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - meta, err := s.ScrapeMetadata(ctx, integrationBookURL) - if err != nil { - t.Fatalf("ScrapeMetadata failed: %v", err) - } - - type check struct { - field string - empty bool - } - checks := []check{ - {"author", meta.Author == ""}, - {"status", meta.Status == ""}, - {"summary", meta.Summary == ""}, - {"genres", len(meta.Genres) == 0}, - {"total_chapters", meta.TotalChapters == 0}, - } - for _, c := range checks { - if c.empty { - t.Errorf("field %q is empty — HTML selector may have broken", c.field) - } - } - - // total_chapters must be a positive integer. - if meta.TotalChapters < 1 { - t.Errorf("total_chapters = %d, want >= 1", meta.TotalChapters) - } -} - -// ── Chapter list ────────────────────────────────────────────────────────────── - -// TestIntegration_Novelfire_ScrapeChapterList_ReturnsRefs verifies that -// ScrapeChapterList returns a non-empty slice of chapter references with -// valid URLs and numbers parsed from those URLs (not list position). -func TestIntegration_Novelfire_ScrapeChapterList_ReturnsRefs(t *testing.T) { - s := newIntegrationScraper(t) - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - refs, err := s.ScrapeChapterList(ctx, integrationBookURL) - if err != nil { - t.Fatalf("ScrapeChapterList failed: %v", err) - } - - t.Logf("total refs returned: %d", len(refs)) - - if len(refs) == 0 { - t.Fatal("ScrapeChapterList returned 0 refs") - } - - // Every ref must have a non-empty URL pointing at the correct book. - for i, ref := range refs { - if ref.URL == "" { - t.Errorf("refs[%d].URL is empty", i) - } - if !strings.Contains(ref.URL, integrationBookSlug) { - t.Errorf("refs[%d].URL %q does not contain book slug", i, ref.URL) - } - if ref.Number <= 0 { - t.Errorf("refs[%d].Number = %d, want > 0 (URL: %s)", i, ref.Number, ref.URL) - } - if ref.Title == "" { - t.Errorf("refs[%d].Title is empty (URL: %s)", i, ref.URL) - } - } -} - -// TestIntegration_Novelfire_ScrapeChapterList_NumbersMatchURLs verifies the -// fix for the newest-first ordering bug: each ref's Number must equal the -// chapter number embedded in its URL, not its position in the list. -func TestIntegration_Novelfire_ScrapeChapterList_NumbersMatchURLs(t *testing.T) { - s := newIntegrationScraper(t) - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - refs, err := s.ScrapeChapterList(ctx, integrationBookURL) - if err != nil { - t.Fatalf("ScrapeChapterList failed: %v", err) - } - if len(refs) == 0 { - t.Fatal("ScrapeChapterList returned 0 refs") - } - - mismatches := 0 - for i, ref := range refs { - wantNum := chapterNumberFromURL(ref.URL) - if wantNum <= 0 { - // URL has no parseable number — skip this entry. - continue - } - if ref.Number != wantNum { - t.Errorf("refs[%d]: Number=%d but URL %q implies number=%d (position-based bug?)", - i, ref.Number, ref.URL, wantNum) - mismatches++ - if mismatches >= 5 { - t.Log("… (further mismatches suppressed)") - break - } - } - } - - // Log the first few refs so failures are easy to diagnose. - limit := 5 - if len(refs) < limit { - limit = len(refs) - } - for i := 0; i < limit; i++ { - t.Logf("refs[%d]: Number=%d Title=%q URL=%s", i, refs[i].Number, refs[i].Title, refs[i].URL) - } -} - -// ── Chapters ────────────────────────────────────────────────────────────────── - -// TestIntegration_Novelfire_ScrapeFirst3Chapters scrapes chapters 1, 2, and 3 -// via ScrapeChapterText and verifies each returns non-empty markdown text. -// Chapters are run as sub-tests so a single failure does not abort the others. -func TestIntegration_Novelfire_ScrapeFirst3Chapters(t *testing.T) { - s := newIntegrationScraper(t) - - chapters := []scraper.ChapterRef{ - { - Number: 1, - Title: "Chapter 1", - URL: integrationBookURL + "/chapter-1", - }, - { - Number: 2, - Title: "Chapter 2", - URL: integrationBookURL + "/chapter-2", - }, - { - Number: 3, - Title: "Chapter 3", - URL: integrationBookURL + "/chapter-3", - }, - } - - for _, ref := range chapters { - ref := ref // capture - t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) { - // Sequential: each chapter needs its own generous timeout. - ctx, cancel := context.WithTimeout(context.Background(), 110*time.Second) - defer cancel() - - ch, err := s.ScrapeChapterText(ctx, ref) - if err != nil { - t.Fatalf("ScrapeChapterText failed: %v", err) - } - - t.Logf("chapter %d: %d bytes of markdown", ref.Number, len(ch.Text)) - t.Logf("first 300 chars:\n%s", truncateStr(ch.Text, 300)) - - // Ref fields must be echoed back unchanged. - if ch.Ref.Number != ref.Number { - t.Errorf("Ref.Number = %d, want %d", ch.Ref.Number, ref.Number) - } - if ch.Ref.URL != ref.URL { - t.Errorf("Ref.URL = %q, want %q", ch.Ref.URL, ref.URL) - } - - // Text must be non-trivially long. - if len(ch.Text) < 100 { - t.Errorf("Text too short (%d bytes) — likely empty or parsing failed:\n%s", - len(ch.Text), ch.Text) - } - - // Text must not contain raw HTML tags — NodeToMarkdown should have - // stripped them. - for _, tag := range []string{"<div", "<span", "<script", "<style"} { - if strings.Contains(ch.Text, tag) { - t.Errorf("Text contains raw HTML tag %q — markdown conversion may be broken", tag) - } - } - }) - } -} - -// TestIntegration_Novelfire_ScrapeFirst3Chapters_FromList is the end-to-end -// variant: it first calls ScrapeChapterList to get the real refs (with -// URL-derived numbers), then scrapes chapters 1–3 using those refs. -// This catches any discrepancy between the list and the chapter URLs. -func TestIntegration_Novelfire_ScrapeFirst3Chapters_FromList(t *testing.T) { - s := newIntegrationScraper(t) - - // Step 1: fetch the chapter list. - listCtx, listCancel := context.WithTimeout(context.Background(), 60*time.Second) - defer listCancel() - - refs, err := s.ScrapeChapterList(listCtx, integrationBookURL) - if err != nil { - t.Fatalf("ScrapeChapterList failed: %v", err) - } - if len(refs) == 0 { - t.Fatal("ScrapeChapterList returned 0 refs") - } - - // Build a map number→ref for fast lookup. - byNumber := make(map[int]scraper.ChapterRef, len(refs)) - for _, r := range refs { - byNumber[r.Number] = r - } - - // Step 2: scrape chapters 1, 2, 3. - for _, wantNum := range []int{1, 2, 3} { - wantNum := wantNum - ref, ok := byNumber[wantNum] - if !ok { - t.Errorf("chapter %d not found in chapter list (list has %d entries)", wantNum, len(refs)) - continue - } - - t.Run(fmt.Sprintf("chapter-%d", wantNum), func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 110*time.Second) - defer cancel() - - ch, err := s.ScrapeChapterText(ctx, ref) - if err != nil { - t.Fatalf("ScrapeChapterText(chapter %d, %s) failed: %v", wantNum, ref.URL, err) - } - - t.Logf("chapter %d (%q): %d bytes", wantNum, ref.Title, len(ch.Text)) - t.Logf("first 300 chars:\n%s", truncateStr(ch.Text, 300)) - - if len(ch.Text) < 100 { - t.Errorf("chapter %d text too short (%d bytes)", wantNum, len(ch.Text)) - } - }) - } -} - -// ── helpers ─────────────────────────────────────────────────────────────────── - -func truncateStr(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "…" -} diff --git a/scraper/internal/novelfire/ranking_test.go b/scraper/internal/novelfire/ranking_test.go deleted file mode 100644 index 0299b19..0000000 --- a/scraper/internal/novelfire/ranking_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package novelfire - -import ( - "context" - "testing" - - "github.com/libnovel/scraper/internal/scraper" -) - -// rankingPage1HTML is a realistic mock of the popular genre listing page -// (novelfire.net/genre-all/sort-popular/status-all/all-novel?page=1). -// It uses the real novelfire.net DOM: <li class="novel-item"> cards with -// <h4 class="novel-title"> and a rel="next" pagination link. -func rankingPage1HTML() string { - return `<!DOCTYPE html> -<html><body> -<ul class="list-novel"> - <li class="novel-item"> - <a title="The Iron Throne" href="/book/the-iron-throne"> - <figure class="novel-cover"><img class="lazy" src="data:image/gif;base64,R0lG" data-src="/covers/iron-throne.jpg" alt="The Iron Throne"></figure> - <h4 class="novel-title text2row">The Iron Throne</h4> - </a> - <div class="novel-stats"><i class="icon-book-open"></i> 500 Chapters</div> - </li> - <li class="novel-item"> - <a title="Shadow Mage" href="/book/shadow-mage"> - <figure class="novel-cover"><img class="lazy" src="data:image/gif;base64,R0lG" data-src="/covers/shadow-mage.jpg" alt="Shadow Mage"></figure> - <h4 class="novel-title text2row">Shadow Mage</h4> - </a> - <div class="novel-stats"><i class="icon-book-open"></i> 200 Chapters</div> - </li> -</ul> -<ul class="pagination"> - <li class="page-item active"><span class="page-link">1</span></li> - <li class="page-item"><a class="page-link" href="/genre-all/sort-popular/status-all/all-novel?page=2" rel="next" aria-label="Next">›</a></li> -</ul> -</body></html>` -} - -func rankingPage2HTML() string { - return `<!DOCTYPE html> -<html><body> -<ul class="list-novel"> - <li class="novel-item"> - <a title="Void Hunter" href="/book/void-hunter"> - <figure class="novel-cover"><img class="lazy" src="data:image/gif;base64,R0lG" data-src="/covers/void-hunter.jpg" alt="Void Hunter"></figure> - <h4 class="novel-title text2row">Void Hunter</h4> - </a> - <div class="novel-stats"><i class="icon-book-open"></i> 100 Chapters</div> - </li> -</ul> -<!-- no rel="next" link → last page --> -<ul class="pagination"> - <li class="page-item"><a class="page-link" href="?page=1" rel="prev">‹</a></li> - <li class="page-item active"><span class="page-link">2</span></li> -</ul> -</body></html>` -} - -// drainRanking collects all entries from a ScrapeRanking call without -// deadlocking. It uses "for A != nil || B != nil" — nil channels are never -// selected, so setting one to nil effectively removes it from the select. -func drainRanking(t *testing.T, entryCh <-chan scraper.BookMeta, errCh <-chan error) []scraper.BookMeta { - t.Helper() - var entries []scraper.BookMeta - for entryCh != nil || errCh != nil { - select { - case meta, ok := <-entryCh: - if !ok { - entryCh = nil - } else { - entries = append(entries, meta) - } - case err, ok := <-errCh: - if !ok { - errCh = nil - } else if err != nil { - t.Fatalf("unexpected scrape error: %v", err) - } - } - } - return entries -} - -// TestScrapeRanking_SinglePage verifies a single page is parsed into entries -// with sequential Ranking numbers using a stub client. -// ScrapeRanking uses s.client (the main client, not urlClient) because the -// ranking page is fully server-rendered. -func TestScrapeRanking_SinglePage(t *testing.T) { - // newScraper passes the stub as s.client — exactly what ScrapeRanking uses. - s := newScraper(rankingPage1HTML()) - entryCh, errCh := s.ScrapeRanking(context.Background(), 1) - entries := drainRanking(t, entryCh, errCh) - - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - if entries[0].Ranking != 1 || entries[0].Title != "The Iron Throne" { - t.Errorf("entry[0]: got rank=%d title=%q, want rank=1 title=%q", - entries[0].Ranking, entries[0].Title, "The Iron Throne") - } - if entries[1].Ranking != 2 || entries[1].Title != "Shadow Mage" { - t.Errorf("entry[1]: got rank=%d title=%q, want rank=2 title=%q", - entries[1].Ranking, entries[1].Title, "Shadow Mage") - } -} - -// TestScrapeRanking_MultiPage verifies pagination across two pages yields -// contiguous rank numbers (1, 2, 3). -func TestScrapeRanking_MultiPage(t *testing.T) { - // Use pagedStubClient for s.client so each GetContent call returns the - // next page. ScrapeRanking now calls s.client directly. - urlClient := &pagedStubClient{pages: []string{rankingPage1HTML(), rankingPage2HTML()}} - s := New(urlClient, nil, nil, nil, nil) // nil cache — no disk I/O in tests - - entryCh, errCh := s.ScrapeRanking(context.Background(), 0) // 0 = all pages - entries := drainRanking(t, entryCh, errCh) - - if len(entries) != 3 { - t.Fatalf("expected 3 entries across 2 pages, got %d", len(entries)) - } - want := []struct { - rank int - title string - }{ - {1, "The Iron Throne"}, - {2, "Shadow Mage"}, - {3, "Void Hunter"}, - } - for i, w := range want { - if entries[i].Ranking != w.rank || entries[i].Title != w.title { - t.Errorf("entry[%d]: got rank=%d title=%q, want rank=%d title=%q", - i, entries[i].Ranking, entries[i].Title, w.rank, w.title) - } - } -} - -// TestScrapeRanking_EmptyPage verifies that a page with no .novel-item -// cards produces zero entries and closes channels cleanly (no deadlock). -func TestScrapeRanking_EmptyPage(t *testing.T) { - s := newScraper(`<!DOCTYPE html><html><body><div class="no-rankings"></div></body></html>`) - entryCh, errCh := s.ScrapeRanking(context.Background(), 1) - entries := drainRanking(t, entryCh, errCh) - - if len(entries) != 0 { - t.Errorf("expected 0 entries for empty page, got %d", len(entries)) - } -} diff --git a/scraper/internal/novelfire/scraper.go b/scraper/internal/novelfire/scraper.go deleted file mode 100644 index c03e86d..0000000 --- a/scraper/internal/novelfire/scraper.go +++ /dev/null @@ -1,730 +0,0 @@ -// Package novelfire provides a NovelScraper implementation for novelfire.net. -// -// Site structure (as of 2025): -// -// Catalogue : https://novelfire.net/genre-all/sort-new/status-all/all-novel?page=N -// Book page : https://novelfire.net/book/{slug} -// Chapters : https://novelfire.net/book/{slug}/chapters?page=N -// Chapter : https://novelfire.net/book/{slug}/{chapter-slug} -package novelfire - -import ( - "context" - "fmt" - "log/slog" - "net/url" - "path" - "strconv" - "strings" - "time" - - "github.com/libnovel/scraper/internal/browser" - "github.com/libnovel/scraper/internal/scraper" - "github.com/libnovel/scraper/internal/scraper/htmlutil" - "golang.org/x/net/html" -) - -const ( - baseURL = "https://novelfire.net" - cataloguePath = "/genre-all/sort-new/status-all/all-novel" - rankingPath = "/genre-all/sort-popular/status-all/all-novel" -) - -// RankingStore is the subset of storage.Store consumed by ScrapeRanking. -type RankingStore interface { - WriteRankingItem(ctx context.Context, item scraper.RankingItem) error - RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) -} - -// Scraper is the novelfire.net implementation of scraper.NovelScraper. -// It uses direct HTTP requests (no headless browser required). -type Scraper struct { - client browser.BrowserClient - urlClient browser.BrowserClient // used for chapter list pagination - chapterClient browser.BrowserClient // used for chapter text fetching - rankingStore RankingStore - log *slog.Logger -} - -// New returns a new novelfire Scraper. -// client is used for catalogue/metadata/ranking fetching (direct HTTP). -// urlClient is used for chapter list pagination; falls back to client if nil. -// chapterClient is used for chapter text fetching; falls back to client if nil. -// rankingStore is optional; pass nil to disable freshness checks and per-item persistence. -func New(client browser.BrowserClient, log *slog.Logger, urlClient browser.BrowserClient, chapterClient browser.BrowserClient, rankingStore RankingStore) *Scraper { - if log == nil { - log = slog.Default() - } - if urlClient == nil { - urlClient = client - } - if chapterClient == nil { - chapterClient = client - } - return &Scraper{client: client, urlClient: urlClient, chapterClient: chapterClient, rankingStore: rankingStore, log: log} -} - -// SourceName implements NovelScraper. -func (s *Scraper) SourceName() string { return "novelfire.net" } - -// ─── CatalogueProvider ─────────────────────────────────────────────────────── - -// ScrapeCatalogue streams all CatalogueEntry values across all pages. -func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan scraper.CatalogueEntry, <-chan error) { - entries := make(chan scraper.CatalogueEntry, 64) - errs := make(chan error, 16) - - go func() { - defer close(entries) - defer close(errs) - - pageURL := baseURL + cataloguePath - page := 1 - - for pageURL != "" { - select { - case <-ctx.Done(): - return - default: - } - - s.log.Info("scraping catalogue page", "page", page, "url", pageURL) - - html, err := s.client.GetContent(ctx, browser.ContentRequest{ - URL: pageURL, - }) - if err != nil { - s.log.Debug("catalogue page fetch failed", - "page", page, - "url", pageURL, - "err", err, - ) - errs <- fmt.Errorf("catalogue page %d: %w", page, err) - return - } - s.log.Debug("catalogue page fetch completed", - "page", page, - "url", pageURL, - "response_bytes", len(html), - ) - - root, err := htmlutil.ParseHTML(html) - if err != nil { - errs <- fmt.Errorf("catalogue page %d parse: %w", page, err) - return - } - - // Extract novel cards: <li class="novel-item"> - // <a href="/book/slug" title="Title"> - // <figure class="novel-cover"><img data-src="..."></figure> - // <h4 class="novel-title text2row">Title</h4> - // </a> - cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true}) - if len(cards) == 0 { - s.log.Warn("no novel cards found, stopping pagination", "page", page) - return - } - - for _, card := range cards { - // The outer <a> carries the href; <h4 class="novel-title"> has the title text. - linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a", Attr: "href"}) - titleNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"}) - - var title, href string - if linkNode != nil { - href = htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"}) - } - if titleNode != nil { - title = strings.TrimSpace(htmlutil.ExtractText(titleNode, scraper.Selector{})) - } - if href == "" || title == "" { - continue - } - - bookURL := resolveURL(baseURL, href) - select { - case <-ctx.Done(): - return - case entries <- scraper.CatalogueEntry{Title: title, URL: bookURL}: - } - } - - // Find next page link: <a rel="next" href="..."> (same structure as ranking pages) - if !hasNextPageLink(root) { - break - } - nextHref := "" - for _, a := range htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true}) { - if htmlutil.AttrVal(a, "rel") == "next" { - nextHref = htmlutil.AttrVal(a, "href") - break - } - } - if nextHref == "" { - break - } - pageURL = resolveURL(baseURL, nextHref) - page++ - } - }() - - return entries, errs -} - -// ─── MetadataProvider ──────────────────────────────────────────────────────── - -func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (scraper.BookMeta, error) { - s.log.Debug("metadata fetch starting", "url", bookURL) - - raw, err := s.client.GetContent(ctx, browser.ContentRequest{ - URL: bookURL, - }) - if err != nil { - s.log.Debug("metadata fetch failed", "url", bookURL, "err", err) - return scraper.BookMeta{}, fmt.Errorf("metadata fetch %s: %w", bookURL, err) - } - s.log.Debug("metadata fetch completed", "url", bookURL, "response_bytes", len(raw)) - - root, err := htmlutil.ParseHTML(raw) - if err != nil { - return scraper.BookMeta{}, fmt.Errorf("metadata parse %s: %w", bookURL, err) - } - - // <h1 class="novel-title">Title</h1> - title := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "h1", Class: "novel-title"}) - // <span class="author"><a>Author Name</a></span> - author := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "author"}) - // <figure class="cover"><img src="..."></figure> - var cover string - if figureCover := htmlutil.FindFirst(root, scraper.Selector{Tag: "figure", Class: "cover"}); figureCover != nil { - cover = htmlutil.ExtractFirst(figureCover, scraper.Selector{Tag: "img", Attr: "src"}) - if cover != "" && !strings.HasPrefix(cover, "http") { - cover = baseURL + cover - } - } - // <span class="status">Ongoing</span> - status := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "status"}) - - // Genres: all <a> tags inside <div class="genres"> - genresNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"}) - var genres []string - if genresNode != nil { - genres = htmlutil.ExtractAll(genresNode, scraper.Selector{Tag: "a", Multiple: true}) - } - - // <div class="summary"><p>...</p></div> - summary := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "div", Class: "summary"}) - // <span class="chapter-count">123 Chapters</span> - totalStr := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "chapter-count"}) - totalChapters := parseChapterCount(totalStr) - - slug := slugFromURL(bookURL) - - meta := scraper.BookMeta{ - Slug: slug, - Title: title, - Author: author, - Cover: cover, - Status: status, - Genres: genres, - Summary: summary, - TotalChapters: totalChapters, - SourceURL: bookURL, - } - s.log.Debug("metadata parsed", - "url", bookURL, - "slug", meta.Slug, - "title", meta.Title, - "author", meta.Author, - "status", meta.Status, - "genres", meta.Genres, - "total_chapters", meta.TotalChapters, - ) - return meta, nil -} - -// ─── ChapterListProvider ───────────────────────────────────────────────────── - -func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]scraper.ChapterRef, error) { - var refs []scraper.ChapterRef - // Chapter list URL: {bookURL}/chapters?page=N - baseChapterURL := strings.TrimRight(bookURL, "/") + "/chapters" - page := 1 - - for { - select { - case <-ctx.Done(): - return refs, ctx.Err() - default: - } - - pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page) - s.log.Info("scraping chapter list", "page", page, "url", pageURL) - - s.log.Debug("chapter list fetch starting", - "page", page, - "payload_url", pageURL, - "strategy", s.urlClient.Strategy(), - ) - - raw, err := s.urlClient.GetContent(ctx, browser.ContentRequest{ - URL: pageURL, - }) - if err != nil { - s.log.Debug("chapter list fetch failed", - "page", page, - "url", pageURL, - "err", err, - ) - return refs, fmt.Errorf("chapter list page %d: %w", page, err) - } - s.log.Debug("chapter list fetch completed", - "page", page, - "url", pageURL, - "response_bytes", len(raw), - ) - - root, err := htmlutil.ParseHTML(raw) - if err != nil { - return refs, fmt.Errorf("chapter list page %d parse: %w", page, err) - } - - chapterList := htmlutil.FindFirst(root, scraper.Selector{Class: "chapter-list"}) - if chapterList == nil { - // No chapter list container on this page — we've gone past the last page. - s.log.Debug("chapter list container not found, stopping pagination", "page", page) - break - } - - // Each chapter row: <li class="chapter-item"><a href="...">Title</a></li> - items := htmlutil.FindAll(chapterList, scraper.Selector{Tag: "li"}) - - s.log.Debug("chapter list page parsed", - "page", page, - "url", pageURL, - "chapters_on_page", len(items), - "total_refs_so_far", len(refs), - ) - - // Zero items on this page means we've gone past the last page. - if len(items) == 0 { - s.log.Debug("no chapters on page, stopping pagination", "page", page) - break - } - - for _, item := range items { - linkNode := htmlutil.FindFirst(item, scraper.Selector{Tag: "a"}) - if linkNode == nil { - continue - } - href := htmlutil.ExtractText(linkNode, scraper.Selector{Attr: "href"}) - chTitle := htmlutil.ExtractText(linkNode, scraper.Selector{}) - if href == "" { - continue - } - chURL := resolveURL(baseURL, href) - num := chapterNumberFromURL(chURL) - if num <= 0 { - // Fall back to position if the URL has no parseable number. - num = len(refs) + 1 - s.log.Warn("chapter number not parseable from URL, falling back to position", - "url", chURL, - "position", num, - ) - } - refs = append(refs, scraper.ChapterRef{ - Number: num, - Title: strings.TrimSpace(chTitle), - URL: chURL, - }) - } - - page++ - } - - return refs, nil -} - -// ScrapeChapterListPage fetches and parses a single chapter-list page URL and -// returns all chapter refs found on that page without following pagination. -// pageURL should be the full URL including query params, e.g.: -// -// https://novelfire.net/book/shadow-slave/chapters?page=1 -func (s *Scraper) ScrapeChapterListPage(ctx context.Context, pageURL string) ([]scraper.ChapterRef, error) { - s.log.Info("scraping chapter list page (single)", "url", pageURL) - - raw, err := s.urlClient.GetContent(ctx, browser.ContentRequest{ - URL: pageURL, - }) - if err != nil { - return nil, fmt.Errorf("chapter list page fetch: %w", err) - } - - root, err := htmlutil.ParseHTML(raw) - if err != nil { - return nil, fmt.Errorf("chapter list page parse: %w", err) - } - - chapterList := htmlutil.FindFirst(root, scraper.Selector{Class: "chapter-list"}) - if chapterList == nil { - return nil, fmt.Errorf("chapter list container not found in %s", pageURL) - } - - items := htmlutil.FindAll(chapterList, scraper.Selector{Tag: "li"}) - var refs []scraper.ChapterRef - for _, item := range items { - linkNode := htmlutil.FindFirst(item, scraper.Selector{Tag: "a"}) - if linkNode == nil { - continue - } - href := htmlutil.ExtractText(linkNode, scraper.Selector{Attr: "href"}) - chTitle := htmlutil.ExtractText(linkNode, scraper.Selector{}) - if href == "" { - continue - } - chURL := resolveURL(baseURL, href) - num := chapterNumberFromURL(chURL) - if num <= 0 { - num = len(refs) + 1 - } - refs = append(refs, scraper.ChapterRef{ - Number: num, - Title: strings.TrimSpace(chTitle), - URL: chURL, - }) - } - return refs, nil -} - -// ─── RankingProvider ─────────────────────────────────────────────────────────── - -// hasNextPageLink returns true if the HTML document contains a pagination link -// with rel="next". novelfire.net uses: -// -// <a class="page-link" href="...?page=N" rel="next" ...> -func hasNextPageLink(root *html.Node) bool { - links := htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true}) - for _, a := range links { - for _, attr := range a.Attr { - if attr.Key == "rel" && attr.Val == "next" { - return true - } - } - } - return false -} - -// ScrapeRanking pages through up to maxPages pages of the popular-novels genre -// listing on novelfire.net (/genre-all/sort-popular/status-all/all-novel). -// Pages are fetched one at a time, strictly sequentially. -// maxPages <= 0 means "fetch all pages until no more are found". -// -// If a RankingStore was provided and the stored ranking is fresh (< 24 hours old), -// both channels are closed immediately without any network traffic. -func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scraper.BookMeta, <-chan error) { - entries := make(chan scraper.BookMeta, 32) - errs := make(chan error, 16) - - go func() { - defer close(entries) - defer close(errs) - - // Freshness check: skip scraping if data is recent enough. - if s.rankingStore != nil { - fresh, err := s.rankingStore.RankingFreshEnough(ctx, 24*time.Hour) - if err != nil { - s.log.Warn("ranking freshness check failed, proceeding with scrape", "err", err) - } else if fresh { - s.log.Info("ranking data is fresh, skipping scrape") - return - } - } - - rank := 1 - - for page := 1; maxPages <= 0 || page <= maxPages; page++ { - select { - case <-ctx.Done(): - return - default: - } - - pageURL := fmt.Sprintf("%s%s?page=%d", baseURL, rankingPath, page) - - s.log.Info("scraping popular ranking page", "page", page, "url", pageURL) - raw, err := s.client.GetContent(ctx, browser.ContentRequest{ - URL: pageURL, - }) - if err != nil { - s.log.Debug("ranking page fetch failed", "page", page, "url", pageURL, "err", err) - errs <- fmt.Errorf("ranking page %d: %w", page, err) - return - } - - root, err := htmlutil.ParseHTML(raw) - if err != nil { - errs <- fmt.Errorf("ranking page %d parse: %w", page, err) - return - } - - // Real novelfire.net popular listing structure: - // <li class="novel-item"> - // <a href="/book/slug" title="Title"> - // <figure class="novel-cover"><img data-src="..."></figure> - // <h4 class="novel-title text2row">Title</h4> - // </a> - // </li> - cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true}) - if len(cards) == 0 { - s.log.Debug("no novel cards found, stopping pagination", "page", page) - break - } - - for _, card := range cards { - // The outer <a> carries the href and title attribute. - linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a"}) - if linkNode == nil { - continue - } - href := htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"}) - bookURL := resolveURL(baseURL, href) - if bookURL == "" { - continue - } - - // Title: prefer <h4 class="novel-title"> text; fall back to <a title="..."> - title := strings.TrimSpace(htmlutil.ExtractFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"})) - if title == "" { - title = strings.TrimSpace(htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "title"})) - } - if title == "" { - continue - } - - // Cover: <figure class="novel-cover"><img data-src="..."> - var cover string - if fig := htmlutil.FindFirst(card, scraper.Selector{Tag: "figure", Class: "novel-cover"}); fig != nil { - cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "data-src"}) - if cover == "" { - cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "src"}) - } - // Filter out base64 placeholder images. - if strings.HasPrefix(cover, "data:") { - cover = "" - } - if cover != "" && !strings.HasPrefix(cover, "http") { - cover = baseURL + cover - } - } - - bookSlug := slugFromURL(bookURL) - - meta := scraper.BookMeta{ - Slug: bookSlug, - Title: title, - Cover: cover, - SourceURL: bookURL, - Ranking: rank, - } - rank++ - - // Persist item to store immediately. - if s.rankingStore != nil { - item := scraper.RankingItem{ - Rank: meta.Ranking, - Slug: meta.Slug, - Title: meta.Title, - Cover: meta.Cover, - SourceURL: meta.SourceURL, - } - if werr := s.rankingStore.WriteRankingItem(ctx, item); werr != nil { - s.log.Warn("ranking item write failed", "slug", meta.Slug, "err", werr) - } - } - - select { - case <-ctx.Done(): - return - case entries <- meta: - } - } - - // Stop if no next-page link exists. - // The real pagination uses <a rel="next" ...> inside .pagination. - if !hasNextPageLink(root) { - s.log.Debug("no next-page link found, stopping pagination", "page", page) - break - } - } - }() - - return entries, errs -} - -// ─── ChapterTextProvider ───────────────────────────────────────────────────── - -// retryGetContent calls client.GetContent up to maxAttempts times, backing off -// exponentially between retries. Only errors that look like transient Browserless -// failures (timeouts, 5xx responses) are retried; context cancellation and -// permanent errors are returned immediately. -func retryGetContent( - ctx context.Context, - log *slog.Logger, - client browser.BrowserClient, - req browser.ContentRequest, - maxAttempts int, - baseDelay time.Duration, -) (string, error) { - var lastErr error - delay := baseDelay - for attempt := 1; attempt <= maxAttempts; attempt++ { - html, err := client.GetContent(ctx, req) - if err == nil { - return html, nil - } - lastErr = err - - // Stop immediately on context cancellation. - if ctx.Err() != nil { - return "", err - } - - if attempt < maxAttempts { - log.Warn("chapter fetch failed, retrying", - "url", req.URL, - "attempt", attempt, - "max_attempts", maxAttempts, - "retry_in", delay, - "err", err, - ) - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(delay): - } - delay *= 2 - } - } - return "", lastErr -} - -func (s *Scraper) ScrapeChapterText(ctx context.Context, ref scraper.ChapterRef) (scraper.Chapter, error) { - s.log.Debug("chapter text fetch starting", - "chapter", ref.Number, - "title", ref.Title, - "payload_url", ref.URL, - "payload_wait_selector", "#content", - "payload_wait_selector_timeout_ms", 5000, - ) - - raw, err := retryGetContent(ctx, s.log, s.chapterClient, browser.ContentRequest{ - URL: ref.URL, - }, 9, 6*time.Second) - if err != nil { - s.log.Debug("chapter text fetch failed", - "chapter", ref.Number, - "url", ref.URL, - "err", err, - ) - return scraper.Chapter{}, fmt.Errorf("chapter %d fetch: %w", ref.Number, err) - } - if len(raw) > 0 { - preview := raw - if len(preview) > 500 { - preview = preview[:500] - } - s.log.Debug("chapter text fetch partial content", - "chapter", ref.Number, - "url", ref.URL, - "response_bytes", len(raw), - "preview", preview, - ) - } - s.log.Debug("chapter text fetch completed", - "chapter", ref.Number, - "url", ref.URL, - "response_bytes", len(raw), - ) - - root, err := htmlutil.ParseHTML(raw) - if err != nil { - return scraper.Chapter{}, fmt.Errorf("chapter %d parse: %w", ref.Number, err) - } - - // <div id="content">…</div> - container := htmlutil.FindFirst(root, scraper.Selector{ID: "content"}) - if container == nil { - return scraper.Chapter{}, fmt.Errorf("chapter %d: #content container not found in %s", ref.Number, ref.URL) - } - - text := htmlutil.NodeToMarkdown(container) - - s.log.Debug("chapter text parsed", - "chapter", ref.Number, - "url", ref.URL, - "text_bytes", len(text), - ) - - return scraper.Chapter{ - Ref: ref, - Text: text, - }, nil -} - -// ─── helpers ───────────────────────────────────────────────────────────────── - -// resolveURL is a thin alias over htmlutil.ResolveURL kept for readability. -func resolveURL(base, href string) string { return htmlutil.ResolveURL(base, href) } - -func slugFromURL(bookURL string) string { - u, err := url.Parse(bookURL) - if err != nil { - return bookURL - } - parts := strings.Split(strings.Trim(u.Path, "/"), "/") - if len(parts) >= 2 && parts[0] == "book" { - return parts[1] - } - if len(parts) > 0 { - return parts[len(parts)-1] - } - return "" -} - -func parseChapterCount(s string) int { - // Formats: "123 Chapters", "1,234 Chapters", "123" - s = strings.ReplaceAll(s, ",", "") - fields := strings.Fields(s) - if len(fields) == 0 { - return 0 - } - n, _ := strconv.Atoi(fields[0]) - return n -} - -// chapterNumberFromURL extracts the chapter number from a novelfire chapter URL. -// -// URL pattern: https://novelfire.net/book/{book-slug}/chapter-{N} -// The last path segment is expected to be "chapter-{N}" or "{N}". -// Returns 0 if no number can be parsed. -func chapterNumberFromURL(chapterURL string) int { - u, err := url.Parse(chapterURL) - if err != nil { - return 0 - } - seg := path.Base(u.Path) // e.g. "chapter-42" or "42" - // Strip a "chapter-" prefix if present. - seg = strings.TrimPrefix(seg, "chapter-") - // Also handle "chap-", "ch-" variants used by some sites. - seg = strings.TrimPrefix(seg, "chap-") - seg = strings.TrimPrefix(seg, "ch-") - // Take only the leading digits (handles slugs like "42-title-text"). - digits := strings.FieldsFunc(seg, func(r rune) bool { - return r < '0' || r > '9' - }) - if len(digits) == 0 { - return 0 - } - n, _ := strconv.Atoi(digits[0]) - return n -} diff --git a/scraper/internal/novelfire/scraper_test.go b/scraper/internal/novelfire/scraper_test.go deleted file mode 100644 index 1f46f36..0000000 --- a/scraper/internal/novelfire/scraper_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package novelfire - -import ( - "context" - "strings" - "testing" - - "github.com/libnovel/scraper/internal/browser" - "github.com/libnovel/scraper/internal/scraper" -) - -// ── stub browser client ─────────────────────────────────────────────────────── - -// stubClient is a BrowserClient that returns a fixed HTML string for every -// GetContent call. ScrapePage and CDPSession are not used by these tests. -type stubClient struct { - html string -} - -func (s *stubClient) Strategy() browser.Strategy { return browser.StrategyContent } - -func (s *stubClient) GetContent(_ context.Context, _ browser.ContentRequest) (string, error) { - return s.html, nil -} - -func (s *stubClient) ScrapePage(_ context.Context, _ browser.ScrapeRequest) (browser.ScrapeResponse, error) { - return browser.ScrapeResponse{}, nil -} - -func (s *stubClient) CDPSession(_ context.Context, _ string, _ browser.CDPSessionFunc) error { - return nil -} - -// pagedStubClient returns a different HTML response for each successive call. -// Once all pages are exhausted it returns an empty page (no chapter-list), -// simulating the paginated chapter-list endpoint terminating correctly. -type pagedStubClient struct { - pages []string - call int -} - -func (c *pagedStubClient) Strategy() browser.Strategy { return browser.StrategyContent } - -func (c *pagedStubClient) GetContent(_ context.Context, _ browser.ContentRequest) (string, error) { - if c.call < len(c.pages) { - html := c.pages[c.call] - c.call++ - return html, nil - } - // Past the last page — return a page with no chapter-list to stop pagination. - return `<!DOCTYPE html><html><body><div class="no-content"></div></body></html>`, nil -} - -func (c *pagedStubClient) ScrapePage(_ context.Context, _ browser.ScrapeRequest) (browser.ScrapeResponse, error) { - return browser.ScrapeResponse{}, nil -} - -func (c *pagedStubClient) CDPSession(_ context.Context, _ string, _ browser.CDPSessionFunc) error { - return nil -} - -// ── helpers ─────────────────────────────────────────────────────────────────── - -func newScraper(html string) *Scraper { - return New(&stubClient{html: html}, nil, &stubClient{html: html}, nil, nil) -} - -func newPagedScraper(pages ...string) *Scraper { - urlClient := &pagedStubClient{pages: pages} - return New(&stubClient{}, nil, urlClient, nil, nil) -} - -// ── ScrapeChapterText ───────────────────────────────────────────────────────── - -func TestScrapeChapterText_ExtractsInnerText(t *testing.T) { - html := `<!DOCTYPE html><html><body> - <div id="content"> - <p>It was a dark and stormy night.</p> - <p>The hero stepped forward.</p> - </div> - </body></html>` - - s := newScraper(html) - ref := scraper.ChapterRef{Number: 1, Title: "Chapter 1", URL: "https://novelfire.net/book/test-novel/chapter-1"} - - ch, err := s.ScrapeChapterText(context.Background(), ref) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ch.Ref.Number != 1 { - t.Errorf("expected chapter number 1, got %d", ch.Ref.Number) - } - if !strings.Contains(ch.Text, "dark and stormy") { - t.Errorf("expected chapter text to contain 'dark and stormy', got: %q", ch.Text) - } - if !strings.Contains(ch.Text, "hero stepped forward") { - t.Errorf("expected chapter text to contain 'hero stepped forward', got: %q", ch.Text) - } -} - -func TestScrapeChapterText_MissingContainer(t *testing.T) { - html := `<!DOCTYPE html><html><body><div class="other">nothing here</div></body></html>` - - s := newScraper(html) - ref := scraper.ChapterRef{Number: 2, Title: "Chapter 2", URL: "https://novelfire.net/book/test-novel/chapter-2"} - - _, err := s.ScrapeChapterText(context.Background(), ref) - if err == nil { - t.Fatal("expected an error when #content container is missing, got nil") - } -} - -// ── chapterNumberFromURL ────────────────────────────────────────────────────── - -func TestChapterNumberFromURL(t *testing.T) { - cases := []struct { - url string - want int - }{ - // Standard novelfire pattern. - {"https://novelfire.net/book/a-dragon-against-the-whole-world/chapter-1", 1}, - {"https://novelfire.net/book/a-dragon-against-the-whole-world/chapter-26", 26}, - {"https://novelfire.net/book/a-dragon-against-the-whole-world/chapter-58", 58}, - // Large chapter numbers. - {"https://novelfire.net/book/some-novel/chapter-1000", 1000}, - // Path segment with trailing slash. - {"https://novelfire.net/book/some-novel/chapter-5/", 5}, - // Slug with title appended after the number (hypothetical future format). - {"https://novelfire.net/book/some-novel/chapter-42-the-battle", 42}, - // Unparseable — should return 0 so the caller can fall back. - {"https://novelfire.net/book/some-novel/prologue", 0}, - {"https://novelfire.net/book/some-novel/", 0}, - {"not-a-url", 0}, - } - - for _, tc := range cases { - got := chapterNumberFromURL(tc.url) - if got != tc.want { - t.Errorf("chapterNumberFromURL(%q) = %d, want %d", tc.url, got, tc.want) - } - } -} - -// ── ScrapeMetadata ──────────────────────────────────────────────────────────── - -func TestScrapeMetadata_ParsesFields(t *testing.T) { - html := `<!DOCTYPE html><html><body> - <h1 class="novel-title">The Iron Throne</h1> - <span class="author"><a>Jane Doe</a></span> - <figure class="cover"><img src="https://cdn.example.com/cover.jpg"></figure> - <span class="status">Ongoing</span> - <div class="genres"><a>Fantasy</a><a>Action</a></div> - <div class="summary"><p>A sweeping epic set in a magical world.</p></div> - <span class="chapter-count">42 Chapters</span> - </body></html>` - - s := newScraper(html) - meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/the-iron-throne") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if meta.Slug != "the-iron-throne" { - t.Errorf("Slug = %q, want %q", meta.Slug, "the-iron-throne") - } - if meta.Title != "The Iron Throne" { - t.Errorf("Title = %q, want %q", meta.Title, "The Iron Throne") - } - if meta.Author != "Jane Doe" { - t.Errorf("Author = %q, want %q", meta.Author, "Jane Doe") - } - if meta.Cover != "https://cdn.example.com/cover.jpg" { - t.Errorf("Cover = %q, want %q", meta.Cover, "https://cdn.example.com/cover.jpg") - } - if meta.Status != "Ongoing" { - t.Errorf("Status = %q, want %q", meta.Status, "Ongoing") - } - if len(meta.Genres) != 2 || meta.Genres[0] != "Fantasy" || meta.Genres[1] != "Action" { - t.Errorf("Genres = %v, want [Fantasy Action]", meta.Genres) - } - if !strings.Contains(meta.Summary, "sweeping epic") { - t.Errorf("Summary = %q, want it to contain 'sweeping epic'", meta.Summary) - } - if meta.TotalChapters != 42 { - t.Errorf("TotalChapters = %d, want 42", meta.TotalChapters) - } -} - -func TestScrapeMetadata_RelativeCoverURL(t *testing.T) { - html := `<!DOCTYPE html><html><body> - <h1 class="novel-title">Relative Cover</h1> - <figure class="cover"><img src="/images/cover.jpg"></figure> - </body></html>` - - s := newScraper(html) - meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/relative-cover") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // Relative cover URL should be resolved against the base domain. - if !strings.HasPrefix(meta.Cover, "https://novelfire.net") { - t.Errorf("Cover = %q, expected it to be resolved to an absolute URL", meta.Cover) - } -} - -func TestScrapeMetadata_MissingFields(t *testing.T) { - // Minimal page — everything absent; should succeed without panicking. - html := `<!DOCTYPE html><html><body></body></html>` - - s := newScraper(html) - meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/empty-novel") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if meta.Slug != "empty-novel" { - t.Errorf("Slug = %q, want %q", meta.Slug, "empty-novel") - } - if meta.TotalChapters != 0 { - t.Errorf("TotalChapters = %d, want 0 for missing chapter-count", meta.TotalChapters) - } -} - -// ── ScrapeChapterList (position vs URL numbering) ───────────────────────────── - -// TestScrapeChapterList_NumbersFromURL verifies that when the chapter list HTML -// is served newest-first (as novelfire.net does), chapter numbers are still -// assigned from the URL — not from list position — so that a re-run correctly -// identifies which chapters are already on disk. -func TestScrapeChapterList_NumbersFromURL(t *testing.T) { - // Simulate a newest-first chapter list with 5 chapters on a single page. - // Positions 1..5 correspond to chapters 5,4,3,2,1 in the site HTML. - page1 := `<!DOCTYPE html><html><body> - <ul class="chapter-list"> - <li class="chapter-item"><a href="/book/test/chapter-5">Chapter 5</a></li> - <li class="chapter-item"><a href="/book/test/chapter-4">Chapter 4</a></li> - <li class="chapter-item"><a href="/book/test/chapter-3">Chapter 3</a></li> - <li class="chapter-item"><a href="/book/test/chapter-2">Chapter 2</a></li> - <li class="chapter-item"><a href="/book/test/chapter-1">Chapter 1</a></li> - </ul> - </body></html>` - - s := newPagedScraper(page1) - refs, err := s.ScrapeChapterList(context.Background(), "https://novelfire.net/book/test") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(refs) != 5 { - t.Fatalf("expected 5 refs, got %d", len(refs)) - } - - // With position-based numbering (the old bug), refs[0].Number would be 1 - // even though its URL is /chapter-5. With URL-based numbering it must be 5. - wantNumbers := []int{5, 4, 3, 2, 1} - for i, ref := range refs { - if ref.Number != wantNumbers[i] { - t.Errorf("refs[%d].Number = %d, want %d (URL: %s)", i, ref.Number, wantNumbers[i], ref.URL) - } - } -} - -// TestScrapeChapterList_Pagination verifies that the scraper correctly follows -// ?page=N pagination and stops when a page returns no chapter items. -func TestScrapeChapterList_Pagination(t *testing.T) { - page1 := `<!DOCTYPE html><html><body> - <ul class="chapter-list"> - <li class="chapter-item"><a href="/book/test/chapter-3">Chapter 3</a></li> - <li class="chapter-item"><a href="/book/test/chapter-2">Chapter 2</a></li> - <li class="chapter-item"><a href="/book/test/chapter-1">Chapter 1</a></li> - </ul> - </body></html>` - - page2 := `<!DOCTYPE html><html><body> - <ul class="chapter-list"> - <li class="chapter-item"><a href="/book/test/chapter-6">Chapter 6</a></li> - <li class="chapter-item"><a href="/book/test/chapter-5">Chapter 5</a></li> - <li class="chapter-item"><a href="/book/test/chapter-4">Chapter 4</a></li> - </ul> - </body></html>` - - // page3 is omitted — pagedStubClient will return empty page to stop pagination. - s := newPagedScraper(page1, page2) - refs, err := s.ScrapeChapterList(context.Background(), "https://novelfire.net/book/test") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(refs) != 6 { - t.Fatalf("expected 6 refs (3 per page × 2 pages), got %d", len(refs)) - } - - wantNumbers := []int{3, 2, 1, 6, 5, 4} - for i, ref := range refs { - if ref.Number != wantNumbers[i] { - t.Errorf("refs[%d].Number = %d, want %d (URL: %s)", i, ref.Number, wantNumbers[i], ref.URL) - } - } -} diff --git a/scraper/internal/orchestrator/orchestrator.go b/scraper/internal/orchestrator/orchestrator.go deleted file mode 100644 index 55b97d0..0000000 --- a/scraper/internal/orchestrator/orchestrator.go +++ /dev/null @@ -1,286 +0,0 @@ -// Package orchestrator coordinates the catalogue walk, metadata extraction, -// chapter-list fetching, and parallel chapter scraping. -// -// Concurrency model -// - One goroutine runs ScrapeCatalogue and feeds book URLs into a channel. -// - For each book, a dedicated goroutine calls ScrapeMetadata (metadata goroutine). -// - ScrapeChapterList is called in the metadata goroutine once metadata is done. -// - N worker goroutines (default: runtime.NumCPU()) each pull ChapterRef values -// from a shared work queue and call ScrapeChapterText. -// - A sync.WaitGroup ensures all chapter workers finish before the orchestrator -// signals completion. -package orchestrator - -import ( - "context" - "fmt" - "log/slog" - "runtime" - "sync" - "sync/atomic" - - "github.com/libnovel/scraper/internal/scraper" - "github.com/libnovel/scraper/internal/storage" -) - -// Progress is a snapshot of counters at a point in time. -type Progress struct { - BooksFound int - ChaptersScraped int - ChaptersSkipped int - Errors int -} - -// Config holds tunable parameters for the orchestrator. -type Config struct { - // Workers is the number of goroutines used to scrape chapters in parallel. - // Defaults to runtime.NumCPU() when 0. - Workers int - - // StaticRoot is kept for backwards-compatibility but is no longer used - // when a Store is provided. - StaticRoot string - - // SingleBookURL when non-empty causes the orchestrator to scrape only - // that one book instead of walking the full catalogue. - SingleBookURL string - - // FromChapter, when > 0, skips chapters with number < FromChapter. - // Only effective in single-book mode. - FromChapter int - - // ToChapter, when > 0, skips chapters with number > ToChapter. - // Only effective in single-book mode. 0 means "no upper limit". - ToChapter int - - // OnProgress is called periodically with the current progress counters. - // It is always called on completion (success or failure). May be nil. - OnProgress func(p Progress) -} - -// Orchestrator coordinates the full scrape pipeline. -type Orchestrator struct { - cfg Config - novel scraper.NovelScraper - store storage.Store - log *slog.Logger - workers int -} - -// New returns a new Orchestrator backed by the provided Store. -func New(cfg Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store) *Orchestrator { - workers := cfg.Workers - if workers <= 0 { - workers = runtime.NumCPU() - } - return &Orchestrator{ - cfg: cfg, - novel: novel, - store: store, - log: log, - workers: workers, - } -} - -// Run executes the full scrape pipeline and blocks until it is complete or ctx -// is cancelled. -func (o *Orchestrator) Run(ctx context.Context) error { - o.log.Info("orchestrator starting", - "source", o.novel.SourceName(), - "workers", o.workers, - ) - - // Atomic counters updated by concurrent goroutines. - var ( - booksFound atomic.Int64 - chaptersScraped atomic.Int64 - chaptersSkipped atomic.Int64 - errors atomic.Int64 - ) - - snapshot := func() Progress { - return Progress{ - BooksFound: int(booksFound.Load()), - ChaptersScraped: int(chaptersScraped.Load()), - ChaptersSkipped: int(chaptersSkipped.Load()), - Errors: int(errors.Load()), - } - } - - notify := func() { - if o.cfg.OnProgress != nil { - o.cfg.OnProgress(snapshot()) - } - } - - // chapterWork is the shared queue consumed by chapter worker goroutines. - type chapterJob struct { - slug string - ref scraper.ChapterRef - } - chapterWork := make(chan chapterJob, o.workers*4) - - // Start chapter worker pool. - var chapterWG sync.WaitGroup - for i := 0; i < o.workers; i++ { - chapterWG.Add(1) - go func(workerID int) { - defer chapterWG.Done() - for job := range chapterWork { - select { - case <-ctx.Done(): - return - default: - } - - // Skip if already stored. - if o.store.ChapterExists(ctx, job.slug, job.ref) { - o.log.Debug("chapter already exists, skipping", - "book", job.slug, "chapter", job.ref.Number) - chaptersSkipped.Add(1) - notify() - continue - } - - chapter, err := o.novel.ScrapeChapterText(ctx, job.ref) - if err != nil { - o.log.Error("chapter scrape failed", - "book", job.slug, - "chapter", job.ref.Number, - "url", job.ref.URL, - "err", err, - ) - errors.Add(1) - notify() - continue - } - - if err := o.store.WriteChapter(ctx, job.slug, chapter); err != nil { - o.log.Error("chapter write failed", - "book", job.slug, - "chapter", job.ref.Number, - "err", err, - ) - errors.Add(1) - notify() - continue - } - - chaptersScraped.Add(1) - notify() - o.log.Info("chapter saved", - "book", job.slug, - "chapter", job.ref.Number, - "worker", workerID, - ) - } - }(i) - } - - // processBook scrapes metadata + chapter list for one book, then enqueues - // chapter jobs. It is called inside a goroutine per book. - processBook := func(bookURL string) { - // Metadata goroutine. - meta, err := o.novel.ScrapeMetadata(ctx, bookURL) - if err != nil { - o.log.Error("metadata scrape failed", "url", bookURL, "err", err) - errors.Add(1) - notify() - return - } - - // Persist / update metadata. - if err := o.store.WriteMetadata(ctx, meta); err != nil { - o.log.Error("metadata write failed", "slug", meta.Slug, "err", err) - // Continue — chapters can still be scraped. - } - - booksFound.Add(1) - notify() - o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title) - - // Fetch chapter list. - refs, err := o.novel.ScrapeChapterList(ctx, bookURL) - if err != nil { - o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err) - errors.Add(1) - notify() - return - } - - o.log.Info("chapter list fetched", "slug", meta.Slug, "chapters", len(refs)) - - // Enqueue chapter jobs. - for _, ref := range refs { - // Apply chapter range filter (only in single-book mode when set). - if o.cfg.FromChapter > 0 && ref.Number < o.cfg.FromChapter { - chaptersSkipped.Add(1) - continue - } - if o.cfg.ToChapter > 0 && ref.Number > o.cfg.ToChapter { - chaptersSkipped.Add(1) - continue - } - select { - case <-ctx.Done(): - return - case chapterWork <- chapterJob{slug: meta.Slug, ref: ref}: - } - } - } - - if o.cfg.SingleBookURL != "" { - // Single-book mode: skip catalogue entirely. - o.log.Info("single-book mode", "url", o.cfg.SingleBookURL) - processBook(o.cfg.SingleBookURL) - } else { - // Catalogue mode: stream every book. - entries, catErrs := o.novel.ScrapeCatalogue(ctx) - - // Drain catalogue errors in a separate goroutine. - go func() { - for err := range catErrs { - o.log.Error("catalogue error", "err", err) - errors.Add(1) - notify() - } - }() - - var bookWG sync.WaitGroup - bookLoop: - for entry := range entries { - select { - case <-ctx.Done(): - break bookLoop - default: - } - - bookWG.Add(1) - bookURL := entry.URL - go func() { - defer bookWG.Done() - processBook(bookURL) - }() - } - - // Wait for all book goroutines to enqueue their chapters before - // closing the chapter work queue. - bookWG.Wait() - } - - // Signal chapter workers there is no more work. - close(chapterWork) - - // Wait for all in-flight chapter scrapes to finish. - chapterWG.Wait() - - // Final progress notification. - notify() - - if ctx.Err() != nil { - return fmt.Errorf("orchestrator: context cancelled: %w", ctx.Err()) - } - - o.log.Info("orchestrator finished") - return nil -} diff --git a/scraper/internal/orchestrator/orchestrator_test.go b/scraper/internal/orchestrator/orchestrator_test.go deleted file mode 100644 index 8d2324a..0000000 --- a/scraper/internal/orchestrator/orchestrator_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package orchestrator - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/libnovel/scraper/internal/scraper" - "github.com/libnovel/scraper/internal/storage" - "io" - "log/slog" -) - -// ── mock NovelScraper ───────────────────────────────────────────────────────── - -type mockScraper struct { - catalogue []scraper.CatalogueEntry - meta scraper.BookMeta - metaErr error - chapters []scraper.ChapterRef - chapterTextFn func(ref scraper.ChapterRef) (scraper.Chapter, error) -} - -func (m *mockScraper) SourceName() string { return "mock" } - -func (m *mockScraper) ScrapeCatalogue(_ context.Context) (<-chan scraper.CatalogueEntry, <-chan error) { - entries := make(chan scraper.CatalogueEntry, len(m.catalogue)) - errs := make(chan error, 1) - for _, e := range m.catalogue { - entries <- e - } - close(entries) - close(errs) - return entries, errs -} - -func (m *mockScraper) ScrapeMetadata(_ context.Context, _ string) (scraper.BookMeta, error) { - return m.meta, m.metaErr -} - -func (m *mockScraper) ScrapeChapterList(_ context.Context, _ string) ([]scraper.ChapterRef, error) { - return m.chapters, nil -} - -func (m *mockScraper) ScrapeChapterText(_ context.Context, ref scraper.ChapterRef) (scraper.Chapter, error) { - if m.chapterTextFn != nil { - return m.chapterTextFn(ref) - } - return scraper.Chapter{Ref: ref, Text: "stub text"}, nil -} - -func (m *mockScraper) ScrapeRanking(_ context.Context, _ int) (<-chan scraper.BookMeta, <-chan error) { - ch := make(chan scraper.BookMeta) - errs := make(chan error) - close(ch) - close(errs) - return ch, errs -} - -// ── mock Store ──────────────────────────────────────────────────────────────── - -// mockStore records which methods were called; only implements what the -// orchestrator touches. All other methods panic so unexpected calls surface -// as test failures rather than silent no-ops. -type mockStore struct { - mu sync.Mutex - writtenMeta []scraper.BookMeta - writtenChapters []scraper.Chapter - existingSlugs map[string]map[int]bool // slug → chapterNum → exists -} - -func newMockStore() *mockStore { - return &mockStore{existingSlugs: make(map[string]map[int]bool)} -} - -func (s *mockStore) ChapterExists(_ context.Context, slug string, ref scraper.ChapterRef) bool { - s.mu.Lock() - defer s.mu.Unlock() - if m, ok := s.existingSlugs[slug]; ok { - return m[ref.Number] - } - return false -} - -func (s *mockStore) WriteChapter(_ context.Context, slug string, ch scraper.Chapter) error { - s.mu.Lock() - defer s.mu.Unlock() - s.writtenChapters = append(s.writtenChapters, ch) - return nil -} - -func (s *mockStore) WriteChapterRefs(_ context.Context, _ string, _ []scraper.ChapterRef) error { - return nil -} - -func (s *mockStore) WriteMetadata(_ context.Context, meta scraper.BookMeta) error { - s.mu.Lock() - defer s.mu.Unlock() - s.writtenMeta = append(s.writtenMeta, meta) - return nil -} - -// Unimplemented Store methods — panic so accidental calls surface immediately. -func (s *mockStore) ReadMetadata(_ context.Context, _ string) (scraper.BookMeta, bool, error) { - panic("ReadMetadata not expected") -} -func (s *mockStore) ListBooks(_ context.Context) ([]scraper.BookMeta, error) { - panic("ListBooks not expected") -} -func (s *mockStore) LocalSlugs(_ context.Context) (map[string]bool, error) { - panic("LocalSlugs not expected") -} -func (s *mockStore) MetadataMtime(_ context.Context, _ string) int64 { return 0 } -func (s *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, error) { - panic("ReadChapter not expected") -} -func (s *mockStore) ListChapters(_ context.Context, _ string) ([]storage.ChapterInfo, error) { - panic("ListChapters not expected") -} -func (s *mockStore) CountChapters(_ context.Context, _ string) int { return 0 } -func (s *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { - panic("ReindexChapters not expected") -} -func (s *mockStore) WriteRankingItem(_ context.Context, _ storage.RankingItem) error { return nil } -func (s *mockStore) ReadRankingItems(_ context.Context) ([]storage.RankingItem, error) { - return nil, nil -} -func (s *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool, error) { - return false, nil -} -func (s *mockStore) GetAudioCache(_ context.Context, _ string) (string, bool) { return "", false } -func (s *mockStore) SetAudioCache(_ context.Context, _, _ string) error { return nil } -func (s *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil } -func (s *mockStore) GetProgress(_ context.Context, _, _ string) (storage.ReadingProgress, bool) { - return storage.ReadingProgress{}, false -} -func (s *mockStore) SetProgress(_ context.Context, _ string, _ storage.ReadingProgress) error { - return nil -} -func (s *mockStore) AllProgress(_ context.Context, _ string) ([]storage.ReadingProgress, error) { - return nil, nil -} -func (s *mockStore) DeleteProgress(_ context.Context, _, _ string) error { return nil } -func (s *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" } - -func (s *mockStore) AudioExists(_ context.Context, _ string) bool { return false } -func (s *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Duration) (string, error) { - return "", nil -} -func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) { - return "", nil -} -func (s *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, string, error) { - return "", "", nil -} -func (s *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) { - return "", false, nil -} -func (s *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil } -func (s *mockStore) SaveBrowsePage(_ context.Context, _, _ string) error { return nil } -func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) { - return "", false, nil -} -func (s *mockStore) BrowseHTMLKey(_ string, _ int) string { return "" } -func (s *mockStore) BrowseFilteredHTMLKey(_ string, _ int, _, _, _ string) string { return "" } -func (s *mockStore) BrowseCoverKey(_, _ string) string { return "" } -func (s *mockStore) SaveBrowseAsset(_ context.Context, _ string, _ []byte, _ string) error { - return nil -} -func (s *mockStore) GetBrowseAsset(_ context.Context, _ string) ([]byte, string, bool, error) { - return nil, "", false, nil -} -func (s *mockStore) CreateScrapeTask(_ context.Context, _, _ string) (string, error) { - return "task-id", nil -} -func (s *mockStore) UpdateScrapeTask(_ context.Context, _ string, _ storage.ScrapeTaskUpdate) error { - return nil -} -func (s *mockStore) ListScrapeTasks(_ context.Context) ([]storage.ScrapeTask, error) { - return nil, nil -} -func (s *mockStore) CreateAudioJob(_ context.Context, _ string, _ int, _ string) (string, error) { - return "audio-job-id", nil -} -func (s *mockStore) UpdateAudioJob(_ context.Context, _, _, _ string, _ time.Time) error { - return nil -} -func (s *mockStore) GetAudioJob(_ context.Context, _ string) (storage.AudioJob, bool, error) { - return storage.AudioJob{}, false, nil -} -func (s *mockStore) ListAudioJobs(_ context.Context) ([]storage.AudioJob, error) { - return nil, nil -} - -// ── helpers ─────────────────────────────────────────────────────────────────── - -func discardLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - -// ── tests ───────────────────────────────────────────────────────────────────── - -// TestRun_SingleBook verifies the happy-path single-book scrape: metadata is -// persisted and all chapters are written to the store. -func TestRun_SingleBook(t *testing.T) { - novel := &mockScraper{ - meta: scraper.BookMeta{Slug: "the-iron-throne", Title: "The Iron Throne"}, - chapters: []scraper.ChapterRef{ - {Number: 1, Title: "Chapter 1", URL: "https://example.com/book/ch-1"}, - {Number: 2, Title: "Chapter 2", URL: "https://example.com/book/ch-2"}, - {Number: 3, Title: "Chapter 3", URL: "https://example.com/book/ch-3"}, - }, - } - store := newMockStore() - - o := New(Config{Workers: 2, SingleBookURL: "https://example.com/book/the-iron-throne"}, novel, discardLogger(), store) - if err := o.Run(context.Background()); err != nil { - t.Fatalf("Run() returned error: %v", err) - } - - store.mu.Lock() - defer store.mu.Unlock() - - if len(store.writtenMeta) != 1 { - t.Errorf("writtenMeta count = %d, want 1", len(store.writtenMeta)) - } - if len(store.writtenChapters) != 3 { - t.Errorf("writtenChapters count = %d, want 3", len(store.writtenChapters)) - } -} - -// TestRun_SingleBook_SkipsExistingChapters verifies that chapters already in -// the store are not re-scraped. -func TestRun_SingleBook_SkipsExistingChapters(t *testing.T) { - novel := &mockScraper{ - meta: scraper.BookMeta{Slug: "test-novel", Title: "Test Novel"}, - chapters: []scraper.ChapterRef{ - {Number: 1, Title: "Chapter 1"}, - {Number: 2, Title: "Chapter 2"}, - }, - } - store := newMockStore() - // Mark chapter 1 as already existing. - store.existingSlugs["test-novel"] = map[int]bool{1: true} - - o := New(Config{Workers: 1, SingleBookURL: "https://example.com/book/test-novel"}, novel, discardLogger(), store) - if err := o.Run(context.Background()); err != nil { - t.Fatalf("Run() returned error: %v", err) - } - - store.mu.Lock() - defer store.mu.Unlock() - - // Only chapter 2 should have been written; chapter 1 was skipped. - if len(store.writtenChapters) != 1 { - t.Errorf("writtenChapters count = %d, want 1 (skipped ch1)", len(store.writtenChapters)) - } - if store.writtenChapters[0].Ref.Number != 2 { - t.Errorf("expected chapter 2 to be written, got chapter %d", store.writtenChapters[0].Ref.Number) - } -} - -// TestRun_CatalogueMode verifies that catalogue mode processes all books. -func TestRun_CatalogueMode(t *testing.T) { - novel := &mockScraper{ - catalogue: []scraper.CatalogueEntry{ - {Title: "Book A", URL: "https://example.com/book/a"}, - {Title: "Book B", URL: "https://example.com/book/b"}, - }, - meta: scraper.BookMeta{Slug: "book-slug", Title: "A Book"}, - chapters: []scraper.ChapterRef{{Number: 1, Title: "Chapter 1"}}, - } - store := newMockStore() - - o := New(Config{Workers: 2}, novel, discardLogger(), store) - if err := o.Run(context.Background()); err != nil { - t.Fatalf("Run() returned error: %v", err) - } - - store.mu.Lock() - defer store.mu.Unlock() - - // 2 books → 2 metadata writes, 2 chapter writes (one chapter per book). - if len(store.writtenMeta) != 2 { - t.Errorf("writtenMeta count = %d, want 2", len(store.writtenMeta)) - } - if len(store.writtenChapters) != 2 { - t.Errorf("writtenChapters count = %d, want 2", len(store.writtenChapters)) - } -} - -// TestRun_OnProgress_Called verifies that the OnProgress callback fires at -// least once upon completion. -func TestRun_OnProgress_Called(t *testing.T) { - novel := &mockScraper{ - meta: scraper.BookMeta{Slug: "progress-book", Title: "Progress Book"}, - chapters: []scraper.ChapterRef{{Number: 1, Title: "Chapter 1"}}, - } - store := newMockStore() - - var callCount int - o := New(Config{ - Workers: 1, - SingleBookURL: "https://example.com/book/progress-book", - OnProgress: func(_ Progress) { - callCount++ - }, - }, novel, discardLogger(), store) - - if err := o.Run(context.Background()); err != nil { - t.Fatalf("Run() returned error: %v", err) - } - if callCount == 0 { - t.Error("OnProgress was never called") - } -} - -// TestRun_ContextCancelled verifies that Run returns a non-nil error when the -// context is cancelled before work completes. -func TestRun_ContextCancelled(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() // cancel immediately - - novel := &mockScraper{ - meta: scraper.BookMeta{Slug: "cancel-book", Title: "Cancel Book"}, - chapters: []scraper.ChapterRef{{Number: 1}}, - } - store := newMockStore() - - o := New(Config{Workers: 1, SingleBookURL: "https://example.com/book/cancel-book"}, novel, discardLogger(), store) - err := o.Run(ctx) - if err == nil { - t.Error("expected non-nil error when context is cancelled, got nil") - } -} diff --git a/scraper/internal/scraper/htmlutil/htmlutil.go b/scraper/internal/scraper/htmlutil/htmlutil.go deleted file mode 100644 index 202cee8..0000000 --- a/scraper/internal/scraper/htmlutil/htmlutil.go +++ /dev/null @@ -1,249 +0,0 @@ -// Package htmlutil provides helper functions for parsing HTML with -// golang.org/x/net/html and extracting values by Selector descriptors. -package htmlutil - -import ( - "net/url" - "regexp" - "strings" - - "github.com/libnovel/scraper/internal/scraper" - "golang.org/x/net/html" -) - -// ResolveURL returns an absolute URL. If href is already absolute it is -// returned unchanged. Otherwise it is resolved against base using standard -// URL resolution (handles relative paths, absolute paths, etc.). -func ResolveURL(base, href string) string { - if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { - return href - } - b, err := url.Parse(base) - if err != nil { - return base + href - } - ref, err := url.Parse(href) - if err != nil { - return base + href - } - return b.ResolveReference(ref).String() -} - -// ParseHTML parses raw HTML and returns the root node. -func ParseHTML(raw string) (*html.Node, error) { - return html.Parse(strings.NewReader(raw)) -} - -// selectorMatches reports whether node n matches sel. -func selectorMatches(n *html.Node, sel scraper.Selector) bool { - if n.Type != html.ElementNode { - return false - } - if sel.Tag != "" && n.Data != sel.Tag { - return false - } - if sel.ID != "" { - for _, a := range n.Attr { - if a.Key == "id" && a.Val == sel.ID { - goto checkClass - } - } - return false - } -checkClass: - if sel.Class != "" { - for _, a := range n.Attr { - if a.Key == "class" { - for _, cls := range strings.Fields(a.Val) { - if cls == sel.Class { - goto matched - } - } - } - } - return false - } -matched: - return true -} - -// AttrVal returns the value of attribute key from node n. -func AttrVal(n *html.Node, key string) string { - for _, a := range n.Attr { - if a.Key == key { - return a.Val - } - } - return "" -} - -// attrVal is an unexported alias kept for internal use within this package. -func attrVal(n *html.Node, key string) string { return AttrVal(n, key) } - -// TextContent returns the concatenated text content of all descendant text nodes. -func TextContent(n *html.Node) string { - var sb strings.Builder - var walk func(*html.Node) - walk = func(cur *html.Node) { - if cur.Type == html.TextNode { - sb.WriteString(cur.Data) - } - for c := cur.FirstChild; c != nil; c = c.NextSibling { - walk(c) - } - } - walk(n) - return strings.TrimSpace(sb.String()) -} - -// textContent is an unexported alias kept for internal use within this package. -func textContent(n *html.Node) string { return TextContent(n) } - -// FindFirst returns the first node matching sel within root. -func FindFirst(root *html.Node, sel scraper.Selector) *html.Node { - var found *html.Node - var walk func(*html.Node) bool - walk = func(n *html.Node) bool { - if selectorMatches(n, sel) { - found = n - return true - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - if walk(c) { - return true - } - } - return false - } - walk(root) - return found -} - -// FindAll returns all nodes matching sel within root. -func FindAll(root *html.Node, sel scraper.Selector) []*html.Node { - var results []*html.Node - var walk func(*html.Node) - walk = func(n *html.Node) { - if selectorMatches(n, sel) { - results = append(results, n) - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - walk(c) - } - } - walk(root) - return results -} - -// ExtractText extracts a string value from node n using sel. -// If sel.Attr is set the attribute value is returned; otherwise the inner text. -func ExtractText(n *html.Node, sel scraper.Selector) string { - if sel.Attr != "" { - return attrVal(n, sel.Attr) - } - return textContent(n) -} - -// ExtractFirst locates the first match in root and returns its text/attr value. -func ExtractFirst(root *html.Node, sel scraper.Selector) string { - n := FindFirst(root, sel) - if n == nil { - return "" - } - return ExtractText(n, sel) -} - -// ExtractAll locates all matches in root and returns their text/attr values. -func ExtractAll(root *html.Node, sel scraper.Selector) []string { - nodes := FindAll(root, sel) - out := make([]string, 0, len(nodes)) - for _, n := range nodes { - if v := ExtractText(n, sel); v != "" { - out = append(out, v) - } - } - return out -} - -// InnerHTML returns the serialized inner HTML of node n. -func InnerHTML(n *html.Node) string { - var sb strings.Builder - for c := n.FirstChild; c != nil; c = c.NextSibling { - _ = html.Render(&sb, c) - } - return sb.String() -} - -// NodeToMarkdown converts the children of an HTML node to a plain-text/Markdown -// representation suitable for chapter storage. Block elements become newlines; -// inline elements are inlined. Runs of more than one blank line are collapsed -// to a single blank line. -func NodeToMarkdown(n *html.Node) string { - var sb strings.Builder - nodeToMD(n, &sb) - // Collapse 3+ consecutive newlines (i.e. more than one blank line) to 2. - out := multiBlankLine.ReplaceAllString(sb.String(), "\n\n") - return strings.TrimSpace(out) -} - -// multiBlankLine matches three or more consecutive newline characters -// (any mix of \n and surrounding whitespace-only lines). -var multiBlankLine = regexp.MustCompile(`\n(\s*\n){2,}`) - -var blockElements = map[string]bool{ - "p": true, "div": true, "br": true, "h1": true, "h2": true, - "h3": true, "h4": true, "h5": true, "h6": true, "li": true, - "blockquote": true, "pre": true, "hr": true, -} - -func nodeToMD(n *html.Node, sb *strings.Builder) { - switch n.Type { - case html.TextNode: - sb.WriteString(n.Data) - case html.ElementNode: - tag := n.Data - switch tag { - case "br": - sb.WriteString("\n") - case "hr": - sb.WriteString("\n---\n") - case "h1", "h2", "h3", "h4", "h5", "h6": - level := int(tag[1] - '0') - sb.WriteString("\n" + strings.Repeat("#", level) + " ") - for c := n.FirstChild; c != nil; c = c.NextSibling { - nodeToMD(c, sb) - } - sb.WriteString("\n\n") - return - case "p", "div", "blockquote": - sb.WriteString("\n") - for c := n.FirstChild; c != nil; c = c.NextSibling { - nodeToMD(c, sb) - } - sb.WriteString("\n") - return - case "em", "i": - sb.WriteString("*") - for c := n.FirstChild; c != nil; c = c.NextSibling { - nodeToMD(c, sb) - } - sb.WriteString("*") - return - case "strong", "b": - sb.WriteString("**") - for c := n.FirstChild; c != nil; c = c.NextSibling { - nodeToMD(c, sb) - } - sb.WriteString("**") - return - case "script", "style", "noscript": - return // drop - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - nodeToMD(c, sb) - } - if blockElements[tag] { - sb.WriteString("\n") - } - } -} diff --git a/scraper/internal/scraper/htmlutil/htmlutil_test.go b/scraper/internal/scraper/htmlutil/htmlutil_test.go deleted file mode 100644 index d9356a3..0000000 --- a/scraper/internal/scraper/htmlutil/htmlutil_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package htmlutil - -import ( - "strings" - "testing" - - "github.com/libnovel/scraper/internal/scraper" -) - -// ── ResolveURL ──────────────────────────────────────────────────────────────── - -func TestResolveURL(t *testing.T) { - cases := []struct{ base, href, want string }{ - // Already absolute → unchanged. - {"https://example.com", "https://other.com/page", "https://other.com/page"}, - {"https://example.com", "http://other.com/page", "http://other.com/page"}, - // Absolute path. - {"https://example.com", "/book/slug", "https://example.com/book/slug"}, - // Relative path. - {"https://example.com/genre/all", "page?p=2", "https://example.com/genre/page?p=2"}, - // Empty href → base itself. - {"https://example.com", "", "https://example.com"}, - } - for _, c := range cases { - got := ResolveURL(c.base, c.href) - if got != c.want { - t.Errorf("ResolveURL(%q, %q) = %q, want %q", c.base, c.href, got, c.want) - } - } -} - -// ── AttrVal ─────────────────────────────────────────────────────────────────── - -func TestAttrVal(t *testing.T) { - root, err := ParseHTML(`<html><body><a href="/book/slug" class="link">text</a></body></html>`) - if err != nil { - t.Fatal(err) - } - a := FindFirst(root, scraper.Selector{Tag: "a"}) - if a == nil { - t.Fatal("expected to find <a>") - } - if got := AttrVal(a, "href"); got != "/book/slug" { - t.Errorf("AttrVal href = %q, want %q", got, "/book/slug") - } - if got := AttrVal(a, "class"); got != "link" { - t.Errorf("AttrVal class = %q, want %q", got, "link") - } - if got := AttrVal(a, "missing"); got != "" { - t.Errorf("AttrVal missing = %q, want empty", got) - } -} - -// ── TextContent ─────────────────────────────────────────────────────────────── - -func TestTextContent(t *testing.T) { - root, err := ParseHTML(`<html><body><p>Hello <b>world</b></p></body></html>`) - if err != nil { - t.Fatal(err) - } - p := FindFirst(root, scraper.Selector{Tag: "p"}) - if p == nil { - t.Fatal("expected to find <p>") - } - if got := TextContent(p); got != "Hello world" { - t.Errorf("TextContent = %q, want %q", got, "Hello world") - } -} - -// ── FindFirst / FindAll ─────────────────────────────────────────────────────── - -func TestFindFirst_ByTag(t *testing.T) { - root, _ := ParseHTML(`<html><body><h1>Title</h1><h2>Sub</h2></body></html>`) - n := FindFirst(root, scraper.Selector{Tag: "h1"}) - if n == nil { - t.Fatal("expected to find <h1>") - } - if TextContent(n) != "Title" { - t.Errorf("h1 text = %q, want %q", TextContent(n), "Title") - } -} - -func TestFindFirst_ByClass(t *testing.T) { - root, _ := ParseHTML(`<html><body><span class="author foo">JR</span></body></html>`) - n := FindFirst(root, scraper.Selector{Tag: "span", Class: "author"}) - if n == nil { - t.Fatal("expected to find span.author") - } - if TextContent(n) != "JR" { - t.Errorf("author text = %q, want %q", TextContent(n), "JR") - } -} - -func TestFindFirst_ByID(t *testing.T) { - root, _ := ParseHTML(`<html><body><div id="content"><p>text</p></div></body></html>`) - n := FindFirst(root, scraper.Selector{ID: "content"}) - if n == nil { - t.Fatal("expected to find #content") - } -} - -func TestFindFirst_NoMatch(t *testing.T) { - root, _ := ParseHTML(`<html><body><p>nothing</p></body></html>`) - n := FindFirst(root, scraper.Selector{Tag: "h1"}) - if n != nil { - t.Errorf("expected nil for missing tag, got %v", n) - } -} - -func TestFindAll_Multiple(t *testing.T) { - root, _ := ParseHTML(`<html><body> - <li class="novel-item">A</li> - <li class="novel-item">B</li> - <li class="other">C</li> - </body></html>`) - nodes := FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item"}) - if len(nodes) != 2 { - t.Errorf("FindAll novel-item = %d, want 2", len(nodes)) - } -} - -// ── ExtractFirst / ExtractAll ───────────────────────────────────────────────── - -func TestExtractFirst_TextNode(t *testing.T) { - root, _ := ParseHTML(`<html><body><h1 class="novel-title">Shadow Slave</h1></body></html>`) - got := ExtractFirst(root, scraper.Selector{Tag: "h1", Class: "novel-title"}) - if got != "Shadow Slave" { - t.Errorf("ExtractFirst title = %q, want %q", got, "Shadow Slave") - } -} - -func TestExtractFirst_AttrNode(t *testing.T) { - root, _ := ParseHTML(`<html><body><img src="/covers/slug.jpg"></body></html>`) - got := ExtractFirst(root, scraper.Selector{Tag: "img", Attr: "src"}) - if got != "/covers/slug.jpg" { - t.Errorf("ExtractFirst img src = %q, want %q", got, "/covers/slug.jpg") - } -} - -func TestExtractFirst_Missing(t *testing.T) { - root, _ := ParseHTML(`<html><body></body></html>`) - got := ExtractFirst(root, scraper.Selector{Tag: "h1"}) - if got != "" { - t.Errorf("ExtractFirst missing = %q, want empty", got) - } -} - -func TestExtractAll_Genres(t *testing.T) { - root, _ := ParseHTML(`<html><body> - <div class="genres"> - <a href="/genre/action">Action</a> - <a href="/genre/fantasy">Fantasy</a> - </div> - </body></html>`) - genresNode := FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"}) - if genresNode == nil { - t.Fatal("expected genres div") - } - genres := ExtractAll(genresNode, scraper.Selector{Tag: "a"}) - if len(genres) != 2 { - t.Fatalf("genres = %v, want 2", genres) - } - if genres[0] != "Action" || genres[1] != "Fantasy" { - t.Errorf("genres = %v, want [Action Fantasy]", genres) - } -} - -// ── NodeToMarkdown ──────────────────────────────────────────────────────────── - -func TestNodeToMarkdown_Paragraphs(t *testing.T) { - root, _ := ParseHTML(`<html><body><div id="content"> - <p>First paragraph.</p> - <p>Second paragraph.</p> - </div></body></html>`) - container := FindFirst(root, scraper.Selector{ID: "content"}) - if container == nil { - t.Fatal("missing #content") - } - md := NodeToMarkdown(container) - if md == "" { - t.Fatal("NodeToMarkdown returned empty string") - } - for _, want := range []string{"First paragraph", "Second paragraph"} { - if !strings.Contains(md, want) { - t.Errorf("NodeToMarkdown missing %q in:\n%s", want, md) - } - } -} - -func TestNodeToMarkdown_Bold(t *testing.T) { - root, _ := ParseHTML(`<html><body><div id="content"><p>He was <strong>very</strong> strong.</p></div></body></html>`) - container := FindFirst(root, scraper.Selector{ID: "content"}) - md := NodeToMarkdown(container) - if !strings.Contains(md, "**very**") { - t.Errorf("NodeToMarkdown should wrap <strong> in **, got:\n%s", md) - } -} - -func TestNodeToMarkdown_ScriptStripped(t *testing.T) { - root, _ := ParseHTML(`<html><body><div id="content"><p>Good</p><script>alert(1)</script></div></body></html>`) - container := FindFirst(root, scraper.Selector{ID: "content"}) - md := NodeToMarkdown(container) - if strings.Contains(md, "alert") { - t.Errorf("NodeToMarkdown should strip <script> content, got:\n%s", md) - } -} - -func TestNodeToMarkdown_CollapseBlankLines(t *testing.T) { - root, _ := ParseHTML(`<html><body><div id="content"> - <p>A</p> - <p></p> - <p></p> - <p>B</p> - </div></body></html>`) - container := FindFirst(root, scraper.Selector{ID: "content"}) - md := NodeToMarkdown(container) - // Should not have more than one consecutive blank line. - if strings.Contains(md, "\n\n\n") { - t.Errorf("NodeToMarkdown should collapse triple newlines, got:\n%q", md) - } -} diff --git a/scraper/internal/scraper/interfaces.go b/scraper/internal/scraper/interfaces.go deleted file mode 100644 index f3d540b..0000000 --- a/scraper/internal/scraper/interfaces.go +++ /dev/null @@ -1,150 +0,0 @@ -// Package scraper defines the core interfaces and domain types for the libnovel -// scraping system. Each novel source implements these interfaces; the orchestrator -// wires them together without knowing anything about the concrete provider. -package scraper - -import ( - "context" - "time" -) - -// ─── Domain types ──────────────────────────────────────────────────────────── - -// BookMeta carries all bibliographic information about a novel. -type BookMeta struct { - // Slug is a URL-safe identifier derived from the book title, e.g. "a-dragon-against-the-whole-world". - Slug string `yaml:"slug"` - // Title is the human-readable novel title. - Title string `yaml:"title"` - // Author of the novel. - Author string `yaml:"author"` - // Cover is an absolute URL to the cover image. - Cover string `yaml:"cover,omitempty"` - // Status is e.g. "Ongoing", "Completed". - Status string `yaml:"status,omitempty"` - // Genres is a list of genre tags. - Genres []string `yaml:"genres,omitempty"` - // Summary is the full description/synopsis text. - Summary string `yaml:"summary,omitempty"` - // TotalChapters is the total number of chapters known at scrape time. - TotalChapters int `yaml:"total_chapters,omitempty"` - // SourceURL is the canonical URL of the book's landing page. - SourceURL string `yaml:"source_url"` - // Ranking is the rank number from ranking pages. - Ranking int `yaml:"ranking,omitempty"` -} - -// CatalogueEntry is a lightweight reference returned by CatalogueProvider. -type CatalogueEntry struct { - // Title is the novel title as shown in the catalogue listing. - Title string - // URL is the canonical landing-page URL of the novel. - URL string -} - -// ChapterRef is a reference to a single chapter returned by ChapterListProvider. -type ChapterRef struct { - // Number is the 1-based chapter index within the book. - Number int - // Title is the chapter display title. - Title string - // URL is the full URL of the chapter page. - URL string - // Volume is an optional volume number (0 means no volume grouping). - Volume int -} - -// Chapter contains the fully-extracted text of a single chapter. -type Chapter struct { - Ref ChapterRef - // Text is the plain / lightly-formatted chapter body (Markdown). - Text string -} - -// RankingItem represents a single entry in the novel ranking list. -type RankingItem struct { - Rank int `json:"rank"` - Slug string `json:"slug"` - Title string `json:"title"` - Author string `json:"author,omitempty"` - Cover string `json:"cover,omitempty"` - Status string `json:"status,omitempty"` - Genres []string `json:"genres,omitempty"` - SourceURL string `json:"source_url,omitempty"` - Updated time.Time `json:"updated,omitempty"` -} - -// ─── Scraping selector descriptors ─────────────────────────────────────────── - -// Selector describes how to locate an element in an HTML document. -// Exactly one of Tag, Class, or ID should be non-empty; when multiple are set -// they are combined (AND semantics). -type Selector struct { - // Tag is the HTML element name, e.g. "div", "p", "h1". - Tag string - // Class is one CSS class name (without the leading dot). - Class string - // ID is the element id attribute (without the leading #). - ID string - // Attr is an optional attribute name whose value should be extracted - // instead of the text content (e.g. "href", "src"). - Attr string - // Multiple indicates that all matching elements should be collected, - // not just the first one. - Multiple bool -} - -// ─── Provider interfaces ────────────────────────────────────────────────────── - -// CatalogueProvider can enumerate every novel available on a source site. -// It handles pagination transparently and streams CatalogueEntry values. -type CatalogueProvider interface { - // ScrapeCatalogue pages through the entire catalogue, sending - // CatalogueEntry values to the returned channel. The channel is closed - // when all pages have been scraped or ctx is cancelled. - // Errors are surfaced via the error channel; a non-nil error does not - // necessarily terminate scraping. - ScrapeCatalogue(ctx context.Context) (<-chan CatalogueEntry, <-chan error) -} - -// MetadataProvider can extract structured book metadata from a novel's landing page. -type MetadataProvider interface { - // ScrapeMetadata fetches and parses the metadata for the book at bookURL. - ScrapeMetadata(ctx context.Context, bookURL string) (BookMeta, error) -} - -// ChapterListProvider can enumerate all chapters of a book from the chapter-list page. -type ChapterListProvider interface { - // ScrapeChapterList returns all chapter references for a book, ordered - // by chapter number ascending. - ScrapeChapterList(ctx context.Context, bookURL string) ([]ChapterRef, error) -} - -// ChapterTextProvider can extract the readable text from a single chapter page. -type ChapterTextProvider interface { - // ScrapeChapterText fetches chapterURL and returns the chapter text as Markdown. - ScrapeChapterText(ctx context.Context, ref ChapterRef) (Chapter, error) -} - -// RankingProvider can enumerate novels from a ranking page. -type RankingProvider interface { - // ScrapeRanking pages through up to maxPages ranking pages, sending BookMeta - // values (with basic info like title, cover, genres, status, sourceURL) to - // the returned channel. Pages are fetched sequentially and lazily: the next - // page is only requested once all entries from the current page have been - // sent. maxPages <= 0 means "all pages". - ScrapeRanking(ctx context.Context, maxPages int) (<-chan BookMeta, <-chan error) -} - -// NovelScraper is the full interface that a concrete novel source must implement. -// It composes all four provider interfaces. -type NovelScraper interface { - CatalogueProvider - MetadataProvider - ChapterListProvider - ChapterTextProvider - RankingProvider - - // SourceName returns the human-readable name of this scraper, e.g. "novelfire.net". - SourceName() string -} diff --git a/scraper/internal/server/handlers_audio.go b/scraper/internal/server/handlers_audio.go deleted file mode 100644 index 1dda33c..0000000 --- a/scraper/internal/server/handlers_audio.go +++ /dev/null @@ -1,712 +0,0 @@ -package server - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" -) - -// ─── Audio generation via Kokoro /v1/audio/speech ──────────────────────────── -// -// handleAudioGenerate handles POST /api/audio/{slug}/{n}. -// -// The handler is non-blocking: it creates an audio_jobs record in PocketBase -// with status="pending", then fires a background goroutine to call Kokoro. -// The caller should poll GET /api/audio/status/{slug}/{n} to track progress. -// -// If audio is already cached (audio_cache hit) the handler returns -// status=200 with the proxy URL immediately — no job is created. -// -// Concurrent requests for the same key are deduplicated via audioJobIDs: -// the second caller gets a 202 with the existing job_id immediately. -func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - n, err := strconv.Atoi(r.PathValue("n")) - if err != nil || n < 1 { - http.Error(w, `{"error":"invalid chapter"}`, http.StatusBadRequest) - return - } - - // Parse optional voice from JSON body. - voice := s.kokoroVoice - var body struct { - Voice string `json:"voice"` - MaxChars int `json:"max_chars"` - } - if r.Body != nil { - _ = json.NewDecoder(r.Body).Decode(&body) - } - if body.Voice != "" { - voice = body.Voice - } - - cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice) - - // Fast path: already generated (check persistent store first). - if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok { - s.writeAudioResponse(w, slug, n, voice, filename) - return - } - - // Deduplicate concurrent generation for the same key. - // If a goroutine is already running for this key, return the existing job_id. - s.audioMu.Lock() - if jobID, ok := s.audioJobIDs[cacheKey]; ok { - s.audioMu.Unlock() - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "generating"}) - return - } - - // Create the PocketBase job record. - jobID, createErr := s.store.CreateAudioJob(r.Context(), slug, n, voice) - if createErr != nil { - s.audioMu.Unlock() - s.log.Warn("audio: failed to create job record", "slug", slug, "chapter", n, "err", createErr) - // Non-fatal: still proceed, just won't have a persistent job record. - jobID = "" - } - - s.audioJobIDs[cacheKey] = jobID - s.audioMu.Unlock() - - // Fire background goroutine — request context must NOT be used here since - // the handler returns immediately. - maxChars := body.MaxChars - go func() { - defer func() { - s.audioMu.Lock() - delete(s.audioJobIDs, cacheKey) - s.audioMu.Unlock() - }() - - bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - - s.runAudioGeneration(bgCtx, jobID, slug, n, voice, maxChars, cacheKey) - }() - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "pending"}) -} - -// runAudioGeneration performs the actual Kokoro TTS work in a goroutine. -// It updates the audio_jobs record as it progresses and writes to audio_cache -// and MinIO on success. -func (s *Server) runAudioGeneration(ctx context.Context, jobID, slug string, n int, voice string, maxChars int, cacheKey string) { - markFailed := func(msg string) { - if jobID == "" { - return - } - if err := s.store.UpdateAudioJob(ctx, jobID, "failed", msg, time.Now()); err != nil { - s.log.Warn("audio: failed to update job to failed", "job_id", jobID, "err", err) - } - } - - // Transition to "generating". - if jobID != "" { - if err := s.store.UpdateAudioJob(ctx, jobID, "generating", "", time.Time{}); err != nil { - s.log.Warn("audio: failed to mark job generating", "job_id", jobID, "err", err) - } - } - - // Load and validate chapter text. - raw, err := s.store.ReadChapter(ctx, slug, n) - if err != nil { - s.log.Error("audio: chapter not found", "slug", slug, "chapter", n, "err", err) - markFailed("chapter not found") - return - } - text := stripMarkdown(raw) - if text == "" { - markFailed("chapter text is empty") - return - } - if maxChars > 0 && len([]rune(text)) > maxChars { - text = string([]rune(text)[:maxChars]) - } - if s.kokoroURL == "" { - markFailed("kokoro not configured") - return - } - - // Call Kokoro. - filename, err := s.generateSpeech(ctx, text, voice, 1.0) - if err != nil { - s.log.Error("audio: kokoro speech generation failed", "slug", slug, "chapter", n, "err", err) - markFailed(err.Error()) - return - } - - if err := s.store.SetAudioCache(ctx, cacheKey, filename); err != nil { - s.log.Warn("audio: cache write failed", "slug", slug, "chapter", n, "err", err) - } - - // Download from Kokoro and persist to MinIO. - minioKey := s.store.AudioObjectKey(slug, n, voice) - audioData, dlErr := s.downloadFromKokoro(ctx, filename) - if dlErr != nil { - s.log.Warn("audio: MinIO upload skipped: kokoro download failed", - "slug", slug, "chapter", n, "filename", filename, "err", dlErr) - } else if putErr := s.store.PutAudio(ctx, minioKey, audioData); putErr != nil { - s.log.Warn("audio: MinIO upload failed", - "slug", slug, "chapter", n, "key", minioKey, "err", putErr) - } else { - s.log.Info("audio: uploaded to MinIO", "slug", slug, "chapter", n, "key", minioKey) - } - - // Mark job done. - if jobID != "" { - if err := s.store.UpdateAudioJob(ctx, jobID, "done", "", time.Now()); err != nil { - s.log.Warn("audio: failed to mark job done", "job_id", jobID, "err", err) - } - } - s.log.Info("audio: generation complete", "slug", slug, "chapter", n, "filename", filename) -} - -// handleAudioStatus handles GET /api/audio/status/{slug}/{n}. -// Returns the current generation status for the given chapter + voice. -// -// Query params: voice (optional, defaults to server default). -// -// Possible responses: -// - 200 {"status":"done","url":"/api/audio-proxy/..."} — audio ready -// - 200 {"status":"pending"|"generating","job_id":"..."} — in progress -// - 200 {"status":"idle"} — no job yet -// - 200 {"status":"failed","error":"..."} — last job failed -func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - n, err := strconv.Atoi(r.PathValue("n")) - if err != nil || n < 1 || slug == "" { - http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest) - return - } - - voice := r.URL.Query().Get("voice") - if voice == "" { - voice = s.kokoroVoice - } - - cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice) - - w.Header().Set("Content-Type", "application/json") - - // Fast path: audio already in audio_cache → done. - if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok { - proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice) - _ = json.NewEncoder(w).Encode(map[string]string{ - "status": "done", - "url": proxyURL, - "filename": filename, - }) - return - } - - // Check in-flight map for live job ID. - s.audioMu.Lock() - liveJobID, inFlight := s.audioJobIDs[cacheKey] - s.audioMu.Unlock() - - if inFlight { - // Look up persistent record for richer status. - if job, ok, _ := s.store.GetAudioJob(r.Context(), cacheKey); ok { - _ = json.NewEncoder(w).Encode(map[string]string{ - "status": job.Status, - "job_id": liveJobID, - }) - return - } - _ = json.NewEncoder(w).Encode(map[string]string{ - "status": "generating", - "job_id": liveJobID, - }) - return - } - - // Not in-flight: check persistent record for last known result. - job, ok, _ := s.store.GetAudioJob(r.Context(), cacheKey) - if !ok { - _ = json.NewEncoder(w).Encode(map[string]string{"status": "idle"}) - return - } - - resp := map[string]string{ - "status": job.Status, - "job_id": job.ID, - } - if job.Status == "failed" && job.ErrorMessage != "" { - resp["error"] = job.ErrorMessage - } - _ = json.NewEncoder(w).Encode(resp) -} - -// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true -// and returns the filename from the X-Download-Path response header. -func (s *Server) generateSpeech(ctx context.Context, text, voice string, speed float64) (string, error) { - reqBody, _ := json.Marshal(map[string]interface{}{ - "model": "kokoro", - "input": text, - "voice": voice, - "response_format": "mp3", - "speed": speed, - "stream": false, - "return_download_link": true, - }) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - s.kokoroURL+"/v1/audio/speech", bytes.NewReader(reqBody)) - if err != nil { - return "", fmt.Errorf("build request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("kokoro request: %w", err) - } - defer resp.Body.Close() - // Drain body so the connection can be reused. - _, _ = io.Copy(io.Discard, resp.Body) - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("kokoro status %d", resp.StatusCode) - } - - // X-Download-Path is e.g. "/download/speech_abc123.mp3" - dlPath := resp.Header.Get("X-Download-Path") - if dlPath == "" { - return "", fmt.Errorf("kokoro did not return X-Download-Path header") - } - - // Extract just the filename from the path. - filename := dlPath - if idx := strings.LastIndex(dlPath, "/"); idx >= 0 { - filename = dlPath[idx+1:] - } - if filename == "" { - return "", fmt.Errorf("empty filename in X-Download-Path: %q", dlPath) - } - return filename, nil -} - -// downloadFromKokoro downloads a generated audio file from Kokoro's temp storage -// using GET /v1/download/{filename} and returns the raw bytes. -func (s *Server) downloadFromKokoro(ctx context.Context, filename string) ([]byte, error) { - url := s.kokoroURL + "/v1/download/" + filename - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("build download request: %w", err) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("kokoro download request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("kokoro download status %d", resp.StatusCode) - } - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read kokoro download body: %w", err) - } - return data, nil -} - -// writeAudioResponse writes the JSON response for an already-cached audio chapter. -// The URL points to our proxy handler GET /api/audio-proxy/{slug}/{n}. -func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, voice string, filename string) { - proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "url": proxyURL, - "filename": filename, - }) -} - -// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}. -// It looks up the Kokoro download filename for this chapter (voice) and -// proxies GET /v1/download/{filename} from the Kokoro server back to the browser. -func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - n, err := strconv.Atoi(r.PathValue("n")) - if err != nil || n < 1 { - http.NotFound(w, r) - return - } - voice := r.URL.Query().Get("voice") - if voice == "" { - voice = s.kokoroVoice - } - - cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice) - filename, ok := s.store.GetAudioCache(r.Context(), cacheKey) - if !ok { - http.Error(w, "audio not generated yet", http.StatusNotFound) - return - } - - kokoroURL := s.kokoroURL + "/v1/download/" + filename - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, kokoroURL, nil) - if err != nil { - http.Error(w, "failed to build proxy request", http.StatusInternalServerError) - return - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - http.Error(w, "kokoro download failed", http.StatusBadGateway) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - http.Error(w, fmt.Sprintf("kokoro returned %d", resp.StatusCode), http.StatusBadGateway) - return - } - - w.Header().Set("Content-Type", "audio/mpeg") - w.Header().Set("Cache-Control", "public, max-age=3600") - if cl := resp.Header.Get("Content-Length"); cl != "" { - w.Header().Set("Content-Length", cl) - } - _, _ = io.Copy(w, resp.Body) -} - -// ─── Presigned URL handlers ─────────────────────────────────────────────────── - -// handlePresignChapter handles GET /api/presign/chapter/{slug}/{n}. -// Returns a short-lived presigned MinIO URL for the chapter markdown object. -// The SvelteKit server uses this to fetch chapter content server-side. -func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - n, err := strconv.Atoi(r.PathValue("n")) - if err != nil || n < 1 || slug == "" { - http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest) - return - } - - url, err := s.store.PresignChapter(r.Context(), slug, n, 15*time.Minute) - if err != nil { - s.log.Error("presign chapter failed", "slug", slug, "n", n, "err", err) - http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"url": url}) -} - -// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}. -// Returns a presigned MinIO URL for the audio object (if it has been generated). -// Query params: voice (optional, defaults to server default). -func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - n, err := strconv.Atoi(r.PathValue("n")) - if err != nil || n < 1 || slug == "" { - http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest) - return - } - - voice := r.URL.Query().Get("voice") - if voice == "" { - voice = s.kokoroVoice - } - - key := s.store.AudioObjectKey(slug, n, voice) - - // Return 404 when the object hasn't been uploaded yet — the client treats - // this as "audio not ready" and will either poll or trigger generation. - if !s.store.AudioExists(r.Context(), key) { - http.NotFound(w, r) - return - } - - url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour) - if err != nil { - s.log.Error("presign audio failed", "slug", slug, "n", n, "err", err) - http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"url": url}) -} - -// ─── Voices API ─────────────────────────────────────────────────────────────── - -// handleVoices handles GET /api/voices. -// Returns the list of available Kokoro voices as JSON: {"voices": [...]} -func (s *Server) handleVoices(w http.ResponseWriter, _ *http.Request) { - voices := s.voices() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"voices": voices}) -} - -// ─── Voice sample generation ────────────────────────────────────────────────── - -// voiceSampleText is the short passage used for voice sample previews. -const voiceSampleText = "The ancient library held secrets older than memory itself, its dust-laden shelves stretching upward into shadow. She reached for the worn leather spine, fingers trembling with anticipation." - -// voiceSampleKey returns the MinIO object key for a voice sample. -// Key: _voice-samples/{voice}.mp3 -func voiceSampleKey(voice string) string { - safe := strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '_' || r == '-' { - return r - } - return '_' - }, voice) - return fmt.Sprintf("_voice-samples/%s.mp3", safe) -} - -// warmVoiceSamples runs at startup in a background goroutine. -// It generates a short audio sample for every available Kokoro voice that -// doesn't already have one in MinIO, so the UI voice selector has playable -// previews without requiring a manual trigger. -// It respects ctx cancellation and waits up to 30 s for Kokoro to become -// reachable before giving up. -func (s *Server) warmVoiceSamples(ctx context.Context) { - if s.kokoroURL == "" { - return - } - - // Wait for Kokoro to be reachable (it may still be starting up). - deadline := time.Now().Add(30 * time.Second) - for time.Now().Before(deadline) { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, s.kokoroURL+"/v1/audio/voices", nil) - resp, err := http.DefaultClient.Do(req) - if err == nil { - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - break - } - } - select { - case <-ctx.Done(): - return - case <-time.After(3 * time.Second): - } - } - - voices := s.voices() - s.log.Info("warming voice samples", "voices", len(voices)) - - generated, skipped, failed := 0, 0, 0 - for _, voice := range voices { - if ctx.Err() != nil { - return - } - - key := voiceSampleKey(voice) - if s.store.AudioExists(ctx, key) { - skipped++ - continue - } - - filename, err := s.generateSpeech(ctx, voiceSampleText, voice, 1.0) - if err != nil { - s.log.Warn("voice sample warmup: generation failed", "voice", voice, "err", err) - failed++ - continue - } - - audioData, err := s.downloadFromKokoro(ctx, filename) - if err != nil { - s.log.Warn("voice sample warmup: download failed", "voice", voice, "err", err) - failed++ - continue - } - - if err := s.store.PutAudio(ctx, key, audioData); err != nil { - s.log.Warn("voice sample warmup: upload failed", "voice", voice, "key", key, "err", err) - failed++ - continue - } - - s.log.Debug("voice sample warmed", "voice", voice) - generated++ - } - - s.log.Info("voice sample warmup complete", - "generated", generated, "skipped", skipped, "failed", failed) -} - -// handleGenerateVoiceSamples handles POST /api/audio/voice-samples. -// It generates short audio samples for each available voice and stores them -// in the audio MinIO bucket so the UI can play them during voice selection. -// Already-generated samples are skipped (idempotent). -// Optional JSON body: {"voices": ["af_bella", ...]} to generate a subset. -// Returns: {"generated": [...], "skipped": [...], "failed": [...]} -func (s *Server) handleGenerateVoiceSamples(w http.ResponseWriter, r *http.Request) { - if s.kokoroURL == "" { - http.Error(w, `{"error":"kokoro not configured"}`, http.StatusServiceUnavailable) - return - } - - // Parse optional voice list from body. - var body struct { - Voices []string `json:"voices"` - } - if r.Body != nil { - _ = json.NewDecoder(r.Body).Decode(&body) - } - - targetVoices := body.Voices - if len(targetVoices) == 0 { - targetVoices = s.voices() - } - - type result struct { - Generated []string `json:"generated"` - Skipped []string `json:"skipped"` - Failed []string `json:"failed"` - } - var res result - - for _, voice := range targetVoices { - key := voiceSampleKey(voice) - - // Skip if already uploaded. - if s.store.AudioExists(r.Context(), key) { - res.Skipped = append(res.Skipped, voice) - s.log.Debug("voice sample already exists, skipping", "voice", voice) - continue - } - - // Generate via Kokoro (speed 1.0 for samples). - filename, err := s.generateSpeech(r.Context(), voiceSampleText, voice, 1.0) - if err != nil { - s.log.Warn("voice sample generation failed", "voice", voice, "err", err) - res.Failed = append(res.Failed, voice) - continue - } - - // Download from Kokoro and upload to MinIO. - audioData, dlErr := s.downloadFromKokoro(r.Context(), filename) - if dlErr != nil { - s.log.Warn("voice sample kokoro download failed", "voice", voice, "err", dlErr) - res.Failed = append(res.Failed, voice) - continue - } - - if putErr := s.store.PutAudio(r.Context(), key, audioData); putErr != nil { - s.log.Warn("voice sample MinIO upload failed", "voice", voice, "key", key, "err", putErr) - res.Failed = append(res.Failed, voice) - continue - } - - s.log.Info("voice sample generated", "voice", voice, "key", key) - res.Generated = append(res.Generated, voice) - } - - if res.Generated == nil { - res.Generated = []string{} - } - if res.Skipped == nil { - res.Skipped = []string{} - } - if res.Failed == nil { - res.Failed = []string{} - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(res) -} - -// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}. -// Returns a presigned URL for the voice sample audio file stored in MinIO. -// Returns 404 if the sample has not been generated yet. -func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) { - voice := r.PathValue("voice") - if voice == "" { - http.Error(w, `{"error":"missing voice"}`, http.StatusBadRequest) - return - } - - key := voiceSampleKey(voice) - - if !s.store.AudioExists(r.Context(), key) { - http.NotFound(w, r) - return - } - - url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour) - if err != nil { - s.log.Error("presign voice sample failed", "voice", voice, "err", err) - http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"url": url}) -} - -// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}. -// Returns a short-lived presigned PUT URL for uploading an avatar image directly -// to MinIO, along with the object key to record in PocketBase after the upload. -// Query param: ext — image extension (jpg, png, webp). Defaults to "jpg". -func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) { - userID := r.PathValue("userId") - if userID == "" { - http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest) - return - } - - ext := r.URL.Query().Get("ext") - switch ext { - case "jpg", "jpeg": - ext = "jpg" - case "png": - ext = "png" - case "webp": - ext = "webp" - default: - ext = "jpg" - } - - uploadURL, key, err := s.store.PresignAvatarUpload(r.Context(), userID, ext) - if err != nil { - s.log.Error("presign avatar upload failed", "userId", userID, "err", err) - http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "upload_url": uploadURL, - "key": key, - }) -} - -// handlePresignAvatar handles GET /api/presign/avatar/{userId}. -// Returns a presigned GET URL for a user's existing avatar, or 404 if none. -func (s *Server) handlePresignAvatar(w http.ResponseWriter, r *http.Request) { - userID := r.PathValue("userId") - if userID == "" { - http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest) - return - } - - url, found, err := s.store.PresignAvatarURL(r.Context(), userID) - if err != nil { - s.log.Error("presign avatar failed", "userId", userID, "err", err) - http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError) - return - } - if !found { - http.NotFound(w, r) - return - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"url": url}) -} diff --git a/scraper/internal/server/handlers_browse.go b/scraper/internal/server/handlers_browse.go deleted file mode 100644 index 033e208..0000000 --- a/scraper/internal/server/handlers_browse.go +++ /dev/null @@ -1,575 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/libnovel/scraper/internal/storage" - "golang.org/x/net/html" - - "github.com/libnovel/scraper/internal/scraper/htmlutil" -) - -// ─── Browse API ─────────────────────────────────────────────────────────────── - -// NovelListing represents a single novel entry from the novelfire browse page. -type NovelListing struct { - Slug string `json:"slug"` - Title string `json:"title"` - Cover string `json:"cover"` - Rank string `json:"rank"` - Rating string `json:"rating"` - Chapters string `json:"chapters"` - URL string `json:"url"` -} - -const novelFireBase = "https://novelfire.net" -const novelFireDomain = "novelfire.net" - -// handleBrowse handles GET /api/browse. -// Query params: -// -// page (default 1) -// genre (default "all") -// sort (default "popular") -// status (default "all") -// type (default "all-novel") -// -// Returns JSON: {"novels":[...], "page": N, "hasNext": bool} -// -// Cache strategy: check MinIO browse bucket first (key: {domain}/html/page-N.html); -// if a snapshot exists, parse it and return structured JSON. -// On a cache miss, fetch live from novelfire.net, return the result, and -// trigger a background SingleFile snapshot + ranking population. -func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - page := q.Get("page") - if page == "" { - page = "1" - } - genre := q.Get("genre") - if genre == "" { - genre = "all" - } - sortBy := q.Get("sort") - if sortBy == "" { - sortBy = "popular" - } - status := q.Get("status") - if status == "" { - status = "all" - } - novelType := q.Get("type") - if novelType == "" { - novelType = "all-novel" - } - - pageNum, _ := strconv.Atoi(page) - if pageNum <= 0 { - pageNum = 1 - } - - ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) - defer cancel() - - // ── Cache-first: try MinIO snapshot (new key layout) ───────────────── - cacheKey := s.store.BrowseFilteredHTMLKey(novelFireDomain, pageNum, sortBy, genre, status) - if html, ok, err := s.store.GetBrowsePage(ctx, cacheKey); err == nil && ok && len(html) > 0 { - novels, hasNext := parseBrowsePage(strings.NewReader(html)) - s.log.Debug("browse: served from cache", "key", cacheKey) - // Still fire background ranking population in case PocketBase ranking - // records are missing (e.g. after a schema reset / fresh deploy). - targetURLForRanking := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%s", - novelFireBase, genre, sortBy, status, novelType, page) - s.triggerDirectScrape(cacheKey, targetURLForRanking) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "public, max-age=300") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "novels": novels, - "page": pageNum, - "hasNext": hasNext, - }) - return - } - - // ── Live fallback: direct fetch from novelfire.net ─────────────────── - // Build URL: /genre-{genre}/sort-{sort}/status-{status}/{type}?page={page} - targetURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%s", - novelFireBase, genre, sortBy, status, novelType, page) - - var novels []NovelListing - var hasNext bool - var fetchErr error - for attempt := 1; attempt <= 3; attempt++ { - if attempt > 1 { - select { - case <-ctx.Done(): - http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable) - return - case <-time.After(time.Duration(attempt) * time.Second): - } - } - - var req *http.Request - req, fetchErr = http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) - if fetchErr != nil { - http.Error(w, `{"error":"failed to build request"}`, http.StatusInternalServerError) - return - } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - // Do NOT set Accept-Encoding manually: Go's http.Transport handles - // transparent gzip decompression only when it adds the header itself. - // If we set it explicitly, Transport disables auto-decompression and - // parseBrowsePage receives raw gzip bytes instead of HTML. - req.Header.Set("Cache-Control", "no-cache") - req.Header.Set("Pragma", "no-cache") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - fetchErr = err - s.log.Warn("browse fetch failed, retrying", "url", targetURL, "attempt", attempt, "err", err) - continue - } - - if resp.StatusCode != http.StatusOK { - _, _ = io.Copy(io.Discard, resp.Body) - resp.Body.Close() - fetchErr = fmt.Errorf("upstream returned %d", resp.StatusCode) - s.log.Warn("browse upstream error, retrying", "url", targetURL, "attempt", attempt, "status", resp.StatusCode) - continue - } - - novels, hasNext = parseBrowsePage(resp.Body) - resp.Body.Close() - fetchErr = nil - break - } - if fetchErr != nil { - s.log.Error("browse fetch failed after retries", "url", targetURL, "err", fetchErr) - // ── In-memory fallback: use cached result from a prior successful fetch ── - s.browseMemCacheMu.RLock() - entry, memHit := s.browseMemCache[cacheKey] - s.browseMemCacheMu.RUnlock() - if memHit { - s.log.Warn("browse: upstream unavailable, serving stale in-memory cache", - "key", cacheKey, "age", time.Since(entry.cachedAt).Round(time.Second)) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "public, max-age=60") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "novels": entry.novels, - "page": pageNum, - "hasNext": entry.hasNext, - }) - return - } - http.Error(w, fmt.Sprintf(`{"error":"%s"}`, fetchErr.Error()), http.StatusBadGateway) - return - } - - // ── Populate in-memory cache with the fresh upstream result ────────── - if len(novels) > 0 { - s.browseMemCacheMu.Lock() - s.browseMemCache[cacheKey] = browseCacheEntry{ - novels: novels, - hasNext: hasNext, - cachedAt: time.Now(), - } - s.browseMemCacheMu.Unlock() - } - - // ── Background: fetch and cache page directly from novelfire.net ───── - // Fire-and-forget: stores raw HTML in MinIO and populates the ranking - // collection in PocketBase (no browser/SingleFile needed). - s.triggerDirectScrape(cacheKey, targetURL) - - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "public, max-age=300") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "novels": novels, - "page": pageNum, - "hasNext": hasNext, - }) -} - -// triggerDirectScrape fires a background goroutine that: -// 1. Fetches pageURL directly from novelfire.net using Go's HTTP client -// (no browser/SingleFile needed — the page is server-rendered HTML). -// 2. Stores the raw HTML in MinIO at cacheKey so future requests are served -// from cache without hitting the origin. -// 3. Parses the HTML to extract novel listings. -// 4. For each listing, upserts a ranking record in PocketBase (rank, slug, -// title, cover key, source_url). -// 5. Fires a separate goroutine per cover image to download and store it at -// {domain}/assets/book-covers/{slug}.jpg in MinIO. -// -// It is a no-op when a refresh for this cache key is already in progress. -// The goroutine uses a fresh context so it outlives the HTTP request. -func (s *Server) triggerDirectScrape(cacheKey, pageURL string) { - s.browseMu.Lock() - if _, inflight := s.browseInFlight[cacheKey]; inflight { - s.browseMu.Unlock() - return - } - s.browseInFlight[cacheKey] = struct{}{} - s.browseMu.Unlock() - - go func() { - defer func() { - s.browseMu.Lock() - delete(s.browseInFlight, cacheKey) - s.browseMu.Unlock() - }() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) - if err != nil { - s.log.Warn("triggerDirectScrape: build request failed", "key", cacheKey, "err", err) - return - } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - s.log.Warn("triggerDirectScrape: fetch failed", "key", cacheKey, "err", err) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - s.log.Warn("triggerDirectScrape: non-200 response", "key", cacheKey, "status", resp.StatusCode) - return - } - - htmlBytes, readErr := io.ReadAll(resp.Body) - if readErr != nil { - s.log.Warn("triggerDirectScrape: read body failed", "key", cacheKey, "err", readErr) - return - } - if len(htmlBytes) == 0 { - s.log.Warn("triggerDirectScrape: empty response body", "key", cacheKey) - return - } - - // Store the HTML in MinIO so subsequent requests are cache-hits. - if putErr := s.store.SaveBrowsePage(ctx, cacheKey, string(htmlBytes)); putErr != nil { - s.log.Warn("triggerDirectScrape: SaveBrowsePage failed", "key", cacheKey, "err", putErr) - // Non-fatal: continue to populate PocketBase/covers even if MinIO write fails. - } else { - s.log.Info("triggerDirectScrape: cached browse page", "key", cacheKey, "bytes", len(htmlBytes)) - } - - // Parse to extract novel listings. - novels, _ := parseBrowsePage(strings.NewReader(string(htmlBytes))) - if len(novels) == 0 { - s.log.Warn("triggerDirectScrape: no novels parsed", "key", cacheKey) - return - } - - // Upsert each novel into PocketBase ranking and kick off cover downloads. - for i, novel := range novels { - rank := i + 1 - coverKey := s.store.BrowseCoverKey(novelFireDomain, novel.Slug) - - item := storage.RankingItem{ - Rank: rank, - Slug: novel.Slug, - Title: novel.Title, - Cover: coverKey, // stored as MinIO key; UI fetches via /api/cover/... - SourceURL: novel.URL, - } - if werr := s.store.WriteRankingItem(ctx, item); werr != nil { - s.log.Warn("triggerDirectScrape: WriteRankingItem failed", - "slug", novel.Slug, "err", werr) - } - - if novel.Cover != "" { - go s.downloadAndStoreCover(coverKey, novel.Cover) - } - } - - s.log.Info("triggerDirectScrape: ranking populated", "count", len(novels), "key", cacheKey) - }() -} - -// warmBrowseCache checks whether the browse cache for page 1 is populated in -// MinIO and, if not, triggers a background direct scrape. This is called -// once on server startup so the first user request is likely served from cache. -func (s *Server) warmBrowseCache() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - cacheKey := s.store.BrowseHTMLKey(novelFireDomain, 1) - if _, ok, err := s.store.GetBrowsePage(ctx, cacheKey); err == nil && ok { - s.log.Debug("warmBrowseCache: page 1 already cached, skipping") - return - } - - targetURL := fmt.Sprintf("%s/genre-all/sort-popular/status-all/all-novel?page=1", novelFireBase) - s.log.Info("warmBrowseCache: page 1 not cached, triggering background scrape") - s.triggerDirectScrape(cacheKey, targetURL) -} - -// downloadAndStoreCover delegates to storage.DownloadAndStoreCover. -func (s *Server) downloadAndStoreCover(key, imageURL string) { - storage.DownloadAndStoreCover(s.store, s.log, key, imageURL) -} - -// parseBrowsePage parses the novelfire HTML and extracts novel listings. -// Returns novels and whether a "next page" link was found. -func parseBrowsePage(r io.Reader) ([]NovelListing, bool) { - doc, err := html.Parse(r) - if err != nil { - return nil, false - } - - var novels []NovelListing - hasNext := false - - var walk func(*html.Node) - walk = func(n *html.Node) { - if n.Type == html.ElementNode { - switch n.Data { - case "li": - if hasClass(n, "novel-item") { - if novel, ok := parseNovelItem(n); ok { - novels = append(novels, novel) - } - } - // pagination li with class "next" - if hasClass(n, "next") { - hasNext = true - } - case "a": - // Detect "next" pagination link - if hasClass(n, "next") || attrVal(n, "rel") == "next" { - hasNext = true - } - // Also check aria-label="Next" - if attrVal(n, "aria-label") == "Next" { - hasNext = true - } - } - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - walk(c) - } - } - walk(doc) - return novels, hasNext -} - -// parseNovelItem extracts a NovelListing from a <li class="novel-item"> node. -func parseNovelItem(li *html.Node) (NovelListing, bool) { - var novel NovelListing - - var walk func(*html.Node) - walk = func(n *html.Node) { - if n.Type == html.ElementNode { - switch n.Data { - case "a": - href := attrVal(n, "href") - if strings.HasPrefix(href, "/book/") { - slug := strings.TrimPrefix(href, "/book/") - slug = strings.TrimSuffix(slug, "/") - if novel.Slug == "" { - novel.Slug = slug - novel.URL = novelFireBase + href - } - } - case "img": - // lazy-loaded covers use data-src - src := attrVal(n, "data-src") - if src == "" { - src = attrVal(n, "src") - } - if src != "" && novel.Cover == "" { - if !strings.HasPrefix(src, "http") { - src = novelFireBase + src - } - novel.Cover = src - } - case "h4": - if hasClass(n, "novel-title") && novel.Title == "" { - novel.Title = strings.TrimSpace(textContent(n)) - } - case "span": - cls := attrVal(n, "class") - if strings.Contains(cls, "_bl") && novel.Rank == "" { - novel.Rank = strings.TrimSpace(textContent(n)) - } - if strings.Contains(cls, "_br") && novel.Rating == "" { - novel.Rating = strings.TrimSpace(textContent(n)) - } - } - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - walk(c) - } - } - walk(li) - - // Extract chapter count from the novel stats text (contains "N Chapters") - novel.Chapters = extractChapters(li) - - if novel.Slug == "" || novel.Title == "" { - return novel, false - } - return novel, true -} - -// extractChapters finds the chapter count text within a novel-item node. -func extractChapters(n *html.Node) string { - var result string - var walk func(*html.Node) - walk = func(node *html.Node) { - if node.Type == html.ElementNode { - cls := attrVal(node, "class") - if strings.Contains(cls, "novel-stats") || strings.Contains(cls, "chapter") { - txt := strings.TrimSpace(textContent(node)) - if strings.Contains(txt, "Chapter") || strings.Contains(txt, "chapter") { - // Extract just the numeric part if possible - result = txt - return - } - } - } - for c := node.FirstChild; c != nil; c = c.NextSibling { - walk(c) - } - } - walk(n) - return result -} - -// hasClass reports whether an HTML node has the given CSS class. -func hasClass(n *html.Node, cls string) bool { - for _, a := range n.Attr { - if a.Key == "class" { - for _, c := range strings.Fields(a.Val) { - if c == cls { - return true - } - } - } - } - return false -} - -// attrVal returns the value of an attribute on an HTML node, or "". -// Delegates to htmlutil.AttrVal. -func attrVal(n *html.Node, key string) string { return htmlutil.AttrVal(n, key) } - -// textContent returns the concatenated text content of a node and its descendants. -// Delegates to htmlutil.TextContent. -func textContent(n *html.Node) string { return htmlutil.TextContent(n) } - -// ─── Search API ─────────────────────────────────────────────────────────────── - -// handleSearch handles GET /api/search. -// -// Query params: -// -// q — search query string (required, min 2 chars) -// source — "local" | "remote" | "all" (default: "all") -// -// When source includes "local", it searches books already in the local store -// by title substring match. When source includes "remote", it fetches the -// novelfire.net search page and parses results. Results from both sources -// are merged with local results first (de-duplicated by slug). -// -// Returns JSON: {"results": [...NovelListing], "local_count": N, "remote_count": N} -func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query().Get("q") - if len([]rune(q)) < 2 { - http.Error(w, `{"error":"query must be at least 2 characters"}`, http.StatusBadRequest) - return - } - - source := r.URL.Query().Get("source") - if source == "" { - source = "all" - } - - ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) - defer cancel() - - var localResults []NovelListing - var remoteResults []NovelListing - - // ── Local search (PocketBase books) ────────────────────────────────── - if source == "local" || source == "all" { - books, err := s.store.ListBooks(ctx) - if err != nil { - s.log.Warn("search: ListBooks failed", "err", err) - } else { - qLower := strings.ToLower(q) - for _, b := range books { - if strings.Contains(strings.ToLower(b.Title), qLower) || - strings.Contains(strings.ToLower(b.Author), qLower) { - listing := NovelListing{ - Slug: b.Slug, - Title: b.Title, - Cover: b.Cover, - URL: b.SourceURL, - } - localResults = append(localResults, listing) - } - } - } - } - - // ── Remote search (novelfire.net /search?keyword=...) ───────────────── - if source == "remote" || source == "all" { - searchURL := novelFireBase + "/search?keyword=" + url.QueryEscape(q) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) - if err == nil { - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - if resp, fetchErr := http.DefaultClient.Do(req); fetchErr == nil { - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - parsed, _ := parseBrowsePage(resp.Body) - remoteResults = parsed - } else { - s.log.Warn("search: remote returned non-200", "status", resp.StatusCode, "url", searchURL) - } - } - } - } - - // ── Merge: de-duplicate remote results already in local ─────────────── - localSlugs := make(map[string]bool, len(localResults)) - for _, item := range localResults { - localSlugs[item.Slug] = true - } - - combined := make([]NovelListing, 0, len(localResults)+len(remoteResults)) - combined = append(combined, localResults...) - for _, item := range remoteResults { - if !localSlugs[item.Slug] { - combined = append(combined, item) - } - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "results": combined, - "local_count": len(localResults), - "remote_count": len(remoteResults), - }) -} diff --git a/scraper/internal/server/handlers_preview.go b/scraper/internal/server/handlers_preview.go deleted file mode 100644 index 15505c4..0000000 --- a/scraper/internal/server/handlers_preview.go +++ /dev/null @@ -1,168 +0,0 @@ -package server - -// handlers_preview.go — on-demand preview endpoints for books not yet in PocketBase. -// -// These endpoints allow the UI to display a book's metadata and chapter list -// (scraped live from novelfire.net) without requiring a full scrape to have -// been run first. They are read-only: nothing is persisted to PocketBase or -// MinIO. -// -// Endpoints: -// -// GET /api/book-preview/{slug} — scrape book metadata + chapter list live -// GET /api/chapter-text-preview/{slug}/{n} — scrape a single chapter text live - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - - "github.com/libnovel/scraper/internal/scraper" -) - -// BookPreviewResponse is the JSON response for /api/book-preview/{slug}. -type BookPreviewResponse struct { - InLib bool `json:"in_lib"` - Meta scraper.BookMeta `json:"meta"` - Chapters []scraper.ChapterRef `json:"chapters"` -} - -// handleBookPreview handles GET /api/book-preview/{slug}. -// -// It scrapes book metadata and the full chapter list live from novelfire.net. -// It also checks whether the book exists in the local store (PocketBase) and -// sets the InLib flag accordingly. Nothing is written to any store. -// -// Query param: source_url (optional) — if provided, uses that URL instead of -// constructing one from the slug. -func (s *Server) handleBookPreview(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - if slug == "" { - http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest) - return - } - - // Determine the book URL: prefer explicit source_url query param. - bookURL := r.URL.Query().Get("source_url") - if bookURL == "" { - bookURL = fmt.Sprintf("%s/book/%s", novelFireBase, slug) - } - - ctx := r.Context() - - // Check whether the book is already in the local library. - _, inLib, err := s.store.ReadMetadata(ctx, slug) - if err != nil { - // Non-fatal: we can still serve the preview. - s.log.Warn("book-preview: ReadMetadata failed", "slug", slug, "err", err) - inLib = false - } - - // Scrape live metadata. - meta, err := s.novel.ScrapeMetadata(ctx, bookURL) - if err != nil { - s.log.Error("book-preview: ScrapeMetadata failed", "slug", slug, "url", bookURL, "err", err) - http.Error(w, fmt.Sprintf(`{"error":"metadata scrape failed: %s"}`, err.Error()), http.StatusBadGateway) - return - } - - // Scrape live chapter list. - chapters, err := s.novel.ScrapeChapterList(ctx, bookURL) - if err != nil { - s.log.Error("book-preview: ScrapeChapterList failed", "slug", slug, "url", bookURL, "err", err) - // Return partial response with metadata only — chapters are non-critical. - chapters = []scraper.ChapterRef{} - } - - // If the book was not already in the library, persist the metadata and - // chapter list skeleton to PocketBase now so that subsequent visits load - // from the local store rather than scraping live again. Chapter text is - // NOT fetched here — that still requires an explicit scrape job. - if !inLib { - go func() { - bgCtx := context.Background() - if werr := s.store.WriteMetadata(bgCtx, meta); werr != nil { - s.log.Warn("book-preview: WriteMetadata failed (non-fatal)", "slug", slug, "err", werr) - } - if len(chapters) > 0 { - if werr := s.store.WriteChapterRefs(bgCtx, slug, chapters); werr != nil { - s.log.Warn("book-preview: WriteChapterRefs failed (non-fatal)", "slug", slug, "err", werr) - } - } - s.log.Info("book-preview: metadata+chapter list persisted", "slug", slug, "chapters", len(chapters)) - }() - inLib = true // will be true by the time the client navigates back - } - - resp := BookPreviewResponse{ - InLib: inLib, - Meta: meta, - Chapters: chapters, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) -} - -// ChapterPreviewResponse is the JSON response for /api/chapter-text-preview/{slug}/{n}. -type ChapterPreviewResponse struct { - Slug string `json:"slug"` - Number int `json:"number"` - Title string `json:"title"` - Text string `json:"text"` // plain text (markdown stripped) - URL string `json:"url"` -} - -// handleChapterTextPreview handles GET /api/chapter-text-preview/{slug}/{n}. -// -// It scrapes a single chapter from novelfire.net live without storing anything. -// The chapter URL is determined from either: -// - the "chapter_url" query param (preferred — used when the UI knows it from -// a prior book-preview call), or -// - a best-effort construction: {novelFireBase}/book/{slug}/chapter-{n} -// -// Returns plain text (markdown stripped) suitable for TTS or display. -func (s *Server) handleChapterTextPreview(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - nStr := r.PathValue("n") - n, err := strconv.Atoi(nStr) - if err != nil || n < 1 || slug == "" { - http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest) - return - } - - // Chapter URL: prefer explicit query param. - chapterURL := r.URL.Query().Get("chapter_url") - if chapterURL == "" { - chapterURL = fmt.Sprintf("%s/book/%s/chapter-%d", novelFireBase, slug, n) - } - - title := r.URL.Query().Get("title") - - ref := scraper.ChapterRef{ - Number: n, - Title: title, - URL: chapterURL, - } - - chapter, err := s.novel.ScrapeChapterText(r.Context(), ref) - if err != nil { - s.log.Error("chapter-text-preview: ScrapeChapterText failed", - "slug", slug, "n", n, "url", chapterURL, "err", err) - http.Error(w, fmt.Sprintf(`{"error":"chapter scrape failed: %s"}`, err.Error()), http.StatusBadGateway) - return - } - - resp := ChapterPreviewResponse{ - Slug: slug, - Number: n, - Title: chapter.Ref.Title, - Text: stripMarkdown(chapter.Text), - URL: chapterURL, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) -} diff --git a/scraper/internal/server/handlers_progress.go b/scraper/internal/server/handlers_progress.go deleted file mode 100644 index e9809e1..0000000 --- a/scraper/internal/server/handlers_progress.go +++ /dev/null @@ -1,103 +0,0 @@ -package server - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" - "time" - - "github.com/libnovel/scraper/internal/storage" -) - -// ─── Reading progress API ───────────────────────────────────────────────────── - -// handleGetProgress handles GET /api/progress. -// Returns JSON: {"slug": chapterNum, ...} merged with {"slug_ts": timestampMs, ...} -func (s *Server) handleGetProgress(w http.ResponseWriter, r *http.Request) { - sid := ensureSession(w, r) - entries, err := s.store.AllProgress(r.Context(), sid) - if err != nil { - s.log.Error("AllProgress failed", "err", err) - entries = nil - } - - progress := make(map[string]interface{}, len(entries)*2) - for _, p := range entries { - progress[p.Slug] = p.Chapter - progress[p.Slug+"_ts"] = p.UpdatedAt.UnixMilli() - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(progress) -} - -// handleSetProgress handles POST /api/progress/{slug}. -// Body: {"chapter": N} -func (s *Server) handleSetProgress(w http.ResponseWriter, r *http.Request) { - sid := ensureSession(w, r) - slug := r.PathValue("slug") - if slug == "" { - http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest) - return - } - - var body struct { - Chapter int `json:"chapter"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Chapter < 1 { - http.Error(w, `{"error":"invalid body"}`, http.StatusBadRequest) - return - } - - p := storage.ReadingProgress{ - Slug: slug, - Chapter: body.Chapter, - UpdatedAt: time.Now(), - } - if err := s.store.SetProgress(r.Context(), sid, p); err != nil { - s.log.Error("SetProgress failed", "slug", slug, "err", err) - http.Error(w, `{"error":"store error"}`, http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{}) -} - -// handleDeleteProgress handles DELETE /api/progress/{slug}. -func (s *Server) handleDeleteProgress(w http.ResponseWriter, r *http.Request) { - sid := ensureSession(w, r) - slug := r.PathValue("slug") - if slug == "" { - http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest) - return - } - - if err := s.store.DeleteProgress(r.Context(), sid, slug); err != nil { - s.log.Error("DeleteProgress failed", "slug", slug, "err", err) - // Non-fatal — treat as success. - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{}) -} - -// handleChapterText returns the plain text of a chapter (markdown stripped) -// for server-side audio generation. Called by handleAudioGenerate internally. -func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - n, err := strconv.Atoi(r.PathValue("n")) - if err != nil || n < 1 { - http.NotFound(w, r) - return - } - raw, err := s.store.ReadChapter(r.Context(), slug, n) - if err != nil { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") - fmt.Fprint(w, stripMarkdown(raw)) -} diff --git a/scraper/internal/server/handlers_ranking.go b/scraper/internal/server/handlers_ranking.go deleted file mode 100644 index 0a19424..0000000 --- a/scraper/internal/server/handlers_ranking.go +++ /dev/null @@ -1,86 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "net/http" - "strings" - "time" - - "github.com/libnovel/scraper/internal/storage" -) - -// handleGetRanking returns all ranking items sorted by rank ascending. -// Cover fields that hold a MinIO object key (e.g. "novelfire.net/assets/book-covers/slug.jpg") -// are rewritten to a /api/cover/{key} proxy URL so the UI can fetch them -// without knowing about the internal MinIO topology. -func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) { - items, err := s.store.ReadRankingItems(r.Context()) - if err != nil { - s.log.Error("ranking read failed", "err", err) - http.Error(w, `{"error":"failed to read ranking"}`, http.StatusInternalServerError) - return - } - if items == nil { - items = []storage.RankingItem{} - } - // Rewrite cover keys to proxy URLs. - // Keys stored by triggerDirectScrape look like: - // "novelfire.net/assets/book-covers/shadow-slave.jpg" - // We expose them as: - // "/api/cover/novelfire.net/shadow-slave" - // (the handler strips the domain and slug from the path, reconstructs the key) - for i := range items { - cover := items[i].Cover - if cover != "" && !strings.HasPrefix(cover, "http") { - // cover is a MinIO key; extract domain + slug for the proxy path. - // Key format: {domain}/assets/book-covers/{slug}.jpg - parts := strings.SplitN(cover, "/assets/book-covers/", 2) - if len(parts) == 2 { - domain := parts[0] - slug := strings.TrimSuffix(parts[1], ".jpg") - items[i].Cover = "/api/cover/" + domain + "/" + slug - } - } - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(items) -} - -// handleGetCover proxies a cover image stored in the MinIO browse bucket. -// Route: GET /api/cover/{domain}/{slug} -// It reconstructs the MinIO key as {domain}/assets/book-covers/{slug}.jpg, -// fetches the object, and streams it to the client. -// Returns 404 if not yet downloaded, allowing the UI to fall back to the -// original source URL. -func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) { - domain := r.PathValue("domain") - slug := r.PathValue("slug") - if domain == "" || slug == "" { - http.Error(w, "missing domain or slug", http.StatusBadRequest) - return - } - - key := s.store.BrowseCoverKey(domain, slug) - - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - data, contentType, ok, err := s.store.GetBrowseAsset(ctx, key) - if err != nil { - s.log.Warn("handleGetCover: GetBrowseAsset error", "key", key, "err", err) - http.Error(w, "storage error", http.StatusInternalServerError) - return - } - if !ok { - http.NotFound(w, r) - return - } - - if contentType == "" { - contentType = "image/jpeg" - } - w.Header().Set("Content-Type", contentType) - w.Header().Set("Cache-Control", "public, max-age=86400") - _, _ = w.Write(data) -} diff --git a/scraper/internal/server/handlers_scrape.go b/scraper/internal/server/handlers_scrape.go deleted file mode 100644 index d99dcb2..0000000 --- a/scraper/internal/server/handlers_scrape.go +++ /dev/null @@ -1,270 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/libnovel/scraper/internal/orchestrator" - "github.com/libnovel/scraper/internal/storage" -) - -func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) { - cfg := s.oCfg - cfg.SingleBookURL = "" // full catalogue - - s.runAsync(w, cfg) -} - -func (s *Server) handleScrapeBook(w http.ResponseWriter, r *http.Request) { - var body struct { - URL string `json:"url"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" { - http.Error(w, `{"error":"request body must be JSON with \"url\" field"}`, http.StatusBadRequest) - return - } - - cfg := s.oCfg - cfg.SingleBookURL = body.URL - - s.runAsync(w, cfg) -} - -// handleScrapeBookRange handles POST /api/scrape/book/range. -// Body: {"url": "...", "from": N, "to": M} -// Scrapes only chapters in the range [from, to] (inclusive). -// from=0 means "start from chapter 1"; to=0 means "no upper limit". -func (s *Server) handleScrapeBookRange(w http.ResponseWriter, r *http.Request) { - var body struct { - URL string `json:"url"` - From int `json:"from"` - To int `json:"to"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" { - http.Error(w, `{"error":"request body must be JSON with \"url\" field"}`, http.StatusBadRequest) - return - } - - cfg := s.oCfg - cfg.SingleBookURL = body.URL - cfg.FromChapter = body.From - cfg.ToChapter = body.To - - s.runAsync(w, cfg) -} - -// runAsync launches an orchestrator in the background and returns 202 Accepted. -// Only one scrape job runs at a time; concurrent requests receive 409 Conflict. -func (s *Server) runAsync(w http.ResponseWriter, cfg orchestrator.Config) { - s.mu.Lock() - if s.running { - s.mu.Unlock() - http.Error(w, `{"error":"a scrape job is already running"}`, http.StatusConflict) - return - } - s.running = true - s.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _ = json.NewEncoder(w).Encode(map[string]string{"status": "accepted"}) - - go func() { - defer func() { - s.mu.Lock() - s.running = false - s.mu.Unlock() - }() - - ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour) - defer cancel() - - // Determine task kind and target. - kind := "catalogue" - targetURL := "" - if cfg.SingleBookURL != "" { - kind = "book" - targetURL = cfg.SingleBookURL - } - - // Create the task record in PocketBase. - taskID, err := s.store.CreateScrapeTask(ctx, kind, targetURL) - if err != nil { - s.log.Warn("could not create scraping_tasks record", "err", err) - // Non-fatal: continue without task tracking. - } - - // flush pushes the latest counters to PocketBase (best-effort). - flush := func(p orchestrator.Progress, status, errMsg string, finished bool) { - if taskID == "" { - return - } - u := storage.ScrapeTaskUpdate{ - Status: status, - BooksFound: p.BooksFound, - ChaptersScraped: p.ChaptersScraped, - ChaptersSkipped: p.ChaptersSkipped, - Errors: p.Errors, - ErrorMessage: errMsg, - } - if finished { - u.Finished = time.Now().UTC() - } - if updateErr := s.store.UpdateScrapeTask(ctx, taskID, u); updateErr != nil { - s.log.Warn("could not update scraping_tasks record", "task_id", taskID, "err", updateErr) - } - } - - cfg.OnProgress = func(p orchestrator.Progress) { - flush(p, "running", "", false) - } - - o := orchestrator.New(cfg, s.novel, s.log, s.store) - runErr := o.Run(ctx) - - // After a successful full-catalogue run, refresh the ranking list. - if runErr == nil && cfg.SingleBookURL == "" { - s.log.Info("runAsync: starting ScrapeRanking after catalogue run") - rankCtx, rankCancel := context.WithTimeout(context.Background(), 30*time.Minute) - defer rankCancel() - rankEntries, rankErrs := s.novel.ScrapeRanking(rankCtx, 0) - rank := 1 - for meta := range rankEntries { - item := storage.RankingItem{ - Rank: rank, - Slug: meta.Slug, - Title: meta.Title, - Author: meta.Author, - Cover: meta.Cover, - Status: meta.Status, - Genres: meta.Genres, - SourceURL: meta.SourceURL, - } - if werr := s.store.WriteRankingItem(rankCtx, item); werr != nil { - s.log.Warn("runAsync: WriteRankingItem failed", "slug", meta.Slug, "err", werr) - } - rank++ - } - if rerr := <-rankErrs; rerr != nil { - s.log.Warn("runAsync: ScrapeRanking finished with error", "err", rerr) - } else { - s.log.Info("runAsync: ScrapeRanking complete", "count", rank-1) - } - } - - // Determine final status. - finalStatus := "done" - errMsg := "" - if runErr != nil { - s.log.Error("scrape job failed", "err", fmt.Sprintf("%v", runErr)) - if ctx.Err() != nil { - finalStatus = "cancelled" - } else { - finalStatus = "failed" - } - errMsg = runErr.Error() - } - - // Best-effort: read last known progress counters via a zero-value - // OnProgress — we don't have a snapshot here, so re-use whatever the - // last OnProgress call delivered (the orchestrator calls notify() at - // the very end, so this is always accurate after Run returns). - // We issue one final flush with the terminal status and finished time. - if taskID != "" { - // Re-fetch current counters by listing the task (cheapest path). - tasks, listErr := s.store.ListScrapeTasks(ctx) - var last storage.ScrapeTaskUpdate - if listErr == nil { - for _, t := range tasks { - if t.ID == taskID { - last = storage.ScrapeTaskUpdate{ - BooksFound: t.BooksFound, - ChaptersScraped: t.ChaptersScraped, - ChaptersSkipped: t.ChaptersSkipped, - Errors: t.Errors, - } - break - } - } - } - last.Status = finalStatus - last.ErrorMessage = errMsg - last.Finished = time.Now().UTC() - if updateErr := s.store.UpdateScrapeTask(ctx, taskID, last); updateErr != nil { - s.log.Warn("could not finalize scraping_tasks record", "task_id", taskID, "err", updateErr) - } - } - }() -} - -// ─── Scrape status API ──────────────────────────────────────────────────────── - -// handleScrapeStatus handles GET /api/scrape/status. -// Returns JSON: {"running": bool} -func (s *Server) handleScrapeStatus(w http.ResponseWriter, _ *http.Request) { - s.mu.Lock() - running := s.running - s.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]bool{"running": running}) -} - -// handleScrapeTasks handles GET /api/scrape/tasks. -// Returns JSON array of all scraping_tasks records, newest first. -func (s *Server) handleScrapeTasks(w http.ResponseWriter, r *http.Request) { - tasks, err := s.store.ListScrapeTasks(r.Context()) - if err != nil { - s.log.Error("handleScrapeTasks: list failed", "err", err) - http.Error(w, `{"error":"failed to list tasks"}`, http.StatusInternalServerError) - return - } - if tasks == nil { - tasks = []storage.ScrapeTask{} - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(tasks) -} - -// handleReindex handles POST /api/reindex/{slug}. -// It rebuilds the chapters_idx PocketBase collection for the given book by -// walking its MinIO objects. Use this when chapters were scraped but the index -// is out of sync (e.g. after a failed UpsertChapterIdx during scraping). -func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - if slug == "" { - http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest) - return - } - - type reindexer interface { - ReindexChapters(ctx context.Context, slug string) (int, error) - } - ri, ok := s.store.(reindexer) - if !ok { - http.Error(w, `{"error":"store does not support reindex"}`, http.StatusNotImplemented) - return - } - - count, err := ri.ReindexChapters(r.Context(), slug) - if err != nil { - s.log.Error("reindex failed", "slug", slug, "indexed", count, "err", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": err.Error(), - "indexed": count, - }) - return - } - - s.log.Info("reindex complete", "slug", slug, "indexed", count) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "slug": slug, - "indexed": count, - }) -} diff --git a/scraper/internal/server/helpers.go b/scraper/internal/server/helpers.go deleted file mode 100644 index b8cda24..0000000 --- a/scraper/internal/server/helpers.go +++ /dev/null @@ -1,62 +0,0 @@ -package server - -import ( - "regexp" - "strings" -) - -// kokoroVoices is the built-in fallback list of voices shipped with Kokoro-FastAPI. -// Used when the live GET /v1/audio/voices request to Kokoro fails. -// Grouped by language prefix: -// -// af_ / am_ American English female / male -// bf_ / bm_ British English female / male -// ef_ / em_ Spanish female / male -// ff_ French female -// hf_ / hm_ Hindi female / male -// if_ / im_ Italian female / male -// jf_ / jm_ Japanese female / male -// pf_ / pm_ Portuguese female / male -// zf_ / zm_ Chinese female / male -var kokoroVoices = []string{ - // American English - "af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia", - "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", - "af_sarah", "af_sky", - "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam", - "am_michael", "am_onyx", "am_puck", - // British English - "bf_alice", "bf_emma", "bf_lily", - "bm_daniel", "bm_fable", "bm_george", "bm_lewis", - // Spanish - "ef_dora", "em_alex", - // French - "ff_siwis", - // Hindi - "hf_alpha", "hf_beta", "hm_omega", "hm_psi", - // Italian - "if_sara", "im_nicola", - // Japanese - "jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo", - // Portuguese - "pf_dora", "pm_alex", - // Chinese - "zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi", - "zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang", -} - -// stripMarkdown removes common markdown syntax from src, returning plain text -// suitable for TTS or display. Not a full markdown parser — handles the most -// common constructs (headings, bold/italic, code blocks, links, blockquotes). -func stripMarkdown(src string) string { - src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "") - src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "") - src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "") - src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "") - src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1") - src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "") - src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "") - src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "") - src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n") - return strings.TrimSpace(src) -} diff --git a/scraper/internal/server/integration_test.go b/scraper/internal/server/integration_test.go deleted file mode 100644 index a2f5688..0000000 --- a/scraper/internal/server/integration_test.go +++ /dev/null @@ -1,412 +0,0 @@ -//go:build integration - -// Integration tests for the HTTP server against live MinIO + PocketBase backends. -// -// The server is started on a random port for each test; real HybridStore -// backends are used. Browserless-dependent tests are skipped unless -// BROWSERLESS_URL is set. -// -// Run with: -// -// MINIO_ENDPOINT=localhost:9000 \ -// POCKETBASE_URL=http://localhost:8090 \ -// go test -v -tags integration -timeout 120s \ -// github.com/libnovel/scraper/internal/server -package server - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "net" - "net/http" - neturl "net/url" - "os" - "strings" - "testing" - "time" - - "github.com/libnovel/scraper/internal/orchestrator" - "github.com/libnovel/scraper/internal/scraper" - "github.com/libnovel/scraper/internal/storage" -) - -// ─── fixture helpers ────────────────────────────────────────────────────────── - -func envOr(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} - -// newTestStore creates a HybridStore from env vars, skipping if not configured. -func newTestStore(t *testing.T) *storage.HybridStore { - t.Helper() - if os.Getenv("MINIO_ENDPOINT") == "" { - t.Skip("MINIO_ENDPOINT not set — skipping server integration test") - } - if os.Getenv("POCKETBASE_URL") == "" { - t.Skip("POCKETBASE_URL not set — skipping server integration test") - } - - pbCfg := storage.PocketBaseConfig{ - BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"), - AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"), - AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"), - } - minioCfg := storage.MinioConfig{ - Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), - AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), - SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), - UseSSL: envOr("MINIO_USE_SSL", "false") == "true", - BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), - BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default()) - if err != nil { - t.Fatalf("NewHybridStore: %v", err) - } - return hs -} - -// startTestServer starts a real Server on a random free port and returns the -// base URL. The server is shut down when the test finishes. -func startTestServer(t *testing.T, store storage.Store) string { - t.Helper() - - // Find a free port. - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("net.Listen: %v", err) - } - addr := ln.Addr().String() - ln.Close() - - log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) - - // nopScraper satisfies scraper.NovelScraper without hitting the network. - srv := New(addr, orchestrator.Config{}, nopScraper{}, log, store, "", "af_bella", "", "") - - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - ready := make(chan struct{}) - go func() { - // Signal readiness after a short delay to let the listener bind. - go func() { - time.Sleep(50 * time.Millisecond) - close(ready) - }() - _ = srv.ListenAndServe(ctx) - }() - - <-ready - - // Wait until the server actually accepts connections. - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - resp, err := http.Get("http://" + addr + "/health") - if err == nil { - resp.Body.Close() - break - } - time.Sleep(20 * time.Millisecond) - } - - return "http://" + addr -} - -// nopScraper is a no-op NovelScraper implementation for tests that don't -// exercise scraping functionality. -type nopScraper struct{} - -func (nopScraper) SourceName() string { return "nop" } -func (nopScraper) ScrapeCatalogue(_ context.Context) (<-chan scraper.CatalogueEntry, <-chan error) { - ch := make(chan scraper.CatalogueEntry) - errs := make(chan error) - close(ch) - close(errs) - return ch, errs -} -func (nopScraper) ScrapeMetadata(_ context.Context, _ string) (scraper.BookMeta, error) { - return scraper.BookMeta{}, nil -} -func (nopScraper) ScrapeChapterList(_ context.Context, _ string) ([]scraper.ChapterRef, error) { - return nil, nil -} -func (nopScraper) ScrapeChapterText(_ context.Context, ref scraper.ChapterRef) (scraper.Chapter, error) { - return scraper.Chapter{Ref: ref}, nil -} -func (nopScraper) ScrapeRanking(_ context.Context, _ int) (<-chan scraper.BookMeta, <-chan error) { - ch := make(chan scraper.BookMeta) - errs := make(chan error) - close(ch) - close(errs) - return ch, errs -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -// TestServer_Health verifies GET /health returns 200 with status:ok. -func TestServer_Health(t *testing.T) { - store := newTestStore(t) - base := startTestServer(t, store) - - resp, err := http.Get(base + "/health") - if err != nil { - t.Fatalf("GET /health: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - t.Errorf("status = %d, want 200", resp.StatusCode) - } - - var body map[string]string - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - t.Fatalf("decode health body: %v", err) - } - if body["status"] != "ok" { - t.Errorf("status field = %q, want %q", body["status"], "ok") - } - t.Logf("health response: %v", body) -} - -// TestServer_ScrapeStatus verifies GET /api/scrape/status returns running:false -// when no scrape is running. -func TestServer_ScrapeStatus(t *testing.T) { - store := newTestStore(t) - base := startTestServer(t, store) - - resp, err := http.Get(base + "/api/scrape/status") - if err != nil { - t.Fatalf("GET /api/scrape/status: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - t.Errorf("status = %d, want 200", resp.StatusCode) - } - - var body map[string]bool - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - t.Fatalf("decode body: %v", err) - } - if body["running"] { - t.Error("scrape/status.running = true, want false") - } - t.Logf("scrape status: %v", body) -} - -// TestServer_PresignChapter writes a chapter to MinIO, then calls -// GET /api/presign/chapter/{slug}/{n} and verifies a URL is returned. -func TestServer_PresignChapter(t *testing.T) { - store := newTestStore(t) - base := startTestServer(t, store) - - // Write a chapter directly via the store so we have something to presign. - slug := fmt.Sprintf("server-presign-test-%d", time.Now().UnixMilli()%100000) - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - ch := scraper.Chapter{ - Ref: scraper.ChapterRef{Number: 1, Title: "Chapter 1: Server Presign Test", Volume: 0}, - Text: "Content for the server presign integration test.", - } - if err := store.WriteChapter(ctx, slug, ch); err != nil { - t.Fatalf("WriteChapter: %v", err) - } - t.Logf("stored chapter for slug=%q", slug) - - // Call the presign endpoint. - url := fmt.Sprintf("%s/api/presign/chapter/%s/1", base, slug) - resp, err := http.Get(url) - if err != nil { - t.Fatalf("GET %s: %v", url, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - t.Errorf("status = %d, want 200", resp.StatusCode) - } - - var body map[string]string - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - t.Fatalf("decode presign response: %v", err) - } - presignedURL := body["url"] - if presignedURL == "" { - t.Fatal("presign response has empty url field") - } - if !strings.HasPrefix(presignedURL, "http") { - t.Errorf("presigned URL does not start with http: %q", presignedURL) - } - t.Logf("presigned URL: %s", presignedURL) -} - -// TestServer_Progress exercises POST /api/progress/{slug} and GET /api/progress. -func TestServer_Progress(t *testing.T) { - store := newTestStore(t) - base := startTestServer(t, store) - - slug := fmt.Sprintf("server-progress-test-%d", time.Now().UnixMilli()%100000) - - // Use a persistent http.Client to carry the session cookie. - jar := &cookieJar{cookies: make(map[string][]*http.Cookie)} - client := &http.Client{Jar: jar} - - // POST /api/progress/{slug} - setURL := fmt.Sprintf("%s/api/progress/%s", base, slug) - body, _ := json.Marshal(map[string]int{"chapter": 5}) - resp, err := client.Post(setURL, "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST %s: %v", setURL, err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Errorf("POST progress status = %d, want 200", resp.StatusCode) - } - t.Logf("POST /api/progress/%s → %d", slug, resp.StatusCode) - - // GET /api/progress - getURL := fmt.Sprintf("%s/api/progress", base) - resp2, err := client.Get(getURL) - if err != nil { - t.Fatalf("GET %s: %v", getURL, err) - } - defer resp2.Body.Close() - - if resp2.StatusCode != http.StatusOK { - t.Errorf("GET progress status = %d, want 200", resp2.StatusCode) - } - - var progress map[string]interface{} - if err := json.NewDecoder(resp2.Body).Decode(&progress); err != nil { - t.Fatalf("decode progress response: %v", err) - } - t.Logf("progress: %v", progress) - - // The slug should appear with chapter value 5. - if ch, ok := progress[slug]; !ok { - t.Errorf("slug %q not found in progress map; keys: %v", slug, mapKeys(progress)) - } else { - // JSON numbers decode as float64. - chNum, _ := ch.(float64) - if int(chNum) != 5 { - t.Errorf("progress[%q] = %v, want 5", slug, ch) - } - } - - // DELETE /api/progress/{slug} - delURL := fmt.Sprintf("%s/api/progress/%s", base, slug) - delReq, _ := http.NewRequest(http.MethodDelete, delURL, nil) - delResp, err := client.Do(delReq) - if err != nil { - t.Fatalf("DELETE %s: %v", delURL, err) - } - delResp.Body.Close() - if delResp.StatusCode != http.StatusOK { - t.Errorf("DELETE progress status = %d, want 200", delResp.StatusCode) - } - t.Logf("DELETE /api/progress/%s → %d", slug, delResp.StatusCode) -} - -// TestServer_PresignChapter_NotFound verifies that presigning a non-existent -// chapter returns 500 (presign fails on missing object). -func TestServer_PresignChapter_NotFound(t *testing.T) { - store := newTestStore(t) - base := startTestServer(t, store) - - url := fmt.Sprintf("%s/api/presign/chapter/does-not-exist-slug/999", base) - resp, err := http.Get(url) - if err != nil { - t.Fatalf("GET %s: %v", url, err) - } - defer resp.Body.Close() - - // MinIO presign on a non-existent key returns an error; server returns 500. - // (Some MinIO versions return a valid presigned URL anyway, which is also acceptable.) - t.Logf("presign non-existent chapter status: %d", resp.StatusCode) - if resp.StatusCode != http.StatusInternalServerError && resp.StatusCode != http.StatusOK { - t.Errorf("status = %d, want 500 or 200", resp.StatusCode) - } -} - -// TestServer_ChapterText writes a chapter and verifies -// GET /api/chapter-text/{slug}/{n} returns the stripped plain text. -func TestServer_ChapterText(t *testing.T) { - store := newTestStore(t) - base := startTestServer(t, store) - - slug := fmt.Sprintf("server-chtext-test-%d", time.Now().UnixMilli()%100000) - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - const chapterText = "The quick brown fox jumps over the lazy dog near the river." - ch := scraper.Chapter{ - Ref: scraper.ChapterRef{Number: 1, Title: "Chapter 1: Text Test", Volume: 0}, - Text: chapterText, - } - if err := store.WriteChapter(ctx, slug, ch); err != nil { - t.Fatalf("WriteChapter: %v", err) - } - - url := fmt.Sprintf("%s/api/chapter-text/%s/1", base, slug) - resp, err := http.Get(url) - if err != nil { - t.Fatalf("GET %s: %v", url, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - t.Errorf("status = %d, want 200", resp.StatusCode) - } - - var buf strings.Builder - rawBytes, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatalf("read body: %v", err) - } - buf.Write(rawBytes) - text := buf.String() - t.Logf("chapter text (%d bytes): %q", len(text), text[:min(len(text), 120)]) - - if text == "" { - t.Error("chapter-text returned empty body") - } - // The stripped text should contain our chapter text (markdown heading stripped). - if !strings.Contains(text, chapterText) { - t.Errorf("chapter text does not contain expected content %q", chapterText) - } -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -func mapKeys(m map[string]interface{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} - -// cookieJar is a minimal http.CookieJar that stores cookies by host. -type cookieJar struct { - cookies map[string][]*http.Cookie -} - -func (j *cookieJar) SetCookies(u *neturl.URL, cookies []*http.Cookie) { - j.cookies[u.Host] = append(j.cookies[u.Host], cookies...) -} - -func (j *cookieJar) Cookies(u *neturl.URL) []*http.Cookie { - return j.cookies[u.Host] -} diff --git a/scraper/internal/server/server.go b/scraper/internal/server/server.go deleted file mode 100644 index 878c9e5..0000000 --- a/scraper/internal/server/server.go +++ /dev/null @@ -1,287 +0,0 @@ -// Package server exposes the scraper as an HTTP API service. -// -// Endpoints: -// -// POST /scrape — enqueue a full catalogue scrape -// POST /scrape/book — enqueue a single-book scrape (JSON body: {"url":"..."}) -// GET /health — liveness probe -// GET /api/progress — get reading progress map (session-scoped) -// POST /api/progress/{slug} — set reading progress -// DELETE /api/progress/{slug} — delete reading progress -// GET /api/presign/chapter/{slug}/{n} — presigned MinIO URL for chapter markdown -// GET /api/presign/audio/{slug}/{n} — presigned MinIO URL for chapter audio -// GET /api/chapter-text/{slug}/{n} — plain text of chapter (markdown stripped) -// POST /api/audio/{slug}/{n} — trigger Kokoro audio generation (async, returns 202) -// GET /api/audio/status/{slug}/{n} — poll audio generation job status -// GET /api/audio-proxy/{slug}/{n} — proxy generated audio from Kokoro -package server - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "sync" - "time" - - "github.com/libnovel/scraper/internal/orchestrator" - "github.com/libnovel/scraper/internal/scraper" - "github.com/libnovel/scraper/internal/storage" -) - -// Server wraps an HTTP mux with the scraping endpoints. -type Server struct { - addr string - oCfg orchestrator.Config - novel scraper.NovelScraper - log *slog.Logger - store storage.Store - mu sync.Mutex - running bool - kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880 - kokoroVoice string // default voice, e.g. af_bella - version string // semver tag, e.g. "v1.2.3" (set via ldflags) - commit string // short git SHA (set via ldflags) - - // voiceMu guards cachedVoices. - voiceMu sync.RWMutex - cachedVoices []string // populated on first request from Kokoro /v1/audio/voices - - // audioMu guards audioJobIDs only. - // Completed audio filenames are persisted to the Store (PocketBase). - // audioJobIDs deduplicates concurrent generation requests for the same key. - audioMu sync.Mutex - audioJobIDs map[string]string // cacheKey → PocketBase job ID (empty string if record creation failed) - - // browseMu guards browseInFlight — keys currently being refreshed - // in the background. - browseMu sync.Mutex - browseInFlight map[string]struct{} - - // browseMemCache is a short-lived in-process cache for browse results. - // It is populated whenever a live upstream fetch succeeds and used as a - // last-resort fallback when both MinIO and the upstream are unavailable. - // Key: the MinIO cache key (same as used for BrowseHTMLKey). - browseMemCacheMu sync.RWMutex - browseMemCache map[string]browseCacheEntry -} - -type browseCacheEntry struct { - novels []NovelListing - hasNext bool - cachedAt time.Time -} - -// New creates a new Server. -func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice, version, commit string) *Server { - return &Server{ - addr: addr, - oCfg: oCfg, - novel: novel, - log: log, - store: store, - kokoroURL: kokoroURL, - kokoroVoice: kokoroVoice, - version: version, - commit: commit, - audioJobIDs: make(map[string]string), - browseInFlight: make(map[string]struct{}), - browseMemCache: make(map[string]browseCacheEntry), - } -} - -// voices returns the list of available Kokoro voices. On the first call it -// fetches GET /v1/audio/voices from the Kokoro service and caches the result. -// If the fetch fails (Kokoro not up yet, network error, etc.) it falls back to -// the hardcoded kokoroVoices list so the UI is never empty. -func (s *Server) voices() []string { - s.voiceMu.RLock() - cached := s.cachedVoices - s.voiceMu.RUnlock() - if len(cached) > 0 { - return cached - } - - if s.kokoroURL == "" { - return kokoroVoices - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.kokoroURL+"/v1/audio/voices", nil) - if err != nil { - s.log.Warn("could not fetch kokoro voices, using built-in list", "err", err) - return kokoroVoices - } - req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - s.log.Warn("could not fetch kokoro voices, using built-in list", "err", err) - return kokoroVoices - } - defer resp.Body.Close() - var payload struct { - Voices []string `json:"voices"` - } - if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&payload) != nil || len(payload.Voices) == 0 { - s.log.Warn("could not fetch kokoro voices, using built-in list") - return kokoroVoices - } - s.voiceMu.Lock() - s.cachedVoices = payload.Voices - s.voiceMu.Unlock() - s.log.Info("fetched kokoro voices", "count", len(payload.Voices)) - return payload.Voices -} - -// ListenAndServe starts the HTTP server and blocks until the provided context -// is cancelled. -func (s *Server) ListenAndServe(ctx context.Context) error { - mux := http.NewServeMux() - mux.HandleFunc("GET /health", s.handleHealth) - mux.HandleFunc("GET /api/version", s.handleVersion) - mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue) - mux.HandleFunc("POST /scrape/book", s.handleScrapeBook) - mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange) - // Browse API — fetches and parses novelfire catalogue page - mux.HandleFunc("GET /api/browse", s.handleBrowse) - // Ranking API - mux.HandleFunc("GET /api/ranking", s.handleGetRanking) - // Cover image proxy (serves images stored in browse MinIO bucket) - mux.HandleFunc("GET /api/cover/{domain}/{slug}", s.handleGetCover) - // Scrape status - mux.HandleFunc("GET /api/scrape/status", s.handleScrapeStatus) - mux.HandleFunc("GET /api/scrape/tasks", s.handleScrapeTasks) - // Re-index chapters for a book from MinIO into PocketBase chapters_idx - mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex) - // On-demand preview (no store writes) — for books not yet in the library - mux.HandleFunc("GET /api/book-preview/{slug}", s.handleBookPreview) - mux.HandleFunc("GET /api/chapter-text-preview/{slug}/{n}", s.handleChapterTextPreview) - // Search: local PocketBase + remote novelfire.net - mux.HandleFunc("GET /api/search", s.handleSearch) - // Progress API - mux.HandleFunc("GET /api/progress", s.handleGetProgress) - mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress) - mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress) - // Presigned URL API (for SvelteKit UI) - mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter) - mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio) - mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample) - mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload) - mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar) - // Plain-text chapter content (used server-side for audio generation) - mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText) - // Voices list (proxied from Kokoro) - mux.HandleFunc("GET /api/voices", s.handleVoices) - // Voice sample generation — generates a short audio clip for each voice - // and stores it in MinIO for UI preview playback. - voiceSampleHandler := http.TimeoutHandler( - http.HandlerFunc(s.handleGenerateVoiceSamples), - 15*time.Minute, - `{"error":"voice sample generation timed out"}`, - ) - mux.Handle("POST /api/audio/voice-samples", voiceSampleHandler) - // Server-side audio generation via Kokoro /v1/audio/speech. - // POST returns 202 immediately and starts a background goroutine; - // poll GET /api/audio/status/{slug}/{n} to track progress. - mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate) - // Audio job status polling endpoint. - mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus) - // Proxy route: fetches the generated file from Kokoro /v1/download/{filename}. - mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy) - - srv := &http.Server{ - Addr: s.addr, - Handler: mux, - ReadTimeout: 15 * time.Second, - WriteTimeout: 60 * time.Second, - IdleTimeout: 60 * time.Second, - } - - errCh := make(chan error, 1) - go func() { errCh <- srv.ListenAndServe() }() - - s.log.Info("HTTP server listening", "addr", s.addr) - - // Pre-populate voice samples in the background so the UI voice selector - // has playable previews without requiring a manual trigger. - go s.warmVoiceSamples(ctx) - - // Warm the browse cache on startup: if page 1 is not cached in MinIO yet, - // trigger a background SingleFile snapshot immediately so the first user - // request is served from cache rather than hitting novelfire.net live. - go s.warmBrowseCache() - - select { - case <-ctx.Done(): - shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return srv.Shutdown(shutCtx) - case err := <-errCh: - return err - } -} - -func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "status": "ok", - "version": s.version, - "commit": s.commit, - }) -} - -func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "version": s.version, - "commit": s.commit, - }) -} - -// ─── Session cookie helpers ─────────────────────────────────────────────────── - -const sessionCookieName = "libnovel_session" - -// sessionID returns the session ID from the request cookie, or "" if absent. -func sessionID(r *http.Request) string { - c, err := r.Cookie(sessionCookieName) - if err != nil { - return "" - } - return c.Value -} - -// newSessionID generates a random 16-byte hex session ID. -func newSessionID() (string, error) { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -// ensureSession issues a new session cookie if the request does not already -// carry one, and returns the session ID (either existing or newly issued). -func ensureSession(w http.ResponseWriter, r *http.Request) string { - if id := sessionID(r); id != "" { - return id - } - id, err := newSessionID() - if err != nil { - // Very unlikely, but fall back to a timestamp-based ID. - id = fmt.Sprintf("fallback-%d", time.Now().UnixNano()) - } - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: id, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - MaxAge: 365 * 24 * 60 * 60, // 1 year - }) - return id -} diff --git a/scraper/internal/storage/coverutil.go b/scraper/internal/storage/coverutil.go deleted file mode 100644 index 787c029..0000000 --- a/scraper/internal/storage/coverutil.go +++ /dev/null @@ -1,59 +0,0 @@ -package storage - -import ( - "context" - "fmt" - "io" - "log/slog" - "net/http" - "time" -) - -// DownloadAndStoreCover fetches the image at imageURL and stores it in the -// store under key. Errors are logged but not returned — this is best-effort. -// If the asset is already present the download is skipped. -func DownloadAndStoreCover(store Store, log *slog.Logger, key, imageURL string) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Skip if already stored. - if _, _, ok, _ := store.GetBrowseAsset(ctx, key); ok { - return - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) - if err != nil { - log.Warn("cover: build request failed", "key", key, "url", imageURL, "err", err) - return - } - req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-scraper/1.0)") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - log.Warn("cover: fetch failed", "key", key, "url", imageURL, "err", fmt.Errorf("%w", err)) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - log.Warn("cover: non-200 response", "key", key, "url", imageURL, "status", resp.StatusCode) - return - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - log.Warn("cover: read body failed", "key", key, "url", imageURL, "err", err) - return - } - - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "image/jpeg" - } - - if err := store.SaveBrowseAsset(ctx, key, data, contentType); err != nil { - log.Warn("cover: SaveBrowseAsset failed", "key", key, "err", err) - return - } - log.Debug("cover: stored", "key", key, "bytes", len(data)) -} diff --git a/scraper/internal/storage/hybrid.go b/scraper/internal/storage/hybrid.go deleted file mode 100644 index 68cee40..0000000 --- a/scraper/internal/storage/hybrid.go +++ /dev/null @@ -1,560 +0,0 @@ -// hybrid.go implements the Store interface using PocketBase for structured data -// and MinIO for binary chapter/audio blobs. -package storage - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "sort" - "strconv" - "strings" - "time" - - "github.com/libnovel/scraper/internal/scraper" -) - -// HybridStore satisfies Store by routing structured data to PocketBase and -// binary objects (chapters, audio) to MinIO. -type HybridStore struct { - pb *PocketBaseStore - minio *MinioClient - log *slog.Logger -} - -// NewHybridStore constructs a HybridStore. It connects to both backends and -// calls EnsureCollections to bootstrap any missing PocketBase collections. -func NewHybridStore(ctx context.Context, pbCfg PocketBaseConfig, minioCfg MinioConfig, log *slog.Logger) (*HybridStore, error) { - mc, err := NewMinioClient(ctx, minioCfg) - if err != nil { - return nil, fmt.Errorf("storage: minio: %w", err) - } - pb := NewPocketBaseStore(pbCfg, log) - // Verify PocketBase credentials before proceeding. - if err := pb.Ping(ctx); err != nil { - return nil, fmt.Errorf("storage: pocketbase auth: %w", err) - } - if err := pb.EnsureCollections(ctx); err != nil { - // Non-fatal: 400/422 means collections already exist. - log.Warn("EnsureCollections returned an error (may be safe to ignore)", "err", err) - } - if err := pb.EnsureMigrations(ctx); err != nil { - log.Warn("EnsureMigrations returned an error", "err", err) - } - return &HybridStore{pb: pb, minio: mc, log: log}, nil -} - -// ─── Book metadata ──────────────────────────────────────────────────────────── - -func (h *HybridStore) WriteMetadata(ctx context.Context, meta scraper.BookMeta) error { - return h.pb.UpsertBook(ctx, - meta.Slug, meta.Title, meta.Author, meta.Cover, - meta.Status, meta.Summary, meta.SourceURL, - meta.Genres, meta.TotalChapters, meta.Ranking, - ) -} - -func (h *HybridStore) ReadMetadata(ctx context.Context, slug string) (scraper.BookMeta, bool, error) { - rec, found, err := h.pb.GetBook(ctx, slug) - if err != nil || !found { - return scraper.BookMeta{}, found, err - } - return recToBookMeta(rec), true, nil -} - -func (h *HybridStore) ListBooks(ctx context.Context) ([]scraper.BookMeta, error) { - rows, err := h.pb.ListBooks(ctx) - if err != nil { - return nil, err - } - books := make([]scraper.BookMeta, 0, len(rows)) - for _, r := range rows { - books = append(books, recToBookMeta(r)) - } - return books, nil -} - -func (h *HybridStore) LocalSlugs(ctx context.Context) (map[string]bool, error) { - books, err := h.ListBooks(ctx) - if err != nil { - return nil, err - } - slugs := make(map[string]bool, len(books)) - for _, b := range books { - slugs[b.Slug] = true - } - return slugs, nil -} - -func (h *HybridStore) MetadataMtime(ctx context.Context, slug string) int64 { - t, err := h.pb.BookMetaUpdated(ctx, slug) - if err != nil { - h.log.Warn("MetadataMtime: BookMetaUpdated failed", "slug", slug, "err", err) - return 0 - } - if t.IsZero() { - return 0 - } - return t.Unix() -} - -// ─── Chapters ───────────────────────────────────────────────────────────────── - -func (h *HybridStore) ChapterExists(ctx context.Context, slug string, ref scraper.ChapterRef) bool { - return h.minio.ChapterExists(ctx, slug, ref.Volume, ref.Number) -} - -func (h *HybridStore) WriteChapter(ctx context.Context, slug string, chapter scraper.Chapter) error { - content := "# " + chapter.Ref.Title + "\n\n" + chapter.Text + "\n" - if err := h.minio.PutChapter(ctx, slug, chapter.Ref.Volume, chapter.Ref.Number, content); err != nil { - return err - } - // Update chapter index in PocketBase. - title, dateLabel := splitChapterTitle(chapter.Ref.Title) - if err := h.pb.UpsertChapterIdx(ctx, slug, chapter.Ref.Number, title, dateLabel); err != nil { - h.log.Warn("WriteChapter: failed to upsert chapter index in PocketBase", - "slug", slug, "chapter", chapter.Ref.Number, "err", err) - } - return nil -} - -// WriteChapterRefs upserts chapter index rows (number + title) for all refs -// without writing any chapter text to MinIO. This pre-populates the chapter -// list when a book is first seen via a live preview. -func (h *HybridStore) WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error { - return h.pb.WriteChapterRefs(ctx, slug, refs) -} - -func (h *HybridStore) ReadChapter(ctx context.Context, slug string, n int) (string, error) { - return h.minio.GetChapter(ctx, slug, 0, n) -} - -func (h *HybridStore) ListChapters(ctx context.Context, slug string) ([]ChapterInfo, error) { - rows, err := h.pb.ListChapterIdx(ctx, slug) - if err != nil { - return nil, err - } - infos := make([]ChapterInfo, 0, len(rows)) - for _, r := range rows { - n := int(floatVal(r, "number")) - title, _ := r["title"].(string) - date, _ := r["date_label"].(string) - infos = append(infos, ChapterInfo{Number: n, Title: title, Date: date}) - } - sort.Slice(infos, func(i, j int) bool { return infos[i].Number < infos[j].Number }) - return infos, nil -} - -func (h *HybridStore) CountChapters(ctx context.Context, slug string) int { - return h.pb.CountChapterIdx(ctx, slug) -} - -// ReindexChapters walks all MinIO objects for slug, reads the title from the -// first line of each chapter markdown, and upserts them into chapters_idx. -// This repairs the PocketBase index when it falls out of sync with MinIO. -// Returns the number of chapters indexed and any non-fatal errors encountered. -func (h *HybridStore) ReindexChapters(ctx context.Context, slug string) (int, error) { - keys, err := h.minio.ListChapterKeys(ctx, slug) - if err != nil { - return 0, fmt.Errorf("reindex: list chapter keys: %w", err) - } - - count := 0 - var errs []string - for _, key := range keys { - // Parse chapter number from key: {slug}/vol-N/lo-hi/chapter-N.md - n := chapterNumberFromKey(key) - if n <= 0 { - h.log.Warn("ReindexChapters: could not parse chapter number from key", "key", key) - continue - } - - raw, readErr := h.minio.GetChapter(ctx, slug, 0, n) - if readErr != nil { - errs = append(errs, fmt.Sprintf("ch%d: %v", n, readErr)) - continue - } - - // Extract title from first line ("# Title text") or fall back to empty. - rawTitle := "" - if line, _, found := strings.Cut(raw, "\n"); found || raw != "" { - rawTitle = strings.TrimPrefix(strings.TrimSpace(line), "# ") - } - title, dateLabel := splitChapterTitle(rawTitle) - - if upsertErr := h.pb.UpsertChapterIdx(ctx, slug, n, title, dateLabel); upsertErr != nil { - errs = append(errs, fmt.Sprintf("ch%d upsert: %v", n, upsertErr)) - continue - } - count++ - } - - if len(errs) > 0 { - return count, fmt.Errorf("reindex: %d error(s): %s", len(errs), strings.Join(errs, "; ")) - } - return count, nil -} - -// chapterNumberFromKey parses the chapter number from a MinIO object key of the -// form "{slug}/vol-N/lo-hi/chapter-N.md". -func chapterNumberFromKey(key string) int { - // Grab the filename portion after the last '/'. - parts := strings.Split(key, "/") - if len(parts) == 0 { - return 0 - } - filename := parts[len(parts)-1] - // filename is "chapter-N.md" - filename = strings.TrimSuffix(filename, ".md") - filename = strings.TrimPrefix(filename, "chapter-") - n, err := strconv.Atoi(filename) - if err != nil || n <= 0 { - return 0 - } - return n -} - -// ─── Ranking ───────────────────────────────────────────────────────────────── - -func (h *HybridStore) WriteRankingItem(ctx context.Context, item RankingItem) error { - return h.pb.UpsertRankingItem(ctx, item) -} - -func (h *HybridStore) ReadRankingItems(ctx context.Context) ([]RankingItem, error) { - return h.pb.ListRankingItems(ctx) -} - -func (h *HybridStore) RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) { - last, err := h.pb.RankingLastUpdated(ctx) - if err != nil { - return false, err - } - if last.IsZero() { - return false, nil - } - return time.Since(last) < maxAge, nil -} - -// ─── Audio cache ────────────────────────────────────────────────────────────── - -func (h *HybridStore) GetAudioCache(ctx context.Context, cacheKey string) (string, bool) { - filename, ok, err := h.pb.GetAudioCache(ctx, cacheKey) - if err != nil { - h.log.Warn("GetAudioCache: PocketBase lookup failed", "cache_key", cacheKey, "err", err) - } - return filename, ok -} - -func (h *HybridStore) SetAudioCache(ctx context.Context, cacheKey, filename string) error { - return h.pb.SetAudioCache(ctx, cacheKey, filename) -} - -// ─── Reading progress ───────────────────────────────────────────────────────── - -func (h *HybridStore) GetProgress(ctx context.Context, sessionID, slug string) (ReadingProgress, bool) { - ch, updated, ok, err := h.pb.GetProgress(ctx, sessionID, slug) - if err != nil { - h.log.Warn("GetProgress: PocketBase lookup failed", "slug", slug, "err", err) - return ReadingProgress{}, false - } - if !ok { - return ReadingProgress{}, false - } - return ReadingProgress{Slug: slug, Chapter: ch, UpdatedAt: updated}, true -} - -func (h *HybridStore) SetProgress(ctx context.Context, sessionID string, p ReadingProgress) error { - return h.pb.SetProgress(ctx, sessionID, p.Slug, p.Chapter) -} - -func (h *HybridStore) AllProgress(ctx context.Context, sessionID string) ([]ReadingProgress, error) { - rows, err := h.pb.AllProgress(ctx, sessionID) - if err != nil { - return nil, err - } - out := make([]ReadingProgress, 0, len(rows)) - for _, r := range rows { - slug, _ := r["slug"].(string) - ch := int(floatVal(r, "chapter")) - var updated time.Time - if ts, ok := r["updated"].(string); ok { - updated, _ = time.Parse(time.RFC3339, ts) - } - out = append(out, ReadingProgress{Slug: slug, Chapter: ch, UpdatedAt: updated}) - } - return out, nil -} - -func (h *HybridStore) DeleteProgress(ctx context.Context, sessionID, slug string) error { - return h.pb.DeleteProgress(ctx, sessionID, slug) -} - -// ─── AudioObjectKey ─────────────────────────────────────────────────────────── - -func (h *HybridStore) AudioObjectKey(slug string, n int, voice string) string { - return AudioObjectKey(slug, n, voice) -} - -func (h *HybridStore) AudioExists(ctx context.Context, key string) bool { - return h.minio.AudioExists(ctx, key) -} - -// ─── PutAudio ───────────────────────────────────────────────────────────────── - -func (h *HybridStore) PutAudio(ctx context.Context, key string, data []byte) error { - return h.minio.PutAudio(ctx, key, data) -} - -// ─── Presigned URLs ─────────────────────────────────────────────────────────── - -func (h *HybridStore) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) { - return h.minio.PresignChapter(ctx, slug, 0, n, expires) -} - -func (h *HybridStore) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) { - return h.minio.PresignAudio(ctx, key, expires) -} - -func (h *HybridStore) PresignAvatarUpload(ctx context.Context, userID, ext string) (string, string, error) { - return h.minio.PresignAvatarUploadURL(ctx, userID, ext) -} - -func (h *HybridStore) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) { - return h.minio.PresignAvatarURL(ctx, userID) -} - -func (h *HybridStore) DeleteAvatar(ctx context.Context, userID string) error { - return h.minio.DeleteAvatar(ctx, userID) -} - -// ─── Browse page snapshots ──────────────────────────────────────────────────── - -func (h *HybridStore) SaveBrowsePage(ctx context.Context, key, html string) error { - return h.minio.PutBrowsePage(ctx, key, html) -} - -func (h *HybridStore) GetBrowsePage(ctx context.Context, key string) (string, bool, error) { - return h.minio.GetBrowsePage(ctx, key) -} - -func (h *HybridStore) BrowseHTMLKey(domain string, page int) string { - return BrowseHTMLKey(domain, page) -} - -func (h *HybridStore) BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string { - return BrowseFilteredHTMLKey(domain, page, sort, genre, status) -} - -func (h *HybridStore) BrowseCoverKey(domain, slug string) string { - return BrowseCoverKey(domain, slug) -} - -func (h *HybridStore) SaveBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error { - return h.minio.PutBrowseAsset(ctx, key, data, contentType) -} - -func (h *HybridStore) GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error) { - return h.minio.GetBrowseAsset(ctx, key) -} - -// ─── Scraping tasks ─────────────────────────────────────────────────────────── - -func (h *HybridStore) CreateScrapeTask(ctx context.Context, kind, targetURL string) (string, error) { - return h.pb.CreateScrapingTask(ctx, kind, targetURL) -} - -func (h *HybridStore) UpdateScrapeTask(ctx context.Context, id string, u ScrapeTaskUpdate) error { - data := map[string]interface{}{ - "status": u.Status, - "books_found": u.BooksFound, - "chapters_scraped": u.ChaptersScraped, - "chapters_skipped": u.ChaptersSkipped, - "errors": u.Errors, - "error_message": u.ErrorMessage, - } - if !u.Finished.IsZero() { - data["finished"] = u.Finished.UTC().Format(time.RFC3339) - } - return h.pb.UpdateScrapingTask(ctx, id, data) -} - -func (h *HybridStore) ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error) { - rows, err := h.pb.ListScrapingTasks(ctx) - if err != nil { - return nil, err - } - tasks := make([]ScrapeTask, 0, len(rows)) - for _, r := range rows { - t := ScrapeTask{ - ID: strVal(r, "id"), - Kind: strVal(r, "kind"), - TargetURL: strVal(r, "target_url"), - Status: strVal(r, "status"), - BooksFound: int(floatVal(r, "books_found")), - ChaptersScraped: int(floatVal(r, "chapters_scraped")), - ChaptersSkipped: int(floatVal(r, "chapters_skipped")), - Errors: int(floatVal(r, "errors")), - ErrorMessage: strVal(r, "error_message"), - } - if ts, ok := r["started"].(string); ok { - t.Started, _ = time.Parse(time.RFC3339, ts) - } - if ts, ok := r["finished"].(string); ok && ts != "" { - t.Finished, _ = time.Parse(time.RFC3339, ts) - } - tasks = append(tasks, t) - } - return tasks, nil -} - -// ─── Audio jobs ─────────────────────────────────────────────────────────────── - -func (h *HybridStore) CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) { - return h.pb.CreateAudioJob(ctx, slug, chapter, voice) -} - -func (h *HybridStore) UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error { - return h.pb.UpdateAudioJob(ctx, id, status, errMsg, finished) -} - -func (h *HybridStore) GetAudioJob(ctx context.Context, cacheKey string) (AudioJob, bool, error) { - rec, ok, err := h.pb.GetAudioJob(ctx, cacheKey) - if err != nil || !ok { - return AudioJob{}, ok, err - } - job := AudioJob{ - ID: strVal(rec, "id"), - CacheKey: strVal(rec, "cache_key"), - Slug: strVal(rec, "slug"), - Chapter: int(floatVal(rec, "chapter")), - Voice: strVal(rec, "voice"), - Status: strVal(rec, "status"), - ErrorMessage: strVal(rec, "error_message"), - } - if ts, ok := rec["started"].(string); ok { - job.Started, _ = time.Parse(time.RFC3339, ts) - } - if ts, ok := rec["finished"].(string); ok && ts != "" { - job.Finished, _ = time.Parse(time.RFC3339, ts) - } - return job, true, nil -} - -func (h *HybridStore) ListAudioJobs(ctx context.Context) ([]AudioJob, error) { - rows, err := h.pb.ListAudioJobs(ctx) - if err != nil { - return nil, err - } - jobs := make([]AudioJob, 0, len(rows)) - for _, r := range rows { - job := AudioJob{ - ID: strVal(r, "id"), - CacheKey: strVal(r, "cache_key"), - Slug: strVal(r, "slug"), - Chapter: int(floatVal(r, "chapter")), - Voice: strVal(r, "voice"), - Status: strVal(r, "status"), - ErrorMessage: strVal(r, "error_message"), - } - if ts, ok := r["started"].(string); ok { - job.Started, _ = time.Parse(time.RFC3339, ts) - } - if ts, ok := r["finished"].(string); ok && ts != "" { - job.Finished, _ = time.Parse(time.RFC3339, ts) - } - jobs = append(jobs, job) - } - return jobs, nil -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -func recToBookMeta(rec map[string]interface{}) scraper.BookMeta { - m := scraper.BookMeta{ - Slug: strVal(rec, "slug"), - Title: strVal(rec, "title"), - Author: strVal(rec, "author"), - Cover: strVal(rec, "cover"), - Status: strVal(rec, "status"), - Summary: strVal(rec, "summary"), - SourceURL: strVal(rec, "source_url"), - } - if tc := floatVal(rec, "total_chapters"); tc > 0 { - m.TotalChapters = int(tc) - } - if rk := floatVal(rec, "ranking"); rk > 0 { - m.Ranking = int(rk) - } - // Genres stored as JSON string or array. - switch v := rec["genres"].(type) { - case string: - _ = json.Unmarshal([]byte(v), &m.Genres) - case []interface{}: - for _, g := range v { - if s, ok := g.(string); ok { - m.Genres = append(m.Genres, s) - } - } - } - return m -} - -func strVal(m map[string]interface{}, key string) string { - if v, ok := m[key].(string); ok { - return v - } - return "" -} - -// splitChapterTitle mirrors writer.SplitChapterTitle logic (simplified). -func splitChapterTitle(raw string) (title, date string) { - raw = strings.TrimSpace(raw) - // Strip leading numeric index. - if idx := strings.IndexFunc(raw, func(r rune) bool { return r == ' ' || r == '\t' }); idx > 0 { - prefix := raw[:idx] - allDigit := true - for _, c := range prefix { - if c < '0' || c > '9' { - allDigit = false - break - } - } - if allDigit { - raw = strings.TrimSpace(raw[idx:]) - } - } - // Detect trailing relative date. Build a flat list of all suffixes once - // to avoid a double-nested loop. - units := []string{"second", "minute", "hour", "day", "week", "month", "year"} - suffixes := make([]string, 0, len(units)*2) - for _, u := range units { - suffixes = append(suffixes, u+"s ago", u+" ago") - } - lower := strings.ToLower(raw) - for _, suffix := range suffixes { - idx := strings.LastIndex(lower, suffix) - if idx <= 0 { - continue - } - // Find start of the numeric token that precedes the unit. - // Strip any whitespace that separates the number from the unit so - // that LastIndex finds the space before the digit, not the one - // between the digit and the unit word. - before := strings.TrimRight(raw[:idx], " \t") - start := strings.LastIndex(before, " ") - if start < 0 { - start = 0 - } else { - start++ // advance past the space to point at the digit - } - numPart := strings.TrimSpace(raw[start:idx]) - fields := strings.Fields(numPart) - if len(fields) > 0 { - if _, err := strconv.Atoi(fields[0]); err == nil { - return strings.TrimSpace(raw[:start]), strings.TrimSpace(raw[start : idx+len(suffix)]) - } - } - } - return raw, "" -} diff --git a/scraper/internal/storage/hybrid_integration_test.go b/scraper/internal/storage/hybrid_integration_test.go deleted file mode 100644 index a278f2d..0000000 --- a/scraper/internal/storage/hybrid_integration_test.go +++ /dev/null @@ -1,473 +0,0 @@ -//go:build integration - -// Integration tests for HybridStore (PocketBase + MinIO) end-to-end. -// -// Run with: -// -// MINIO_ENDPOINT=localhost:9000 \ -// POCKETBASE_URL=http://localhost:8090 \ -// go test -v -tags integration -timeout 120s \ -// github.com/libnovel/scraper/internal/storage -package storage - -import ( - "context" - "fmt" - "log/slog" - "strings" - "testing" - "time" - - "github.com/libnovel/scraper/internal/scraper" -) - -// newTestHybridStore constructs a HybridStore from environment variables. -// Skips the test if either MINIO_ENDPOINT or POCKETBASE_URL is unset. -func newTestHybridStore(t *testing.T) *HybridStore { - t.Helper() - if ep := envOr("MINIO_ENDPOINT", ""); ep == "" { - t.Skip("MINIO_ENDPOINT not set — skipping HybridStore integration test") - } - if u := envOr("POCKETBASE_URL", ""); u == "" { - t.Skip("POCKETBASE_URL not set — skipping HybridStore integration test") - } - - pbCfg := PocketBaseConfig{ - BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"), - AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"), - AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"), - } - minioCfg := MinioConfig{ - Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), - AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), - SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), - UseSSL: envOr("MINIO_USE_SSL", "false") == "true", - BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), - BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - hs, err := NewHybridStore(ctx, pbCfg, minioCfg, slog.Default()) - if err != nil { - t.Fatalf("NewHybridStore: %v", err) - } - return hs -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -// TestHybridStore_WriteReadMetadata exercises WriteMetadata → ReadMetadata round-trip. -func TestHybridStore_WriteReadMetadata(t *testing.T) { - hs := newTestHybridStore(t) - slug := testSlug(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = hs.pb.pb.deleteWhere(cleanCtx, "books", fmt.Sprintf(`slug="%s"`, slug)) - }) - - meta := scraper.BookMeta{ - Slug: slug, - Title: "Hybrid Store Test Novel", - Author: "Test Author", - Cover: "https://example.com/cover.jpg", - Status: "Ongoing", - Genres: []string{"Fantasy", "Action"}, - Summary: "A novel for integration testing.", - TotalChapters: 99, - SourceURL: fmt.Sprintf("https://example.com/book/%s", slug), - Ranking: 5, - } - - t.Run("WriteMetadata", func(t *testing.T) { - if err := hs.WriteMetadata(ctx, meta); err != nil { - t.Fatalf("WriteMetadata: %v", err) - } - t.Logf("wrote metadata for slug=%q", slug) - }) - - t.Run("ReadMetadata", func(t *testing.T) { - got, found, err := hs.ReadMetadata(ctx, slug) - if err != nil { - t.Fatalf("ReadMetadata: %v", err) - } - if !found { - t.Fatal("ReadMetadata: not found after WriteMetadata") - } - t.Logf("read: %+v", got) - if got.Title != meta.Title { - t.Errorf("Title = %q, want %q", got.Title, meta.Title) - } - if got.Author != meta.Author { - t.Errorf("Author = %q, want %q", got.Author, meta.Author) - } - if got.TotalChapters != meta.TotalChapters { - t.Errorf("TotalChapters = %d, want %d", got.TotalChapters, meta.TotalChapters) - } - if got.Ranking != meta.Ranking { - t.Errorf("Ranking = %d, want %d", got.Ranking, meta.Ranking) - } - }) - - t.Run("MetadataMtime", func(t *testing.T) { - mtime := hs.MetadataMtime(ctx, slug) - if mtime == 0 { - t.Error("MetadataMtime returned 0") - } - t.Logf("mtime: %d (%s)", mtime, time.Unix(mtime, 0)) - }) - - t.Run("ReadMetadata_NotFound", func(t *testing.T) { - _, found, err := hs.ReadMetadata(ctx, "this-slug-does-not-exist-xyz") - if err != nil { - t.Fatalf("ReadMetadata (miss): %v", err) - } - if found { - t.Error("ReadMetadata returned found=true for a non-existent slug") - } - }) -} - -// TestHybridStore_WriteReadChapter exercises WriteChapter (MinIO blob + PocketBase -// index), ReadChapter, CountChapters, and ListChapters. -func TestHybridStore_WriteReadChapter(t *testing.T) { - hs := newTestHybridStore(t) - slug := testSlug(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = hs.pb.pb.deleteWhere(cleanCtx, "chapters_idx", fmt.Sprintf(`slug="%s"`, slug)) - // MinIO objects are not cleaned up — they use the test slug as prefix - // and are effectively isolated. - }) - - chapters := []scraper.Chapter{ - { - Ref: scraper.ChapterRef{Number: 1, Title: "Chapter 1: The Beginning", Volume: 0}, - Text: "The first chapter text with enough content to be meaningful for a real novel chapter.", - }, - { - Ref: scraper.ChapterRef{Number: 2, Title: "Chapter 2: Rising Action", Volume: 0}, - Text: "The second chapter text continues the story from where the first left off.", - }, - { - Ref: scraper.ChapterRef{Number: 3, Title: "Chapter 3: Climax", Volume: 0}, - Text: "The third chapter text reaches the peak of tension and conflict.", - }, - } - - t.Run("WriteChapter", func(t *testing.T) { - for _, ch := range chapters { - if err := hs.WriteChapter(ctx, slug, ch); err != nil { - t.Fatalf("WriteChapter(%d): %v", ch.Ref.Number, err) - } - t.Logf("wrote chapter %d", ch.Ref.Number) - } - }) - - t.Run("ChapterExists", func(t *testing.T) { - for _, ch := range chapters { - if !hs.ChapterExists(ctx, slug, ch.Ref) { - t.Errorf("ChapterExists(chapter %d) = false after WriteChapter", ch.Ref.Number) - } - } - missing := scraper.ChapterRef{Number: 999, Volume: 0} - if hs.ChapterExists(ctx, slug, missing) { - t.Error("ChapterExists(999) = true for a chapter that was never written") - } - }) - - t.Run("ReadChapter", func(t *testing.T) { - for _, ch := range chapters { - got, err := hs.ReadChapter(ctx, slug, ch.Ref.Number) - if err != nil { - t.Fatalf("ReadChapter(%d): %v", ch.Ref.Number, err) - } - // WriteChapter prepends "# <title>\n\n" and appends "\n". - expectedPrefix := "# " + ch.Ref.Title - if !strings.HasPrefix(got, expectedPrefix) { - t.Errorf("chapter %d: content doesn't start with expected header\ngot: %q\nwant prefix: %q", - ch.Ref.Number, got[:min(len(got), 80)], expectedPrefix) - } - if !strings.Contains(got, ch.Text) { - t.Errorf("chapter %d: content doesn't contain original text", ch.Ref.Number) - } - t.Logf("chapter %d: %d bytes", ch.Ref.Number, len(got)) - } - }) - - t.Run("CountChapters", func(t *testing.T) { - count := hs.CountChapters(ctx, slug) - if count != len(chapters) { - t.Errorf("CountChapters = %d, want %d", count, len(chapters)) - } - }) - - t.Run("ListChapters", func(t *testing.T) { - infos, err := hs.ListChapters(ctx, slug) - if err != nil { - t.Fatalf("ListChapters: %v", err) - } - if len(infos) != len(chapters) { - t.Errorf("ListChapters returned %d entries, want %d", len(infos), len(chapters)) - } - for i, info := range infos { - t.Logf("infos[%d]: number=%d title=%q date=%q", i, info.Number, info.Title, info.Date) - } - // Verify sorted order. - for i := 1; i < len(infos); i++ { - if infos[i].Number <= infos[i-1].Number { - t.Errorf("ListChapters not sorted: infos[%d].Number=%d <= infos[%d].Number=%d", - i, infos[i].Number, i-1, infos[i-1].Number) - } - } - }) -} - -// TestHybridStore_WriteReadRanking exercises WriteRankingItem → ReadRankingItems -// round-trip and RankingFreshEnough. -func TestHybridStore_WriteReadRanking(t *testing.T) { - hs := newTestHybridStore(t) - slug1 := "integ-rank-1-" + fmt.Sprintf("%d", time.Now().UnixMilli()) - slug2 := "integ-rank-2-" + fmt.Sprintf("%d", time.Now().UnixMilli()) - slug3 := "integ-rank-3-" + fmt.Sprintf("%d", time.Now().UnixMilli()) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - for _, sl := range []string{slug1, slug2, slug3} { - _ = hs.pb.pb.deleteWhere(cleanCtx, "ranking", fmt.Sprintf(`slug="%s"`, sl)) - } - }) - - items := []RankingItem{ - {Rank: 1, Slug: slug1, Title: "Top Novel", Author: "Author A", Status: "Ongoing", SourceURL: "https://example.com/book/top"}, - {Rank: 2, Slug: slug2, Title: "Second Novel", Author: "Author B", Genres: []string{"Action"}, Status: "Completed"}, - {Rank: 3, Slug: slug3, Title: "Third Novel"}, - } - - t.Run("WriteRankingItem", func(t *testing.T) { - for _, item := range items { - if err := hs.WriteRankingItem(ctx, item); err != nil { - t.Fatalf("WriteRankingItem(%s): %v", item.Slug, err) - } - } - t.Logf("wrote %d ranking items", len(items)) - }) - - t.Run("ReadRankingItems", func(t *testing.T) { - got, err := hs.ReadRankingItems(ctx) - if err != nil { - t.Fatalf("ReadRankingItems: %v", err) - } - // Filter to just our test slugs (other tests may leave rows). - var ours []RankingItem - slugSet := map[string]bool{slug1: true, slug2: true, slug3: true} - for _, g := range got { - if slugSet[g.Slug] { - ours = append(ours, g) - } - } - if len(ours) != 3 { - t.Fatalf("ReadRankingItems returned %d test items, want 3", len(ours)) - } - // Verify order by rank. - for i := 1; i < len(ours); i++ { - if ours[i].Rank <= ours[i-1].Rank { - t.Errorf("items not sorted by rank: ours[%d].Rank=%d, ours[%d].Rank=%d", - i, ours[i].Rank, i-1, ours[i-1].Rank) - } - } - // Verify fields. - if ours[0].Title != "Top Novel" { - t.Errorf("ours[0].Title = %q, want %q", ours[0].Title, "Top Novel") - } - if ours[0].Author != "Author A" { - t.Errorf("ours[0].Author = %q, want %q", ours[0].Author, "Author A") - } - t.Logf("ranking items: %+v", ours) - }) - - t.Run("RankingFreshEnough", func(t *testing.T) { - fresh, err := hs.RankingFreshEnough(ctx, 24*time.Hour) - if err != nil { - t.Fatalf("RankingFreshEnough: %v", err) - } - if !fresh { - t.Error("RankingFreshEnough(24h) returned false immediately after writing items") - } - t.Logf("ranking fresh=true") - }) -} - -// TestHybridStore_Progress exercises SetProgress → GetProgress → AllProgress → -// DeleteProgress via the HybridStore. -func TestHybridStore_Progress(t *testing.T) { - hs := newTestHybridStore(t) - slug := testSlug(t) - const sessionID = "hybrid-test-session-abc" - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = hs.pb.pb.deleteWhere(cleanCtx, "progress", - fmt.Sprintf(`session_id="%s"`, sessionID)) - }) - - p := ReadingProgress{Slug: slug, Chapter: 7, UpdatedAt: time.Now()} - - t.Run("SetProgress", func(t *testing.T) { - if err := hs.SetProgress(ctx, sessionID, p); err != nil { - t.Fatalf("SetProgress: %v", err) - } - }) - - t.Run("GetProgress", func(t *testing.T) { - got, ok := hs.GetProgress(ctx, sessionID, slug) - if !ok { - t.Fatal("GetProgress: not found after SetProgress") - } - if got.Chapter != 7 { - t.Errorf("Chapter = %d, want 7", got.Chapter) - } - if got.Slug != slug { - t.Errorf("Slug = %q, want %q", got.Slug, slug) - } - t.Logf("progress: chapter=%d slug=%q updated=%s", got.Chapter, got.Slug, got.UpdatedAt) - }) - - t.Run("AllProgress", func(t *testing.T) { - all, err := hs.AllProgress(ctx, sessionID) - if err != nil { - t.Fatalf("AllProgress: %v", err) - } - found := false - for _, item := range all { - if item.Slug == slug { - found = true - } - } - if !found { - t.Errorf("AllProgress did not contain slug %q (total=%d)", slug, len(all)) - } - }) - - t.Run("DeleteProgress", func(t *testing.T) { - if err := hs.DeleteProgress(ctx, sessionID, slug); err != nil { - t.Fatalf("DeleteProgress: %v", err) - } - _, ok := hs.GetProgress(ctx, sessionID, slug) - if ok { - t.Error("GetProgress returned ok=true after DeleteProgress") - } - }) -} - -// TestHybridStore_PresignChapter writes a chapter to MinIO via HybridStore, -// then calls PresignChapter and verifies a non-empty URL is returned. -func TestHybridStore_PresignChapter(t *testing.T) { - hs := newTestHybridStore(t) - slug := testSlug(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - ch := scraper.Chapter{ - Ref: scraper.ChapterRef{Number: 1, Title: "Chapter 1: Presign Test", Volume: 0}, - Text: "Text for the presign chapter test.", - } - - if err := hs.WriteChapter(ctx, slug, ch); err != nil { - t.Fatalf("WriteChapter: %v", err) - } - - url, err := hs.PresignChapter(ctx, slug, 1, 10*time.Minute) - if err != nil { - t.Fatalf("PresignChapter: %v", err) - } - if url == "" { - t.Fatal("PresignChapter returned empty URL") - } - if !strings.HasPrefix(url, "http") { - t.Errorf("PresignChapter URL does not start with http: %q", url) - } - t.Logf("presigned chapter URL: %s", url) -} - -// TestHybridStore_PresignAudio puts a fake audio blob into MinIO via the -// underlying MinioClient and verifies PresignAudio returns a valid URL. -func TestHybridStore_PresignAudio(t *testing.T) { - hs := newTestHybridStore(t) - slug := testSlug(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - key := hs.AudioObjectKey(slug, 1, "af_bella") - fakeAudio := []byte("ID3\x03\x00\x00\x00\x00\x00\x00hybrid-presign-audio-test") - - if err := hs.minio.PutAudio(ctx, key, fakeAudio); err != nil { - t.Fatalf("PutAudio: %v", err) - } - - url, err := hs.PresignAudio(ctx, key, 10*time.Minute) - if err != nil { - t.Fatalf("PresignAudio: %v", err) - } - if url == "" { - t.Fatal("PresignAudio returned empty URL") - } - if !strings.HasPrefix(url, "http") { - t.Errorf("PresignAudio URL does not start with http: %q", url) - } - t.Logf("presigned audio URL: %s", url) -} - -// TestHybridStore_AudioCache exercises SetAudioCache → GetAudioCache via HybridStore. -func TestHybridStore_AudioCache(t *testing.T) { - hs := newTestHybridStore(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - cacheKey := fmt.Sprintf("hybrid-audio-test-%d", time.Now().UnixMilli()) - const filename = "speech_hybrid123.mp3" - - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = hs.pb.pb.deleteWhere(cleanCtx, "audio_cache", - fmt.Sprintf(`cache_key="%s"`, cacheKey)) - }) - - if err := hs.SetAudioCache(ctx, cacheKey, filename); err != nil { - t.Fatalf("SetAudioCache: %v", err) - } - - got, ok := hs.GetAudioCache(ctx, cacheKey) - if !ok { - t.Fatal("GetAudioCache returned ok=false after SetAudioCache") - } - if got != filename { - t.Errorf("filename = %q, want %q", got, filename) - } - t.Logf("audio cache: cacheKey=%q filename=%q", cacheKey, got) -} - -// ─── helpers ────────────────────────────────────────────────────────────────── diff --git a/scraper/internal/storage/hybrid_unit_test.go b/scraper/internal/storage/hybrid_unit_test.go deleted file mode 100644 index c90f9f7..0000000 --- a/scraper/internal/storage/hybrid_unit_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package storage - -import ( - "testing" -) - -// ── chapterNumberFromKey ────────────────────────────────────────────────────── - -func TestChapterNumberFromKey(t *testing.T) { - cases := []struct { - key string - want int - }{ - // Standard four-segment key. - {"my-novel/vol-0/1-50/chapter-1.md", 1}, - {"my-novel/vol-0/1-50/chapter-42.md", 42}, - {"my-novel/vol-0/51-100/chapter-99.md", 99}, - // Large chapter numbers. - {"some-novel/vol-1/1001-1050/chapter-1024.md", 1024}, - // Nested deeper paths should still work (last segment used). - {"a/b/c/d/chapter-7.md", 7}, - // Malformed / unexpected inputs — should return 0 without panicking. - {"chapter-notanumber.md", 0}, - {"", 0}, - // No .md extension — TrimSuffix is a no-op; TrimPrefix still strips - // "chapter-", so the number is parsed successfully. - {"no-md-extension/chapter-5", 5}, - {"my-novel/vol-0/1-50/chapter-0.md", 0}, // 0 is invalid (chapters are 1-based) - {"my-novel/vol-0/1-50/chapter--1.md", 0}, - } - - for _, tc := range cases { - got := chapterNumberFromKey(tc.key) - if got != tc.want { - t.Errorf("chapterNumberFromKey(%q) = %d, want %d", tc.key, got, tc.want) - } - } -} - -// ── splitChapterTitle ───────────────────────────────────────────────────────── - -func TestSplitChapterTitle(t *testing.T) { - cases := []struct { - raw string - wantTitle string - wantDate string - }{ - // No date — title is returned as-is. - {"The Great Battle", "The Great Battle", ""}, - // Leading numeric index is stripped. - {"42 The Great Battle", "The Great Battle", ""}, - // Relative date with plural unit. - {"The Storm Arrives 3 days ago", "The Storm Arrives", "3 days ago"}, - // Singular unit. - {"A New Hope 1 week ago", "A New Hope", "1 week ago"}, - // Minutes and seconds. - {"Flash Fight 5 minutes ago", "Flash Fight", "5 minutes ago"}, - {"Quick Strike 30 seconds ago", "Quick Strike", "30 seconds ago"}, - // Months and years. - {"Old Chapter 2 months ago", "Old Chapter", "2 months ago"}, - {"Ancient Story 1 year ago", "Ancient Story", "1 year ago"}, - // Leading index AND trailing date. - {"5 The Final Chapter 2 hours ago", "The Final Chapter", "2 hours ago"}, - // Extra whitespace. - {" The Calm ", "The Calm", ""}, - // Empty string. - {"", "", ""}, - } - - for _, tc := range cases { - title, date := splitChapterTitle(tc.raw) - if title != tc.wantTitle || date != tc.wantDate { - t.Errorf("splitChapterTitle(%q) = (%q, %q), want (%q, %q)", - tc.raw, title, date, tc.wantTitle, tc.wantDate) - } - } -} diff --git a/scraper/internal/storage/integration_test.go b/scraper/internal/storage/integration_test.go deleted file mode 100644 index a7da7b7..0000000 --- a/scraper/internal/storage/integration_test.go +++ /dev/null @@ -1,655 +0,0 @@ -//go:build integration - -// Integration tests for MinioClient and PocketBaseStore against live instances. -// -// These tests require running MinIO and PocketBase services. They are gated -// behind the "integration" build tag and are never run in a normal `go test ./...`. -// -// Run with: -// -// MINIO_ENDPOINT=localhost:9000 \ -// POCKETBASE_URL=http://localhost:8090 \ -// go test -v -tags integration -timeout 120s \ -// github.com/libnovel/scraper/internal/storage -package storage - -import ( - "context" - "fmt" - "log/slog" - "os" - "strings" - "testing" - "time" -) - -// ─── helpers ────────────────────────────────────────────────────────────────── - -func envOr(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} - -func newTestMinioClient(t *testing.T) *MinioClient { - t.Helper() - endpoint := os.Getenv("MINIO_ENDPOINT") - if endpoint == "" { - t.Skip("MINIO_ENDPOINT not set — skipping MinIO integration test") - } - useSSL := os.Getenv("MINIO_USE_SSL") == "true" - cfg := MinioConfig{ - Endpoint: endpoint, - AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), - SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), - UseSSL: useSSL, - BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), - BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), - } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - mc, err := NewMinioClient(ctx, cfg) - if err != nil { - t.Fatalf("NewMinioClient: %v", err) - } - return mc -} - -func newTestPocketBaseStore(t *testing.T) *PocketBaseStore { - t.Helper() - pbURL := os.Getenv("POCKETBASE_URL") - if pbURL == "" { - t.Skip("POCKETBASE_URL not set — skipping PocketBase integration test") - } - cfg := PocketBaseConfig{ - BaseURL: pbURL, - AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"), - AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"), - } - store := NewPocketBaseStore(cfg, slog.Default()) - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if err := store.EnsureCollections(ctx); err != nil { - t.Logf("EnsureCollections (may be harmless): %v", err) - } - return store -} - -// testSlug generates a unique test slug to avoid collisions between parallel runs. -func testSlug(t *testing.T) string { - t.Helper() - safe := strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - return r - } - return '-' - }, strings.ToLower(t.Name())) - // Truncate and append a timestamp to keep it unique. - if len(safe) > 30 { - safe = safe[:30] - } - return fmt.Sprintf("test-%s-%d", safe, time.Now().UnixMilli()%100000) -} - -// ─── MinioClient tests ──────────────────────────────────────────────────────── - -// TestMinioClient_ChapterRoundTrip verifies PutChapter → GetChapter → -// ChapterExists → ListChapterKeys for a single chapter. -func TestMinioClient_ChapterRoundTrip(t *testing.T) { - mc := newTestMinioClient(t) - slug := testSlug(t) - const vol = 0 - const n = 1 - content := "# Chapter 1\n\nHello integration world.\n" - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - t.Run("PutChapter", func(t *testing.T) { - if err := mc.PutChapter(ctx, slug, vol, n, content); err != nil { - t.Fatalf("PutChapter: %v", err) - } - t.Logf("stored chapter at key: %s", chapterKey(slug, vol, n)) - }) - - t.Run("GetChapter", func(t *testing.T) { - got, err := mc.GetChapter(ctx, slug, vol, n) - if err != nil { - t.Fatalf("GetChapter: %v", err) - } - if got != content { - t.Errorf("GetChapter round-trip mismatch:\ngot: %q\nwant: %q", got, content) - } - t.Logf("retrieved %d bytes", len(got)) - }) - - t.Run("ChapterExists", func(t *testing.T) { - if !mc.ChapterExists(ctx, slug, vol, n) { - t.Error("ChapterExists returned false for a just-stored chapter") - } - if mc.ChapterExists(ctx, slug, vol, 999) { - t.Error("ChapterExists returned true for a chapter that was never stored") - } - }) - - t.Run("ListChapterKeys", func(t *testing.T) { - keys, err := mc.ListChapterKeys(ctx, slug) - if err != nil { - t.Fatalf("ListChapterKeys: %v", err) - } - if len(keys) != 1 { - t.Fatalf("ListChapterKeys returned %d keys, want 1: %v", len(keys), keys) - } - expectedKey := chapterKey(slug, vol, n) - if keys[0] != expectedKey { - t.Errorf("key = %q, want %q", keys[0], expectedKey) - } - t.Logf("keys: %v", keys) - }) -} - -// TestMinioClient_MultiChapterList stores several chapters and verifies -// ListChapterKeys returns them all. -func TestMinioClient_MultiChapterList(t *testing.T) { - mc := newTestMinioClient(t) - slug := testSlug(t) - const vol = 0 - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Store chapters 1, 2, 51 (crosses the 1-50 folder boundary). - chapters := []int{1, 2, 51} - for _, n := range chapters { - content := fmt.Sprintf("# Chapter %d\n\nContent for chapter %d.\n", n, n) - if err := mc.PutChapter(ctx, slug, vol, n, content); err != nil { - t.Fatalf("PutChapter(%d): %v", n, err) - } - } - - keys, err := mc.ListChapterKeys(ctx, slug) - if err != nil { - t.Fatalf("ListChapterKeys: %v", err) - } - t.Logf("keys: %v", keys) - if len(keys) != len(chapters) { - t.Errorf("ListChapterKeys returned %d keys, want %d", len(keys), len(chapters)) - } - - count := mc.CountChapters(ctx, slug) - if count != len(chapters) { - t.Errorf("CountChapters = %d, want %d", count, len(chapters)) - } -} - -// TestMinioClient_PresignChapter verifies PresignChapter returns a non-empty URL. -func TestMinioClient_PresignChapter(t *testing.T) { - mc := newTestMinioClient(t) - slug := testSlug(t) - const vol = 0 - const n = 1 - content := "# Presign test\n\nSome content.\n" - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := mc.PutChapter(ctx, slug, vol, n, content); err != nil { - t.Fatalf("PutChapter: %v", err) - } - - url, err := mc.PresignChapter(ctx, slug, vol, n, 10*time.Minute) - if err != nil { - t.Fatalf("PresignChapter: %v", err) - } - if url == "" { - t.Fatal("PresignChapter returned empty URL") - } - t.Logf("presigned URL: %s", url) - - // URL must be an http(s) URL and contain the slug somewhere. - if !strings.HasPrefix(url, "http") { - t.Errorf("URL does not start with http: %q", url) - } -} - -// TestMinioClient_AudioRoundTrip verifies PutAudio → GetAudio → AudioExists. -func TestMinioClient_AudioRoundTrip(t *testing.T) { - mc := newTestMinioClient(t) - slug := testSlug(t) - key := AudioObjectKey(slug, 1, "af_bella") - - // Use minimal fake MP3 bytes (just a recognisable prefix). - fakeAudio := []byte("ID3\x03\x00\x00\x00\x00\x00\x00integration-test-audio") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - t.Run("PutAudio", func(t *testing.T) { - if err := mc.PutAudio(ctx, key, fakeAudio); err != nil { - t.Fatalf("PutAudio: %v", err) - } - t.Logf("stored audio at key: %s", key) - }) - - t.Run("GetAudio", func(t *testing.T) { - got, err := mc.GetAudio(ctx, key) - if err != nil { - t.Fatalf("GetAudio: %v", err) - } - if string(got) != string(fakeAudio) { - t.Errorf("GetAudio round-trip mismatch: got %d bytes, want %d", len(got), len(fakeAudio)) - } - t.Logf("retrieved %d bytes", len(got)) - }) - - t.Run("AudioExists", func(t *testing.T) { - if !mc.AudioExists(ctx, key) { - t.Error("AudioExists returned false for a just-stored audio object") - } - if mc.AudioExists(ctx, "nonexistent/key.mp3") { - t.Error("AudioExists returned true for a key that was never stored") - } - }) -} - -// TestMinioClient_PresignAudio verifies PresignAudio returns a non-empty URL. -func TestMinioClient_PresignAudio(t *testing.T) { - mc := newTestMinioClient(t) - slug := testSlug(t) - key := AudioObjectKey(slug, 1, "af_bella") - fakeAudio := []byte("ID3\x03\x00\x00\x00\x00\x00\x00presign-audio-test") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := mc.PutAudio(ctx, key, fakeAudio); err != nil { - t.Fatalf("PutAudio: %v", err) - } - - url, err := mc.PresignAudio(ctx, key, 10*time.Minute) - if err != nil { - t.Fatalf("PresignAudio: %v", err) - } - if url == "" { - t.Fatal("PresignAudio returned empty URL") - } - if !strings.HasPrefix(url, "http") { - t.Errorf("URL does not start with http: %q", url) - } - t.Logf("presigned audio URL: %s", url) -} - -// ─── PocketBaseStore tests ──────────────────────────────────────────────────── - -// TestPocketBaseStore_Ping verifies that admin auth works. -func TestPocketBaseStore_Ping(t *testing.T) { - store := newTestPocketBaseStore(t) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if err := store.Ping(ctx); err != nil { - t.Fatalf("Ping: %v", err) - } - t.Log("Ping succeeded") -} - -// TestPocketBaseStore_BookRoundTrip tests UpsertBook → GetBook → ListBooks → -// BookMetaUpdated. -func TestPocketBaseStore_BookRoundTrip(t *testing.T) { - store := newTestPocketBaseStore(t) - slug := testSlug(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Clean up after test. - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = store.pb.deleteWhere(cleanCtx, "books", fmt.Sprintf(`slug="%s"`, slug)) - }) - - t.Run("UpsertBook_Create", func(t *testing.T) { - err := store.UpsertBook(ctx, slug, - "Integration Test Novel", "Test Author", - "https://example.com/cover.jpg", "Ongoing", - "A test summary.", "https://example.com/book/test", - []string{"Action", "Fantasy"}, 42, 7, - ) - if err != nil { - t.Fatalf("UpsertBook (create): %v", err) - } - t.Logf("created book %q", slug) - }) - - t.Run("GetBook", func(t *testing.T) { - rec, found, err := store.GetBook(ctx, slug) - if err != nil { - t.Fatalf("GetBook: %v", err) - } - if !found { - t.Fatal("GetBook: book not found after UpsertBook") - } - t.Logf("GetBook record: %v", rec) - if rec["title"] != "Integration Test Novel" { - t.Errorf("title = %v, want %q", rec["title"], "Integration Test Novel") - } - if rec["author"] != "Test Author" { - t.Errorf("author = %v, want %q", rec["author"], "Test Author") - } - }) - - t.Run("ListBooks", func(t *testing.T) { - books, err := store.ListBooks(ctx) - if err != nil { - t.Fatalf("ListBooks: %v", err) - } - found := false - for _, b := range books { - if s, _ := b["slug"].(string); s == slug { - found = true - break - } - } - if !found { - t.Errorf("ListBooks did not return book with slug %q (total=%d)", slug, len(books)) - } - }) - - t.Run("UpsertBook_Update", func(t *testing.T) { - err := store.UpsertBook(ctx, slug, - "Integration Test Novel", "Test Author Updated", - "", "Completed", "", "https://example.com/book/test", - nil, 100, 3, - ) - if err != nil { - t.Fatalf("UpsertBook (update): %v", err) - } - rec, found, err := store.GetBook(ctx, slug) - if err != nil || !found { - t.Fatalf("GetBook after update: found=%v err=%v", found, err) - } - if rec["author"] != "Test Author Updated" { - t.Errorf("author after update = %v, want %q", rec["author"], "Test Author Updated") - } - if rec["status"] != "Completed" { - t.Errorf("status after update = %v, want %q", rec["status"], "Completed") - } - }) - - t.Run("BookMetaUpdated", func(t *testing.T) { - ts, err := store.BookMetaUpdated(ctx, slug) - if err != nil { - t.Fatalf("BookMetaUpdated: %v", err) - } - if ts.IsZero() { - t.Error("BookMetaUpdated returned zero time") - } - t.Logf("meta_updated: %s", ts) - }) -} - -// TestPocketBaseStore_ChapterIdx tests UpsertChapterIdx → ListChapterIdx → -// CountChapterIdx. -func TestPocketBaseStore_ChapterIdx(t *testing.T) { - store := newTestPocketBaseStore(t) - slug := testSlug(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = store.pb.deleteWhere(cleanCtx, "chapters_idx", fmt.Sprintf(`slug="%s"`, slug)) - }) - - chapters := []struct { - n int - title string - date string - }{ - {1, "Chapter 1: The Beginning", "2 days ago"}, - {2, "Chapter 2: Rising Action", "1 day ago"}, - {3, "Chapter 3: Climax", "3 hours ago"}, - } - - for _, ch := range chapters { - if err := store.UpsertChapterIdx(ctx, slug, ch.n, ch.title, ch.date); err != nil { - t.Fatalf("UpsertChapterIdx(%d): %v", ch.n, err) - } - } - - t.Run("ListChapterIdx", func(t *testing.T) { - rows, err := store.ListChapterIdx(ctx, slug) - if err != nil { - t.Fatalf("ListChapterIdx: %v", err) - } - if len(rows) != len(chapters) { - t.Errorf("ListChapterIdx returned %d rows, want %d", len(rows), len(chapters)) - } - for i, row := range rows { - t.Logf("row[%d]: number=%v title=%v date_label=%v", i, row["number"], row["title"], row["date_label"]) - } - }) - - t.Run("CountChapterIdx", func(t *testing.T) { - count := store.CountChapterIdx(ctx, slug) - if count != len(chapters) { - t.Errorf("CountChapterIdx = %d, want %d", count, len(chapters)) - } - }) - - t.Run("UpsertChapterIdx_Update", func(t *testing.T) { - // Re-upsert chapter 2 with an updated title. - if err := store.UpsertChapterIdx(ctx, slug, 2, "Chapter 2: Revised Title", "1 day ago"); err != nil { - t.Fatalf("UpsertChapterIdx (update): %v", err) - } - rows, err := store.ListChapterIdx(ctx, slug) - if err != nil { - t.Fatalf("ListChapterIdx after update: %v", err) - } - if store.CountChapterIdx(ctx, slug) != len(chapters) { - t.Errorf("count changed after update: got %d, want %d", len(rows), len(chapters)) - } - }) -} - -// TestPocketBaseStore_Ranking tests SetRanking → GetRanking → RankingModTime. -func TestPocketBaseStore_Ranking(t *testing.T) { - store := newTestPocketBaseStore(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - slug1 := testSlug(t) + "-rank1" - slug2 := testSlug(t) + "-rank2" - - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - for _, sl := range []string{slug1, slug2} { - _ = store.pb.deleteWhere(cleanCtx, "ranking", fmt.Sprintf(`slug="%s"`, sl)) - } - }) - - items := []RankingItem{ - {Rank: 1, Slug: slug1, Title: "Test Book One", SourceURL: "https://example.com/1"}, - {Rank: 2, Slug: slug2, Title: "Test Book Two", SourceURL: "https://example.com/2"}, - } - - t.Run("WriteRankingItem", func(t *testing.T) { - for _, item := range items { - if err := store.UpsertRankingItem(ctx, item); err != nil { - t.Fatalf("UpsertRankingItem(%q): %v", item.Slug, err) - } - } - t.Log("UpsertRankingItem succeeded") - }) - - t.Run("ReadRankingItems", func(t *testing.T) { - got, err := store.ListRankingItems(ctx) - if err != nil { - t.Fatalf("ListRankingItems: %v", err) - } - found := 0 - for _, g := range got { - if g.Slug == slug1 || g.Slug == slug2 { - found++ - } - } - if found != 2 { - t.Errorf("ListRankingItems: found %d of 2 test items in %d total", found, len(got)) - } - t.Logf("ListRankingItems returned %d total items, %d test items", len(got), found) - }) - - t.Run("RankingFreshEnough", func(t *testing.T) { - updated, err := store.RankingLastUpdated(ctx) - if err != nil { - t.Fatalf("RankingLastUpdated: %v", err) - } - if updated.IsZero() { - t.Error("RankingLastUpdated returned zero time immediately after write") - } - fresh := time.Since(updated) < 24*time.Hour - if !fresh { - t.Errorf("RankingLastUpdated = %s; want within 24h", updated) - } - t.Logf("RankingLastUpdated = %s (fresh=%v)", updated, fresh) - }) -} - -// TestPocketBaseStore_Progress tests SetProgress → GetProgress → AllProgress → -// DeleteProgress. -func TestPocketBaseStore_Progress(t *testing.T) { - store := newTestPocketBaseStore(t) - slug := testSlug(t) - const sessionID = "integration-test-session-xyz" - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = store.pb.deleteWhere(cleanCtx, "progress", - fmt.Sprintf(`session_id="%s"`, sessionID)) - }) - - t.Run("SetProgress", func(t *testing.T) { - if err := store.SetProgress(ctx, sessionID, slug, 5); err != nil { - t.Fatalf("SetProgress: %v", err) - } - }) - - t.Run("GetProgress", func(t *testing.T) { - ch, updated, found, err := store.GetProgress(ctx, sessionID, slug) - if err != nil { - t.Fatalf("GetProgress: %v", err) - } - if !found { - t.Fatal("GetProgress: not found after SetProgress") - } - if ch != 5 { - t.Errorf("chapter = %d, want 5", ch) - } - if updated.IsZero() { - t.Error("updated time is zero") - } - t.Logf("chapter=%d updated=%s", ch, updated) - }) - - t.Run("AllProgress", func(t *testing.T) { - rows, err := store.AllProgress(ctx, sessionID) - if err != nil { - t.Fatalf("AllProgress: %v", err) - } - found := false - for _, r := range rows { - if s, _ := r["slug"].(string); s == slug { - found = true - } - } - if !found { - t.Errorf("AllProgress did not include slug %q (total=%d)", slug, len(rows)) - } - }) - - t.Run("SetProgress_Update", func(t *testing.T) { - if err := store.SetProgress(ctx, sessionID, slug, 12); err != nil { - t.Fatalf("SetProgress (update): %v", err) - } - ch, _, found, err := store.GetProgress(ctx, sessionID, slug) - if err != nil || !found { - t.Fatalf("GetProgress after update: found=%v err=%v", found, err) - } - if ch != 12 { - t.Errorf("chapter after update = %d, want 12", ch) - } - }) - - t.Run("DeleteProgress", func(t *testing.T) { - if err := store.DeleteProgress(ctx, sessionID, slug); err != nil { - t.Fatalf("DeleteProgress: %v", err) - } - _, _, found, err := store.GetProgress(ctx, sessionID, slug) - if err != nil { - t.Fatalf("GetProgress after delete: %v", err) - } - if found { - t.Error("GetProgress returned found=true after DeleteProgress") - } - t.Log("DeleteProgress confirmed") - }) -} - -// TestPocketBaseStore_AudioCache tests SetAudioCache → GetAudioCache. -func TestPocketBaseStore_AudioCache(t *testing.T) { - store := newTestPocketBaseStore(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - cacheKey := fmt.Sprintf("integration-audio-cache-test-%d", time.Now().UnixMilli()) - const filename = "speech_abc123.mp3" - - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = store.pb.deleteWhere(cleanCtx, "audio_cache", - fmt.Sprintf(`cache_key="%s"`, cacheKey)) - }) - - t.Run("SetAudioCache", func(t *testing.T) { - if err := store.SetAudioCache(ctx, cacheKey, filename); err != nil { - t.Fatalf("SetAudioCache: %v", err) - } - }) - - t.Run("GetAudioCache", func(t *testing.T) { - got, found, err := store.GetAudioCache(ctx, cacheKey) - if err != nil { - t.Fatalf("GetAudioCache: %v", err) - } - if !found { - t.Fatal("GetAudioCache: not found after SetAudioCache") - } - if got != filename { - t.Errorf("filename = %q, want %q", got, filename) - } - t.Logf("filename: %s", got) - }) - - t.Run("GetAudioCache_Miss", func(t *testing.T) { - got, found, err := store.GetAudioCache(ctx, "does-not-exist-ever") - if err != nil { - t.Fatalf("GetAudioCache (miss): %v", err) - } - if found { - t.Errorf("GetAudioCache returned found=true for missing key, filename=%q", got) - } - }) -} diff --git a/scraper/internal/storage/minio.go b/scraper/internal/storage/minio.go deleted file mode 100644 index 77589bc..0000000 --- a/scraper/internal/storage/minio.go +++ /dev/null @@ -1,420 +0,0 @@ -package storage - -import ( - "bytes" - "context" - "fmt" - "io" - "strings" - "time" - - "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" -) - -// MinioConfig holds connection parameters for MinIO. -type MinioConfig struct { - Endpoint string // e.g. "minio:9000" — internal address used for all operations - PublicEndpoint string // e.g. "minio.kalekber.cc" — used to sign presigned URLs so browsers can reach them; leave empty to use Endpoint - AccessKey string - SecretKey string - UseSSL bool - PublicUseSSL bool // TLS for the public endpoint (usually true in prod) - BucketChapters string // e.g. "libnovel-chapters" - BucketAudio string // e.g. "libnovel-audio" - BucketBrowse string // e.g. "libnovel-browse" - BucketAvatars string // e.g. "libnovel-avatars" -} - -// MinioClient wraps a minio.Client and exposes object operations for -// chapters and audio files. -type MinioClient struct { - c *minio.Client // internal client — used for all read/write operations - pub *minio.Client // public client — used only for generating presigned URLs - cfg MinioConfig -} - -// NewMinioClient creates a connected MinIO client and ensures the required -// buckets exist. -func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error) { - // minio-go expects a bare "host:port" endpoint — strip any scheme prefix that - // callers may accidentally include (e.g. "https://minio.example.com"). - cfg.Endpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.Endpoint, "https://"), "http://") - if cfg.PublicEndpoint != "" { - cfg.PublicEndpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.PublicEndpoint, "https://"), "http://") - } - - c, err := minio.New(cfg.Endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), - Secure: cfg.UseSSL, - }) - if err != nil { - return nil, fmt.Errorf("minio: new client: %w", err) - } - - // Public client: signs presigned URLs with the public hostname so browsers - // can fetch them directly. Falls back to the internal client if no public - // endpoint is configured. - pub := c - if cfg.PublicEndpoint != "" && cfg.PublicEndpoint != cfg.Endpoint { - pub, err = minio.New(cfg.PublicEndpoint, &minio.Options{ - Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), - Secure: cfg.PublicUseSSL, - }) - if err != nil { - return nil, fmt.Errorf("minio: new public client: %w", err) - } - } - - mc := &MinioClient{c: c, pub: pub, cfg: cfg} - for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse, cfg.BucketAvatars} { - if bucket == "" { - continue - } - if err := mc.ensureBucket(ctx, bucket); err != nil { - return nil, err - } - } - return mc, nil -} - -// ensureBucket creates a bucket if it does not exist. -func (m *MinioClient) ensureBucket(ctx context.Context, bucket string) error { - exists, err := m.c.BucketExists(ctx, bucket) - if err != nil { - return fmt.Errorf("minio: bucket exists %q: %w", bucket, err) - } - if !exists { - if err := m.c.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil { - return fmt.Errorf("minio: make bucket %q: %w", bucket, err) - } - } - return nil -} - -// ─── Chapter objects ────────────────────────────────────────────────────────── - -// chapterKey returns the MinIO object key for a chapter. -// Layout: {slug}/vol-{vol}/{lo}-{hi}/chapter-{n}.md -func chapterKey(slug string, vol, n int) string { - const chaptersPerFolder = 50 - lo := ((n-1)/chaptersPerFolder)*chaptersPerFolder + 1 - hi := lo + chaptersPerFolder - 1 - return fmt.Sprintf("%s/vol-%d/%d-%d/chapter-%d.md", slug, vol, lo, hi, n) -} - -// PutChapter stores chapter markdown in MinIO. -func (m *MinioClient) PutChapter(ctx context.Context, slug string, vol, n int, content string) error { - key := chapterKey(slug, vol, n) - data := []byte(content) - _, err := m.c.PutObject(ctx, m.cfg.BucketChapters, key, - bytes.NewReader(data), int64(len(data)), - minio.PutObjectOptions{ContentType: "text/markdown; charset=utf-8"}) - if err != nil { - return fmt.Errorf("minio: put chapter %s: %w", key, err) - } - return nil -} - -// GetChapter retrieves chapter markdown from MinIO. -func (m *MinioClient) GetChapter(ctx context.Context, slug string, vol, n int) (string, error) { - key := chapterKey(slug, vol, n) - obj, err := m.c.GetObject(ctx, m.cfg.BucketChapters, key, minio.GetObjectOptions{}) - if err != nil { - return "", fmt.Errorf("minio: get chapter %s: %w", key, err) - } - defer obj.Close() - data, err := io.ReadAll(obj) - if err != nil { - return "", fmt.Errorf("minio: read chapter %s: %w", key, err) - } - return string(data), nil -} - -// ChapterExists returns true if the object for this chapter is present. -func (m *MinioClient) ChapterExists(ctx context.Context, slug string, vol, n int) bool { - key := chapterKey(slug, vol, n) - _, err := m.c.StatObject(ctx, m.cfg.BucketChapters, key, minio.StatObjectOptions{}) - return err == nil -} - -// ListChapterKeys returns all object keys under slug/ in the chapters bucket, -// sorted lexicographically (MinIO returns them in order). -func (m *MinioClient) ListChapterKeys(ctx context.Context, slug string) ([]string, error) { - prefix := slug + "/" - var keys []string - for obj := range m.c.ListObjects(ctx, m.cfg.BucketChapters, - minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) { - if obj.Err != nil { - return nil, fmt.Errorf("minio: list chapters %s: %w", slug, obj.Err) - } - keys = append(keys, obj.Key) - } - return keys, nil -} - -// CountChapters returns the number of chapter objects for a slug. -func (m *MinioClient) CountChapters(ctx context.Context, slug string) int { - keys, _ := m.ListChapterKeys(ctx, slug) - return len(keys) -} - -// ─── Audio objects ──────────────────────────────────────────────────────────── - -// AudioObjectKey returns the MinIO key for a cached audio file. -// Key: {slug}/ch{n}-{voice}.mp3 -func AudioObjectKey(slug string, n int, voice string) string { - safe := sanitiseVoice(voice) - return fmt.Sprintf("%s/ch%d-%s.mp3", slug, n, safe) -} - -// PutAudio stores an audio file in the audio bucket. -func (m *MinioClient) PutAudio(ctx context.Context, key string, data []byte) error { - _, err := m.c.PutObject(ctx, m.cfg.BucketAudio, key, - bytes.NewReader(data), int64(len(data)), - minio.PutObjectOptions{ContentType: "audio/mpeg"}) - if err != nil { - return fmt.Errorf("minio: put audio %s: %w", key, err) - } - return nil -} - -// GetAudio retrieves audio bytes from the audio bucket. -func (m *MinioClient) GetAudio(ctx context.Context, key string) ([]byte, error) { - obj, err := m.c.GetObject(ctx, m.cfg.BucketAudio, key, minio.GetObjectOptions{}) - if err != nil { - return nil, fmt.Errorf("minio: get audio %s: %w", key, err) - } - defer obj.Close() - return io.ReadAll(obj) -} - -// AudioExists returns true if the audio object is present in the bucket. -func (m *MinioClient) AudioExists(ctx context.Context, key string) bool { - _, err := m.c.StatObject(ctx, m.cfg.BucketAudio, key, minio.StatObjectOptions{}) - return err == nil -} - -// ─── Presigned URLs ─────────────────────────────────────────────────────────── - -// PresignChapter returns a presigned GET URL for a chapter object signed with -// the internal endpoint — intended for server-side fetches only. -func (m *MinioClient) PresignChapter(ctx context.Context, slug string, vol, n int, expires time.Duration) (string, error) { - key := chapterKey(slug, vol, n) - u, err := m.c.PresignedGetObject(ctx, m.cfg.BucketChapters, key, expires, nil) - if err != nil { - return "", fmt.Errorf("minio: presign chapter %s: %w", key, err) - } - return u.String(), nil -} - -// PresignAudio returns a presigned GET URL for an audio object signed with -// the public endpoint so the browser can fetch it directly. -func (m *MinioClient) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) { - u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAudio, key, expires, nil) - if err != nil { - return "", fmt.Errorf("minio: presign audio %s: %w", key, err) - } - return u.String(), nil -} - -// ─── Browse page snapshots ──────────────────────────────────────────────────── -// -// New bucket layout (libnovel-browse): -// -// {domain}/html/page-{n}.html — SingleFile HTML snapshot -// {domain}/assets/book-covers/{slug}.jpg — downloaded cover image -// -// The domain segment is derived from the source URL hostname -// (e.g. "novelfire.net"). This makes the bucket self-describing and -// extensible to multiple sources. - -// BrowseHTMLKey returns the MinIO object key for a SingleFile HTML snapshot. -// Layout: {domain}/html/page-{n}.html -// This uses the default (popular/all/all) filter combination. -func BrowseHTMLKey(domain string, page int) string { - return fmt.Sprintf("%s/html/page-%d.html", domain, page) -} - -// BrowseFilteredHTMLKey returns the MinIO object key for a browse page snapshot -// that includes filter parameters (sort, genre, status) in the key so that -// different filter combinations are cached independently. -// Layout: {domain}/html/{sort}-{genre}-{status}/page-{n}.html -// Falls back to BrowseHTMLKey when all filters are at their default values -// (sort=popular, genre=all, status=all) for cache compatibility. -func BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string { - if (sort == "" || sort == "popular") && (genre == "" || genre == "all") && (status == "" || status == "all") { - return BrowseHTMLKey(domain, page) - } - if sort == "" { - sort = "popular" - } - if genre == "" { - genre = "all" - } - if status == "" { - status = "all" - } - return fmt.Sprintf("%s/html/%s-%s-%s/page-%d.html", domain, sort, genre, status, page) -} - -// BrowseCoverKey returns the MinIO object key for a cached book cover image. -// Layout: {domain}/assets/book-covers/{slug}.jpg -func BrowseCoverKey(domain, slug string) string { - return fmt.Sprintf("%s/assets/book-covers/%s.jpg", domain, slug) -} - -// PutBrowsePage stores a SingleFile HTML snapshot in the browse bucket. -func (m *MinioClient) PutBrowsePage(ctx context.Context, key, html string) error { - data := []byte(html) - _, err := m.c.PutObject(ctx, m.cfg.BucketBrowse, key, - bytes.NewReader(data), int64(len(data)), - minio.PutObjectOptions{ContentType: "text/html; charset=utf-8"}) - if err != nil { - return fmt.Errorf("minio: put browse page %s: %w", key, err) - } - return nil -} - -// GetBrowsePage retrieves a SingleFile HTML snapshot from the browse bucket. -// Returns ("", false, nil) when the object does not exist. -func (m *MinioClient) GetBrowsePage(ctx context.Context, key string) (string, bool, error) { - obj, err := m.c.GetObject(ctx, m.cfg.BucketBrowse, key, minio.GetObjectOptions{}) - if err != nil { - return "", false, fmt.Errorf("minio: get browse page %s: %w", key, err) - } - defer obj.Close() - // Check whether the object actually exists by inspecting the Stat. - if _, statErr := obj.Stat(); statErr != nil { - return "", false, nil // not found - } - data, err := io.ReadAll(obj) - if err != nil { - return "", false, fmt.Errorf("minio: read browse page %s: %w", key, err) - } - return string(data), true, nil -} - -// BrowsePageExists returns true if a snapshot object is present in the browse bucket. -func (m *MinioClient) BrowsePageExists(ctx context.Context, key string) bool { - _, err := m.c.StatObject(ctx, m.cfg.BucketBrowse, key, minio.StatObjectOptions{}) - return err == nil -} - -// PutBrowseAsset stores a binary asset (e.g. a cover image) in the browse bucket. -// contentType should be the MIME type, e.g. "image/jpeg". -func (m *MinioClient) PutBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error { - _, err := m.c.PutObject(ctx, m.cfg.BucketBrowse, key, - bytes.NewReader(data), int64(len(data)), - minio.PutObjectOptions{ContentType: contentType}) - if err != nil { - return fmt.Errorf("minio: put browse asset %s: %w", key, err) - } - return nil -} - -// GetBrowseAsset retrieves a binary asset from the browse bucket. -// Returns (nil, false, nil) when the object does not exist. -func (m *MinioClient) GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error) { - obj, err := m.c.GetObject(ctx, m.cfg.BucketBrowse, key, minio.GetObjectOptions{}) - if err != nil { - return nil, "", false, fmt.Errorf("minio: get browse asset %s: %w", key, err) - } - defer obj.Close() - info, statErr := obj.Stat() - if statErr != nil { - return nil, "", false, nil // not found - } - data, err := io.ReadAll(obj) - if err != nil { - return nil, "", false, fmt.Errorf("minio: read browse asset %s: %w", key, err) - } - return data, info.ContentType, true, nil -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -// sanitiseVoice converts a voice name to a filename-safe string. -func sanitiseVoice(voice string) string { - return strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '_' || r == '-' { - return r - } - return '_' - }, voice) -} - -// ─── Avatar objects ─────────────────────────────────────────────────────────── - -// avatarKey returns the MinIO object key for a user avatar. -// Layout: avatars/{userId}.{ext} -func avatarKey(userID, ext string) string { - return fmt.Sprintf("avatars/%s.%s", userID, ext) -} - -// PutAvatar stores an avatar image in the avatars bucket. -// ext should be "jpg", "png", or "webp". -func (m *MinioClient) PutAvatar(ctx context.Context, userID, ext string, data []byte, contentType string) error { - if m.cfg.BucketAvatars == "" { - return fmt.Errorf("minio: avatars bucket not configured") - } - key := avatarKey(userID, ext) - _, err := m.c.PutObject(ctx, m.cfg.BucketAvatars, key, - bytes.NewReader(data), int64(len(data)), - minio.PutObjectOptions{ContentType: contentType}) - if err != nil { - return fmt.Errorf("minio: put avatar %s: %w", key, err) - } - return nil -} - -// PresignAvatarUploadURL returns a presigned PUT URL for uploading an avatar image -// directly to MinIO from the client. Signed with the public endpoint so iOS/browser -// can PUT bytes straight to MinIO without routing through the server. -// ext should be "jpg", "png", or "webp". Expires in 15 minutes. -func (m *MinioClient) PresignAvatarUploadURL(ctx context.Context, userID, ext string) (string, string, error) { - if m.cfg.BucketAvatars == "" { - return "", "", fmt.Errorf("minio: avatars bucket not configured") - } - key := avatarKey(userID, ext) - u, err := m.pub.PresignedPutObject(ctx, m.cfg.BucketAvatars, key, 15*time.Minute) - if err != nil { - return "", "", fmt.Errorf("minio: presign avatar upload %s: %w", key, err) - } - return u.String(), key, nil -} - -// PresignAvatarURL returns a presigned GET URL for a user avatar. -// Returns ("", false, nil) when no avatar exists for the given userID. -func (m *MinioClient) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) { - if m.cfg.BucketAvatars == "" { - return "", false, nil - } - // Try common extensions in order of preference. - for _, ext := range []string{"jpg", "png", "webp", "gif"} { - key := avatarKey(userID, ext) - _, statErr := m.c.StatObject(ctx, m.cfg.BucketAvatars, key, minio.StatObjectOptions{}) - if statErr != nil { - continue - } - u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAvatars, key, 24*time.Hour, nil) - if err != nil { - return "", false, fmt.Errorf("minio: presign avatar %s: %w", key, err) - } - return u.String(), true, nil - } - return "", false, nil -} - -// DeleteAvatar removes any existing avatar for the given userID (all extensions). -func (m *MinioClient) DeleteAvatar(ctx context.Context, userID string) error { - if m.cfg.BucketAvatars == "" { - return nil - } - for _, ext := range []string{"jpg", "png", "webp", "gif"} { - key := avatarKey(userID, ext) - _ = m.c.RemoveObject(ctx, m.cfg.BucketAvatars, key, minio.RemoveObjectOptions{}) - } - return nil -} diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go deleted file mode 100644 index 94cf579..0000000 --- a/scraper/internal/storage/pocketbase.go +++ /dev/null @@ -1,939 +0,0 @@ -// Package storage — PocketBase REST client. -// -// Collections expected in PocketBase: -// -// books — slug(text,unique), title, author, cover, status, genres(json), -// summary, total_chapters(number), source_url, ranking(number), updated(date) -// chapters_idx — slug(text), number(number), title, date_label, updated(date) -// ranking — rank(number), slug(text,unique), title, author, cover, status, -// genres(json), source_url, updated(date) -// progress — session_id(text), slug(text), chapter(number), updated(date) -// audio_cache — cache_key(text,unique), filename(text), updated(date) -// app_users — username(text,unique), password_hash(text), role(text), created(date) -// scraping_tasks — id(auto), kind(text), target_url(text), status(text), -// books_found(number), chapters_scraped(number), -// chapters_skipped(number), errors(number), -// started(date), finished(date), error_message(text) -// user_sessions — user_id(text), session_id(text,unique), user_agent(text), -// ip(text), created_at(date), last_seen(date) -// book_comments — slug(text), user_id(text), username(text), body(text), -// upvotes(number), downvotes(number), created(date) -// comment_votes — comment_id(text), user_id(text), session_id(text), vote(text: up|down) -package storage - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "strings" - "sync" - "time" - - "github.com/libnovel/scraper/internal/scraper" -) - -// PocketBaseConfig holds PocketBase connection settings. -type PocketBaseConfig struct { - BaseURL string // e.g. "http://pocketbase:8090" - AdminEmail string - AdminPassword string -} - -// pbClient is a minimal PocketBase admin REST client. -type pbClient struct { - cfg PocketBaseConfig - httpClient *http.Client - log *slog.Logger - - tokenMu sync.RWMutex - token string - tokenExp time.Time -} - -// newPBClient creates a new PocketBase client. It does not authenticate yet; -// authentication happens lazily on the first API call. -func newPBClient(cfg PocketBaseConfig, log *slog.Logger) *pbClient { - return &pbClient{ - cfg: cfg, - httpClient: &http.Client{Timeout: 15 * time.Second}, - log: log, - } -} - -// ─── Auth ───────────────────────────────────────────────────────────────────── - -func (p *pbClient) authenticate(ctx context.Context) error { - body, _ := json.Marshal(map[string]string{ - "identity": p.cfg.AdminEmail, - "password": p.cfg.AdminPassword, - }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - p.cfg.BaseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - resp, err := p.httpClient.Do(req) - if err != nil { - return fmt.Errorf("pocketbase: auth: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("pocketbase: auth status %d: %s", resp.StatusCode, b) - } - var result struct { - Token string `json:"token"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("pocketbase: decode auth: %w", err) - } - p.tokenMu.Lock() - p.token = result.Token - p.tokenExp = time.Now().Add(12 * time.Hour) - p.tokenMu.Unlock() - return nil -} - -func (p *pbClient) authToken(ctx context.Context) (string, error) { - p.tokenMu.RLock() - tok, exp := p.token, p.tokenExp - p.tokenMu.RUnlock() - if tok != "" && time.Now().Before(exp) { - return tok, nil - } - if err := p.authenticate(ctx); err != nil { - return "", err - } - p.tokenMu.RLock() - defer p.tokenMu.RUnlock() - return p.token, nil -} - -// ─── Generic CRUD helpers ────────────────────────────────────────────────────── - -func (p *pbClient) do(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { - tok, err := p.authToken(ctx) - if err != nil { - return nil, err - } - - var bodyReader io.Reader - if body != nil { - b, _ := json.Marshal(body) - bodyReader = bytes.NewReader(b) - } - - req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, bodyReader) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+tok) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - return p.httpClient.Do(req) -} - -// listOne fetches the first matching record from a collection. -func (p *pbClient) listOne(ctx context.Context, collection, filter string) (map[string]interface{}, error) { - q := url.Values{} - q.Set("filter", filter) - q.Set("perPage", "1") - path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode()) - resp, err := p.do(ctx, http.MethodGet, path, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return nil, nil - } - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("pocketbase: listOne %s: status %d: %s", collection, resp.StatusCode, b) - } - var result struct { - Items []map[string]interface{} `json:"items"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("pocketbase: listOne %s: decode: %w", collection, err) - } - if len(result.Items) == 0 { - return nil, nil - } - return result.Items[0], nil -} - -// listAll returns all records from a collection matching filter by paginating -// through all pages (PocketBase default page size is capped at 500). -func (p *pbClient) listAll(ctx context.Context, collection, filter, sort string) ([]map[string]interface{}, error) { - const perPage = 500 - var all []map[string]interface{} - - for page := 1; ; page++ { - q := url.Values{} - if filter != "" { - q.Set("filter", filter) - } - if sort != "" { - q.Set("sort", sort) - } - q.Set("perPage", fmt.Sprintf("%d", perPage)) - q.Set("page", fmt.Sprintf("%d", page)) - path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode()) - resp, err := p.do(ctx, http.MethodGet, path, nil) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return nil, fmt.Errorf("pocketbase: listAll %s: status %d: %s", collection, resp.StatusCode, b) - } - var result struct { - Page int `json:"page"` - TotalPages int `json:"totalPages"` - Items []map[string]interface{} `json:"items"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - resp.Body.Close() - return nil, fmt.Errorf("pocketbase: listAll %s: decode: %w", collection, err) - } - resp.Body.Close() - all = append(all, result.Items...) - if page >= result.TotalPages || len(result.Items) == 0 { - break - } - } - return all, nil -} - -// upsert creates a record; if one matching filter already exists it updates it. -func (p *pbClient) upsert(ctx context.Context, collection, filter string, data map[string]interface{}) error { - existing, err := p.listOne(ctx, collection, filter) - if err != nil { - return err - } - if existing != nil { - id := existing["id"].(string) - resp, err := p.do(ctx, http.MethodPatch, - fmt.Sprintf("/api/collections/%s/records/%s", collection, id), data) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("pocketbase: upsert (patch) %s id=%s: status %d: %s", collection, id, resp.StatusCode, b) - } - return nil - } - resp, err := p.do(ctx, http.MethodPost, - fmt.Sprintf("/api/collections/%s/records", collection), data) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("pocketbase: upsert (create) %s: status %d: %s", collection, resp.StatusCode, b) - } - return nil -} - -// deleteWhere deletes all records matching filter in collection. -func (p *pbClient) deleteWhere(ctx context.Context, collection, filter string) error { - items, err := p.listAll(ctx, collection, filter, "") - if err != nil { - return err - } - for _, item := range items { - id, _ := item["id"].(string) - resp, err := p.do(ctx, http.MethodDelete, - fmt.Sprintf("/api/collections/%s/records/%s", collection, id), nil) - if err != nil { - return err - } - if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return fmt.Errorf("pocketbase: deleteWhere %s id=%s: status %d: %s", collection, id, resp.StatusCode, b) - } - resp.Body.Close() - } - return nil -} - -// ─── PocketBaseStore ────────────────────────────────────────────────────────── - -// PocketBaseStore implements the structured-data portion of the Store interface -// backed by PocketBase REST API. -type PocketBaseStore struct { - pb *pbClient - log *slog.Logger -} - -// NewPocketBaseStore returns a connected PocketBaseStore. -func NewPocketBaseStore(cfg PocketBaseConfig, log *slog.Logger) *PocketBaseStore { - return &PocketBaseStore{pb: newPBClient(cfg, log), log: log} -} - -// Ping verifies connectivity by authenticating. -func (s *PocketBaseStore) Ping(ctx context.Context) error { - _, err := s.pb.authToken(ctx) - return err -} - -// ─── Collections schema bootstrap ──────────────────────────────────────────── -// CollectionDef maps a collection name to its fields for auto-creation. - -// EnsureCollections creates missing collections via the PocketBase API. -// Safe to call on every startup — existing collections are skipped. -func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { - // We just attempt to create each collection; 400/422 errors for "already - // exists" are silently ignored. - // PocketBase v0.22+ uses "fields"; older versions used "schema". - // We use "fields" which is the current API. - collections := []map[string]interface{}{ - { - "name": "books", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "slug", "type": "text", "required": true}, - {"name": "title", "type": "text", "required": true}, - {"name": "author", "type": "text"}, - {"name": "cover", "type": "text"}, - {"name": "status", "type": "text"}, - {"name": "genres", "type": "json"}, - {"name": "summary", "type": "text"}, - {"name": "total_chapters", "type": "number"}, - {"name": "source_url", "type": "text"}, - {"name": "ranking", "type": "number"}, - {"name": "meta_updated", "type": "date"}, - }, - }, - { - "name": "chapters_idx", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "slug", "type": "text", "required": true}, - {"name": "number", "type": "number", "required": true}, - {"name": "title", "type": "text"}, - {"name": "date_label", "type": "text"}, - }, - }, - { - "name": "ranking", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "rank", "type": "number", "required": true}, - {"name": "slug", "type": "text", "required": true}, - {"name": "title", "type": "text"}, - {"name": "author", "type": "text"}, - {"name": "cover", "type": "text"}, - {"name": "status", "type": "text"}, - {"name": "genres", "type": "json"}, - {"name": "source_url", "type": "text"}, - {"name": "updated", "type": "date"}, - }, - }, - { - "name": "progress", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "session_id", "type": "text", "required": true}, - {"name": "user_id", "type": "text"}, - {"name": "slug", "type": "text", "required": true}, - {"name": "chapter", "type": "number"}, - {"name": "updated", "type": "date"}, - }, - }, - { - "name": "audio_cache", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "cache_key", "type": "text", "required": true}, - {"name": "filename", "type": "text"}, - {"name": "updated", "type": "date"}, - }, - }, - { - "name": "app_users", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "username", "type": "text", "required": true}, - {"name": "password_hash", "type": "text", "required": true}, - {"name": "role", "type": "text"}, - {"name": "created", "type": "date"}, - }, - }, - { - "name": "user_library", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "session_id", "type": "text", "required": true}, - {"name": "user_id", "type": "text"}, - {"name": "slug", "type": "text", "required": true}, - {"name": "saved_at", "type": "date"}, - }, - }, - { - "name": "scraping_tasks", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "kind", "type": "text", "required": true}, // "catalogue" | "book" - {"name": "target_url", "type": "text"}, // set for single-book scrapes - {"name": "status", "type": "text", "required": true}, // "running" | "done" | "failed" | "cancelled" - {"name": "books_found", "type": "number"}, - {"name": "chapters_scraped", "type": "number"}, - {"name": "chapters_skipped", "type": "number"}, - {"name": "errors", "type": "number"}, - {"name": "started", "type": "date"}, - {"name": "finished", "type": "date"}, - {"name": "error_message", "type": "text"}, - }, - }, - { - "name": "audio_jobs", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "cache_key", "type": "text", "required": true}, // "slug/chapter/voice" - {"name": "slug", "type": "text", "required": true}, - {"name": "chapter", "type": "number"}, - {"name": "voice", "type": "text"}, - {"name": "status", "type": "text", "required": true}, // "pending" | "generating" | "done" | "failed" - {"name": "error_message", "type": "text"}, - {"name": "started", "type": "date"}, - {"name": "finished", "type": "date"}, - }, - }, - { - "name": "user_sessions", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "user_id", "type": "text", "required": true}, - {"name": "session_id", "type": "text", "required": true}, // random ID embedded in auth token - {"name": "user_agent", "type": "text"}, - {"name": "ip", "type": "text"}, - {"name": "created_at", "type": "date"}, - {"name": "last_seen", "type": "date"}, - }, - }, - { - "name": "book_comments", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "slug", "type": "text", "required": true}, - {"name": "user_id", "type": "text"}, - {"name": "username", "type": "text"}, - {"name": "body", "type": "text", "required": true}, - {"name": "upvotes", "type": "number"}, - {"name": "downvotes", "type": "number"}, - {"name": "created", "type": "date"}, - {"name": "parent_id", "type": "text"}, // empty = top-level; set = reply to that comment ID - }, - }, - { - "name": "comment_votes", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "comment_id", "type": "text", "required": true}, - {"name": "user_id", "type": "text"}, - {"name": "session_id", "type": "text", "required": true}, - {"name": "vote", "type": "text", "required": true}, // "up" | "down" - }, - }, - { - // follower_id follows followee_id - "name": "user_subscriptions", - "type": "base", - "fields": []map[string]interface{}{ - {"name": "follower_id", "type": "text", "required": true}, - {"name": "followee_id", "type": "text", "required": true}, - {"name": "created", "type": "date"}, - }, - }, - } - for _, col := range collections { - name, _ := col["name"].(string) - resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections", col) - if err != nil { - return fmt.Errorf("pocketbase: ensure collection %q: %w", name, err) - } - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - switch resp.StatusCode { - case http.StatusOK, http.StatusCreated: - s.log.Info("pocketbase: collection created", "collection", name) - case http.StatusBadRequest, http.StatusUnprocessableEntity: - // Already exists or schema mismatch — expected on subsequent startups. - s.log.Debug("pocketbase: collection already exists (skipped)", "collection", name) - default: - s.log.Warn("pocketbase: unexpected status ensuring collection", - "collection", name, "status", resp.StatusCode, "body", string(b)) - } - } - return nil -} - -// ─── Schema migrations ──────────────────────────────────────────────────────── - -// migration describes a single field to guarantee exists in a collection. -type migration struct { - collection string - fieldName string - fieldType string -} - -// migrations is the ordered list of schema changes applied on every startup. -var migrations = []migration{ - // user_id was added to progress after initial deploy. - {"progress", "user_id", "text"}, - // avatar_url stores the MinIO presign path for the user's profile picture. - {"app_users", "avatar_url", "text"}, - // parent_id enables 1-level comment nesting (replies). Empty = top-level comment. - {"book_comments", "parent_id", "text"}, -} - -// EnsureMigrations idempotently adds any fields that are missing from existing -// collections. It fetches the current schema, checks for each field by name, -// and PATCHes the collection only when something is absent. -// Safe to call on every startup — no-ops when schema is already up to date. -func (s *PocketBaseStore) EnsureMigrations(ctx context.Context) error { - for _, m := range migrations { - if err := s.ensureField(ctx, m); err != nil { - return err - } - } - return nil -} - -func (s *PocketBaseStore) ensureField(ctx context.Context, m migration) error { - // Fetch current collection schema. - resp, err := s.pb.do(ctx, http.MethodGet, "/api/collections/"+m.collection, nil) - if err != nil { - return fmt.Errorf("pocketbase: ensureField %s.%s: fetch schema: %w", m.collection, m.fieldName, err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("pocketbase: ensureField %s.%s: fetch schema status %d: %s", m.collection, m.fieldName, resp.StatusCode, body) - } - - var schema struct { - ID string `json:"id"` - Fields []map[string]interface{} `json:"fields"` - } - if err := json.Unmarshal(body, &schema); err != nil { - return fmt.Errorf("pocketbase: ensureField %s.%s: decode schema: %w", m.collection, m.fieldName, err) - } - - // Check if field already exists. - for _, f := range schema.Fields { - if name, _ := f["name"].(string); name == m.fieldName { - s.log.Debug("pocketbase: field already exists, skipping migration", - "collection", m.collection, "field", m.fieldName) - return nil - } - } - - // Append the new field and PATCH the collection. - newFields := append(schema.Fields, map[string]interface{}{ - "name": m.fieldName, - "type": m.fieldType, - }) - patch := map[string]interface{}{"fields": newFields} - patchResp, err := s.pb.do(ctx, http.MethodPatch, "/api/collections/"+schema.ID, patch) - if err != nil { - return fmt.Errorf("pocketbase: ensureField %s.%s: patch: %w", m.collection, m.fieldName, err) - } - defer patchResp.Body.Close() - patchBody, _ := io.ReadAll(patchResp.Body) - if patchResp.StatusCode != http.StatusOK { - return fmt.Errorf("pocketbase: ensureField %s.%s: patch status %d: %s", m.collection, m.fieldName, patchResp.StatusCode, patchBody) - } - s.log.Info("pocketbase: schema migration applied", "collection", m.collection, "field", m.fieldName, "type", m.fieldType) - return nil -} - -// ─── Book metadata ──────────────────────────────────────────────────────────── - -func (s *PocketBaseStore) UpsertBook(ctx context.Context, slug, title, author, cover, status, summary, sourceURL string, genres []string, totalChapters, ranking int) error { - genresJSON, _ := json.Marshal(genres) - return s.pb.upsert(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug)), map[string]interface{}{ - "slug": slug, - "title": title, - "author": author, - "cover": cover, - "status": status, - "genres": string(genresJSON), - "summary": summary, - "total_chapters": totalChapters, - "source_url": sourceURL, - "ranking": ranking, - "meta_updated": time.Now().UTC().Format(time.RFC3339), - }) -} - -func (s *PocketBaseStore) GetBook(ctx context.Context, slug string) (map[string]interface{}, bool, error) { - rec, err := s.pb.listOne(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug))) - if err != nil { - return nil, false, err - } - if rec == nil { - return nil, false, nil - } - return rec, true, nil -} - -func (s *PocketBaseStore) ListBooks(ctx context.Context) ([]map[string]interface{}, error) { - return s.pb.listAll(ctx, "books", "", "+title") -} - -func (s *PocketBaseStore) BookMetaUpdated(ctx context.Context, slug string) (time.Time, error) { - rec, err := s.pb.listOne(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug))) - if err != nil || rec == nil { - return time.Time{}, err - } - if ts, ok := rec["meta_updated"].(string); ok { - t, err := time.Parse(time.RFC3339, ts) - if err == nil { - return t, nil - } - } - return time.Time{}, nil -} - -// ─── Chapter index ──────────────────────────────────────────────────────────── - -func (s *PocketBaseStore) UpsertChapterIdx(ctx context.Context, slug string, number int, title, dateLabel string) error { - return s.pb.upsert(ctx, "chapters_idx", - fmt.Sprintf(`slug="%s"&&number=%d`, pbEsc(slug), number), - map[string]interface{}{ - "slug": slug, - "number": number, - "title": title, - "date_label": dateLabel, - }) -} - -// WriteChapterRefs upserts chapter index rows (number + title) for all refs -// without writing any chapter text. Errors are logged and skipped; the -// operation is best-effort. -func (s *PocketBaseStore) WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error { - var firstErr error - for _, ref := range refs { - if err := s.UpsertChapterIdx(ctx, slug, ref.Number, ref.Title, ""); err != nil { - s.log.Warn("pocketbase: WriteChapterRefs: upsert failed", - "slug", slug, "chapter", ref.Number, "err", err) - if firstErr == nil { - firstErr = err - } - } - } - return firstErr -} - -func (s *PocketBaseStore) ListChapterIdx(ctx context.Context, slug string) ([]map[string]interface{}, error) { - return s.pb.listAll(ctx, "chapters_idx", - fmt.Sprintf(`slug="%s"`, pbEsc(slug)), "+number") -} - -func (s *PocketBaseStore) CountChapterIdx(ctx context.Context, slug string) int { - rows, err := s.ListChapterIdx(ctx, slug) - if err != nil { - s.log.Warn("pocketbase: CountChapterIdx failed", "slug", slug, "err", err) - return 0 - } - return len(rows) -} - -// ─── Ranking (per-item) ─────────────────────────────────────────────────────── - -func (s *PocketBaseStore) UpsertRankingItem(ctx context.Context, item RankingItem) error { - genresJSON, _ := json.Marshal(item.Genres) - return s.pb.upsert(ctx, "ranking", fmt.Sprintf(`slug="%s"`, pbEsc(item.Slug)), map[string]interface{}{ - "rank": item.Rank, - "slug": item.Slug, - "title": item.Title, - "author": item.Author, - "cover": item.Cover, - "status": item.Status, - "genres": string(genresJSON), - "source_url": item.SourceURL, - "updated": time.Now().UTC().Format(time.RFC3339), - }) -} - -func (s *PocketBaseStore) ListRankingItems(ctx context.Context) ([]RankingItem, error) { - rows, err := s.pb.listAll(ctx, "ranking", "", "+rank") - if err != nil { - return nil, err - } - items := make([]RankingItem, 0, len(rows)) - for _, r := range rows { - item := RankingItem{ - Rank: int(floatVal(r, "rank")), - Slug: strVal(r, "slug"), - Title: strVal(r, "title"), - Author: strVal(r, "author"), - Cover: strVal(r, "cover"), - Status: strVal(r, "status"), - SourceURL: strVal(r, "source_url"), - } - if ts, ok := r["updated"].(string); ok { - item.Updated, _ = time.Parse(time.RFC3339, ts) - } - switch v := r["genres"].(type) { - case string: - _ = json.Unmarshal([]byte(v), &item.Genres) - case []interface{}: - for _, g := range v { - if s, ok := g.(string); ok { - item.Genres = append(item.Genres, s) - } - } - } - items = append(items, item) - } - return items, nil -} - -// RankingLastUpdated returns the most recent Updated time across all ranking rows, -// or the zero time if no rows exist. -func (s *PocketBaseStore) RankingLastUpdated(ctx context.Context) (time.Time, error) { - // listAll with sort "-updated" and perPage=1 is the cheapest approach. - q := url.Values{} - q.Set("sort", "-updated") - q.Set("perPage", "1") - path := fmt.Sprintf("/api/collections/ranking/records?%s", q.Encode()) - resp, err := s.pb.do(ctx, http.MethodGet, path, nil) - if err != nil { - return time.Time{}, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return time.Time{}, fmt.Errorf("pocketbase: RankingLastUpdated: status %d: %s", resp.StatusCode, b) - } - var result struct { - Items []map[string]interface{} `json:"items"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return time.Time{}, fmt.Errorf("pocketbase: RankingLastUpdated: decode: %w", err) - } - if len(result.Items) == 0 { - return time.Time{}, nil - } - ts, _ := result.Items[0]["updated"].(string) - t, _ := time.Parse(time.RFC3339, ts) - return t, nil -} - -// ─── Reading progress ───────────────────────────────────────────────────────── - -func (s *PocketBaseStore) SetProgress(ctx context.Context, sessionID, slug string, chapter int) error { - return s.pb.upsert(ctx, "progress", - fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug)), - map[string]interface{}{ - "session_id": sessionID, - "slug": slug, - "chapter": chapter, - "updated": time.Now().UTC().Format(time.RFC3339), - }) -} - -func (s *PocketBaseStore) GetProgress(ctx context.Context, sessionID, slug string) (int, time.Time, bool, error) { - rec, err := s.pb.listOne(ctx, "progress", - fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug))) - if err != nil { - return 0, time.Time{}, false, err - } - if rec == nil { - return 0, time.Time{}, false, nil - } - ch := int(floatVal(rec, "chapter")) - var updated time.Time - if ts, ok := rec["updated"].(string); ok { - updated, _ = time.Parse(time.RFC3339, ts) - } - return ch, updated, true, nil -} - -func (s *PocketBaseStore) AllProgress(ctx context.Context, sessionID string) ([]map[string]interface{}, error) { - return s.pb.listAll(ctx, "progress", - fmt.Sprintf(`session_id="%s"`, pbEsc(sessionID)), "-updated") -} - -func (s *PocketBaseStore) DeleteProgress(ctx context.Context, sessionID, slug string) error { - return s.pb.deleteWhere(ctx, "progress", - fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug))) -} - -// ─── Audio cache ────────────────────────────────────────────────────────────── - -func (s *PocketBaseStore) SetAudioCache(ctx context.Context, cacheKey, filename string) error { - return s.pb.upsert(ctx, "audio_cache", - fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey)), - map[string]interface{}{ - "cache_key": cacheKey, - "filename": filename, - "updated": time.Now().UTC().Format(time.RFC3339), - }) -} - -func (s *PocketBaseStore) GetAudioCache(ctx context.Context, cacheKey string) (string, bool, error) { - rec, err := s.pb.listOne(ctx, "audio_cache", - fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey))) - if err != nil { - return "", false, err - } - if rec == nil { - return "", false, nil - } - filename, _ := rec["filename"].(string) - return filename, filename != "", nil -} - -// ─── Scraping tasks ─────────────────────────────────────────────────────────── - -// CreateScrapingTask inserts a new scraping_tasks record with status="running" -// and returns the newly created record's ID. -func (s *PocketBaseStore) CreateScrapingTask(ctx context.Context, kind, targetURL string) (string, error) { - data := map[string]interface{}{ - "kind": kind, - "target_url": targetURL, - "status": "running", - "books_found": 0, - "chapters_scraped": 0, - "chapters_skipped": 0, - "errors": 0, - "started": time.Now().UTC().Format(time.RFC3339), - } - resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections/scraping_tasks/records", data) - if err != nil { - return "", err - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - return "", fmt.Errorf("pocketbase: CreateScrapingTask: status %d: %s", resp.StatusCode, b) - } - var rec map[string]interface{} - if err := json.Unmarshal(b, &rec); err != nil { - return "", fmt.Errorf("pocketbase: CreateScrapingTask: decode: %w", err) - } - id, _ := rec["id"].(string) - return id, nil -} - -// UpdateScrapingTask patches counters on an existing scraping_tasks record. -func (s *PocketBaseStore) UpdateScrapingTask(ctx context.Context, id string, data map[string]interface{}) error { - resp, err := s.pb.do(ctx, http.MethodPatch, - fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), data) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("pocketbase: UpdateScrapingTask id=%s: status %d: %s", id, resp.StatusCode, b) - } - return nil -} - -// ListScrapingTasks returns all scraping_tasks sorted by started descending. -func (s *PocketBaseStore) ListScrapingTasks(ctx context.Context) ([]map[string]interface{}, error) { - return s.pb.listAll(ctx, "scraping_tasks", "", "-started") -} - -// ─── Audio jobs ─────────────────────────────────────────────────────────────── - -// CreateAudioJob inserts a new audio_jobs record with status="pending". -func (s *PocketBaseStore) CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) { - cacheKey := fmt.Sprintf("%s/%d/%s", slug, chapter, voice) - data := map[string]interface{}{ - "cache_key": cacheKey, - "slug": slug, - "chapter": chapter, - "voice": voice, - "status": "pending", - "started": time.Now().UTC().Format(time.RFC3339), - } - resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections/audio_jobs/records", data) - if err != nil { - return "", err - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - return "", fmt.Errorf("pocketbase: CreateAudioJob: status %d: %s", resp.StatusCode, b) - } - var rec map[string]interface{} - if err := json.Unmarshal(b, &rec); err != nil { - return "", fmt.Errorf("pocketbase: CreateAudioJob: decode: %w", err) - } - id, _ := rec["id"].(string) - return id, nil -} - -// UpdateAudioJob patches status, error_message, and optionally finished on an audio_jobs record. -func (s *PocketBaseStore) UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error { - data := map[string]interface{}{ - "status": status, - "error_message": errMsg, - } - if !finished.IsZero() { - data["finished"] = finished.UTC().Format(time.RFC3339) - } - resp, err := s.pb.do(ctx, http.MethodPatch, - fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), data) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("pocketbase: UpdateAudioJob id=%s: status %d: %s", id, resp.StatusCode, b) - } - return nil -} - -// GetAudioJob returns the most recent audio_jobs record for the given cache key. -func (s *PocketBaseStore) GetAudioJob(ctx context.Context, cacheKey string) (map[string]interface{}, bool, error) { - rec, err := s.pb.listOne(ctx, "audio_jobs", - fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey))) - if err != nil { - return nil, false, err - } - if rec == nil { - return nil, false, nil - } - return rec, true, nil -} - -// ListAudioJobs returns all audio_jobs sorted by started descending. -func (s *PocketBaseStore) ListAudioJobs(ctx context.Context) ([]map[string]interface{}, error) { - return s.pb.listAll(ctx, "audio_jobs", "", "-started") -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -// pbEsc escapes a string for use in a PocketBase filter expression. -// Only escapes double-quotes to prevent injection. -func pbEsc(s string) string { - return strings.ReplaceAll(s, `"`, `\"`) -} - -func floatVal(m map[string]interface{}, key string) float64 { - if v, ok := m[key].(float64); ok { - return v - } - return 0 -} diff --git a/scraper/internal/storage/scrape_integration_test.go b/scraper/internal/storage/scrape_integration_test.go deleted file mode 100644 index 515875a..0000000 --- a/scraper/internal/storage/scrape_integration_test.go +++ /dev/null @@ -1,203 +0,0 @@ -//go:build integration - -// Integration tests that combine live scraping (Browserless) with real storage -// (MinIO + PocketBase) via HybridStore. -// -// These tests require ALL THREE services to be running. They are gated behind -// the "integration" build tag and skipped when any service URL is missing. -// -// Run with: -// -// BROWSERLESS_URL=http://localhost:3030 \ -// MINIO_ENDPOINT=localhost:9000 \ -// POCKETBASE_URL=http://localhost:8090 \ -// go test -v -tags integration -timeout 600s \ -// github.com/libnovel/scraper/internal/storage -package storage - -import ( - "context" - "fmt" - "log/slog" - "os" - "strings" - "testing" - "time" - - "github.com/libnovel/scraper/internal/browser" - "github.com/libnovel/scraper/internal/novelfire" - "github.com/libnovel/scraper/internal/scraper" -) - -const ( - scrapeTestBookURL = "https://novelfire.net/book/a-dragon-against-the-whole-world" - scrapeTestBookSlug = "a-dragon-against-the-whole-world" -) - -// newScrapeAndStoreFixture builds a novelfire Scraper and a HybridStore, -// skipping the test if any required env var is absent. -func newScrapeAndStoreFixture(t *testing.T) (*novelfire.Scraper, *HybridStore) { - t.Helper() - - browserlessURL := os.Getenv("BROWSERLESS_URL") - if browserlessURL == "" { - t.Skip("BROWSERLESS_URL not set — skipping scrape+store integration test") - } - if os.Getenv("MINIO_ENDPOINT") == "" { - t.Skip("MINIO_ENDPOINT not set — skipping scrape+store integration test") - } - if os.Getenv("POCKETBASE_URL") == "" { - t.Skip("POCKETBASE_URL not set — skipping scrape+store integration test") - } - - client := browser.NewContentClient(browser.Config{ - BaseURL: browserlessURL, - Token: os.Getenv("BROWSERLESS_TOKEN"), - Timeout: 120 * time.Second, - MaxConcurrent: 1, - }) - log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) - sc := novelfire.New(client, log, client, nil, nil) - hs := newTestHybridStore(t) - return sc, hs -} - -// TestScrapeAndStore_BookMetadata scrapes the test book's metadata and stores -// it via HybridStore.WriteMetadata, then verifies a ReadMetadata round-trip. -func TestScrapeAndStore_BookMetadata(t *testing.T) { - sc, hs := newScrapeAndStoreFixture(t) - - slug := scrapeTestBookSlug + "-scrapetest" - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = hs.pb.pb.deleteWhere(cleanCtx, "books", fmt.Sprintf(`slug="%s"`, slug)) - }) - - // 1. Scrape metadata from the live site. - scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 60*time.Second) - defer scrapeCancel() - - meta, err := sc.ScrapeMetadata(scrapeCtx, scrapeTestBookURL) - if err != nil { - t.Fatalf("ScrapeMetadata: %v", err) - } - t.Logf("scraped: slug=%q title=%q author=%q totalChapters=%d", - meta.Slug, meta.Title, meta.Author, meta.TotalChapters) - - // Override slug with our test-specific value to avoid polluting real data. - meta.Slug = slug - - // 2. Write to HybridStore. - storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second) - defer storeCancel() - - if err := hs.WriteMetadata(storeCtx, meta); err != nil { - t.Fatalf("WriteMetadata: %v", err) - } - - // 3. Read back and verify. - got, found, err := hs.ReadMetadata(storeCtx, slug) - if err != nil { - t.Fatalf("ReadMetadata: %v", err) - } - if !found { - t.Fatal("ReadMetadata: not found after WriteMetadata") - } - - t.Logf("read back: title=%q author=%q totalChapters=%d", got.Title, got.Author, got.TotalChapters) - - if got.Title == "" { - t.Error("Title is empty after round-trip") - } - if got.Author == "" { - t.Error("Author is empty after round-trip") - } - if got.TotalChapters < 1 { - t.Errorf("TotalChapters = %d, want >= 1", got.TotalChapters) - } -} - -// TestScrapeAndStore_First3Chapters scrapes chapters 1, 2, and 3 from the -// live site and stores each via HybridStore.WriteChapter, then verifies -// ReadChapter returns non-empty markdown with the expected header. -func TestScrapeAndStore_First3Chapters(t *testing.T) { - sc, hs := newScrapeAndStoreFixture(t) - - // Use a unique test slug so we don't pollute the real book. - slug := fmt.Sprintf("%s-chtest-%d", scrapeTestBookSlug, time.Now().UnixMilli()%100000) - - t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = hs.pb.pb.deleteWhere(cleanCtx, "chapters_idx", fmt.Sprintf(`slug="%s"`, slug)) - }) - - // Pre-build chapter refs (known URLs for this test book). - refs := []scraper.ChapterRef{ - {Number: 1, Title: "Chapter 1", Volume: 0, URL: scrapeTestBookURL + "/chapter-1"}, - {Number: 2, Title: "Chapter 2", Volume: 0, URL: scrapeTestBookURL + "/chapter-2"}, - {Number: 3, Title: "Chapter 3", Volume: 0, URL: scrapeTestBookURL + "/chapter-3"}, - } - - for _, ref := range refs { - ref := ref // capture loop variable - t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) { - // 1. Scrape chapter text. - scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 120*time.Second) - defer scrapeCancel() - - ch, err := sc.ScrapeChapterText(scrapeCtx, ref) - if err != nil { - t.Fatalf("ScrapeChapterText(%d): %v", ref.Number, err) - } - t.Logf("scraped chapter %d: %d bytes of markdown", ref.Number, len(ch.Text)) - - if len(ch.Text) < 100 { - t.Errorf("scraped text too short (%d bytes)", len(ch.Text)) - } - - // 2. Write to HybridStore. - storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second) - defer storeCancel() - - if err := hs.WriteChapter(storeCtx, slug, ch); err != nil { - t.Fatalf("WriteChapter(%d): %v", ref.Number, err) - } - - // 3. Read back and verify. - got, err := hs.ReadChapter(storeCtx, slug, ref.Number) - if err != nil { - t.Fatalf("ReadChapter(%d): %v", ref.Number, err) - } - if got == "" { - t.Fatalf("ReadChapter(%d): returned empty string", ref.Number) - } - if len(got) < 100 { - t.Errorf("ReadChapter(%d): content too short (%d bytes)", ref.Number, len(got)) - } - - // WriteChapter prepends "# <title>\n\n". - if !strings.HasPrefix(got, "# ") { - t.Errorf("chapter %d: stored content does not start with markdown header: %q", - ref.Number, got[:min(len(got), 60)]) - } - - // Verify the original scraped text body is present. - if !strings.Contains(got, ch.Text[:min(len(ch.Text), 50)]) { - t.Errorf("chapter %d: stored content does not contain scraped text excerpt", ref.Number) - } - - t.Logf("chapter %d stored and verified: %d bytes", ref.Number, len(got)) - }) - } - - // After all chapters written, verify count. - countCtx, countCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer countCancel() - - count := hs.CountChapters(countCtx, slug) - if count != len(refs) { - t.Errorf("CountChapters = %d, want %d", count, len(refs)) - } -} diff --git a/scraper/internal/storage/store.go b/scraper/internal/storage/store.go deleted file mode 100644 index 6dfb286..0000000 --- a/scraper/internal/storage/store.go +++ /dev/null @@ -1,218 +0,0 @@ -// Package storage defines the unified Store interface and helper types used by -// the server and orchestrator. Concrete implementations back the interface -// with PocketBase (structured data) and MinIO (binary objects). -package storage - -import ( - "context" - "time" - - "github.com/libnovel/scraper/internal/scraper" -) - -// ─── Shared types ───────────────────────────────────────────────────────────── - -// ChapterInfo is a lightweight chapter descriptor (mirrors writer.ChapterInfo). -type ChapterInfo struct { - Number int - Title string - Date string -} - -// RankingItem represents a single entry in the novel ranking list. -// Aliased from scraper.RankingItem for convenience within this package. -type RankingItem = scraper.RankingItem - -// ReadingProgress holds a single user's reading position for one book. -type ReadingProgress struct { - Slug string `json:"slug"` - Chapter int `json:"chapter"` - UpdatedAt time.Time `json:"updated_at"` -} - -// AudioJob represents a single audio-generation job record from the -// audio_jobs collection. -type AudioJob struct { - ID string `json:"id"` - CacheKey string `json:"cache_key"` // "slug/chapter/voice" - Slug string `json:"slug"` - Chapter int `json:"chapter"` - Voice string `json:"voice"` - Status string `json:"status"` // "pending" | "generating" | "done" | "failed" - ErrorMessage string `json:"error_message,omitempty"` - Started time.Time `json:"started"` - Finished time.Time `json:"finished,omitempty"` -} - -// ScrapeTask represents a single scraping job record from the scraping_tasks -// collection. -type ScrapeTask struct { - ID string `json:"id"` - Kind string `json:"kind"` // "catalogue" | "book" - TargetURL string `json:"target_url"` // non-empty for single-book scrapes - Status string `json:"status"` // "running" | "done" | "failed" | "cancelled" - BooksFound int `json:"books_found"` - ChaptersScraped int `json:"chapters_scraped"` - ChaptersSkipped int `json:"chapters_skipped"` - Errors int `json:"errors"` - Started time.Time `json:"started"` - Finished time.Time `json:"finished,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` -} - -// ScrapeTaskUpdate carries the fields that can be patched on a ScrapeTask. -// Zero-value fields are still sent; callers should only include keys they want -// to change via the map form used inside the store implementation. -type ScrapeTaskUpdate struct { - Status string - BooksFound int - ChaptersScraped int - ChaptersSkipped int - Errors int - Finished time.Time // zero = not finished yet - ErrorMessage string -} - -// ─── Store interface ────────────────────────────────────────────────────────── - -// Store is the single persistence abstraction consumed by the server and the -// orchestrator. Implementations may route calls to different backends -// (PocketBase for structured records, MinIO for binary blobs). -type Store interface { - // ── Book metadata ────────────────────────────────────────────────────── - - // WriteMetadata upserts book metadata. - WriteMetadata(ctx context.Context, meta scraper.BookMeta) error - // ReadMetadata returns the metadata for slug. Returns (zero, false, nil) - // when the book is not found. - ReadMetadata(ctx context.Context, slug string) (scraper.BookMeta, bool, error) - // ListBooks returns all books, sorted alphabetically by title. - ListBooks(ctx context.Context) ([]scraper.BookMeta, error) - // LocalSlugs returns the set of slugs that have metadata stored. - LocalSlugs(ctx context.Context) (map[string]bool, error) - // MetadataMtime returns the Unix-second mtime of the metadata record, or 0. - MetadataMtime(ctx context.Context, slug string) int64 - - // ── Chapters (binary blobs in MinIO) ─────────────────────────────────── - - // ChapterExists returns true if the markdown file for the given ref exists. - ChapterExists(ctx context.Context, slug string, ref scraper.ChapterRef) bool - // WriteChapter stores the chapter markdown. - WriteChapter(ctx context.Context, slug string, chapter scraper.Chapter) error - // WriteChapterRefs persists chapter metadata (number + title) into the - // chapters_idx table without fetching or storing any chapter text. - // It is used to pre-populate the chapter list when a book is first seen - // via a live preview, before its chapter text has been scraped. - WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error - // ReadChapter returns the raw markdown for chapter number n. - ReadChapter(ctx context.Context, slug string, n int) (string, error) - // ListChapters returns all stored chapters for slug, sorted by number. - ListChapters(ctx context.Context, slug string) ([]ChapterInfo, error) - // CountChapters returns the number of stored chapters for slug. - CountChapters(ctx context.Context, slug string) int - // ReindexChapters rebuilds chapters_idx from MinIO objects for slug. - // Returns the number of chapters indexed. - ReindexChapters(ctx context.Context, slug string) (int, error) - - // ── Ranking ──────────────────────────────────────────────────────────── - - // WriteRankingItem upserts a single ranking entry (keyed on Slug). - WriteRankingItem(ctx context.Context, item RankingItem) error - // ReadRankingItems returns all ranking items sorted by rank ascending. - ReadRankingItems(ctx context.Context) ([]RankingItem, error) - // RankingFreshEnough returns true when ranking rows exist and the most - // recent Updated timestamp is within maxAge of now. - RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) - - // ── Audio cache ──────────────────────────────────────────────────────── - - // GetAudioCache returns the Kokoro filename for cacheKey, or ("", false). - GetAudioCache(ctx context.Context, cacheKey string) (string, bool) - // SetAudioCache persists a Kokoro filename for cacheKey. - SetAudioCache(ctx context.Context, cacheKey, filename string) error - // PutAudio stores raw audio bytes under the given MinIO object key. - PutAudio(ctx context.Context, key string, data []byte) error - - // ── Reading progress ─────────────────────────────────────────────────── - - // GetProgress returns the reading progress for the given session ID and slug. - // Returns (zero, false) if no progress is recorded. - GetProgress(ctx context.Context, sessionID, slug string) (ReadingProgress, bool) - // SetProgress saves or updates reading progress. - SetProgress(ctx context.Context, sessionID string, p ReadingProgress) error - // AllProgress returns all progress entries for a session. - AllProgress(ctx context.Context, sessionID string) ([]ReadingProgress, error) - // DeleteProgress removes progress for a specific slug. - DeleteProgress(ctx context.Context, sessionID, slug string) error - - // ── Audio object paths (MinIO) ───────────────────────────────────────── - - // AudioObjectKey returns the MinIO object key for a cached audio file. - AudioObjectKey(slug string, n int, voice string) string - // AudioExists returns true when the audio object is present in the bucket. - AudioExists(ctx context.Context, key string) bool - - // ── Presigned URLs ───────────────────────────────────────────────────── - - // PresignChapter returns a presigned GET URL for a chapter markdown object. - PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) - - // PresignAudio returns a presigned GET URL for an audio object. - PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) - - // PresignAvatarUpload returns a short-lived presigned PUT URL for uploading - // an avatar image directly to MinIO, and the object key that will be stored. - // ext should be "jpg", "png", or "webp". - PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error) - - // PresignAvatarURL returns a presigned GET URL for a user's avatar, or ("", false, nil) if none. - PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) - - // DeleteAvatar removes all avatar objects for a user (all extensions). - DeleteAvatar(ctx context.Context, userID string) error - - // ── Browse page snapshots (MinIO) ────────────────────────────────────── - - // SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key. - SaveBrowsePage(ctx context.Context, key, html string) error - // GetBrowsePage retrieves a cached HTML snapshot. Returns ("", false, nil) - // when no snapshot exists for the key. - GetBrowsePage(ctx context.Context, key string) (string, bool, error) - // BrowseHTMLKey returns the MinIO object key for a SingleFile HTML snapshot. - // Layout: {domain}/html/page-{n}.html - BrowseHTMLKey(domain string, page int) string - // BrowseFilteredHTMLKey returns the MinIO object key for a browse page snapshot - // that incorporates sort/genre/status so different filter combos are cached separately. - BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string - // BrowseCoverKey returns the MinIO object key for a cached book cover image. - // Layout: {domain}/assets/book-covers/{slug}.jpg - BrowseCoverKey(domain, slug string) string - // SaveBrowseAsset stores a binary asset (e.g. a cover image) in the browse bucket. - SaveBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error - // GetBrowseAsset retrieves a binary asset from the browse bucket. - // Returns (nil, "", false, nil) when the object does not exist. - GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error) - - // ── Scraping tasks ───────────────────────────────────────────────────── - - // CreateScrapeTask inserts a new scraping_tasks record with status="running" - // and returns the assigned ID. - CreateScrapeTask(ctx context.Context, kind, targetURL string) (string, error) - // UpdateScrapeTask patches an existing task record. - UpdateScrapeTask(ctx context.Context, id string, u ScrapeTaskUpdate) error - // ListScrapeTasks returns all tasks sorted by started descending. - ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error) - - // ── Audio jobs ───────────────────────────────────────────────────────── - - // CreateAudioJob inserts a new audio_jobs record with status="pending" - // and returns the assigned ID. - CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) - // UpdateAudioJob patches an existing audio job record (status, error, finished). - UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error - // GetAudioJob returns the most recent audio job for the given cache key, - // or (zero, false, nil) if none exists. - GetAudioJob(ctx context.Context, cacheKey string) (AudioJob, bool, error) - // ListAudioJobs returns all audio jobs sorted by started descending. - ListAudioJobs(ctx context.Context) ([]AudioJob, error) -} diff --git a/scraper/tools.go b/scraper/tools.go deleted file mode 100644 index 320cbbd..0000000 --- a/scraper/tools.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build tools - -package tools - -import _ "honnef.co/go/tools/cmd/staticcheck" diff --git a/scripts/.runner b/scripts/.runner deleted file mode 100644 index 54c7fc4..0000000 --- a/scripts/.runner +++ /dev/null @@ -1,13 +0,0 @@ -{ - "WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.", - "id": 11, - "uuid": "d5d04e0a-572c-46c0-83be-405508948391", - "name": "runner-mac-1", - "token": "ddf214ce148b4673a186f29cb684b407cb8c2ecc", - "address": "https://gitea.kalekber.cc/", - "labels": [ - "macos-latest:host", - "macos-14:host" - ], - "ephemeral": false -} diff --git a/v3/scripts/e2e-test.mjs b/scripts/e2e-test.mjs similarity index 100% rename from v3/scripts/e2e-test.mjs rename to scripts/e2e-test.mjs diff --git a/scripts/link-tooltip.user.js b/scripts/link-tooltip.user.js deleted file mode 100644 index 245aaa0..0000000 --- a/scripts/link-tooltip.user.js +++ /dev/null @@ -1,99 +0,0 @@ -// ==UserScript== -// @name Link URL Tooltip -// @namespace https://github.com/kalekber/libnovel-v2 -// @version 1.0.0 -// @description Show the destination URL near the cursor when hovering over any link -// @author kalekber -// @match *://*/* -// @run-at document-idle -// @grant none -// ==/UserScript== - -(function () { - 'use strict'; - - // --- Inject styles --- - const style = document.createElement('style'); - style.textContent = ` - #lnk-tooltip { - position: fixed; - display: none; - background-color: #333; - color: #fff; - padding: 5px 10px; - border-radius: 4px; - font-size: 12px; - font-family: monospace; - pointer-events: none; - z-index: 2147483647; - white-space: nowrap; - max-width: 600px; - overflow: hidden; - text-overflow: ellipsis; - box-shadow: 0 2px 6px rgba(0,0,0,0.4); - } - `; - document.head.appendChild(style); - - // --- Inject tooltip element --- - const tooltip = document.createElement('div'); - tooltip.id = 'lnk-tooltip'; - document.body.appendChild(tooltip); - - // --- Helpers --- - function getAnchor(target) { - // Walk up the DOM to find the nearest <a href="..."> - // (handles clicks on nested elements like <a><span>text</span></a>) - return target.closest('a[href]'); - } - - function show(anchor, clientX, clientY) { - tooltip.textContent = anchor.href; - tooltip.style.display = 'block'; - position(clientX, clientY); - } - - function hide() { - tooltip.style.display = 'none'; - } - - function position(clientX, clientY) { - const offset = 12; - const tw = tooltip.offsetWidth; - const th = tooltip.offsetHeight; - const vw = window.innerWidth; - const vh = window.innerHeight; - - let x = clientX + offset; - let y = clientY + offset; - - // Flip horizontally if it would overflow the right edge - if (x + tw > vw - 4) { - x = clientX - tw - offset; - } - // Flip vertically if it would overflow the bottom edge - if (y + th > vh - 4) { - y = clientY - th - offset; - } - - tooltip.style.left = Math.max(0, x) + 'px'; - tooltip.style.top = Math.max(0, y) + 'px'; - } - - // --- Event delegation on document --- - document.addEventListener('mouseover', (e) => { - const anchor = getAnchor(e.target); - if (anchor) show(anchor, e.clientX, e.clientY); - }); - - document.addEventListener('mousemove', (e) => { - if (tooltip.style.display === 'block') { - position(e.clientX, e.clientY); - } - }); - - document.addEventListener('mouseout', (e) => { - const anchor = getAnchor(e.target); - if (anchor) hide(); - }); -})(); diff --git a/scripts/pb-init-v2.sh b/scripts/pb-init-v2.sh deleted file mode 100755 index 69678c6..0000000 --- a/scripts/pb-init-v2.sh +++ /dev/null @@ -1,257 +0,0 @@ -#!/bin/sh -# pb-init-v2.sh — idempotent PocketBase collection bootstrap for the v2 stack -# -# Creates all collections required by libnovel v2 (backend + runner + ui-v2). -# Safe to re-run: POST returns 400/422 when a collection already exists; both -# are treated as success. The ensure_field helper adds fields to existing -# instances without touching fields that are already present. -# -# Collections created: -# books — book metadata -# chapters_idx — per-chapter index (title, number) -# ranking — novelfire ranking snapshots -# progress — per-session reading progress -# scraping_tasks — scrape job queue (runner ↔ backend) -# audio_jobs — TTS job queue (runner ↔ backend) -# -# Required env vars (with defaults matching docker-compose-new.yml): -# POCKETBASE_URL http://pocketbase:8090 -# POCKETBASE_ADMIN_EMAIL admin@libnovel.local -# POCKETBASE_ADMIN_PASSWORD changeme123 - -set -e - -PB_URL="${POCKETBASE_URL:-http://pocketbase:8090}" -PB_EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" -PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}" - -log() { echo "[pb-init-v2] $*"; } - -# ─── 0. Ensure curl and python3 are available ──────────────────────────────── -if ! command -v curl > /dev/null 2>&1; then - apk add --no-cache curl > /dev/null 2>&1 -fi -if ! command -v python3 > /dev/null 2>&1; then - apk add --no-cache python3 > /dev/null 2>&1 -fi - -# ─── 1. Wait for PocketBase to be ready ────────────────────────────────────── -log "waiting for PocketBase at $PB_URL ..." -until curl -sf "$PB_URL/api/health" > /dev/null 2>&1; do - sleep 2 -done -log "PocketBase is up" - -# ─── 2. Ensure the superuser exists ────────────────────────────────────────── -# -# On a fresh install PocketBase v0.23+ exposes a one-time install token in the -# /_/ redirect Location header. Use it to create the superuser if needed; on -# subsequent runs the token is gone and we fall through to normal auth. - -log "ensuring superuser $PB_EMAIL exists ..." - -LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB_URL/_/" 2>/dev/null || true) -if echo "$LOCATION" | grep -q "pbinstal/"; then - INSTALL_TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n') - log "install token found — creating superuser via install endpoint" - curl -sf -X POST "$PB_URL/api/collections/_superusers/records" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $INSTALL_TOKEN" \ - -d "{\"email\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\",\"passwordConfirm\":\"$PB_PASSWORD\"}" \ - > /dev/null 2>&1 || true - log "superuser create attempted (may already exist)" -fi - -# ─── 3. Authenticate and obtain a superuser token ──────────────────────────── -log "authenticating as $PB_EMAIL ..." -AUTH_RESPONSE=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \ - -H "Content-Type: application/json" \ - -d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}") - -TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/') -if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then - log "ERROR: failed to obtain auth token. Response: $AUTH_RESPONSE" - exit 1 -fi -log "auth token obtained" - -# ─── 4. Helpers ────────────────────────────────────────────────────────────── - -# create_collection NAME JSON_BODY -# POSTs to /api/collections. 400/422 = already exists → treated as success. -create_collection() { - NAME="$1" - BODY="$2" - STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - -X POST "$PB_URL/api/collections" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d "$BODY") - case "$STATUS" in - 200|201) log "created collection: $NAME" ;; - 400|422) log "collection already exists (skipped): $NAME" ;; - *) log "WARNING: unexpected status $STATUS for collection: $NAME" ;; - esac -} - -# ensure_field COLLECTION FIELD_NAME FIELD_TYPE -# -# Uses python3 to parse the collection schema, then PATCHes the full fields -# array with the new field appended — only if it is not already present. -# python3 is required to correctly extract the top-level collection id from -# the JSON response (sed-based extraction is unreliable on multi-field schemas -# because the greedy pattern picks up a field id instead of the collection id). -ensure_field() { - COLL="$1" - FIELD_NAME="$2" - FIELD_TYPE="$3" - - SCHEMA=$(curl -sf \ - -H "Authorization: Bearer $TOKEN" \ - "$PB_URL/api/collections/$COLL" 2>/dev/null) - - PARSED=$(echo "$SCHEMA" | python3 -c " -import sys, json -try: - d = json.load(sys.stdin) - fields = d.get('fields', []) - exists = any(f.get('name') == '$FIELD_NAME' for f in fields) - print('exists=' + str(exists)) - print('id=' + d.get('id', '')) - if not exists: - fields.append({'name': '$FIELD_NAME', 'type': '$FIELD_TYPE'}) - print('fields=' + json.dumps(fields)) -except Exception as e: - print('error=' + str(e)) -" 2>/dev/null) - - if echo "$PARSED" | grep -q "^exists=True"; then - log "field $COLL.$FIELD_NAME already exists — skipping" - return - fi - - COLLECTION_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//') - if [ -z "$COLLECTION_ID" ]; then - log "WARNING: could not get id for collection $COLL — skipping ensure_field" - return - fi - - NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//') - STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - -X PATCH "$PB_URL/api/collections/$COLLECTION_ID" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d "{\"fields\":${NEW_FIELDS}}") - case "$STATUS" in - 200|201) log "patched $COLL — added field: $FIELD_NAME ($FIELD_TYPE)" ;; - *) log "WARNING: patch returned $STATUS when adding $FIELD_NAME to $COLL" ;; - esac -} - -# ─── 5. Collections ─────────────────────────────────────────────────────────── - -# books — one record per scraped novel -create_collection "books" '{ - "name": "books", - "type": "base", - "fields": [ - {"name": "slug", "type": "text", "required": true}, - {"name": "title", "type": "text", "required": true}, - {"name": "author", "type": "text"}, - {"name": "cover", "type": "text"}, - {"name": "status", "type": "text"}, - {"name": "genres", "type": "json"}, - {"name": "summary", "type": "text"}, - {"name": "total_chapters", "type": "number"}, - {"name": "source_url", "type": "text"}, - {"name": "ranking", "type": "number"} - ] -}' - -# chapters_idx — lightweight chapter list (no content; content lives in MinIO) -create_collection "chapters_idx" '{ - "name": "chapters_idx", - "type": "base", - "fields": [ - {"name": "slug", "type": "text", "required": true}, - {"name": "number", "type": "number", "required": true}, - {"name": "title", "type": "text"} - ] -}' - -# ranking — periodic novelfire ranking snapshots -create_collection "ranking" '{ - "name": "ranking", - "type": "base", - "fields": [ - {"name": "rank", "type": "number", "required": true}, - {"name": "slug", "type": "text", "required": true}, - {"name": "title", "type": "text"}, - {"name": "author", "type": "text"}, - {"name": "cover", "type": "text"}, - {"name": "status", "type": "text"}, - {"name": "genres", "type": "json"}, - {"name": "source_url", "type": "text"} - ] -}' - -# progress — per-session reading progress (no user accounts required) -create_collection "progress" '{ - "name": "progress", - "type": "base", - "fields": [ - {"name": "session_id", "type": "text", "required": true}, - {"name": "slug", "type": "text", "required": true}, - {"name": "chapter", "type": "number"} - ] -}' - -# scraping_tasks — scrape job queue consumed by the runner -create_collection "scraping_tasks" '{ - "name": "scraping_tasks", - "type": "base", - "fields": [ - {"name": "kind", "type": "text"}, - {"name": "target_url", "type": "text"}, - {"name": "from_chapter", "type": "number"}, - {"name": "to_chapter", "type": "number"}, - {"name": "worker_id", "type": "text"}, - {"name": "status", "type": "text", "required": true}, - {"name": "books_found", "type": "number"}, - {"name": "chapters_scraped", "type": "number"}, - {"name": "chapters_skipped", "type": "number"}, - {"name": "errors", "type": "number"}, - {"name": "error_message", "type": "text"}, - {"name": "started", "type": "date"}, - {"name": "finished", "type": "date"}, - {"name": "heartbeat_at", "type": "date"} - ] -}' - -# audio_jobs — TTS generation queue consumed by the runner -create_collection "audio_jobs" '{ - "name": "audio_jobs", - "type": "base", - "fields": [ - {"name": "cache_key", "type": "text", "required": true}, - {"name": "slug", "type": "text", "required": true}, - {"name": "chapter", "type": "number", "required": true}, - {"name": "voice", "type": "text"}, - {"name": "worker_id", "type": "text"}, - {"name": "status", "type": "text", "required": true}, - {"name": "error_message", "type": "text"}, - {"name": "started", "type": "date"}, - {"name": "finished", "type": "date"}, - {"name": "heartbeat_at", "type": "date"} - ] -}' - -# ─── 6. Schema migrations (idempotent — safe to re-run on existing instances) ─ -# -# heartbeat_at was added after the initial v2 deploy. ensure_field is a no-op -# if the field already exists (e.g. fresh installs that ran this script from -# the start already have it from the create_collection call above). -ensure_field "scraping_tasks" "heartbeat_at" "date" -ensure_field "audio_jobs" "heartbeat_at" "date" - -log "all collections ready" diff --git a/v3/scripts/pb-init-v3.sh b/scripts/pb-init-v3.sh similarity index 100% rename from v3/scripts/pb-init-v3.sh rename to scripts/pb-init-v3.sh diff --git a/scripts/pb-init.sh b/scripts/pb-init.sh deleted file mode 100755 index 1657489..0000000 --- a/scripts/pb-init.sh +++ /dev/null @@ -1,345 +0,0 @@ -#!/bin/sh -# pb-init.sh — idempotent PocketBase collection bootstrap -# -# Creates all collections required by libnovel. Safe to re-run: POST returns -# 400/422 when a collection already exists; both are treated as success. -# -# Required env vars (with defaults): -# POCKETBASE_URL http://pocketbase:8090 -# POCKETBASE_ADMIN_EMAIL admin@libnovel.local -# POCKETBASE_ADMIN_PASSWORD changeme123 - -set -e - -PB_URL="${POCKETBASE_URL:-http://pocketbase:8090}" -PB_EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" -PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}" - -log() { echo "[pb-init] $*"; } - -# ─── 0. Ensure curl is available ───────────────────────────────────────────── -if ! command -v curl > /dev/null 2>&1; then - apk add --no-cache curl > /dev/null 2>&1 -fi - -# ─── 1. Wait for PocketBase to be ready ────────────────────────────────────── -log "waiting for PocketBase at $PB_URL ..." -until curl -sf "$PB_URL/api/health" > /dev/null 2>&1; do - sleep 2 -done -log "PocketBase is up" - -# ─── 2. Ensure the superuser exists, then authenticate ─────────────────────── -# -# The muchobien/pocketbase image does NOT auto-create a superuser from env vars. -# On a fresh install PocketBase exposes a one-time install JWT in its log output -# at /pb_data/logs/ — but we can't read that from here. -# -# Strategy: -# a) Try to auth normally (works on subsequent runs once the account exists). -# b) If that returns 400/401, PocketBase is fresh. Use the install token -# obtained from the /_/ redirect Location header (PocketBase v0.23+). - -log "ensuring superuser $PB_EMAIL exists ..." - -# Try to get the install token from the /_/ redirect Location header. -LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB_URL/_/" 2>/dev/null || true) -if echo "$LOCATION" | grep -q "pbinstal/"; then - INSTALL_TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n') - log "install token found — creating superuser via install endpoint" - curl -sf -X POST "$PB_URL/api/collections/_superusers/records" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $INSTALL_TOKEN" \ - -d "{\"email\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\",\"passwordConfirm\":\"$PB_PASSWORD\"}" \ - > /dev/null 2>&1 || true - log "superuser create attempted (may already exist)" -fi - -# ─── 3. Authenticate and obtain a superuser token ──────────────────────────── -log "authenticating as $PB_EMAIL ..." -AUTH_RESPONSE=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \ - -H "Content-Type: application/json" \ - -d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}") - -TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/') -if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then - log "ERROR: failed to obtain auth token. Response: $AUTH_RESPONSE" - exit 1 -fi -log "auth token obtained" - -# ─── 4. Helpers ─────────────────────────────────────────────────────────────── - -create_collection() { - NAME="$1" - BODY="$2" - STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - -X POST "$PB_URL/api/collections" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d "$BODY") - case "$STATUS" in - 200|201) log "created collection: $NAME" ;; - 400|422) log "collection already exists (skipped): $NAME" ;; - *) log "WARNING: unexpected status $STATUS for collection: $NAME" ;; - esac -} - -# ensure_field COLLECTION FIELD_NAME FIELD_TYPE -# -# Checks whether FIELD_NAME exists in COLLECTION's schema. If it is missing, -# sends a PATCH with the full current fields list plus the new field appended. -ensure_field() { - COLL="$1" - FIELD_NAME="$2" - FIELD_TYPE="$3" - - SCHEMA=$(curl -sf \ - -H "Authorization: Bearer $TOKEN" \ - "$PB_URL/api/collections/$COLL" 2>/dev/null) - - # Use python3 to reliably parse the JSON schema. - PARSED=$(echo "$SCHEMA" | python3 -c " -import sys, json -try: - d = json.load(sys.stdin) - fields = d.get('fields', []) - exists = any(f.get('name') == '$FIELD_NAME' for f in fields) - print('exists=' + str(exists)) - print('id=' + d.get('id', '')) - if not exists: - fields.append({'name': '$FIELD_NAME', 'type': '$FIELD_TYPE'}) - print('fields=' + json.dumps(fields)) -except Exception as e: - print('error=' + str(e)) -" 2>/dev/null) - - if echo "$PARSED" | grep -q "^exists=True"; then - log "field $COLL.$FIELD_NAME already exists — skipping" - return - fi - - COLLECTION_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//') - if [ -z "$COLLECTION_ID" ]; then - log "WARNING: could not get id for collection $COLL — skipping ensure_field" - return - fi - - NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//') - PATCH_BODY="{\"fields\":${NEW_FIELDS}}" - - STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - -X PATCH "$PB_URL/api/collections/$COLLECTION_ID" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d "$PATCH_BODY") - case "$STATUS" in - 200|201) log "patched $COLL — added field: $FIELD_NAME ($FIELD_TYPE)" ;; - *) log "WARNING: patch returned $STATUS when adding $FIELD_NAME to $COLL" ;; - esac -} - -# ─── 5. Create collections (idempotent — skips if already exist) ───────────── - -create_collection "books" '{ - "name": "books", - "type": "base", - "fields": [ - {"name": "slug", "type": "text", "required": true}, - {"name": "title", "type": "text", "required": true}, - {"name": "author", "type": "text"}, - {"name": "cover", "type": "text"}, - {"name": "status", "type": "text"}, - {"name": "genres", "type": "json"}, - {"name": "summary", "type": "text"}, - {"name": "total_chapters", "type": "number"}, - {"name": "source_url", "type": "text"}, - {"name": "ranking", "type": "number"}, - {"name": "meta_updated", "type": "date"} - ] -}' - -create_collection "chapters_idx" '{ - "name": "chapters_idx", - "type": "base", - "fields": [ - {"name": "slug", "type": "text", "required": true}, - {"name": "number", "type": "number", "required": true}, - {"name": "title", "type": "text"}, - {"name": "date_label", "type": "text"} - ] -}' - -create_collection "ranking" '{ - "name": "ranking", - "type": "base", - "fields": [ - {"name": "rank", "type": "number", "required": true}, - {"name": "slug", "type": "text", "required": true}, - {"name": "title", "type": "text"}, - {"name": "author", "type": "text"}, - {"name": "cover", "type": "text"}, - {"name": "status", "type": "text"}, - {"name": "genres", "type": "json"}, - {"name": "source_url", "type": "text"}, - {"name": "updated", "type": "date"} - ] -}' - -create_collection "progress" '{ - "name": "progress", - "type": "base", - "fields": [ - {"name": "session_id", "type": "text", "required": true}, - {"name": "user_id", "type": "text"}, - {"name": "slug", "type": "text", "required": true}, - {"name": "chapter", "type": "number"}, - {"name": "updated", "type": "date"} - ] -}' - -create_collection "audio_cache" '{ - "name": "audio_cache", - "type": "base", - "fields": [ - {"name": "cache_key", "type": "text", "required": true}, - {"name": "filename", "type": "text"}, - {"name": "updated", "type": "date"} - ] -}' - -create_collection "app_users" '{ - "name": "app_users", - "type": "base", - "fields": [ - {"name": "username", "type": "text", "required": true}, - {"name": "password_hash", "type": "text", "required": true}, - {"name": "role", "type": "text"}, - {"name": "created", "type": "date"}, - {"name": "avatar_url", "type": "text"} - ] -}' - -create_collection "user_settings" '{ - "name": "user_settings", - "type": "base", - "fields": [ - {"name": "session_id", "type": "text", "required": true}, - {"name": "user_id", "type": "text"}, - {"name": "auto_next", "type": "bool"}, - {"name": "voice", "type": "text"}, - {"name": "speed", "type": "number"}, - {"name": "updated", "type": "date"} - ] -}' - -# ─── 6. Schema migrations (idempotent field additions) ─────────────────────── -# Ensures fields added after initial deploy are present in existing instances. - -ensure_field "progress" "user_id" "text" -ensure_field "progress" "audio_time" "number" -ensure_field "user_settings" "user_id" "text" -ensure_field "app_users" "avatar_url" "text" - -create_collection "book_comments" '{ - "name": "book_comments", - "type": "base", - "fields": [ - {"name": "slug", "type": "text", "required": true}, - {"name": "user_id", "type": "text"}, - {"name": "username", "type": "text"}, - {"name": "body", "type": "text", "required": true}, - {"name": "upvotes", "type": "number"}, - {"name": "downvotes", "type": "number"}, - {"name": "created", "type": "date"}, - {"name": "parent_id", "type": "text"} - ] -}' - -create_collection "comment_votes" '{ - "name": "comment_votes", - "type": "base", - "fields": [ - {"name": "comment_id", "type": "text", "required": true}, - {"name": "user_id", "type": "text"}, - {"name": "session_id", "type": "text", "required": true}, - {"name": "vote", "type": "text", "required": true} - ] -}' - -create_collection "scraping_tasks" '{ - "name": "scraping_tasks", - "type": "base", - "fields": [ - {"name": "kind", "type": "text"}, - {"name": "target_url", "type": "text"}, - {"name": "from_chapter", "type": "number"}, - {"name": "to_chapter", "type": "number"}, - {"name": "worker_id", "type": "text"}, - {"name": "status", "type": "text", "required": true}, - {"name": "books_found", "type": "number"}, - {"name": "chapters_scraped", "type": "number"}, - {"name": "chapters_skipped", "type": "number"}, - {"name": "errors", "type": "number"}, - {"name": "error_message", "type": "text"}, - {"name": "started", "type": "date"}, - {"name": "finished", "type": "date"} - ] -}' - -create_collection "audio_jobs" '{ - "name": "audio_jobs", - "type": "base", - "fields": [ - {"name": "cache_key", "type": "text", "required": true}, - {"name": "slug", "type": "text", "required": true}, - {"name": "chapter", "type": "number", "required": true}, - {"name": "voice", "type": "text"}, - {"name": "worker_id", "type": "text"}, - {"name": "status", "type": "text", "required": true}, - {"name": "error_message", "type": "text"}, - {"name": "started", "type": "date"}, - {"name": "finished", "type": "date"} - ] -}' - -create_collection "user_library" '{ - "name": "user_library", - "type": "base", - "fields": [ - {"name": "session_id", "type": "text", "required": true}, - {"name": "user_id", "type": "text"}, - {"name": "slug", "type": "text", "required": true}, - {"name": "saved_at", "type": "date"} - ] -}' - -create_collection "user_sessions" '{ - "name": "user_sessions", - "type": "base", - "fields": [ - {"name": "user_id", "type": "text", "required": true}, - {"name": "session_id", "type": "text", "required": true}, - {"name": "user_agent", "type": "text"}, - {"name": "ip", "type": "text"}, - {"name": "created_at", "type": "date"}, - {"name": "last_seen", "type": "date"} - ] -}' - -create_collection "user_subscriptions" '{ - "name": "user_subscriptions", - "type": "base", - "fields": [ - {"name": "follower_id", "type": "text", "required": true}, - {"name": "followee_id", "type": "text", "required": true}, - {"name": "created", "type": "date"} - ] -}' - -# ─── 7. Post-initial-deploy field additions ─────────────────────────────────── -# heartbeat_at is used by the backend runner to detect stale tasks. -ensure_field "scraping_tasks" "heartbeat_at" "date" -ensure_field "audio_jobs" "heartbeat_at" "date" - -log "all collections ready" diff --git a/scripts/runner-config-mac.yaml b/scripts/runner-config-mac.yaml deleted file mode 100644 index 11cf0aa..0000000 --- a/scripts/runner-config-mac.yaml +++ /dev/null @@ -1,39 +0,0 @@ -log: - level: info - -runner: - file: .runner - capacity: 1 - envs: {} - env_file: .env - timeout: 3h - shutdown_timeout: 0s - insecure: false - fetch_timeout: 5s - fetch_interval: 2s - github_mirror: '' - labels: - - "macos-latest:host" - - "macos-14:host" - -cache: - enabled: true - dir: "" - host: "__HOST_IP__" - port: 8088 - external_server: "" - -container: - network: "" - privileged: false - options: "" - workdir_parent: "" - valid_volumes: [] - docker_host: "" - force_pull: false - force_rebuild: false - require_docker: false - docker_timeout: 0s - -host: - workdir_parent: "" diff --git a/scripts/runner-config.yaml b/scripts/runner-config.yaml deleted file mode 100644 index 1e76ad2..0000000 --- a/scripts/runner-config.yaml +++ /dev/null @@ -1,109 +0,0 @@ -# Example configuration file, it's safe to copy this as the default config file without any modification. - -# You don't have to copy this file to your instance, -# just run `./act_runner generate-config > config.yaml` to generate a config file. - -log: - # The level of logging, can be trace, debug, info, warn, error, fatal - level: info - -runner: - # Where to store the registration result. - file: .runner - # Execute how many tasks concurrently at the same time. - capacity: 1 - # Extra environment variables to run jobs. - envs: - # Extra environment variables to run jobs from a file. - # It will be ignored if it's empty or the file doesn't exist. - env_file: .env - # The timeout for a job to be finished. - # Please note that the Gitea instance also has a timeout (3h by default) for the job. - # So the job could be stopped by the Gitea instance if its timeout is shorter than this. - timeout: 3h - # The timeout for the runner to wait for running jobs to finish when shutting down. - # Any running jobs that haven't finished after this timeout will be cancelled. - shutdown_timeout: 0s - # Whether skip verifying the TLS certificate of the Gitea instance. - insecure: false - # The timeout for fetching the job from the Gitea instance. - fetch_timeout: 5s - # The interval for fetching the job from the Gitea instance. - fetch_interval: 2s - # The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository. - # It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github, - # and github_mirror is not empty. In this case, - # it replaces https://github.com with the value here, which is useful for some special network environments. - github_mirror: '' - # The labels of a runner are used to determine which jobs the runner can run, and how to run them. - # Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" - # Find more images provided by Gitea at https://gitea.com/gitea/runner-images . - # If it's empty when registering, it will ask for inputting labels. - # If it's empty when execute `daemon`, will use labels in `.runner` file. - labels: - - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" - - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" - - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" - -cache: - # Enable cache server to use actions/cache. - enabled: true - # The directory to store the cache data. - # If it's empty, the cache data will be stored in $HOME/.cache/actcache. - dir: "" - # The host of the cache server. - # It's not for the address to listen, but the address to connect from job containers. - # So 0.0.0.0 is a bad choice, leave it empty to detect automatically. - host: "" - # The port of the cache server. - # 0 means to use a random available port. - port: 8088 - # The external cache server URL. Valid only when enable is true. - # If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself. - # The URL should generally end with "/". - external_server: "" - -container: - # Specifies the network to which the container will connect. - # Could be host, bridge or the name of a custom network. - # If it's empty, act_runner will create a network automatically. - network: "" - # Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker). - privileged: false - # Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway). - options: - - # The parent directory of a job's working directory. - # NOTE: There is no need to add the first '/' of the path as act_runner will add it automatically. - # If the path starts with '/', the '/' will be trimmed. - # For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir - # If it's empty, /workspace will be used. - workdir_parent: - # Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob - # You can specify multiple volumes. If the sequence is empty, no volumes can be mounted. - # For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, youshould change the config to: - # valid_volumes: - # - data - # - /src/*.json - # If you want to allow any volume, please use the following configuration: - # valid_volumes: - # - '**' - valid_volumes: [] - # Overrides the docker client host with the specified one. - # If it's empty, act_runner will find an available docker host automatically. - # If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers. - # If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work. - docker_host: "" - # Pull docker image(s) even if already present - force_pull: false - # Rebuild docker image(s) even if already present - force_rebuild: false - # Always require a reachable docker daemon, even if not required by act_runner - require_docker: false - # Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner - docker_timeout: 0s - -host: - # The parent directory of a job's working directory. - # If it's empty, $HOME/.cache/act/ will be used. - workdir_parent: diff --git a/scripts/setup_runner.sh b/scripts/setup_runner.sh deleted file mode 100755 index 1afbd40..0000000 --- a/scripts/setup_runner.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ── usage ───────────────────────────────────────────────────────────────────── -usage() { - echo "Usage: $0 <runner-name>" - echo " runner-name: runner-node-1 | runner-node-2 | runner-node-3" - exit 1 -} - -[[ $# -ne 1 ]] && usage - -RUNNER_NAME="$1" - -# validate -case "$RUNNER_NAME" in - runner-node-1|runner-node-2|runner-node-3) ;; - *) echo "ERROR: unknown runner name '$RUNNER_NAME'"; usage ;; -esac - -# ── config ──────────────────────────────────────────────────────────────────── -CACHE_PORT=8088 -GITEA_URL="https://gitea.kalekber.cc/" -REGISTRATION_TOKEN="AboxpDKWx7gizwJ9xeheHVqKjj9J9N9BgyX96wvu" -IMAGE="docker.io/gitea/act_runner:latest" -DATA_DIR="$PWD/data/$RUNNER_NAME" -CFG_PATH="$DATA_DIR/config.yaml" - -# ── detect THIS machine's LAN IP ────────────────────────────────────────────── -HOST_IP=$(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1); exit}') -if [[ -z "$HOST_IP" ]]; then - echo "ERROR: could not detect host LAN IP" >&2 - exit 1 -fi -echo "Host LAN IP: $HOST_IP" - -# ── generate config.yaml ────────────────────────────────────────────────────── -mkdir -p "$DATA_DIR" - -docker run --rm --entrypoint="" "$IMAGE" \ - act_runner generate-config > "$CFG_PATH" - -awk -v host="$HOST_IP" -v port="$CACHE_PORT" ' - /^cache:/ { in_cache=1 } - in_cache && /enabled:/ { $0 = " enabled: true" } - in_cache && /dir:/ { $0 = " dir: \"/data/cache\"" } - in_cache && /host:/ { $0 = " host: \"" host "\"" } - in_cache && /port:/ { $0 = " port: " port; in_cache=0 } - { print } -' "$CFG_PATH" > "${CFG_PATH}.tmp" && mv "${CFG_PATH}.tmp" "$CFG_PATH" - -echo "Config written to $CFG_PATH (cache $HOST_IP:$CACHE_PORT)" - -# ── stop + remove old container if exists ──────────────────────────────────── -if docker inspect "$RUNNER_NAME" &>/dev/null; then - echo "Removing existing $RUNNER_NAME..." - docker stop "$RUNNER_NAME" || true - docker rm "$RUNNER_NAME" || true -fi - -# ── start runner ────────────────────────────────────────────────────────────── -docker run \ - -v "$DATA_DIR:/data" \ - -v "$CFG_PATH:/config.yaml" \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -e CONFIG_FILE=/config.yaml \ - -e GITEA_INSTANCE_URL="$GITEA_URL" \ - -e GITEA_RUNNER_REGISTRATION_TOKEN="$REGISTRATION_TOKEN" \ - -e GITEA_RUNNER_NAME="$RUNNER_NAME" \ - -p "${CACHE_PORT}:${CACHE_PORT}" \ - --restart unless-stopped \ - --name "$RUNNER_NAME" \ - -d "$IMAGE" - -echo "Runner $RUNNER_NAME started" -docker ps --filter "name=$RUNNER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" diff --git a/scripts/setup_runner_mac.sh b/scripts/setup_runner_mac.sh deleted file mode 100755 index d73b720..0000000 --- a/scripts/setup_runner_mac.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ── setup_runner_mac.sh ─────────────────────────────────────────────────────── -# Sets up act_runner as a host-mode runner on macOS for iOS CI/CD. -# Installs the binary, generates a config, registers against Gitea, -# and installs a LaunchDaemon so the runner starts at boot. -# -# Usage: sudo ./setup_runner_mac.sh <runner-name> -# Example: sudo ./setup_runner_mac.sh mac-runner-1 -# ───────────────────────────────────────────────────────────────────────────── - -usage() { - echo "Usage: sudo $0 <runner-name>" - exit 1 -} - -[[ $# -ne 1 ]] && usage -[[ "$EUID" -ne 0 ]] && { echo "ERROR: run with sudo"; exit 1; } - -RUNNER_NAME="$1" -GITEA_URL="https://gitea.kalekber.cc/" -REGISTRATION_TOKEN="AboxpDKWx7gizwJ9xeheHVqKjj9J9N9BgyX96wvu" -CACHE_PORT=8088 -INSTALL_DIR="/usr/local/bin" -WORK_DIR="/var/lib/act_runner" -CONFIG_PATH="/etc/act_runner/config.yaml" -LAUNCHDAEMON_PLIST="/Library/LaunchDaemons/com.gitea.act_runner.plist" - -# ── detect Mac LAN IP ───────────────────────────────────────────────────────── -HOST_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "") -if [[ -z "$HOST_IP" ]]; then - echo "ERROR: could not detect LAN IP via en0/en1. Set cache.host manually in $CONFIG_PATH" - HOST_IP="127.0.0.1" -fi -echo "Host LAN IP: $HOST_IP" - -# ── download act_runner binary ──────────────────────────────────────────────── -ARCH=$(uname -m) -if [[ "$ARCH" == "arm64" ]]; then - BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-arm64" -else - BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-amd64" -fi - -echo "Downloading act_runner for $ARCH..." -curl -fsSL "$BINARY_URL" -o "$INSTALL_DIR/act_runner" -chmod +x "$INSTALL_DIR/act_runner" -echo "Installed: $("$INSTALL_DIR/act_runner" --version)" - -# ── create working directory ────────────────────────────────────────────────── -mkdir -p "$WORK_DIR" -mkdir -p "$(dirname "$CONFIG_PATH")" - -# ── install config ──────────────────────────────────────────────────────────── -# Use the checked-in static config and substitute the LAN IP placeholder. -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -sed "s/__HOST_IP__/$HOST_IP/" "$SCRIPT_DIR/runner-config-mac.yaml" > "$CONFIG_PATH" -echo "Config written: labels=macos-latest:host, cache=$HOST_IP:$CACHE_PORT" - -# ── register runner ─────────────────────────────────────────────────────────── -echo "Registering runner '$RUNNER_NAME'..." -"$INSTALL_DIR/act_runner" register \ - --no-interactive \ - --config "$CONFIG_PATH" \ - --instance "$GITEA_URL" \ - --token "$REGISTRATION_TOKEN" \ - --name "$RUNNER_NAME" \ - --labels "macos-latest:host,macos-14:host" - -# Copy .runner file to work dir if it was created in cwd -[[ -f ".runner" ]] && cp .runner "$WORK_DIR/.runner" - -# ── install LaunchDaemon ────────────────────────────────────────────────────── -# PATH must include Homebrew + Xcode tools so xcodebuild, xcrun, npm, etc. are found. -HOMEBREW_PREFIX=$([ "$ARCH" = "arm64" ] && echo "/opt/homebrew" || echo "/usr/local") - -cat > "$LAUNCHDAEMON_PLIST" <<PLIST -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>Label</key> - <string>com.gitea.act_runner</string> - <key>ProgramArguments</key> - <array> - <string>${INSTALL_DIR}/act_runner</string> - <string>daemon</string> - <string>--config</string> - <string>${CONFIG_PATH}</string> - </array> - <key>RunAtLoad</key> - <true/> - <key>KeepAlive</key> - <true/> - <key>WorkingDirectory</key> - <string>${WORK_DIR}</string> - <key>StandardOutPath</key> - <string>${WORK_DIR}/act_runner.log</string> - <key>StandardErrorPath</key> - <string>${WORK_DIR}/act_runner.err</string> - <key>EnvironmentVariables</key> - <dict> - <key>PATH</key> - <string>${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Xcode.app/Contents/Developer/usr/bin</string> - <key>HOME</key> - <string>${WORK_DIR}</string> - </dict> -</dict> -</plist> -PLIST - -echo "LaunchDaemon written to $LAUNCHDAEMON_PLIST" - -# ── load the daemon ─────────────────────────────────────────────────────────── -launchctl unload "$LAUNCHDAEMON_PLIST" 2>/dev/null || true -launchctl load "$LAUNCHDAEMON_PLIST" -echo "Runner '$RUNNER_NAME' started via LaunchDaemon" -echo "" -echo "Useful commands:" -echo " View logs: tail -f $WORK_DIR/act_runner.log" -echo " Stop runner: sudo launchctl unload $LAUNCHDAEMON_PLIST" -echo " Start runner: sudo launchctl load $LAUNCHDAEMON_PLIST" diff --git a/scripts/test-ci-signing.sh b/scripts/test-ci-signing.sh deleted file mode 100755 index 8fb32f7..0000000 --- a/scripts/test-ci-signing.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -set -euo pipefail - -cd "$(dirname "$0")/../ios/LibNovel" - -echo "=== Testing CI-like signing process ===" - -# 1. Install provisioning profile (simulate CI) -PP_PATH=~/Downloads/LibNovel_Distribution.mobileprovision -UUID=$(security cms -D -i "$PP_PATH" | plutil -extract UUID raw -) -PROFILE_NAME=$(security cms -D -i "$PP_PATH" | plutil -extract Name raw -) -mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles -cp "$PP_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision - -echo "Installed profile: $PROFILE_NAME (UUID: $UUID)" - -# 2. Generate Xcode project -echo "Generating Xcode project..." -xcodegen generate --spec project.yml --project . - -# 3. List available provisioning profiles -echo -e "\n=== Available provisioning profiles ===" -ls -la ~/Library/MobileDevice/Provisioning\ Profiles/ - -# 4. Try building with xcodebuild using manual signing -echo -e "\n=== Attempting archive with manual signing ===" -xcodebuild archive \ - -scheme LibNovel \ - -project LibNovel.xcodeproj \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -archivePath /tmp/LibNovel.xcarchive \ - CODE_SIGN_STYLE=Manual \ - CODE_SIGN_IDENTITY="Apple Distribution: Kamil Alekberov (GHZXC6FVMU)" \ - DEVELOPMENT_TEAM=GHZXC6FVMU \ - PROVISIONING_PROFILE_SPECIFIER="$UUID" \ - | xcpretty || true - -echo -e "\n=== Build complete ===" diff --git a/scripts/test-ios-build-simple.sh b/scripts/test-ios-build-simple.sh deleted file mode 100755 index 82d5553..0000000 --- a/scripts/test-ios-build-simple.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash -# Simple iOS build test without fastlane -# Run from project root: ./scripts/test-ios-build-simple.sh /path/to/profile.mobileprovision - -set -e - -PROFILE_PATH="$1" - -if [ -z "$PROFILE_PATH" ]; then - echo "Usage: $0 /path/to/profile.mobileprovision" - exit 1 -fi - -echo "=== Step 1: Extract profile info ===" -UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -) -PROFILE_NAME=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Name raw -) -TEAM_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract TeamIdentifier.0 raw -) - -echo "Profile Name: $PROFILE_NAME" -echo "UUID: $UUID" -echo "Team ID: $TEAM_ID" - -echo "" -echo "=== Step 2: Install profile ===" -mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles -cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision -echo "✓ Installed" - -echo "" -echo "=== Step 3: Check signing identities ===" -security find-identity -v -p codesigning - -echo "" -echo "=== Step 4: Generate Xcode project ===" -cd ios/LibNovel -export USER=runner -xcodegen generate --spec project.yml --project . -echo "✓ Project generated" - -echo "" -echo "=== Step 5: Try automatic signing build ===" -xcodebuild archive \ - -project LibNovel.xcodeproj \ - -scheme LibNovel \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -archivePath ./build/LibNovel.xcarchive \ - -allowProvisioningUpdates \ - CODE_SIGN_STYLE=Automatic \ - DEVELOPMENT_TEAM="$TEAM_ID" - -echo "" -echo "=== ✓ BUILD SUCCEEDED! ===" diff --git a/scripts/test-ios-build.sh b/scripts/test-ios-build.sh deleted file mode 100755 index a255712..0000000 --- a/scripts/test-ios-build.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash -# Local build test script -# Run from the project root: ./scripts/test-ios-build.sh /path/to/your/profile.mobileprovision - -set -e - -PROFILE_PATH="$1" - -if [ -z "$PROFILE_PATH" ]; then - echo "Usage: $0 /path/to/profile.mobileprovision" - exit 1 -fi - -if [ ! -f "$PROFILE_PATH" ]; then - echo "Error: Profile not found at $PROFILE_PATH" - exit 1 -fi - -echo "=== Extracting profile info ===" -UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -) -PROFILE_NAME=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Name raw -) -BUNDLE_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Entitlements.application-identifier raw - 2>/dev/null | sed 's/.*\.//') -TEAM_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract TeamIdentifier.0 raw -) - -echo "Profile Name: $PROFILE_NAME" -echo "UUID: $UUID" -echo "Bundle ID: $BUNDLE_ID" -echo "Team ID: $TEAM_ID" - -echo "" -echo "=== Installing provisioning profile ===" -mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles -cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision -echo "Installed to: ~/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision" - -echo "" -echo "=== Listing signing identities ===" -security find-identity -v -p codesigning - -echo "" -echo "=== Navigating to iOS project ===" -cd ios/LibNovel - -echo "" -echo "=== Generating Xcode project ===" -xcodegen generate --spec project.yml --project . - -echo "" -echo "=== Testing fastlane build ===" -export USER=runner -export BUILD_NUMBER=999 -export PROVISIONING_PROFILE_NAME="$PROFILE_NAME" - -# Run fastlane beta lane -fastlane beta --verbose - -echo "" -echo "=== Build succeeded! ===" diff --git a/ui-v2/.env.example b/ui-v2/.env.example deleted file mode 100644 index a887a51..0000000 --- a/ui-v2/.env.example +++ /dev/null @@ -1,20 +0,0 @@ -# libnovel UI — environment variables -# Copy to .env and adjust; do NOT commit with real secrets. - -# Public URL of the scraper API (used by SvelteKit server-side load functions) -# In docker-compose this is the internal service name -SCRAPER_API_URL=http://localhost:8080 - -# Public URL of PocketBase (used by SvelteKit server-side load functions) -POCKETBASE_URL=http://localhost:8090 - -# PocketBase admin credentials (server-side only, never exposed to browser) -POCKETBASE_ADMIN_EMAIL=admin@libnovel.local -POCKETBASE_ADMIN_PASSWORD=changeme123 - -# Public-facing MinIO URL (used to rewrite presigned URLs for the browser) -# In dev this is localhost; in prod set to your MinIO public domain -PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000 - -# Secret used to sign auth tokens stored in cookies (generate with: openssl rand -hex 32) -AUTH_SECRET=change_this_to_a_long_random_secret diff --git a/ui-v2/.gitignore b/ui-v2/.gitignore deleted file mode 100644 index 3b462cb..0000000 --- a/ui-v2/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -node_modules - -# Output -.output -.vercel -.netlify -.wrangler -/.svelte-kit -/build - -# OS -.DS_Store -Thumbs.db - -# Env -.env -.env.* -!.env.example -!.env.test - -# Vite -vite.config.js.timestamp-* -vite.config.ts.timestamp-* diff --git a/ui-v2/.npmrc b/ui-v2/.npmrc deleted file mode 100644 index b6f27f1..0000000 --- a/ui-v2/.npmrc +++ /dev/null @@ -1 +0,0 @@ -engine-strict=true diff --git a/ui-v2/Dockerfile b/ui-v2/Dockerfile deleted file mode 100644 index bd9fe31..0000000 --- a/ui-v2/Dockerfile +++ /dev/null @@ -1,39 +0,0 @@ -# syntax=docker/dockerfile:1 -FROM node:22-alpine AS builder -WORKDIR /app - -# Install dependencies in a separate layer so it is cached as long as -# package-lock.json does not change. The npm cache mount persists the -# ~/.npm cache across builds so packages are not re-downloaded. -COPY package.json package-lock.json ./ -RUN --mount=type=cache,target=/root/.npm \ - npm ci - -COPY . . - -# Build-time version info — injected by docker-compose or CI via --build-arg. -ARG BUILD_VERSION=dev -ARG BUILD_COMMIT=unknown - -# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them. -ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION -ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT - -RUN npm run build - -# ── Runtime image ────────────────────────────────────────────────────────────── -# adapter-node bundles all server-side dependencies into build/ — no npm install -# needed at runtime. We do need package.json (for "type": "module") so Node -# resolves the ESM output correctly when there is no parent package.json. -FROM node:22-alpine -WORKDIR /app - -COPY --from=builder /app/build ./build -COPY --from=builder /app/package.json ./package.json - -ENV NODE_ENV=production -ENV PORT=3000 -ENV HOST=0.0.0.0 - -EXPOSE $PORT -CMD ["node", "build"] diff --git a/ui-v2/README.md b/ui-v2/README.md deleted file mode 100644 index 7c12da4..0000000 --- a/ui-v2/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# sv - -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). - -## Creating a project - -If you're seeing this, you've probably already done this step. Congrats! - -```sh -# create a new project -npx sv create my-app -``` - -To recreate this project with the same configuration: - -```sh -# recreate this project -npx sv@0.12.4 create --template minimal --types ts --install npm ui -``` - -## Developing - -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: - -```sh -npm run dev - -# or start the server and open the app in a new browser tab -npm run dev -- --open -``` - -## Building - -To create a production version of your app: - -```sh -npm run build -``` - -You can preview the production build with `npm run preview`. - -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/ui-v2/package-lock.json b/ui-v2/package-lock.json deleted file mode 100644 index 1c8f9ab..0000000 --- a/ui-v2/package-lock.json +++ /dev/null @@ -1,4160 +0,0 @@ -{ - "name": "ui", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "ui", - "version": "0.0.1", - "dependencies": { - "@aws-sdk/client-s3": "^3.1005.0", - "@aws-sdk/s3-request-presigner": "^3.1005.0", - "cropperjs": "^1.6.2", - "marked": "^17.0.3", - "pocketbase": "^0.26.8" - }, - "devDependencies": { - "@sveltejs/adapter-auto": "^7.0.0", - "@sveltejs/adapter-node": "^5.5.4", - "@sveltejs/kit": "^2.50.2", - "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@tailwindcss/vite": "^4.2.1", - "@types/node": "^25.3.3", - "svelte": "^5.51.0", - "svelte-check": "^4.4.2", - "tailwindcss": "^4.2.1", - "typescript": "^5.9.3", - "vite": "^7.3.1" - } - }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.1005.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1005.0.tgz", - "integrity": "sha512-EVl5IElgh7l9M242JYZGBt2AtdylpSKEFiEHBfB2OKuh2es19IQkDNfLFGfzThXWbapfBjXuB0zs9nplNviOSQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-node": "^3.972.19", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.7", - "@aws-sdk/middleware-expect-continue": "^3.972.7", - "@aws-sdk/middleware-flexible-checksums": "^3.973.5", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-location-constraint": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-sdk-s3": "^3.972.19", - "@aws-sdk/middleware-ssec": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.5", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/eventstream-serde-browser": "^4.2.11", - "@smithy/eventstream-serde-config-resolver": "^4.3.11", - "@smithy/eventstream-serde-node": "^4.2.11", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-blob-browser": "^4.2.12", - "@smithy/hash-node": "^4.2.11", - "@smithy/hash-stream-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/md5-js": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-retry": "^4.4.40", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.39", - "@smithy/util-defaults-mode-node": "^4.2.42", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/util-stream": "^4.5.17", - "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.973.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.19.tgz", - "integrity": "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/xml-builder": "^3.972.10", - "@smithy/core": "^3.23.9", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.4.tgz", - "integrity": "sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.17.tgz", - "integrity": "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.19.tgz", - "integrity": "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.17", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.18.tgz", - "integrity": "sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/credential-provider-env": "^3.972.17", - "@aws-sdk/credential-provider-http": "^3.972.19", - "@aws-sdk/credential-provider-login": "^3.972.18", - "@aws-sdk/credential-provider-process": "^3.972.17", - "@aws-sdk/credential-provider-sso": "^3.972.18", - "@aws-sdk/credential-provider-web-identity": "^3.972.18", - "@aws-sdk/nested-clients": "^3.996.8", - "@aws-sdk/types": "^3.973.5", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.18.tgz", - "integrity": "sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/nested-clients": "^3.996.8", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.19.tgz", - "integrity": "sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.17", - "@aws-sdk/credential-provider-http": "^3.972.19", - "@aws-sdk/credential-provider-ini": "^3.972.18", - "@aws-sdk/credential-provider-process": "^3.972.17", - "@aws-sdk/credential-provider-sso": "^3.972.18", - "@aws-sdk/credential-provider-web-identity": "^3.972.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.17.tgz", - "integrity": "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.18.tgz", - "integrity": "sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/nested-clients": "^3.996.8", - "@aws-sdk/token-providers": "3.1005.0", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.18.tgz", - "integrity": "sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/nested-clients": "^3.996.8", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.7.tgz", - "integrity": "sha512-goX+axlJ6PQlRnzE2bQisZ8wVrlm6dXJfBzMJhd8LhAIBan/w1Kl73fJnalM/S+18VnpzIHumyV6DtgmvqG5IA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.7.tgz", - "integrity": "sha512-mvWqvm61bmZUKmmrtl2uWbokqpenY3Mc3Jf4nXB/Hse6gWxLPaCQThmhPBDzsPSV8/Odn8V6ovWt3pZ7vy4BFQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.973.5.tgz", - "integrity": "sha512-Dp3hqE5W6hG8HQ3Uh+AINx9wjjqYmFHbxede54sGj3akx/haIQrkp85lNdTdC+ouNUcSYNiuGkzmyDREfHX1Gg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/crc64-nvme": "^3.972.4", - "@aws-sdk/types": "^3.973.5", - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", - "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.7.tgz", - "integrity": "sha512-vdK1LJfffBp87Lj0Bw3WdK1rJk9OLDYdQpqoKgmpIZPe+4+HawZ6THTbvjhJt4C4MNnRrHTKHQjkwBiIpDBoig==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", - "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", - "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.19.tgz", - "integrity": "sha512-/CtOHHVFg4ZuN6CnLnYkrqWgVEnbOBC4kNiKa+4fldJ9cioDt3dD/f5vpq0cWLOXwmGL2zgVrVxNhjxWpxNMkg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.9", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.7.tgz", - "integrity": "sha512-G9clGVuAml7d8DYzY6DnRi7TIIDRvZ3YpqJPz/8wnWS5fYx/FNWNmkO6iJVlVkQg9BfeMzd+bVPtPJOvC4B+nQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.20.tgz", - "integrity": "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@smithy/core": "^3.23.9", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-retry": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.8.tgz", - "integrity": "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.5", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.9", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-retry": "^4.4.40", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.39", - "@smithy/util-defaults-mode-node": "^4.2.42", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", - "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/config-resolver": "^4.4.10", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.1005.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1005.0.tgz", - "integrity": "sha512-5lM7MxgIyF64RFNc70dqcjA1WlPXP/vwLOsJ/7+zFjiubu3q5xaTAw7kdAUXk+BRkYiweO4OVz2v4MHNugY7OA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/signature-v4-multi-region": "^3.996.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-format-url": "^3.972.7", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.7.tgz", - "integrity": "sha512-mYhh7FY+7OOqjkYkd6+6GgJOsXK1xBWmuR+c5mxJPj2kr5TBNeZq+nUvE9kANWAux5UxDVrNOSiEM/wlHzC3Lg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.19", - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1005.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1005.0.tgz", - "integrity": "sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.19", - "@aws-sdk/nested-clients": "^3.996.8", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", - "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", - "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", - "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-endpoints": "^3.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.7.tgz", - "integrity": "sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", - "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.5.tgz", - "integrity": "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.20", - "@aws-sdk/types": "^3.973.5", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", - "integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "fast-xml-parser": "5.4.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", - "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/plugin-commonjs": { - "version": "29.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz", - "integrity": "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "fdir": "^6.2.0", - "is-reference": "1.2.1", - "magic-string": "^0.30.3", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@rollup/plugin-json": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.1.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", - "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", - "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", - "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", - "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", - "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.23.9", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.9.tgz", - "integrity": "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/middleware-serde": "^4.2.12", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", - "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.11.tgz", - "integrity": "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.11.tgz", - "integrity": "sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.11", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.11.tgz", - "integrity": "sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.11.tgz", - "integrity": "sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.11.tgz", - "integrity": "sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", - "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.12.tgz", - "integrity": "sha512-1wQE33DsxkM/waftAhCH9VtJbUGyt1PJ9YRDpOu+q9FUi73LLFUZ2fD8A61g2mT1UY9k7b99+V1xZ41Rz4SHRQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/chunked-blob-reader": "^5.2.2", - "@smithy/chunked-blob-reader-native": "^4.2.3", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", - "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.11.tgz", - "integrity": "sha512-hQsTjwPCRY8w9GK07w1RqJi3e+myh0UaOWBBhZ1UMSDgofH/Q1fEYzU1teaX6HkpX/eWDdm7tAGR0jBPlz9QEQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", - "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/md5-js": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.11.tgz", - "integrity": "sha512-350X4kGIrty0Snx2OWv7rPM6p6vM7RzryvFs6B/56Cux3w3sChOb3bymo5oidXJlPcP9fIRxGUCk7GqpiSOtng==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", - "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.23", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.23.tgz", - "integrity": "sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.9", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-middleware": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.4.40", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.40.tgz", - "integrity": "sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/service-error-classification": "^4.2.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", - "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", - "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.11", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", - "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", - "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", - "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", - "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", - "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-uri-escape": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", - "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", - "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", - "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", - "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.3.tgz", - "integrity": "sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.9", - "@smithy/middleware-endpoint": "^4.4.23", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.17", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", - "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", - "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", - "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", - "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", - "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", - "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.39", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.39.tgz", - "integrity": "sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.42", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.42.tgz", - "integrity": "sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.10", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/smithy-client": "^4.12.3", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", - "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", - "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", - "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", - "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.5.17", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", - "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-waiter": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.11.tgz", - "integrity": "sha512-x7Rh2azQPs3XxbvCzcttRErKKvLnbZfqRf/gOjw2pb+ZscX88e5UkRPCB67bVnsFHxayvMvmePfKTqsRb+is1A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", - "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/adapter-auto": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", - "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@sveltejs/kit": "^2.0.0" - } - }, - "node_modules/@sveltejs/adapter-node": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", - "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.0", - "rollup": "^4.59.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.4.0" - } - }, - "node_modules/@sveltejs/kit": { - "version": "2.53.4", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", - "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/cookie": "^0.6.0", - "acorn": "^8.14.1", - "cookie": "^0.6.0", - "devalue": "^5.6.3", - "esm-env": "^1.2.2", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "mrmime": "^2.0.0", - "set-cookie-parser": "^3.0.0", - "sirv": "^3.0.0" - }, - "bin": { - "svelte-kit": "svelte-kit.js" - }, - "engines": { - "node": ">=18.13" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", - "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", - "deepmerge": "^4.3.1", - "magic-string": "^0.30.21", - "obug": "^2.1.0", - "vitefu": "^1.1.1" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", - "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "obug": "^2.1.0" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.31.1", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" - } - }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cropperjs": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz", - "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==", - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", - "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", - "dev": true, - "license": "MIT" - }, - "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", - "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/marked": { - "version": "17.0.3", - "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", - "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pocketbase": { - "version": "0.26.8", - "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.8.tgz", - "integrity": "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==", - "license": "MIT" - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/set-cookie-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", - "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svelte": { - "version": "5.53.6", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", - "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.6.3", - "esm-env": "^1.2.1", - "esrap": "^2.2.2", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/svelte-check": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.4.tgz", - "integrity": "sha512-F1pGqXc710Oi/wTI4d/x7d6lgPwwfx1U6w3Q35n4xsC2e8C/yN2sM1+mWxjlMcpAfWucjlq4vPi+P4FZ8a14sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "chokidar": "^4.0.1", - "fdir": "^6.2.0", - "picocolors": "^1.0.0", - "sade": "^1.7.4" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" - } - }, - "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitefu": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", - "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/ui-v2/package.json b/ui-v2/package.json deleted file mode 100644 index bd67878..0000000 --- a/ui-v2/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "ui", - "private": true, - "version": "0.0.1", - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" - }, - "devDependencies": { - "@sveltejs/adapter-auto": "^7.0.0", - "@sveltejs/adapter-node": "^5.5.4", - "@sveltejs/kit": "^2.50.2", - "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@tailwindcss/vite": "^4.2.1", - "@types/node": "^25.3.3", - "svelte": "^5.51.0", - "svelte-check": "^4.4.2", - "tailwindcss": "^4.2.1", - "typescript": "^5.9.3", - "vite": "^7.3.1" - }, - "dependencies": { - "@aws-sdk/client-s3": "^3.1005.0", - "@aws-sdk/s3-request-presigner": "^3.1005.0", - "cropperjs": "^1.6.2", - "marked": "^17.0.3", - "pocketbase": "^0.26.8" - } -} diff --git a/ui-v2/src/app.css b/ui-v2/src/app.css deleted file mode 100644 index c106a57..0000000 --- a/ui-v2/src/app.css +++ /dev/null @@ -1,65 +0,0 @@ -@import "tailwindcss"; - -@theme { - --color-brand: #f59e0b; /* amber-400 */ - --color-brand-dim: #d97706; /* amber-600 */ - --color-surface: #18181b; /* zinc-900 */ - --color-surface-2: #27272a; /* zinc-800 */ - --color-surface-3: #3f3f46; /* zinc-700 */ - --color-muted: #a1a1aa; /* zinc-400 */ - --color-text: #f4f4f5; /* zinc-100 */ -} - -html { - background-color: var(--color-surface); - color: var(--color-text); -} - -/* ── Chapter prose ─────────────────────────────────────────────────── */ -.prose-chapter { - max-width: 72ch; - line-height: 1.85; - font-size: 1.05rem; - color: #d4d4d8; /* zinc-300 */ -} - -.prose-chapter h1, -.prose-chapter h2, -.prose-chapter h3 { - color: #f4f4f5; - font-weight: 700; - margin-top: 1.5em; - margin-bottom: 0.5em; -} - -.prose-chapter h1 { font-size: 1.4rem; } -.prose-chapter h2 { font-size: 1.2rem; } -.prose-chapter h3 { font-size: 1.05rem; } - -.prose-chapter p { - margin-bottom: 1.2em; -} - -.prose-chapter em { - color: #a1a1aa; -} - -.prose-chapter strong { - color: #f4f4f5; -} - -.prose-chapter hr { - border-color: #3f3f46; - margin: 2em 0; -} - -/* ── Navigation progress bar ───────────────────────────────────────── */ -@keyframes progress-bar { - 0% { width: 0%; opacity: 1; } - 80% { width: 90%; opacity: 1; } - 100% { width: 100%; opacity: 0; } -} -.animate-progress-bar { - animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards; -} - diff --git a/ui-v2/src/app.d.ts b/ui-v2/src/app.d.ts deleted file mode 100644 index 75eecc0..0000000 --- a/ui-v2/src/app.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces -declare global { - namespace App { - // interface Error {} - interface Locals { - sessionId: string; - user: { id: string; username: string; role: string; authSessionId: string } | null; - } - interface PageData { - user?: { id: string; username: string; role: string; authSessionId: string } | null; - } - // interface PageState {} - // interface Platform {} - } -} - -export {}; diff --git a/ui-v2/src/app.html b/ui-v2/src/app.html deleted file mode 100644 index c1b5e52..0000000 --- a/ui-v2/src/app.html +++ /dev/null @@ -1,17 +0,0 @@ -<!doctype html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - <link rel="icon" href="/favicon.ico" sizes="16x16 32x32" /> - <link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" /> - <link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" /> - <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> - <link rel="icon" type="image/png" href="/icon-192.png" sizes="192x192" /> - <link rel="icon" type="image/png" href="/icon-512.png" sizes="512x512" /> - %sveltekit.head% - </head> - <body data-sveltekit-preload-data="hover"> - <div style="display: contents">%sveltekit.body%</div> - </body> -</html> diff --git a/ui-v2/src/hooks.server.ts b/ui-v2/src/hooks.server.ts deleted file mode 100644 index d33fced..0000000 --- a/ui-v2/src/hooks.server.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { Handle } from '@sveltejs/kit'; -import { randomBytes, createHmac } from 'node:crypto'; -import { env } from '$env/dynamic/private'; -import { log } from '$lib/server/logger'; -import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase'; -import { drain as drainPresignCache } from '$lib/server/presignCache'; - -// ─── Graceful shutdown ──────────────────────────────────────────────────────── -// -// When Docker/Kubernetes sends SIGTERM (or the user sends SIGINT), we: -// 1. Set shuttingDown = true so new requests immediately receive 503. -// 2. Flush/drain in-process caches (presign URL cache). -// 3. Allow Node.js to exit naturally once in-flight requests finish. -// -// adapter-node does not provide a built-in hook for this, so we wire it here -// in hooks.server.ts which runs in the server Node.js process. - -let shuttingDown = false; - -function shutdown(signal: string) { - if (shuttingDown) return; - shuttingDown = true; - log.info('shutdown', `received ${signal}, draining in-flight requests`); - drainPresignCache(); - // Don't call process.exit() — let Node exit naturally once the event loop - // is empty (adapter-node closes the HTTP server on its own). -} - -process.once('SIGTERM', () => shutdown('SIGTERM')); -process.once('SIGINT', () => shutdown('SIGINT')); - -const SESSION_COOKIE = 'libnovel_session'; -const AUTH_COOKIE = 'libnovel_auth'; -const ONE_YEAR = 60 * 60 * 24 * 365; - -const AUTH_SECRET = env.AUTH_SECRET ?? 'dev_secret_change_in_production'; - -// ─── Token helpers ──────────────────────────────────────────────────────────── - -/** - * Sign a payload string with HMAC-SHA256 using AUTH_SECRET. - * Returns "<payload>.<signature>". - */ -export function signToken(payload: string): string { - const sig = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); - return `${payload}.${sig}`; -} - -/** - * Verify a signed token. Returns the payload string on success, null on failure. - */ -export function verifyToken(token: string): string | null { - const lastDot = token.lastIndexOf('.'); - if (lastDot < 0) return null; - const payload = token.slice(0, lastDot); - const expected = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); - const actual = token.slice(lastDot + 1); - // constant-time comparison - if (expected.length !== actual.length) return null; - let diff = 0; - for (let i = 0; i < expected.length; i++) { - diff |= expected.charCodeAt(i) ^ actual.charCodeAt(i); - } - return diff === 0 ? payload : null; -} - -/** - * Create a signed auth token for a user. - * Payload format: "<userId>:<username>:<role>:<authSessionId>" - * authSessionId uniquely identifies this login session (for revocation). - */ -export function createAuthToken(userId: string, username: string, role: string, authSessionId: string): string { - return signToken(`${userId}:${username}:${role}:${authSessionId}`); -} - -/** - * Parse a verified auth token into user data. Returns null if invalid. - * Supports both old format (3 segments) and new format (4 segments). - */ -export function parseAuthToken(token: string): { id: string; username: string; role: string; authSessionId: string } | null { - const payload = verifyToken(token); - if (!payload) return null; - const parts = payload.split(':'); - // New format: userId:username:role:authSessionId (4 parts) - // Old format: userId:username:role (3 parts — legacy tokens before session tracking) - if (parts.length < 3) return null; - const id = parts[0]; - const username = parts[1]; - const role = parts[2]; - const authSessionId = parts[3] ?? ''; // empty string for legacy tokens - if (!id || !username) return null; - return { id, username, role, authSessionId }; -} - -// ─── Hook ───────────────────────────────────────────────────────────────────── - -export const handle: Handle = async ({ event, resolve }) => { - // During graceful shutdown, reject new requests immediately so the load - // balancer / Docker health-check can drain existing connections. - if (shuttingDown) { - return new Response('Service shutting down', { status: 503 }); - } - - // Anonymous session cookie (for reading progress) - let sessionId = event.cookies.get(SESSION_COOKIE) ?? ''; - if (!sessionId) { - sessionId = randomBytes(16).toString('hex'); - event.cookies.set(SESSION_COOKIE, sessionId, { - path: '/', - httpOnly: true, - sameSite: 'lax', - maxAge: ONE_YEAR - }); - } - event.locals.sessionId = sessionId; - - // Auth cookie → resolve logged-in user - const authToken = event.cookies.get(AUTH_COOKIE); - if (authToken) { - const user = parseAuthToken(authToken); - if (!user) { - log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)'); - event.locals.user = null; - } else { - // Validate session against DB (only for new-format tokens with authSessionId) - let sessionValid = true; - if (user.authSessionId) { - try { - const revoked = await isSessionRevoked(user.authSessionId); - if (revoked) { - log.info('auth', 'auth cookie references revoked session', { - userId: user.id, - authSessionId: user.authSessionId - }); - sessionValid = false; - // Clear the invalid cookie - event.cookies.delete(AUTH_COOKIE, { path: '/' }); - } else { - // Best-effort: update last_seen in the background - touchUserSession(user.authSessionId).catch(() => {}); - } - } catch (err) { - // DB error — fail open to avoid locking everyone out - log.warn('auth', 'session check failed (fail open)', { err: String(err) }); - } - } - event.locals.user = sessionValid ? user : null; - } - } else { - event.locals.user = null; - } - - return resolve(event); -}; - diff --git a/ui-v2/src/lib/assets/favicon.svg b/ui-v2/src/lib/assets/favicon.svg deleted file mode 100644 index cc5dc66..0000000 --- a/ui-v2/src/lib/assets/favicon.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo \ No newline at end of file diff --git a/ui-v2/src/lib/audio.svelte.ts b/ui-v2/src/lib/audio.svelte.ts deleted file mode 100644 index de4f9a0..0000000 --- a/ui-v2/src/lib/audio.svelte.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Global audio player state for libnovel. - * - * A single shared instance (module singleton) keeps audio playing across - * SvelteKit navigations. The layout mounts the